Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -6,6 +22,7 @@ export interface AppConfig {
sorobanRpcUrl: string;
settlementContractId: string;
solverRegistryContractId: string;
feePercentile: FeePercentile;
};
corsOrigin: string;
}
Expand All @@ -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 ?? "*",
});
5 changes: 3 additions & 2 deletions src/soroban/soroban.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
14 changes: 13 additions & 1 deletion src/soroban/soroban.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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);
}
}
134 changes: 134 additions & 0 deletions src/soroban/stellar-tx.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<SorobanService, "getFeeStats" | "simulateTransaction" | "prepareTransaction">>;
let configService: jest.Mocked<Pick<ConfigService<AppConfig, true>, "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<AppConfig, true>,
);
});

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");
});
});
});
87 changes: 87 additions & 0 deletions src/soroban/stellar-tx.service.ts
Original file line number Diff line number Diff line change
@@ -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<AppConfig, true>,
) {
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<string> {
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<FeeEstimate> {
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<Transaction> {
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();
}
}