UNPKG

@ledgerhq/coin-stellar

Version:
269 lines (236 loc) 8.99 kB
// SPDX-FileCopyrightText: © 2026 LEDGER SAS // SPDX-License-Identifier: Apache-2.0 import type { CoinModuleApi, Operation } from '@ledgerhq/coin-module-framework/api/index' import { xdr } from '@stellar/stellar-sdk' import { StellarMemo } from '../types' import { createApi, envelopeFromAnyXDR } from '.' /** * Mainnet explorer: https://stellar.expert/explorer/public */ describe('Stellar Api', () => { let module: CoinModuleApi<StellarMemo> const ADDRESS = 'GBAMU3EJX6KLW2JEIAIEAYNLPKHFPJR6OYQYX5HPYB3CVQ6QD4XUJ23J' beforeAll(() => { module = createApi({ explorer: { url: 'https://stellar.coin.ledger.com', }, }) }) describe('estimateFees', () => { it('returns a default value', async () => { // Given const amount = BigInt(100_000) // When const result = await module.estimateFees({ intentType: 'transaction', asset: { type: 'native' }, type: 'send', sender: ADDRESS, recipient: 'address', amount: amount, memo: { type: 'NO_MEMO' }, }) expect(result.value).toBeGreaterThanOrEqual(100n) }) }) describe('listOperations', () => { let txs: Operation[] let nextCursor: string | undefined beforeAll(async () => { const result = await module.listOperations(ADDRESS, { minHeight: 0, order: 'asc', }) txs = result.items nextCursor = result.next }) it('returns a page of operations for the address', async () => { expect(txs.length).toBeGreaterThanOrEqual(1) txs.forEach((operation) => { const isSenderOrReceipt = operation.senders.includes(ADDRESS) || operation.recipients.includes(ADDRESS) expect(isSenderOrReceipt).toBe(true) expect(operation.value).toBeGreaterThanOrEqual(0) expect(operation.tx.hash).toMatch(/^[A-Fa-f0-9]{64}$/) expect(operation.tx.block.hash).toMatch(/^[A-Fa-f0-9]{64}$/) expect(operation.tx.block.height).toBeGreaterThanOrEqual(0) expect(operation.tx.fees).toBeGreaterThan(0) expect(operation.tx.date).toBeInstanceOf(Date) expect(operation.tx.feesPayer).toMatch(/^G[A-Z2-7]{55}$/) }) }) it('returns unique operations within the page', async () => { expect(txs.length).toBeGreaterThanOrEqual(1) const checkSet = new Set(txs.map((elt) => elt.id)) expect(checkSet.size).toEqual(txs.length) }) it('returns a cursor for pagination when more pages exist', async () => { // The test account has enough operations to fill at least one page expect(typeof nextCursor).toBe('string') }) it('supports cursor-based pagination', async () => { if (!nextCursor) return // skip if account has fewer ops than page size const page2 = await module.listOperations(ADDRESS, { minHeight: 0, order: 'asc', cursor: nextCursor, }) expect(page2.items.length).toBeGreaterThanOrEqual(1) // No overlap between pages const page1Ids = new Set(txs.map((op) => op.id)) page2.items.forEach((op) => { expect(page1Ids.has(op.id)).toBe(false) }) }) }) describe('lastBlock', () => { it('returns last block info', async () => { // When const result = await module.lastBlock() // Then expect(result.hash).toMatch(/^[A-Fa-f0-9]{64}$/) expect(result.height).toBeGreaterThan(0) expect(result.time).toBeInstanceOf(Date) }) }) describe('getBalance', () => { it('returns a list regarding address parameter', async () => { // When const result = await module.getBalance(ADDRESS) // Then expect(result).toBeInstanceOf(Array) expect(result[0]).toMatchObject({ value: expect.any(BigInt), asset: { type: 'native' }, }) expect(result[0].value).toBeGreaterThan(0) result.slice(1).forEach((balance) => { expect(balance.asset.type).not.toEqual('native') expect(balance.value).toBeGreaterThanOrEqual(0) }) }) it('returns 0 when address is not found', async () => { const result = await module.getBalance( 'GAJSV2O545Z6ZK7FTPW2GOYNKMYJMP2REUJV4AW6DSYTYUHVI3000000' ) expect(result).toEqual([{ value: BigInt(0), asset: { type: 'native' }, locked: 0n }]) }) }) describe('craftTransaction', () => { const TYPE = 'send' const RECIPIENT = 'GD6QELUZPSKPRWVXOQ3F6GBF4OBRMCHO5PHREXH4ZRTPJAG7V5MD7JGX' const AMOUNT = BigInt(1_000_000) function readFees(transactionXdr: string) { const transactionEnvelope = envelopeFromAnyXDR(transactionXdr, 'base64') return transactionEnvelope.value().tx().fee() } function readMemo(transactionXdr: string) { const transactionEnvelope = envelopeFromAnyXDR(transactionXdr, 'base64') return (transactionEnvelope.value().tx() as xdr.TransactionV0).memo() } it('returns a raw transaction', async () => { const { transaction: result } = await module.craftTransaction({ intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }) const envelope = envelopeFromAnyXDR(result, 'base64') expect(envelope.toXDR('base64').length).toEqual(188) }) it('should use estimated fees when user does not provide them for crafting a transaction', async () => { const { transaction: transactionXdr } = await module.craftTransaction({ intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }) const fees = readFees(transactionXdr) expect(fees).toBeGreaterThan(0) }) it('should use custom user fees when user provides it for crafting a transaction', async () => { const customFees = 99n const { transaction: transactionXdr } = await module.craftTransaction( { intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }, { value: customFees } ) const fees = readFees(transactionXdr) expect(fees).toEqual(Number(customFees)) }) it('should have no memo when not provided by user', async () => { const { transaction: transactionXdr } = await module.craftTransaction({ intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'NO_MEMO' }, }) expect(readMemo(transactionXdr)).toEqual(xdr.Memo.memoNone()) }) it('should have a memo when provided by user', async () => { const { transaction: transactionXdr } = await module.craftTransaction({ intentType: 'transaction', asset: { type: 'native' }, type: TYPE, sender: ADDRESS, recipient: RECIPIENT, amount: AMOUNT, memo: { type: 'MEMO_TEXT', value: 'test', }, }) expect(readMemo(transactionXdr)).toEqual(xdr.Memo.memoText(Buffer.from('test', 'ascii'))) }) }) /** * Signed transaction envelope from BACK-10960 / Jira (mainnet). * Live Horizon reports `tx_failed` with operation-level `op_underfunded`. */ describe('broadcast', () => { let broadcastModule: CoinModuleApi<StellarMemo> const tx = 'AAAAAgAAAABRUCgFba+DTbei2ifpyYt5w2Hh0VyZ+X9fayjIDne7YAAAAGQCkDOGAAAABQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAACEIuPfWXgM8WhyqjrpFdIcGV1SUVhMzPUm4YspNHF60QAAAAAAAAAAALkd2QAAAAAAAAABDne7YAAAAEAASzsT/yDIfCfEDstkfnznXjiN7rNd7PkKQEn+rRIFm9EHoirGfHipWoBdYMrc6ixQD/0y0of1piSid8TLiFAB' beforeAll(() => { broadcastModule = createApi({ explorer: { url: 'https://horizon.stellar.org/', }, }) }) it('maps Horizon transaction failure to StellarBroadcastFailedError with XDR context', async () => { await expect(broadcastModule.broadcast(tx)).rejects.toMatchObject({ name: 'StellarBroadcastFailedError', documentationSummary: 'The source account does not have enough balance to send the payment amount while maintaining its minimum reserve.', horizonTransactionCode: 'tx_failed', horizonOperationCodes: ['op_underfunded'], stellarDocUrl: 'https://developers.stellar.org/docs/data/apis/horizon/api-reference/errors/result-codes/transactions', decodedResultXdr: { feeChargedStroops: expect.stringMatching(/^\d+$/), resultSwitch: 'txFailed', }, envelopeXdr: tx, cause: expect.objectContaining({ name: 'AxiosError' }), }) }) }) })