diff --git a/src/blockchain/soroban/soroban.service.ts b/src/blockchain/soroban/soroban.service.ts index 2962c03..6d5275d 100644 --- a/src/blockchain/soroban/soroban.service.ts +++ b/src/blockchain/soroban/soroban.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; +import { TransactionBuilderService } from '../../stellar/transaction-builder.service'; /** * Low-level Soroban RPC client for interacting with smart contracts on the Stellar network. @@ -12,7 +13,10 @@ export class SorobanService { private readonly server: StellarSdk.SorobanRpc.Server; private readonly networkPassphrase: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly transactionBuilderService: TransactionBuilderService, + ) { const rpcUrl = this.configService.get('STELLAR_SOROBAN_URL') || 'https://soroban-testnet.stellar.org'; @@ -55,28 +59,34 @@ export class SorobanService { const account = new StellarSdk.Account(sourcePublic, '0'); - const tx = new StellarSdk.TransactionBuilder(account, { - fee: '100', - networkPassphrase: this.networkPassphrase, - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(30) - .build(); + return this.transactionBuilderService.buildWithFeeRetry( + 1, + `${method} simulation`, + async (fee) => { + const tx = new StellarSdk.TransactionBuilder(account, { + fee, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); - const simulation = await this.server.simulateTransaction(tx); + const simulation = await this.server.simulateTransaction(tx); - if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { - const errorMsg = - (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || - 'Unknown simulation error'; - throw new Error(`Soroban simulation failed: ${errorMsg}`); - } + if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { + const errorMsg = + (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || + 'Unknown simulation error'; + throw new Error(`Soroban simulation failed: ${errorMsg}`); + } - const successResult = simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse; - if (!successResult.result) { - throw new Error('Soroban simulation returned no result'); - } + const successResult = simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse; + if (!successResult.result) { + throw new Error('Soroban simulation returned no result'); + } - return successResult.result.retval; + return successResult.result.retval; + }, + ); } } diff --git a/src/modules/transactions/transactions.service.ts b/src/modules/transactions/transactions.service.ts index c5baa08..d5f5c27 100644 --- a/src/modules/transactions/transactions.service.ts +++ b/src/modules/transactions/transactions.service.ts @@ -12,6 +12,7 @@ import { ConfigService } from '@nestjs/config'; import { Cache } from 'cache-manager'; import * as StellarSdk from 'stellar-sdk'; import { SupabaseService } from '../../database/supabase.client'; +import { TransactionBuilderService } from '../../stellar/transaction-builder.service'; import { SubmitTransactionRequestDto, TransactionType } from './dto/submit-transaction-request.dto'; import { SubmitTransactionResponseDto } from './dto/submit-transaction-response.dto'; import { @@ -71,6 +72,7 @@ export class TransactionsService { @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, private readonly configService: ConfigService, private readonly supabaseService: SupabaseService, + private readonly transactionBuilderService: TransactionBuilderService, ) { const horizonUrl = this.configService.get('STELLAR_HORIZON_URL') || @@ -92,7 +94,10 @@ export class TransactionsService { let transactionHash: string; try { - const horizonResult = await this.horizonServer.submitTransaction(transaction); + const horizonResult = await this.transactionBuilderService.submitWithFeeRetry( + transaction, + (retryTransaction) => this.horizonServer.submitTransaction(retryTransaction), + ); transactionHash = horizonResult.hash; } catch (error) { this.handleHorizonError(error); diff --git a/src/stellar/contracts/clients/creditline.client.ts b/src/stellar/contracts/clients/creditline.client.ts index 48070e5..9866448 100644 --- a/src/stellar/contracts/clients/creditline.client.ts +++ b/src/stellar/contracts/clients/creditline.client.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; import { SorobanService } from '../../../blockchain/soroban/soroban.service'; +import { TransactionBuilderService } from '../../transaction-builder.service'; import { CreateLoanParams, CREDIT_LINE_CONTRACT_ID_KEY } from '../interfaces/creditline.interface'; import { ContractNotConfiguredError, @@ -15,6 +16,7 @@ export class CreditLineContractClient { constructor( private readonly sorobanService: SorobanService, + private readonly transactionBuilderService: TransactionBuilderService, private readonly configService: ConfigService, ) { this.contractId = @@ -43,26 +45,32 @@ export class CreditLineContractClient { const loanAmount = this.toContractAmount(params.loanAmount); const guarantee = this.toContractAmount(params.guarantee); - const tx = new StellarSdk.TransactionBuilder(sourceAccount, { - fee: '100', - networkPassphrase, - }) - .addOperation( - contract.call( - 'create_loan', - StellarSdk.nativeToScVal(params.loanId, { type: 'string' }), - StellarSdk.nativeToScVal(params.vendorId, { type: 'string' }), - StellarSdk.nativeToScVal(amount, { type: 'i128' }), - StellarSdk.nativeToScVal(loanAmount, { type: 'i128' }), - StellarSdk.nativeToScVal(guarantee, { type: 'i128' }), - StellarSdk.nativeToScVal(params.interestRate, { type: 'u32' }), - StellarSdk.nativeToScVal(params.term, { type: 'u32' }), - ), - ) - .setTimeout(30) - .build(); + const prepared = await this.transactionBuilderService.buildWithFeeRetry( + 1, + 'create_loan transaction', + async (fee) => { + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee, + networkPassphrase, + }) + .addOperation( + contract.call( + 'create_loan', + StellarSdk.nativeToScVal(params.loanId, { type: 'string' }), + StellarSdk.nativeToScVal(params.vendorId, { type: 'string' }), + StellarSdk.nativeToScVal(amount, { type: 'i128' }), + StellarSdk.nativeToScVal(loanAmount, { type: 'i128' }), + StellarSdk.nativeToScVal(guarantee, { type: 'i128' }), + StellarSdk.nativeToScVal(params.interestRate, { type: 'u32' }), + StellarSdk.nativeToScVal(params.term, { type: 'u32' }), + ), + ) + .setTimeout(30) + .build(); - const prepared = await server.prepareTransaction(tx); + return server.prepareTransaction(tx); + }, + ); return prepared.toXDR(); } @@ -83,15 +91,21 @@ export class CreditLineContractClient { const sourceKeypair = StellarSdk.Keypair.random(); const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); - const tx = new StellarSdk.TransactionBuilder(sourceAccount, { - fee: StellarSdk.BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('repay_loan', userArg, loanIdArg, amountArg)) - .setTimeout(300) - .build(); + const prepared = await this.transactionBuilderService.buildWithFeeRetry( + 1, + 'repay_loan transaction', + async (fee) => { + const tx = new StellarSdk.TransactionBuilder(sourceAccount, { + fee, + networkPassphrase, + }) + .addOperation(contract.call('repay_loan', userArg, loanIdArg, amountArg)) + .setTimeout(300) + .build(); - const prepared = await server.prepareTransaction(tx); + return server.prepareTransaction(tx); + }, + ); return prepared.toXDR(); } catch (error) { if (error instanceof ContractNotConfiguredError) { diff --git a/src/stellar/contracts/clients/liquidity-pool.client.ts b/src/stellar/contracts/clients/liquidity-pool.client.ts index b3c0d42..843c7cb 100644 --- a/src/stellar/contracts/clients/liquidity-pool.client.ts +++ b/src/stellar/contracts/clients/liquidity-pool.client.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; import { SorobanService } from '../../../blockchain/soroban/soroban.service'; +import { TransactionBuilderService } from '../../transaction-builder.service'; import { PoolStats, LIQUIDITY_POOL_CONTRACT_ID_KEY } from '../interfaces/liquidity-pool.interface'; import { ContractNotConfiguredError, @@ -20,6 +21,7 @@ export class LiquidityPoolContractClient { constructor( private readonly sorobanService: SorobanService, + private readonly transactionBuilderService: TransactionBuilderService, private readonly configService: ConfigService, ) { this.contractId = @@ -172,23 +174,36 @@ export class LiquidityPoolContractClient { const sourceKeypair = StellarSdk.Keypair.random(); const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); - const tx = new StellarSdk.TransactionBuilder(sourceAccount, { - fee: StellarSdk.BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('deposit', userArg, amountArg)) - .setTimeout(300) - .build(); - - const simulation = await server.simulateTransaction(tx); - - if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { - const errorMsg = - (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || - 'Unknown simulation error'; - this.logger.error(`deposit simulation failed: ${errorMsg}`); - throw new ContractSimulationError('deposit'); - } + const buildTransaction = (fee: string): StellarSdk.Transaction => + new StellarSdk.TransactionBuilder(sourceAccount, { + fee, + networkPassphrase, + }) + .addOperation(contract.call('deposit', userArg, amountArg)) + .setTimeout(300) + .build(); + + const { tx, simulation } = await this.transactionBuilderService.buildWithFeeRetry( + 1, + 'deposit transaction', + async (fee) => { + const tx = buildTransaction(fee); + const simulation = await server.simulateTransaction(tx); + + if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { + const errorMsg = + (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || + 'Unknown simulation error'; + if (this.transactionBuilderService.isInsufficientFeeError(errorMsg)) { + throw new Error(errorMsg); + } + this.logger.error(`deposit simulation failed: ${errorMsg}`); + throw new ContractSimulationError('deposit'); + } + + return { tx, simulation }; + }, + ); const assembledTx = StellarSdk.SorobanRpc.assembleTransaction( tx, @@ -224,23 +239,36 @@ export class LiquidityPoolContractClient { const sourceKeypair = StellarSdk.Keypair.random(); const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0'); - const tx = new StellarSdk.TransactionBuilder(sourceAccount, { - fee: StellarSdk.BASE_FEE, - networkPassphrase, - }) - .addOperation(contract.call('withdraw', userArg, sharesArg)) - .setTimeout(300) - .build(); - - const simulation = await server.simulateTransaction(tx); - - if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { - const errorMsg = - (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || - 'Unknown simulation error'; - this.logger.error(`withdraw simulation failed: ${errorMsg}`); - throw new ContractSimulationError('withdraw'); - } + const buildTransaction = (fee: string): StellarSdk.Transaction => + new StellarSdk.TransactionBuilder(sourceAccount, { + fee, + networkPassphrase, + }) + .addOperation(contract.call('withdraw', userArg, sharesArg)) + .setTimeout(300) + .build(); + + const { tx, simulation } = await this.transactionBuilderService.buildWithFeeRetry( + 1, + 'withdraw transaction', + async (fee) => { + const tx = buildTransaction(fee); + const simulation = await server.simulateTransaction(tx); + + if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) { + const errorMsg = + (simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error || + 'Unknown simulation error'; + if (this.transactionBuilderService.isInsufficientFeeError(errorMsg)) { + throw new Error(errorMsg); + } + this.logger.error(`withdraw simulation failed: ${errorMsg}`); + throw new ContractSimulationError('withdraw'); + } + + return { tx, simulation }; + }, + ); const assembledTx = StellarSdk.SorobanRpc.assembleTransaction( tx, diff --git a/src/stellar/stellar.module.ts b/src/stellar/stellar.module.ts index 702699e..81346a1 100644 --- a/src/stellar/stellar.module.ts +++ b/src/stellar/stellar.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { SorobanService } from '../blockchain/soroban/soroban.service'; +import { StellarService } from './stellar.service'; +import { TransactionBuilderService } from './transaction-builder.service'; import { CreditLineContractClient } from './contracts/clients/creditline.client'; import { ReputationContractClient } from './contracts/clients/reputation.client'; import { LiquidityPoolContractClient } from './contracts/clients/liquidity-pool.client'; @@ -10,6 +12,8 @@ import { ParametersContractClient } from './contracts/clients/parameters.client' @Module({ imports: [ConfigModule], providers: [ + StellarService, + TransactionBuilderService, SorobanService, CreditLineContractClient, ReputationContractClient, @@ -18,6 +22,8 @@ import { ParametersContractClient } from './contracts/clients/parameters.client' ParametersContractClient, ], exports: [ + StellarService, + TransactionBuilderService, SorobanService, CreditLineContractClient, ReputationContractClient, diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts new file mode 100644 index 0000000..8ea78b0 --- /dev/null +++ b/src/stellar/stellar.service.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; + +@Injectable() +export class StellarService { + private readonly logger = new Logger(StellarService.name); + private readonly horizonServer: StellarSdk.Horizon.Server; + private readonly networkPassphrase: string; + + constructor(private readonly configService: ConfigService) { + const horizonUrl = + this.configService.get('STELLAR_HORIZON_URL') || + 'https://horizon-testnet.stellar.org'; + + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + this.logger.log(`Stellar Horizon client initialized: ${horizonUrl}`); + } + + getHorizonServer(): StellarSdk.Horizon.Server { + return this.horizonServer; + } + + getNetworkPassphrase(): string { + return this.networkPassphrase; + } +} diff --git a/src/stellar/transaction-builder.service.ts b/src/stellar/transaction-builder.service.ts new file mode 100644 index 0000000..5815400 --- /dev/null +++ b/src/stellar/transaction-builder.service.ts @@ -0,0 +1,268 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { StellarService } from './stellar.service'; + +interface CachedFeeStats { + stats: FeeStats; + expiresAt: number; +} + +interface FeeStats { + lastLedgerBaseFee: bigint; + feeChargedP95: bigint; +} + +interface FeeBumpTransactionOptions { + baseFee?: string; + percentile?: number; +} + +@Injectable() +export class TransactionBuilderService { + private readonly logger = new Logger(TransactionBuilderService.name); + private readonly horizonServer: StellarSdk.Horizon.Server; + private readonly networkPassphrase: string; + private feeStatsCache: CachedFeeStats | null = null; + + private readonly feeStatsCacheTtlMs = 15_000; + private readonly maxBuildAttempts = 2; + private readonly maxSubmitAttempts = 1; + private readonly feeRetryMultipliers = [125, 150, 200]; + + constructor( + private readonly configService: ConfigService, + private readonly stellarService: StellarService, + ) { + this.horizonServer = this.stellarService.getHorizonServer(); + this.networkPassphrase = this.stellarService.getNetworkPassphrase(); + } + + getNetworkPassphrase(): string { + return this.networkPassphrase; + } + + async estimateBaseFee(percentile = 95): Promise { + const stats = await this.getFeeStats(); + const baseFee = this.max([ + BigInt(StellarSdk.BASE_FEE), + stats.lastLedgerBaseFee, + stats.feeChargedP95, + ]); + + return baseFee.toString(); + } + + async estimateFee(operationCount: number, percentile = 95): Promise { + const normalizedOperationCount = Math.max(1, Math.ceil(operationCount)); + const baseFee = BigInt(await this.estimateBaseFee(percentile)); + return (baseFee * BigInt(normalizedOperationCount)).toString(); + } + + async estimateFeeForTransaction( + transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + percentile = 95, + ): Promise { + return this.estimateFee(this.getTransactionOperationCount(transaction), percentile); + } + + async buildFeeBumpTransaction( + innerXdr: string, + options: FeeBumpTransactionOptions = {}, + ): Promise { + const parsed = StellarSdk.TransactionBuilder.fromXDR(innerXdr, this.networkPassphrase); + + if (this.isFeeBumpTransaction(parsed)) { + throw new Error('Cannot fee bump an already fee-bumped transaction.'); + } + + const innerTransaction = parsed; + const baseFee = + options.baseFee ?? (await this.estimateBaseFee(options.percentile ?? 95)); + const minimumBaseFee = this.getInnerBaseFee(innerTransaction); + const normalizedBaseFee = this.max([ + BigInt(StellarSdk.BASE_FEE), + BigInt(baseFee), + minimumBaseFee, + ]).toString(); + const feeSourceSecret = + this.configService.get('STELLAR_FEE_BUMP_SECRET') || + this.configService.get('STELLAR_FEE_SOURCE_SECRET'); + + if (!feeSourceSecret) { + throw new Error('STELLAR_FEE_BUMP_SECRET is not configured.'); + } + + const feeSourceKeypair = StellarSdk.Keypair.fromSecret(feeSourceSecret); + const feeBumpTransaction = StellarSdk.TransactionBuilder.buildFeeBumpTransaction( + feeSourceKeypair, + normalizedBaseFee, + innerTransaction, + this.networkPassphrase, + ); + + feeBumpTransaction.sign(feeSourceKeypair); + return feeBumpTransaction.toXDR(); + } + + async buildWithFeeRetry( + operationCount: number, + label: string, + build: (fee: string) => Promise, + ): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= this.maxBuildAttempts; attempt++) { + const percentile = attempt === 0 ? 95 : 99; + const fee = await this.estimateFee(operationCount, percentile); + + try { + return await build(fee); + } catch (error) { + lastError = error; + + if (!this.isInsufficientFeeError(error) || attempt >= this.maxBuildAttempts) { + throw error; + } + + this.logger.warn(`${label} failed with an insufficient fee; retrying with a higher fee`); + } + } + + throw new Error(`Failed to build ${label} after fee retries`); + } + + async submitWithFeeRetry( + transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + submit: ( + transactionToSubmit: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + ) => Promise, + ): Promise { + let currentTransaction = transaction; + let lastError: unknown; + + for (let attempt = 0; attempt <= this.maxSubmitAttempts; attempt++) { + try { + return await submit(currentTransaction); + } catch (error) { + lastError = error; + + if ( + !this.isInsufficientFeeError(error) || + attempt >= this.maxSubmitAttempts || + this.isFeeBumpTransaction(currentTransaction) + ) { + throw error; + } + + const baseFee = this.scaleBaseFee( + await this.estimateBaseFee(attempt === 0 ? 95 : 99), + this.feeRetryMultipliers[attempt], + ); + currentTransaction = StellarSdk.TransactionBuilder.fromXDR( + await this.buildFeeBumpTransaction(currentTransaction.toXDR(), { baseFee }), + this.networkPassphrase, + ); + } + } + + throw lastError; + } + + isInsufficientFeeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + + return ( + normalized.includes('tx_insufficient_fee') || + normalized.includes('insufficient fee') || + normalized.includes('fee is too low') + ); + } + + private async getFeeStats(): Promise { + const now = Date.now(); + + if (this.feeStatsCache && this.feeStatsCache.expiresAt > now) { + return this.feeStatsCache.stats; + } + + try { + const response = await this.horizonServer.feeStats(); + const stats = { + lastLedgerBaseFee: this.parseStroopValue( + response.last_ledger_base_fee, + BigInt(StellarSdk.BASE_FEE), + ), + feeChargedP95: this.parseStroopValue( + response.fee_charged.p95, + BigInt(StellarSdk.BASE_FEE), + ), + }; + + this.feeStatsCache = { + stats, + expiresAt: now + this.feeStatsCacheTtlMs, + }; + + return stats; + } catch (error) { + this.logger.warn(`Horizon fee_stats unavailable; falling back to base fee: ${error.message}`); + const fallback = BigInt(StellarSdk.BASE_FEE); + + return { + lastLedgerBaseFee: fallback, + feeChargedP95: fallback, + }; + } + } + + private parseStroopValue(value: unknown, fallback: bigint): bigint { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return BigInt(Math.round(value)); + } + + if (typeof value === 'string') { + const normalized = value.trim(); + + if (/^\d+$/.test(normalized)) { + return BigInt(normalized); + } + } + + return fallback; + } + + private getInnerBaseFee(transaction: StellarSdk.Transaction): bigint { + const operationCount = BigInt(Math.max(1, transaction.operations.length)); + const fee = BigInt(transaction.fee); + + return (fee + operationCount - 1n) / operationCount; + } + + private getTransactionOperationCount( + transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + ): number { + if (this.isFeeBumpTransaction(transaction)) { + return transaction.innerTransaction.operations.length + 1; + } + + return transaction.operations.length; + } + + private isFeeBumpTransaction( + transaction: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, + ): transaction is StellarSdk.FeeBumpTransaction { + return 'innerTransaction' in transaction; + } + + private scaleBaseFee(baseFee: string, multiplier: number): string { + const fee = BigInt(baseFee); + + return ((fee * BigInt(multiplier) + 99n) / 100n).toString(); + } + + private max(values: bigint[]): bigint { + return values.reduce((current, value) => (value > current ? value : current), 0n); + } +}