@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
1,200 lines (1,110 loc) • 41 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import type { BalanceAsset, RawOperation } from '../types'
import type { Horizon } from '@stellar/stellar-sdk'
import {
BASE_RESERVE,
MIN_BALANCE,
getReservedBalance,
rawOperationsToOperations,
} from './serialization'
const ADDR = 'GBADDR'
const OTHER_ADDR = 'GBOTHER'
const THIRD_ADDR = 'GBTHIRD'
const ACCOUNT_ID = 'stellar:GBADDR'
const TX_HASH = 'txh'
const BLOCK_HASH = 'block-hash'
const BLOCK_TIME = '2025-06-01T12:00:00Z'
const CREATED_AT = '2025-06-01T12:00:00Z'
type TxOverrides = Partial<{
ledger_attr: number
fee_charged: string
fee_account: string
source_account_sequence: string
memo_type: string
memo: string | undefined
ledger_hash: string
ledger_closed_at: string
}>
function makeTx(overrides: TxOverrides = {}) {
const {
ledger_attr = 100,
fee_charged = '100',
fee_account,
source_account_sequence = '42',
memo_type = 'none',
memo,
ledger_hash = BLOCK_HASH,
ledger_closed_at = BLOCK_TIME,
} = overrides
return {
ledger_attr,
fee_charged,
fee_account,
source_account_sequence,
memo_type,
memo,
ledger: jest.fn().mockResolvedValue({
hash: ledger_hash,
closed_at: ledger_closed_at,
}),
}
}
// `RawOperation.type` is constrained to a narrow union of Horizon operation type
// literals which makes building fixtures with plain strings impractical, so we
// accept any string and cast at the construction site.
type RawOpInput = { type: string } & Record<string, unknown>
function makeOp(rawOpOverrides: RawOpInput, txOverrides: TxOverrides = {}): RawOperation {
return {
id: 'op-1',
paging_token: 'pt-1',
source_account: ADDR,
transaction_hash: TX_HASH,
transaction_successful: true,
created_at: CREATED_AT,
transaction: jest.fn().mockResolvedValue(makeTx(txOverrides)),
...rawOpOverrides,
} as unknown as RawOperation
}
describe('constants', () => {
it('exports BASE_RESERVE equal to 0.5 (stellar protocol base reserve in XLM)', () => {
expect(BASE_RESERVE).toBe(0.5)
})
it('exports MIN_BALANCE equal to 1 (minimum stellar account balance in XLM)', () => {
expect(MIN_BALANCE).toBe(1)
})
})
describe('getReservedBalance', () => {
type AccountInput = Partial<{
num_sponsoring: string
num_sponsored: string
subentry_count: number
balances: Array<Partial<BalanceAsset>>
}>
function makeAccount(overrides: AccountInput = {}): Horizon.ServerApi.AccountRecord {
return {
num_sponsoring: '0',
num_sponsored: '0',
subentry_count: 0,
balances: [{ asset_type: 'native', selling_liabilities: '0' }],
...overrides,
} as unknown as Horizon.ServerApi.AccountRecord
}
it('should return 1 XLM (the minimum reserve) when account has no subentries, sponsors or liabilities', () => {
expect(getReservedBalance(makeAccount()).toNumber()).toBe(1)
})
it('should add 0.5 XLM per subentry', () => {
expect(getReservedBalance(makeAccount({ subentry_count: 4 })).toNumber()).toBe(3)
})
it('should add 0.5 XLM per sponsoring entry', () => {
expect(getReservedBalance(makeAccount({ num_sponsoring: '2' })).toNumber()).toBe(2)
})
it('should subtract 0.5 XLM per sponsored entry', () => {
expect(
getReservedBalance(makeAccount({ num_sponsoring: '4', num_sponsored: '2' })).toNumber()
).toBe(2)
})
it('should add native selling_liabilities on top of the entry reserve', () => {
expect(
getReservedBalance(
makeAccount({ balances: [{ asset_type: 'native', selling_liabilities: '10.5' }] })
).toNumber()
).toBe(11.5)
})
it('should ignore non-native selling_liabilities (only the native balance contributes)', () => {
expect(
getReservedBalance(
makeAccount({
balances: [
{ asset_type: 'credit_alphanum4', selling_liabilities: '100', asset_code: 'USDC' },
{ asset_type: 'native', selling_liabilities: '2' },
],
})
).toNumber()
).toBe(3)
})
it('should fall back to 0 selling_liabilities when no native balance entry exists', () => {
expect(getReservedBalance(makeAccount({ balances: [] })).toNumber()).toBe(1)
})
it('should treat undefined selling_liabilities on the native balance as 0', () => {
expect(
getReservedBalance(makeAccount({ balances: [{ asset_type: 'native' }] })).toNumber()
).toBe(1)
})
it('should combine subentries, sponsoring/sponsored entries and selling_liabilities', () => {
// (2 + 3 + 2 - 1) * 0.5 + 4.5 = 6 * 0.5 + 4.5 = 7.5
expect(
getReservedBalance(
makeAccount({
subentry_count: 3,
num_sponsoring: '2',
num_sponsored: '1',
balances: [{ asset_type: 'native', selling_liabilities: '4.5' }],
})
).toNumber()
).toBe(7.5)
})
})
describe('rawOperationsToOperations', () => {
describe('address filtering', () => {
it('should exclude operations not involving the address', async () => {
const op = makeOp({
type: 'payment',
from: OTHER_ADDR,
to: THIRD_ADDR,
amount: '10',
source_account: OTHER_ADDR,
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(0)
})
it('should include operations where `from` equals the address', async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '10',
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
it('should include operations where `to` equals the address', async () => {
const op = makeOp({
type: 'payment',
from: OTHER_ADDR,
to: ADDR,
amount: '10',
source_account: OTHER_ADDR,
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
it('should include operations where `funder` equals the address', async () => {
const op = makeOp({
type: 'create_account',
funder: ADDR,
account: OTHER_ADDR,
starting_balance: '10',
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
it('should include operations where `account` equals the address', async () => {
const op = makeOp({
type: 'create_account',
funder: OTHER_ADDR,
account: ADDR,
starting_balance: '10',
source_account: OTHER_ADDR,
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
it('should include operations where `trustor` equals the address', async () => {
const op = makeOp({
type: 'change_trust',
trustor: ADDR,
limit: '1000',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
source_account: OTHER_ADDR,
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
it('should include operations where `source_account` equals the address', async () => {
const op = makeOp({
type: 'change_trust',
trustor: OTHER_ADDR,
limit: '1000',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
source_account: ADDR,
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
})
describe('operation type filtering', () => {
it.each(['manage_offer', 'set_options', 'allow_trust', 'manage_data', 'bump_sequence'])(
'should not surface a typed Operation for unsupported %s operation',
async (type) => {
const op = makeOp({
type,
source_account: ADDR,
})
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
// No typed Operation (OUT/IN/OPT_IN/...) is emitted for unsupported types
expect(result.find((o) => o.type !== 'FEES')).toBeUndefined()
}
)
it.each([
['create_account', { funder: ADDR, account: OTHER_ADDR, starting_balance: '1' }],
['payment', { from: ADDR, to: OTHER_ADDR, amount: '1' }],
['path_payment_strict_send', { from: ADDR, to: OTHER_ADDR, amount: '1' }],
['path_payment_strict_receive', { from: ADDR, to: OTHER_ADDR, amount: '1' }],
[
'change_trust',
{ trustor: ADDR, limit: '1000', asset_code: 'USDC', asset_issuer: 'GISSUER' },
],
])('should include supported %s operation', async (type, extra) => {
const op = makeOp({ type, ...extra } as RawOpInput)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
})
})
describe('fee-only operations', () => {
// Non-fee-bump Stellar transactions have `fee_account === source_account`,
// which is what most of these fixtures model.
it.each(['set_options', 'manage_offer', 'manage_data', 'bump_sequence', 'allow_trust'])(
'emits a single FEES Operation for unsupported %s when address paid the fee',
async (type) => {
const op = makeOp(
{ type, source_account: ADDR, transaction_hash: `tx-${type}` },
{ fee_account: ADDR, fee_charged: '150' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
type: 'FEES',
value: 0n,
asset: { type: 'native' },
senders: [ADDR],
recipients: [],
tx: { hash: `tx-${type}`, fees: 150n, feesPayer: ADDR, failed: false },
})
expect(result[0].details).toMatchObject({ ledgerOpType: 'FEES' })
expect(result[0].id).toBe(`${ACCOUNT_ID}-tx-${type}-FEES`)
}
)
it('emits a FEES Operation for create_claimable_balance when address is the sponsor', async () => {
const op = makeOp(
{
type: 'create_claimable_balance',
source_account: ADDR,
asset: 'USDC:GISSUER',
amount: '100',
sponsor: ADDR,
transaction_hash: 'tx-ccb',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('FEES')
expect(out.tx.fees).toBe(100n)
// The token amount locked in the claimable balance is intentionally NOT
// exposed on the FEES op; only the XLM fee deduction is.
expect(out.value).toBe(0n)
expect(out.asset).toEqual({ type: 'native' })
})
it('does NOT emit a FEES Operation when address is not the fee payer (fee-bump or unrelated)', async () => {
const op = makeOp(
{
type: 'set_options',
source_account: ADDR,
transaction_hash: 'tx-fee-bumped',
},
// fee bumped by another account
{ fee_account: OTHER_ADDR, fee_charged: '150' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(0)
})
it('does NOT emit a FEES Operation when the tx already has a surfaced Operation (avoids double-counting)', async () => {
// Multi-op transaction: one supported (change_trust, OPT_IN) + one
// unsupported (set_options). Only the supported op is surfaced; the
// unsupported one does not produce an extra FEES row since the tx fee is
// already carried on the OPT_IN op's `tx.fees`.
const supportedOp = makeOp(
{
type: 'change_trust',
trustor: ADDR,
limit: '1000',
asset_code: 'BTC',
asset_issuer: 'GISSUER',
source_account: ADDR,
transaction_hash: 'tx-mixed',
id: 'op-1',
paging_token: 'pt-1',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const unsupportedOp = makeOp(
{
type: 'set_options',
source_account: ADDR,
transaction_hash: 'tx-mixed',
id: 'op-2',
paging_token: 'pt-2',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const result = await rawOperationsToOperations(
[supportedOp, unsupportedOp],
ADDR,
ACCOUNT_ID,
0
)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('OPT_IN')
expect(result[0].tx.fees).toBe(100n)
})
it('emits a single FEES Operation per tx even when multiple unsupported ops share the same tx hash', async () => {
const opA = makeOp(
{
type: 'set_options',
source_account: ADDR,
transaction_hash: 'tx-multi',
id: 'op-a',
paging_token: 'pt-a',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const opB = makeOp(
{
type: 'manage_data',
source_account: ADDR,
transaction_hash: 'tx-multi',
id: 'op-b',
paging_token: 'pt-b',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const result = await rawOperationsToOperations([opA, opB], ADDR, ACCOUNT_ID, 0)
expect(result).toHaveLength(1)
expect(result[0].type).toBe('FEES')
expect(result[0].tx.hash).toBe('tx-multi')
// The FEES op anchors at the first raw op's index/pagingToken so the
// descending Horizon page order is preserved.
expect(result[0].details).toMatchObject({ index: 'op-a', pagingToken: 'pt-a' })
})
it('honors minHeight for fee-only operations', async () => {
const op = makeOp(
{ type: 'set_options', source_account: ADDR },
{ fee_account: ADDR, fee_charged: '100', ledger_attr: 99 }
)
expect(await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 100)).toHaveLength(0)
})
it('marks the FEES Operation as failed when the transaction failed', async () => {
const op = makeOp(
{
type: 'set_options',
source_account: ADDR,
transaction_successful: false,
transaction_hash: 'tx-failed',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('FEES')
expect(out.tx.failed).toBe(true)
expect(out.tx.fees).toBe(100n)
})
})
describe('minHeight filtering', () => {
it('should exclude operations from blocks strictly below minHeight', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '5' },
{ ledger_attr: 99 }
)
expect(await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 100)).toHaveLength(0)
})
it('should include operations at minHeight', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '5' },
{ ledger_attr: 100 }
)
expect(await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 100)).toHaveLength(1)
})
})
describe('operation type determination', () => {
it('should mark create_account as OUT when funder is the address', async () => {
const op = makeOp({
type: 'create_account',
funder: ADDR,
account: OTHER_ADDR,
starting_balance: '10',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OUT')
})
it('should mark create_account as IN when account is the address', async () => {
const op = makeOp({
type: 'create_account',
funder: OTHER_ADDR,
account: ADDR,
starting_balance: '10',
source_account: OTHER_ADDR,
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('IN')
})
it('should mark payment as OUT when from is the address and recipient differs', async () => {
const op = makeOp({ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '5' })
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OUT')
})
it('should mark payment as IN when the address is the recipient', async () => {
const op = makeOp({
type: 'payment',
from: OTHER_ADDR,
to: ADDR,
amount: '5',
source_account: OTHER_ADDR,
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('IN')
})
it('should mark self-payments (from=to=address) as IN', async () => {
const op = makeOp({ type: 'payment', from: ADDR, to: ADDR, amount: '5' })
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('IN')
})
it('should mark path_payment_strict_send as OUT when address is not the recipient', async () => {
const op = makeOp({
type: 'path_payment_strict_send',
from: ADDR,
to: OTHER_ADDR,
amount: '5',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OUT')
})
it('should mark path_payment_strict_send as IN when address is the recipient', async () => {
const op = makeOp({
type: 'path_payment_strict_send',
from: OTHER_ADDR,
to: ADDR,
amount: '5',
source_account: OTHER_ADDR,
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('IN')
})
it('should mark path_payment_strict_receive as OUT when address is the source', async () => {
const op = makeOp({
type: 'path_payment_strict_receive',
from: ADDR,
to: OTHER_ADDR,
amount: '5',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OUT')
})
it('should mark path_payment_strict_receive as IN when address is the recipient', async () => {
const op = makeOp({
type: 'path_payment_strict_receive',
from: OTHER_ADDR,
to: ADDR,
amount: '5',
source_account: OTHER_ADDR,
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('IN')
})
it('should mark change_trust as OPT_IN when limit is non-zero', async () => {
const op = makeOp({
type: 'change_trust',
trustor: ADDR,
limit: '1000',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OPT_IN')
})
it('should mark change_trust as OPT_OUT when limit is zero', async () => {
const op = makeOp({
type: 'change_trust',
trustor: ADDR,
limit: '0',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OPT_OUT')
})
})
describe('memo handling', () => {
it("should set NO_MEMO when memo_type is 'none'", async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ memo_type: 'none' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({ memo: { type: 'NO_MEMO' } })
})
it("should set MEMO_ID with raw memo when memo_type is 'id'", async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ memo_type: 'id', memo: '12345' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({ memo: { type: 'MEMO_ID', value: '12345' } })
})
it("should set MEMO_TEXT with raw memo when memo_type is 'text'", async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ memo_type: 'text', memo: 'hello' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({ memo: { type: 'MEMO_TEXT', value: 'hello' } })
})
it('should convert MEMO_RETURN from base64 to hex', async () => {
const memoBase64 = Buffer.from('deadbeef', 'hex').toString('base64')
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ memo_type: 'return', memo: memoBase64 }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({
memo: { type: 'MEMO_RETURN', value: 'deadbeef' },
})
})
it('should convert MEMO_HASH from base64 to hex', async () => {
const memoBase64 = Buffer.from('cafebabe', 'hex').toString('base64')
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ memo_type: 'hash', memo: memoBase64 }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({
memo: { type: 'MEMO_HASH', value: 'cafebabe' },
})
})
it('should leave memo undefined when memo_type is set but memo value is missing', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ memo_type: 'id', memo: undefined }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect((out.details as { memo?: unknown }).memo).toBeUndefined()
})
})
describe('asset and value handling', () => {
it('should set native asset and value=amount*10^7 for native payments', async () => {
const op = makeOp({ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '10' })
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.asset).toEqual({ type: 'native' })
expect(out.value).toBe(100000000n)
expect(out.details).toMatchObject({
assetCode: undefined,
assetIssuer: undefined,
assetAmount: undefined,
})
})
it('should set token asset, NONE type and value=fee_charged when asset_code+asset_issuer are present', async () => {
const op = makeOp(
{
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '10',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
},
{ fee_charged: '200' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.asset).toEqual({
type: 'token',
assetReference: 'USDC',
assetOwner: 'GISSUER',
})
expect(out.value).toBe(200n)
expect(out.type).toBe('NONE')
expect(out.details).toMatchObject({
assetCode: 'USDC',
assetIssuer: 'GISSUER',
assetAmount: '100000000',
})
})
it('should fall back to native asset when asset_code is set but asset_issuer is missing', async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '10',
asset_code: 'USDC',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.asset).toEqual({ type: 'native' })
})
it('should preserve OPT_IN/OPT_OUT type for token change_trust operations', async () => {
const op = makeOp({
type: 'change_trust',
trustor: ADDR,
limit: '1000',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('OPT_IN')
})
it('should parse create_account starting_balance through the stellar unit (10^7)', async () => {
const op = makeOp({
type: 'create_account',
funder: ADDR,
account: OTHER_ADDR,
starting_balance: '2.5',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.value).toBe(25000000n)
})
})
describe('path_payment_strict_send native source side', () => {
it('emits a native OUT row using source_amount when address is the source and source asset is native', async () => {
const op = makeOp(
{
type: 'path_payment_strict_send',
from: ADDR,
to: OTHER_ADDR,
source_account: ADDR,
source_asset_type: 'native',
source_amount: '0.7920000',
asset_type: 'credit_alphanum12',
asset_code: 'SPDX',
asset_issuer: 'GISSUER',
amount: '1234.5678901',
},
{ fee_charged: '100' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
// No FEES row: native OUT already carries the fee on `tx.fees`.
expect(result.map((o) => `${o.type}:${o.asset.type}`)).toEqual(['OUT:native'])
expect(result[0]).toMatchObject({
type: 'OUT',
asset: { type: 'native' },
value: 7920000n,
senders: [ADDR],
recipients: [OTHER_ADDR],
tx: { fees: 100n },
})
})
it('emits a native IN row using `amount` when address is the destination and dest asset is native', async () => {
const op = makeOp(
{
type: 'path_payment_strict_send',
from: OTHER_ADDR,
to: ADDR,
source_account: OTHER_ADDR,
source_asset_type: 'credit_alphanum4',
source_asset_code: 'USDC',
source_asset_issuer: 'GISSUER',
source_amount: '10.0000000',
asset_type: 'native',
amount: '9.9000000',
},
{ fee_charged: '100' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result).toEqual([
expect.objectContaining({
type: 'IN',
asset: { type: 'native' },
value: 99000000n,
senders: [OTHER_ADDR],
recipients: [ADDR],
}),
])
})
it('emits a token NONE row when the source asset is a token and address is the source', async () => {
const op = makeOp(
{
type: 'path_payment_strict_send',
from: ADDR,
to: OTHER_ADDR,
source_account: ADDR,
source_asset_type: 'credit_alphanum4',
source_asset_code: 'USDC',
source_asset_issuer: 'GISSUER',
source_amount: '5.0000000',
asset_type: 'credit_alphanum4',
asset_code: 'EURC',
asset_issuer: 'GISSUER2',
amount: '4.5000000',
},
{ fee_charged: '300' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.type).toBe('NONE')
expect(out.asset).toEqual({
type: 'token',
assetReference: 'USDC',
assetOwner: 'GISSUER',
})
// legacy convention: NONE token rows carry `value = fee_charged` and the
// real token amount on `details.assetAmount`.
expect(out.value).toBe(300n)
expect(out.details).toMatchObject({
assetCode: 'USDC',
assetIssuer: 'GISSUER',
assetAmount: '50000000',
ledgerOpType: 'NONE',
})
})
it('emits both a native OUT (source) and a token NONE (dest) row for self-swaps', async () => {
const op = makeOp(
{
type: 'path_payment_strict_send',
from: ADDR,
to: ADDR,
source_account: ADDR,
source_asset_type: 'native',
source_amount: '0.7920000',
asset_type: 'credit_alphanum12',
asset_code: 'SPDX',
asset_issuer: 'GISSUER',
amount: '1234.5678901',
id: 'op-self-swap',
},
{ fee_charged: '4580' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result.map((o) => `${o.type}:${o.asset.type}`)).toEqual(['OUT:native', 'NONE:token'])
expect(result.map((o) => (o.details as { index: string }).index)).toEqual([
'op-self-swap-OUT-NATIVE',
'op-self-swap-IN-TOKEN-SPDX',
])
expect(result[0]).toMatchObject({ value: 7920000n, senders: [ADDR], recipients: [ADDR] })
expect(result[1]).toMatchObject({
value: 4580n,
details: { assetAmount: '12345678901' },
})
})
})
describe('liquidity_pool_deposit', () => {
it('emits a native OUT row using reserves_deposited when one of the reserves is native', async () => {
const op = makeOp(
{
type: 'liquidity_pool_deposit',
source_account: ADDR,
liquidity_pool_id: 'pool-id',
reserves_deposited: [
{ asset: 'native', amount: '10.0000000' },
{ asset: 'SPDX:GISSUER', amount: '57173830.2052035' },
],
shares_received: '23909.3448196',
id: 'op-lp-deposit',
},
{ fee_charged: '100' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result.map((o) => `${o.type}:${o.asset.type}`)).toEqual(['OUT:native', 'NONE:token'])
expect(result[0]).toMatchObject({
type: 'OUT',
asset: { type: 'native' },
value: 100000000n,
tx: { fees: 100n },
})
// Token leg: legacy NONE convention with the deposited token amount on
// `details.assetAmount`.
expect(result[1]).toMatchObject({
type: 'NONE',
asset: { type: 'token', assetReference: 'SPDX', assetOwner: 'GISSUER' },
value: 100n,
details: { assetAmount: '571738302052035' },
})
expect(result.map((o) => (o.details as { index: string }).index)).toEqual([
'op-lp-deposit-NATIVE',
'op-lp-deposit-TOKEN-SPDX',
])
})
it('does NOT emit a duplicate FEES row when liquidity_pool_deposit is surfaced', async () => {
const op = makeOp(
{
type: 'liquidity_pool_deposit',
source_account: ADDR,
liquidity_pool_id: 'pool-id',
reserves_deposited: [
{ asset: 'native', amount: '10.0000000' },
{ asset: 'SPDX:GISSUER', amount: '57173830.2052035' },
],
},
{ fee_account: ADDR, fee_charged: '100' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result.map((o) => o.type)).toEqual(['OUT', 'NONE'])
expect(result.find((o) => o.type === 'FEES')).toBeUndefined()
})
})
describe('liquidity_pool_withdraw', () => {
it('emits a native IN row using reserves_received when one of the reserves is native', async () => {
const op = makeOp(
{
type: 'liquidity_pool_withdraw',
source_account: ADDR,
liquidity_pool_id: 'pool-id',
reserves_received: [
{ asset: 'native', amount: '26.6614878' },
{ asset: 'SPDX:GISSUER', amount: '152433937.6498273' },
],
shares: '63745.8705215',
id: 'op-lp-withdraw',
},
{ fee_charged: '100' }
)
const result = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(result.map((o) => `${o.type}:${o.asset.type}`)).toEqual(['IN:native', 'NONE:token'])
expect(result[0]).toMatchObject({
type: 'IN',
asset: { type: 'native' },
value: 266614878n,
senders: [ADDR],
})
})
})
describe('failed transactions', () => {
it('should set value=0 for IN ops when transaction failed', async () => {
const op = makeOp(
{
type: 'payment',
from: OTHER_ADDR,
to: ADDR,
amount: '10',
source_account: OTHER_ADDR,
transaction_successful: false,
},
{ fee_charged: '150' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.value).toBe(0n)
expect(out.tx.failed).toBe(true)
})
it('should set value=fee_charged for OUT ops when transaction failed', async () => {
const op = makeOp(
{
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '10',
transaction_successful: false,
},
{ fee_charged: '150' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.value).toBe(150n)
expect(out.tx.failed).toBe(true)
})
})
describe('recipients', () => {
it('should use to_muxed for payment when present', async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
to_muxed: 'MMUXED',
amount: '10',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.recipients).toEqual(['MMUXED'])
})
it('should fall back to `to` for payment when no to_muxed is set', async () => {
const op = makeOp({ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '10' })
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.recipients).toEqual([OTHER_ADDR])
})
it('should use `account` for create_account recipients', async () => {
const op = makeOp({
type: 'create_account',
funder: ADDR,
account: OTHER_ADDR,
starting_balance: '10',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.recipients).toEqual([OTHER_ADDR])
})
it('should use `to` for path_payment_strict_send recipients', async () => {
const op = makeOp({
type: 'path_payment_strict_send',
from: ADDR,
to: OTHER_ADDR,
amount: '10',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.recipients).toEqual([OTHER_ADDR])
})
it('should return empty recipients for change_trust', async () => {
const op = makeOp({
type: 'change_trust',
trustor: ADDR,
limit: '1000',
asset_code: 'USDC',
asset_issuer: 'GISSUER',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.recipients).toEqual([])
})
it('should return empty recipients for path_payment_strict_receive', async () => {
const op = makeOp({
type: 'path_payment_strict_receive',
from: OTHER_ADDR,
to: ADDR,
amount: '10',
source_account: OTHER_ADDR,
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.recipients).toEqual([ADDR])
})
})
describe('sequence handling', () => {
it('should expose source_account_sequence as a string when it is a number', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ source_account_sequence: '123456789' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({ sequence: '123456789' })
})
it('should expose sequence as undefined when source_account_sequence is not numeric', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ source_account_sequence: 'not-a-number' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect((out.details as { sequence?: string }).sequence).toBeUndefined()
})
})
describe('transaction metadata', () => {
it('should expose fee_account as feesPayer when present (fee bump transactions)', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{ fee_account: 'GSPONSOR', fee_charged: '500' }
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.tx.feesPayer).toBe('GSPONSOR')
expect(out.tx.fees).toBe(500n)
})
it('should omit feesPayer when fee_account is not set', async () => {
const op = makeOp({ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' })
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect('feesPayer' in out.tx).toBe(false)
})
it('should build the operation id as `${accountId}-${transactionHash}-${type}`', async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '1',
transaction_hash: 'my-tx-hash',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.id).toBe(`${ACCOUNT_ID}-my-tx-hash-OUT`)
})
it('should populate block metadata from the ledger record', async () => {
const op = makeOp(
{ type: 'payment', from: ADDR, to: OTHER_ADDR, amount: '1' },
{
ledger_attr: 4242,
ledger_hash: 'ledger-hash',
ledger_closed_at: '2025-07-04T08:30:00Z',
}
)
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.tx.block).toEqual({
hash: 'ledger-hash',
height: 4242,
time: new Date('2025-07-04T08:30:00Z'),
})
})
it("should set tx.date from the operation's created_at", async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '1',
created_at: '2024-02-29T01:02:03Z',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.tx.date).toEqual(new Date('2024-02-29T01:02:03Z'))
})
it('should include senders from the operation source_account', async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '1',
source_account: 'GSOURCE',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.senders).toEqual(['GSOURCE'])
})
it('should expose pagingToken and index in details', async () => {
const op = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '1',
id: 'op-42',
paging_token: 'pt-42',
})
const [out] = await rawOperationsToOperations([op], ADDR, ACCOUNT_ID, 0)
expect(out.details).toMatchObject({ pagingToken: 'pt-42', index: 'op-42' })
})
})
describe('batch processing', () => {
it('should preserve relative order of the operations that pass filters', async () => {
const opA = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '1',
transaction_hash: 'tx-a',
})
// Unrelated to the listed address: not in any filter field and not the
// fee payer, so it must be dropped entirely (no fee row).
const opIgnored = makeOp(
{
type: 'manage_offer',
source_account: OTHER_ADDR,
transaction_hash: 'tx-ignored',
},
{ fee_account: OTHER_ADDR }
)
const opB = makeOp({
type: 'payment',
from: OTHER_ADDR,
to: ADDR,
amount: '2',
source_account: OTHER_ADDR,
transaction_hash: 'tx-b',
})
const result = await rawOperationsToOperations([opA, opIgnored, opB], ADDR, ACCOUNT_ID, 0)
expect(result.map((o) => o.tx.hash)).toEqual(['tx-a', 'tx-b'])
})
it('should interleave fee-only ops at the position of their first raw op in the page', async () => {
const opA = makeOp({
type: 'payment',
from: ADDR,
to: OTHER_ADDR,
amount: '1',
transaction_hash: 'tx-a',
})
const opFeeOnly = makeOp(
{
type: 'set_options',
source_account: ADDR,
transaction_hash: 'tx-fees',
},
{ fee_account: ADDR, fee_charged: '100' }
)
const opB = makeOp({
type: 'payment',
from: OTHER_ADDR,
to: ADDR,
amount: '2',
source_account: OTHER_ADDR,
transaction_hash: 'tx-b',
})
const result = await rawOperationsToOperations([opA, opFeeOnly, opB], ADDR, ACCOUNT_ID, 0)
expect(result.map((o) => `${o.tx.hash}:${o.type}`)).toEqual([
'tx-a:OUT',
'tx-fees:FEES',
'tx-b:IN',
])
})
it('should return an empty array when given no operations', async () => {
expect(await rawOperationsToOperations([], ADDR, ACCOUNT_ID, 0)).toEqual([])
})
})
})