diff --git a/src/config/configuration.ts b/src/config/configuration.ts index b6c1100..c8a9211 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,3 +1,19 @@ +export type FeePercentile = + | "min" + | "mode" + | "p10" + | "p20" + | "p30" + | "p40" + | "p50" + | "p60" + | "p70" + | "p80" + | "p90" + | "p95" + | "p99" + | "max"; + export interface AppConfig { nodeEnv: string; port: number; @@ -6,6 +22,7 @@ export interface AppConfig { sorobanRpcUrl: string; settlementContractId: string; solverRegistryContractId: string; + feePercentile: FeePercentile; }; corsOrigin: string; } @@ -18,6 +35,7 @@ export default (): AppConfig => ({ sorobanRpcUrl: process.env.SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org", settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "", solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "", + feePercentile: (process.env.STELLAR_FEE_PERCENTILE ?? "p50") as FeePercentile, }, corsOrigin: process.env.CORS_ORIGIN ?? "*", }); diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 67d377d..edbbbfc 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -1,10 +1,11 @@ import { Module } from "@nestjs/common"; import { SorobanController } from "./soroban.controller"; import { SorobanService } from "./soroban.service"; +import { StellarTxService } from "./stellar-tx.service"; @Module({ controllers: [SorobanController], - providers: [SorobanService], - exports: [SorobanService], + providers: [SorobanService, StellarTxService], + exports: [SorobanService, StellarTxService], }) export class SorobanModule {} diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index 0b32651..0e417f7 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { SorobanRpc } from "@stellar/stellar-sdk"; +import { FeeBumpTransaction, SorobanRpc, Transaction } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; @Injectable() @@ -27,4 +27,16 @@ export class SorobanService { getAccount(publicKey: string) { return this.server.getAccount(publicKey); } + + getFeeStats() { + return this.server.getFeeStats(); + } + + simulateTransaction(transaction: Transaction | FeeBumpTransaction) { + return this.server.simulateTransaction(transaction); + } + + prepareTransaction(transaction: Transaction | FeeBumpTransaction) { + return this.server.prepareTransaction(transaction); + } } diff --git a/src/soroban/stellar-tx.service.spec.ts b/src/soroban/stellar-tx.service.spec.ts new file mode 100644 index 0000000..77450c8 --- /dev/null +++ b/src/soroban/stellar-tx.service.spec.ts @@ -0,0 +1,134 @@ +import { + Account, + Asset, + BASE_FEE, + Keypair, + Networks, + Operation, + SorobanRpc, + Transaction, + TransactionBuilder, +} from "@stellar/stellar-sdk"; +import { ConfigService } from "@nestjs/config"; +import { StellarTxService } from "./stellar-tx.service"; +import { SorobanService } from "./soroban.service"; +import { AppConfig } from "../config/configuration"; + +function buildTestTransaction(fee = "100"): Transaction { + const keypair = Keypair.random(); + const account = new Account(keypair.publicKey(), "1"); + return new TransactionBuilder(account, { fee, networkPassphrase: Networks.TESTNET }) + .addOperation(Operation.payment({ destination: keypair.publicKey(), asset: Asset.native(), amount: "1" })) + .setTimeout(30) + .build(); +} + +function feeStats(sorobanInclusionFeeP50: string): SorobanRpc.Api.GetFeeStatsResponse { + const distribution = { + max: sorobanInclusionFeeP50, + min: sorobanInclusionFeeP50, + mode: sorobanInclusionFeeP50, + p10: sorobanInclusionFeeP50, + p20: sorobanInclusionFeeP50, + p30: sorobanInclusionFeeP50, + p40: sorobanInclusionFeeP50, + p50: sorobanInclusionFeeP50, + p60: sorobanInclusionFeeP50, + p70: sorobanInclusionFeeP50, + p80: sorobanInclusionFeeP50, + p90: sorobanInclusionFeeP50, + p95: sorobanInclusionFeeP50, + p99: sorobanInclusionFeeP50, + transactionCount: "1", + ledgerCount: 1, + }; + return { sorobanInclusionFee: distribution, inclusionFee: distribution, latestLedger: 1 }; +} + +function simulationSuccess(minResourceFee: string): SorobanRpc.Api.SimulateTransactionSuccessResponse { + return { + id: "1", + latestLedger: 1, + events: [], + _parsed: true, + minResourceFee, + transactionData: {} as SorobanRpc.Api.SimulateTransactionSuccessResponse["transactionData"], + cost: { cpuInsns: "0", memBytes: "0" }, + }; +} + +function simulationError(message: string): SorobanRpc.Api.SimulateTransactionErrorResponse { + return { id: "1", error: message, latestLedger: 1, events: [], _parsed: true }; +} + +describe("StellarTxService", () => { + let sorobanService: jest.Mocked>; + let configService: jest.Mocked, "get">>; + let service: StellarTxService; + + beforeEach(() => { + sorobanService = { + getFeeStats: jest.fn(), + simulateTransaction: jest.fn(), + prepareTransaction: jest.fn(), + }; + configService = { get: jest.fn().mockReturnValue("p50") }; + service = new StellarTxService( + sorobanService as unknown as SorobanService, + configService as unknown as ConfigService, + ); + }); + + describe("estimateBaseFee", () => { + it("returns the configured fee percentile from network fee stats", async () => { + sorobanService.getFeeStats.mockResolvedValue(feeStats("250")); + + await expect(service.estimateBaseFee()).resolves.toBe("250"); + }); + + it("falls back to BASE_FEE when the reported fee is 0", async () => { + sorobanService.getFeeStats.mockResolvedValue(feeStats("0")); + + await expect(service.estimateBaseFee()).resolves.toBe(BASE_FEE); + }); + + it("falls back to BASE_FEE when fee stats are unavailable", async () => { + sorobanService.getFeeStats.mockRejectedValue(new Error("rpc unavailable")); + + await expect(service.estimateBaseFee()).resolves.toBe(BASE_FEE); + }); + }); + + describe("estimateFee", () => { + it("combines the network base fee with the simulated resource fee", async () => { + sorobanService.getFeeStats.mockResolvedValue(feeStats("300")); + sorobanService.simulateTransaction.mockResolvedValue(simulationSuccess("45000")); + + const estimate = await service.estimateFee(buildTestTransaction()); + + expect(estimate).toEqual({ baseFee: "300", resourceFee: "45000", totalFee: "45300" }); + }); + + it("throws when simulation fails, without marking anything as prepared", async () => { + sorobanService.getFeeStats.mockResolvedValue(feeStats("300")); + sorobanService.simulateTransaction.mockResolvedValue(simulationError("boom")); + + await expect(service.estimateFee(buildTestTransaction())).rejects.toThrow(/simulation error: boom/); + expect(sorobanService.prepareTransaction).not.toHaveBeenCalled(); + }); + }); + + describe("prepareTransaction", () => { + it("submits the transaction with the estimated base fee applied", async () => { + sorobanService.getFeeStats.mockResolvedValue(feeStats("300")); + const prepared = buildTestTransaction("45300"); + sorobanService.prepareTransaction.mockResolvedValue(prepared); + + const result = await service.prepareTransaction(buildTestTransaction()); + + expect(result).toBe(prepared); + const [submittedTx] = sorobanService.prepareTransaction.mock.calls[0]; + expect((submittedTx as Transaction).fee).toBe("300"); + }); + }); +}); diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts new file mode 100644 index 0000000..66dc4f3 --- /dev/null +++ b/src/soroban/stellar-tx.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { BASE_FEE, FeeBumpTransaction, SorobanRpc, Transaction, TransactionBuilder } from "@stellar/stellar-sdk"; +import { AppConfig } from "../config/configuration"; +import { SorobanService } from "./soroban.service"; + +export interface FeeEstimate { + /** Classic inclusion fee, in stroops. */ + baseFee: string; + /** Soroban resource fee returned by simulation, in stroops. */ + resourceFee: string; + /** baseFee + resourceFee, in stroops. */ + totalFee: string; +} + +@Injectable() +export class StellarTxService { + private readonly logger = new Logger(StellarTxService.name); + private readonly feePercentile: AppConfig["stellar"]["feePercentile"]; + + constructor( + private readonly sorobanService: SorobanService, + configService: ConfigService, + ) { + this.feePercentile = configService.get("stellar.feePercentile", { infer: true }); + } + + /** + * Recommended classic inclusion fee based on recent network activity. + * Falls back to the network's minimum base fee if fee stats are unavailable + * or the reported fee is degenerate (e.g. an idle network reporting "0"). + */ + async estimateBaseFee(): Promise { + try { + const stats = await this.sorobanService.getFeeStats(); + const fee = stats.sorobanInclusionFee[this.feePercentile]; + return fee && fee !== "0" ? fee : BASE_FEE; + } catch (err) { + this.logger.warn( + `Failed to fetch Soroban fee stats, falling back to base fee ${BASE_FEE}: ${(err as Error).message}`, + ); + return BASE_FEE; + } + } + + /** + * Estimates the total fee (base + resource) required to submit `transaction` + * by simulating it against the network, instead of hardcoding a fee value. + */ + async estimateFee(transaction: Transaction): Promise { + const baseFee = await this.estimateBaseFee(); + const simulation = await this.sorobanService.simulateTransaction(this.withFee(transaction, baseFee)); + + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw new Error(`Fee estimation failed: transaction simulation error: ${simulation.error}`); + } + + const resourceFee = simulation.minResourceFee; + const totalFee = (BigInt(baseFee) + BigInt(resourceFee)).toString(); + + return { baseFee, resourceFee, totalFee }; + } + + /** + * Simulates `transaction` and returns it assembled with the estimated + * base + resource fee and Soroban transaction data, ready to sign. + */ + async prepareTransaction(transaction: Transaction): Promise { + const baseFee = await this.estimateBaseFee(); + const prepared = await this.sorobanService.prepareTransaction(this.withFee(transaction, baseFee)); + + this.logger.log(`Prepared transaction with fee ${prepared.fee} stroops (base fee ${baseFee})`); + + return prepared as Transaction; + } + + private withFee(transaction: Transaction | FeeBumpTransaction, fee: string): Transaction { + if ("innerTransaction" in transaction) { + throw new TypeError("fee bump transactions are not supported"); + } + + return TransactionBuilder.cloneFrom(transaction, { + fee, + networkPassphrase: transaction.networkPassphrase, + }).build(); + } +}