@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
250 lines • 8.46 kB
JavaScript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
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 = { 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([
'create_account',
'payment',
'path_payment_strict_send',
'path_payment_strict_receive',
'change_trust',
]);
function pathStrictDualLegTransfers(params) {
const { fromAddr, toAddr, sourceAsset, destAsset, sourceStroops, destStroops } = params;
const ops = [];
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) {
if (!amountStr) {
return 0n;
}
return BigInt(parseAPIValue(amountStr).integerValue(BigNumber.ROUND_FLOOR).toFixed(0));
}
function assetFromHorizonFields(assetType, assetCode, assetIssuer) {
if (!assetType || assetType === 'native') {
return NATIVE_ASSET;
}
return {
type: 'token',
assetReference: assetCode ?? '',
assetOwner: assetIssuer ?? '',
};
}
function isSupportedGetBlockOperation(op) {
return SUPPORTED_GETBLOCK_OP_TYPES.has(op.type);
}
function orderTransactionHashes(rawOps) {
const order = [];
const seen = new Set();
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) {
const map = new Map();
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, toAddr, amountStr, asset) {
const amount = toStroops(amountStr);
if (amount === 0n) {
return [];
}
const ops = [];
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) {
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) {
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),
});
}
function mapSupportedOperationToBlockOperations(op, txCtx) {
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, ops) {
if (ops.length === 0)
return null;
const failed = !ops[0].transaction_successful;
const tx = await ops[0].transaction();
const txCtx = {
memo: decodeMemo(tx),
sequence: parseSequence(tx.source_account_sequence),
feeCharged: new BigNumber(tx.fee_charged || '0').toString(),
};
let blockOperations = [];
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) {
const order = orderTransactionHashes(rawOps);
const byHash = groupOpsByHash(rawOps);
const out = [];
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) {
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;
if (height > 1) {
const prevHash = ledger.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 = {
height: ledger.sequence,
hash: ledger.hash,
time: new Date(ledger.closed_at),
...(parent && { parent }),
};
const transactions = await buildBlockTransactions(rawOps);
return { info, transactions };
}
//# sourceMappingURL=getBlock.js.map