UNPKG

@ledgerhq/coin-stellar

Version:
331 lines (301 loc) 9.5 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { RawOperation, StellarMemo } from '../types' import type { AssetInfo, Block, BlockInfo, BlockOperation, BlockTransaction, } from '@ledgerhq/coin-module-framework/api/index' import BigNumber from 'bignumber.js' import { fetchAllLedgerOperations, fetchLedgerRecord } from '../network' import { decodeMemo, parseSequence } from '../network/serialization' import { parseAPIValue } from './common' import { assertUnreachable } from './utils' const NATIVE_ASSET: AssetInfo = { type: 'native', name: 'XLM' } /** * Horizon `type` strings that `getBlock` maps into `BlockOperation` entries. * * Same subset as user-facing history in `rawOperationsToOperations` (serialization); * other operation kinds are ignored until a later iteration expands coverage. */ const SUPPORTED_GETBLOCK_OP_TYPES = new Set<string>([ 'create_account', 'payment', 'path_payment_strict_send', 'path_payment_strict_receive', 'change_trust', ]) type RawPathStrictSendOp = Extract<RawOperation, { type: 'path_payment_strict_send' }> type RawPathStrictReceiveOp = Extract<RawOperation, { type: 'path_payment_strict_receive' }> & { send_max?: string } /** Horizon ops mapped by `getBlock`; kept in sync with `SUPPORTED_GETBLOCK_OP_TYPES`. */ type SupportedGetBlockOperation = Extract< RawOperation, | { type: 'payment' } | { type: 'create_account' } | { type: 'change_trust' } | { type: 'path_payment_strict_send' } | { type: 'path_payment_strict_receive' } > function pathStrictDualLegTransfers(params: { fromAddr: string | undefined toAddr: string | undefined sourceAsset: AssetInfo destAsset: AssetInfo sourceStroops: bigint destStroops: bigint }): BlockOperation[] { const { fromAddr, toAddr, sourceAsset, destAsset, sourceStroops, destStroops } = params const ops: BlockOperation[] = [] if (fromAddr && sourceStroops !== 0n) { ops.push({ type: 'transfer', address: fromAddr, ...(toAddr && { peer: toAddr }), asset: sourceAsset, amount: -sourceStroops, }) } if (toAddr && destStroops !== 0n) { ops.push({ type: 'transfer', address: toAddr, ...(fromAddr && { peer: fromAddr }), asset: destAsset, amount: destStroops, }) } return ops } function toStroops(amountStr: string | undefined): bigint { if (!amountStr) { return 0n } return BigInt(parseAPIValue(amountStr).integerValue(BigNumber.ROUND_FLOOR).toFixed(0)) } function assetFromHorizonFields( assetType: string | undefined, assetCode: string | undefined, assetIssuer: string | undefined ): AssetInfo { if (!assetType || assetType === 'native') { return NATIVE_ASSET } return { type: 'token', assetReference: assetCode ?? '', assetOwner: assetIssuer ?? '', } } function isSupportedGetBlockOperation(op: RawOperation): op is SupportedGetBlockOperation { return SUPPORTED_GETBLOCK_OP_TYPES.has(op.type) } function orderTransactionHashes(rawOps: RawOperation[]): string[] { const order: string[] = [] const seen = new Set<string>() for (const op of rawOps) { const h = op.transaction_hash if (!seen.has(h)) { seen.add(h) order.push(h) } } return order } function groupOpsByHash(rawOps: RawOperation[]): Map<string, RawOperation[]> { const map = new Map<string, RawOperation[]>() for (const op of rawOps) { const h = op.transaction_hash const list = map.get(h) if (list) list.push(op) else map.set(h, [op]) } return map } function mapPaymentLikeTransfer( fromAddr: string | undefined, toAddr: string | undefined, amountStr: string | undefined, asset: AssetInfo ): BlockOperation[] { const amount = toStroops(amountStr) if (amount === 0n) { return [] } const ops: BlockOperation[] = [] if (fromAddr) { ops.push({ type: 'transfer', address: fromAddr, ...(toAddr && { peer: toAddr }), asset, amount: -amount, }) } if (toAddr) { ops.push({ type: 'transfer', address: toAddr, ...(fromAddr && { peer: fromAddr }), asset, amount, }) } return ops } function blockOperationsPathStrictSend(op: RawPathStrictSendOp): BlockOperation[] { const destAsset = assetFromHorizonFields(op.asset_type, op.asset_code, op.asset_issuer) const sourceAsset = assetFromHorizonFields( op.source_asset_type, op.source_asset_code, op.source_asset_issuer ) return pathStrictDualLegTransfers({ fromAddr: op.from, toAddr: op.to, sourceAsset, destAsset, sourceStroops: toStroops(op.source_amount), destStroops: toStroops(op.amount), }) } function blockOperationsPathStrictReceive(op: RawPathStrictReceiveOp): BlockOperation[] { const destAsset = assetFromHorizonFields(op.asset_type, op.asset_code, op.asset_issuer) const sourceAsset = assetFromHorizonFields( op.source_asset_type, op.source_asset_code, op.source_asset_issuer ) // Horizon reports actual source debit in `source_amount`; `send_max` is only the envelope cap. const sourceStroops = toStroops(op.source_amount ?? op.send_max) return pathStrictDualLegTransfers({ fromAddr: op.from, toAddr: op.to, sourceAsset, destAsset, sourceStroops, destStroops: toStroops(op.amount), }) } type TxContext = { memo: StellarMemo | undefined sequence: string | undefined feeCharged: string } function mapSupportedOperationToBlockOperations( op: SupportedGetBlockOperation, txCtx: TxContext ): BlockOperation[] { if (op.type === 'payment') { const asset = assetFromHorizonFields(op.asset_type, op.asset_code, op.asset_issuer) const toAddr = op.to_muxed || op.to return mapPaymentLikeTransfer(op.from, toAddr, op.amount, asset) } if (op.type === 'create_account') { return mapPaymentLikeTransfer(op.funder, op.account, op.starting_balance, NATIVE_ASSET) } if (op.type === 'change_trust') { const isOptOut = new BigNumber(op.limit || '0').eq(0) // Align with listOperations: assetAmount = fee_charged (not the trustline limit) return [ { type: 'other', ledgerOpType: isOptOut ? 'OPT_OUT' : 'OPT_IN', assetAmount: op.asset_code ? txCtx.feeCharged : undefined, ...(txCtx.memo && { memo: txCtx.memo }), ...(txCtx.sequence && { sequence: txCtx.sequence }), }, ] } if (op.type === 'path_payment_strict_send') { return blockOperationsPathStrictSend(op) } if (op.type === 'path_payment_strict_receive') { return blockOperationsPathStrictReceive(op) } return assertUnreachable(op) } async function blockTransactionForHash( hash: string, ops: RawOperation[] ): Promise<BlockTransaction | null> { if (ops.length === 0) return null const failed = !ops[0].transaction_successful const tx = await ops[0].transaction() const txCtx: TxContext = { memo: decodeMemo(tx), sequence: parseSequence(tx.source_account_sequence), feeCharged: new BigNumber(tx.fee_charged || '0').toString(), } let blockOperations: BlockOperation[] = [] if (!failed) { for (const op of ops) { if (isSupportedGetBlockOperation(op)) { blockOperations = blockOperations.concat(mapSupportedOperationToBlockOperations(op, txCtx)) } } } const fees = BigInt(tx.fee_charged || '0') const feesPayer = tx.fee_account || tx.source_account return { hash, failed, fees, ...(feesPayer ? { feesPayer } : {}), operations: blockOperations, } } async function buildBlockTransactions(rawOps: RawOperation[]): Promise<BlockTransaction[]> { const order = orderTransactionHashes(rawOps) const byHash = groupOpsByHash(rawOps) const out: BlockTransaction[] = [] for (const hash of order) { // `order` and `byHash` are built from the same `rawOps`; every ordered hash has a non-empty op list. const ops = byHash.get(hash)! const row = await blockTransactionForHash(hash, ops) if (row) { out.push(row) } } return out } /** * Returns the Stellar closed ledger at `height` (ledger sequence) with mapped * `BlockTransaction` entries (see `SUPPORTED_GETBLOCK_OP_TYPES` for which Horizon * operations are included). * * Only supported payment / path / trust / create-account operations contribute * balance operations; other Horizon operation types are skipped for successful * transactions. Failed transactions still appear with `failed: true`, fees * populated, and empty `operations`. */ export async function getBlock(height: number): Promise<Block> { if (!Number.isSafeInteger(height) || height <= 0) { throw new Error(`getBlock: height must be a positive integer, got ${height}`) } const [ledger, rawOps] = await Promise.all([ fetchLedgerRecord(height), fetchAllLedgerOperations(height), ]) let parent: BlockInfo['parent'] if (height > 1) { const prevHash = (ledger as { prev_hash?: string }).prev_hash if (prevHash) { parent = { height: height - 1, hash: prevHash } } else { const parentLedger = await fetchLedgerRecord(height - 1) parent = { height: parentLedger.sequence, hash: parentLedger.hash } } } const info: BlockInfo = { height: ledger.sequence, hash: ledger.hash, time: new Date(ledger.closed_at), ...(parent && { parent }), } const transactions = await buildBlockTransactions(rawOps) return { info, transactions } }