@ledgerhq/coin-stellar
Version:
Ledger Stellar Coin integration
177 lines (165 loc) • 5.7 kB
text/typescript
// SPDX-FileCopyrightText: © 2026 LEDGER SAS
// SPDX-License-Identifier: Apache-2.0
import { rejectBalanceOptions } from '@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions'
import {
CoinModuleApi,
BalanceOptions,
CraftedTransaction,
Cursor,
FeeEstimation,
ListOperationsOptions,
Operation,
Page,
Reward,
Stake,
TransactionIntent,
Validator,
} from '@ledgerhq/coin-module-framework/api/index'
import { craftTransactionData } from '@ledgerhq/coin-module-framework/logic/craftTransactionData'
import { xdr } from '@stellar/stellar-sdk'
import coinConfig, { type StellarConfig } from '../config'
import {
broadcast,
combine,
craftTransaction,
estimateFees,
getBalance,
getBlock,
getBlockInfo,
lastBlock,
validateIntent,
} from '../logic'
import { listOperations } from '../logic'
import { validateAddress } from '../logic/validateAddress'
import { fetchSequence, registerHorizonInterceptors } from '../network'
import { StellarMemo } from '../types'
export function createApi(config: StellarConfig): CoinModuleApi<StellarMemo> {
coinConfig.setCoinConfig(() => ({ ...config, status: { type: 'active' } }))
registerHorizonInterceptors()
return {
broadcast,
combine: compose,
craftTransaction: craft,
craftRawTransaction: (
_transaction: string,
_sender: string,
_publicKey: string,
_sequence: bigint
): Promise<CraftedTransaction> => {
throw new Error('craftRawTransaction is not supported')
},
estimateFees: estimate,
getBalance: (address: string, options?: BalanceOptions) =>
rejectBalanceOptions(() => getBalance(address), options),
lastBlock,
listOperations: operations,
getBlock,
getBlockInfo,
getStakes(_address: string, _cursor?: Cursor): Promise<Page<Stake>> {
throw new Error('getStakes is not supported')
},
getRewards(_address: string, _cursor?: Cursor): Promise<Page<Reward>> {
throw new Error('getRewards is not supported')
},
validateIntent,
getNextSequence: async (address: string) => {
const sequence = await fetchSequence(address)
return BigInt(sequence.plus(1).toFixed())
},
getValidators(_cursor?: Cursor): Promise<Page<Validator>> {
throw new Error('getValidators is not supported')
},
validateAddress,
craftTransactionData,
}
}
async function craft(
transactionIntent: TransactionIntent<StellarMemo>,
customFees?: FeeEstimation
): Promise<CraftedTransaction> {
const fees = customFees?.value || (await estimateFees())
// NOTE: check how many memos, throw if more than one?
// if (transactionIntent.memos && transactionIntent.memos.length > 1) {
// throw new Error("Stellar only supports one memo per transaction.");
// }
const memo = 'memo' in transactionIntent ? transactionIntent.memo : undefined
const hasMemoValue = memo && memo.type !== 'NO_MEMO'
const tx = await craftTransaction(
{ address: transactionIntent.sender },
{
type: transactionIntent.type,
recipient: transactionIntent.recipient,
amount: transactionIntent.amount,
fee: fees,
...(transactionIntent.asset.type !== 'native' && 'assetReference' in transactionIntent.asset
? {
assetCode: transactionIntent.asset.assetReference,
assetIssuer: transactionIntent.asset.assetOwner,
}
: {}),
memoType: memo?.type,
...(hasMemoValue ? { memoValue: (memo as { value: string }).value } : {}),
}
)
// Note: the API returns the signature base, not the full XDR, see BACK-8727 for more context
return { transaction: tx.signatureBase }
}
function compose(tx: string, signature: string, pubkey?: string): string {
if (!pubkey) {
throw new Error('Missing pubkey')
}
// note: accept here `TransactionEnvelope` or `TransactionSignaturePayload`, see BACK-8727 for more context
return combine(envelopeFromAnyXDR(tx, 'base64'), signature, pubkey)
}
async function estimate(_transactionIntent: TransactionIntent): Promise<FeeEstimation> {
const value = await estimateFees()
return { value }
}
async function operations(
address: string,
options: ListOperationsOptions
): Promise<Page<Operation>> {
const { items, next } = await listOperations(address, options)
return { items, next: next || undefined }
}
/**
* Deserialize a transaction envelope, also accepting transaction signature payload form.
*
* @param input serialized `TransactionEnvelope` or `TransactionSignaturePayload`
* @param format serialization encoding
*/
export function envelopeFromAnyXDR(
input: string,
format: 'hex' | 'base64'
): xdr.TransactionEnvelope {
try {
return xdr.TransactionEnvelope.fromXDR(input, format)
} catch (envelopeError) {
try {
return signatureBaseToEnvelope(xdr.TransactionSignaturePayload.fromXDR(input, format))
} catch (signatureBaseError) {
throw new Error(
`Failed decoding transaction as an envelope (${envelopeError}) or as a signature base: (${signatureBaseError})`
)
}
}
}
/**
* Convert a `TransactionSignaturePayload` into a `TransactionEnvelope`.
*
* @param signatureBase deserialized `TransactionSignaturePayload`
*/
function signatureBaseToEnvelope(
signatureBase: xdr.TransactionSignaturePayload
): xdr.TransactionEnvelope {
const tx = signatureBase.taggedTransaction().value()
if (tx instanceof xdr.Transaction) {
return xdr.TransactionEnvelope.envelopeTypeTx(
new xdr.TransactionV1Envelope({ tx, signatures: [] })
)
} else {
return xdr.TransactionEnvelope.envelopeTypeTxFeeBump(
new xdr.FeeBumpTransactionEnvelope({ tx, signatures: [] })
)
}
}