@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
487 lines • 19.9 kB
JavaScript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
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([
'create_account',
'payment',
'path_payment_strict_send',
'path_payment_strict_receive',
'change_trust',
'liquidity_pool_deposit',
'liquidity_pool_withdraw',
]);
export 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(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, 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);
}
}
export function parseSequence(seq) {
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) {
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, parseAPIValue(amountStr), isMultiLeg));
}
if (isDest) {
const amountStr = pathOp.amount ?? '0';
ops.push(buildPathPaymentLeg(ctx, 'IN', destAsset, 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: 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),
},
};
}
export 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(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, 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(operation.limit).eq(0)) {
return 'OPT_OUT';
}
return 'OPT_IN';
default:
if (operation.source_account === addr) {
return 'OUT';
}
return 'IN';
}
}
//# sourceMappingURL=serialization.js.map