UNPKG

@ledgerhq/coin-stellar

Version:
497 lines 20.5 kB
"use strict"; // SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MIN_BALANCE = exports.BASE_RESERVE = void 0; exports.getReservedBalance = getReservedBalance; exports.rawOperationsToOperations = rawOperationsToOperations; exports.parseSequence = parseSequence; exports.decodeMemo = decodeMemo; const bignumber_js_1 = __importDefault(require("bignumber.js")); const common_1 = require("../logic/common"); // Constants const BASE_RESERVE_MIN_COUNT = 2; exports.BASE_RESERVE = 0.5; exports.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', ]); function getReservedBalance(account) { const numOfSponsoringEntries = Number(account.num_sponsoring); const numOfSponsoredEntries = Number(account.num_sponsored); const nativeAsset = account.balances?.find((b) => b.asset_type === 'native'); const amountInOffers = new bignumber_js_1.default(nativeAsset?.selling_liabilities || 0); const numOfEntries = new bignumber_js_1.default(account.subentry_count || 0); return new bignumber_js_1.default(BASE_RESERVE_MIN_COUNT) .plus(numOfEntries) .plus(numOfSponsoringEntries) .minus(numOfSponsoredEntries) .times(exports.BASE_RESERVE) .plus(amountInOffers); } async function rawOperationsToOperations(operations, addr, accountId, minHeight) { // 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(); 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 = []; 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, addr) { return (operation.from === addr || operation.to === addr || operation.funder === addr || operation.account === addr || operation.trustor === addr || operation.source_account === addr); } async function formatOperation(rawOperation, accountId, addr, minHeight) { 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 = { 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 = { 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); } } function parseSequence(seq) { const bn = new bignumber_js_1.default(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) { 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) { const { rawOperation, addr } = ctx; const ops = []; 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; if (isSource) { const amountStr = pathOp.source_amount ?? pathOp.amount ?? '0'; ops.push(buildPathPaymentLeg(ctx, 'OUT', sourceAsset, (0, common_1.parseAPIValue)(amountStr), isMultiLeg)); } if (isDest) { const amountStr = pathOp.amount ?? '0'; ops.push(buildPathPaymentLeg(ctx, 'IN', destAsset, (0, common_1.parseAPIValue)(amountStr), isMultiLeg)); } return ops; } function readSourceAsset(rawOperation) { const o = rawOperation; 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) { const o = rawOperation; 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, direction, isNative, amount, feeCharged) { if (isFailed) { return direction === 'IN' ? 0n : BigInt(feeCharged); } return isNative ? BigInt(amount.toString()) : BigInt(feeCharged); } function describeLegAsset(asset, amount) { 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 }, direction, asset, amount, isMultiLeg) { 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) { 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) { const reserves = readPoolReserves(ctx.rawOperation, 'reserves_received'); return reserves.map((reserve) => buildPoolLeg(ctx, 'IN', reserve, reserves.length > 1)); } function readPoolReserves(rawOperation, field) { const arr = rawOperation[field]; if (!arr || arr.length === 0) return []; return arr.map((r) => ({ asset: parseAssetString(r.asset), amount: (0, common_1.parseAPIValue)(r.amount), })); } function parseAssetString(asset) { 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 }, direction, reserve, isMultiLeg) { 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, accountId, addr, minHeight) { 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), }, }; } function decodeMemo(transaction) { 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) { switch (operation.type) { case 'create_account': return [operation.account]; case 'payment': return [operation.to_muxed || operation.to]; default: return []; } } function getSimpleOperationValue(operation, transaction, type) { let value = new bignumber_js_1.default(0); if (!operation.transaction_successful) { return type === 'IN' ? value : new bignumber_js_1.default(transaction.fee_charged || 0); } switch (operation.type) { case 'create_account': return (0, common_1.parseAPIValue)(operation.starting_balance); case 'payment': return (0, common_1.parseAPIValue)(operation.amount); default: return type !== 'IN' ? new bignumber_js_1.default(transaction.fee_charged) : value; } } function getSimpleOperationType(operation, addr) { 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_js_1.default(operation.limit).eq(0)) { return 'OPT_OUT'; } return 'OPT_IN'; default: if (operation.source_account === addr) { return 'OUT'; } return 'IN'; } } //# sourceMappingURL=serialization.js.map