Skip to content

Commit a52a442

Browse files
implemented XDR transaction builder with fee estimation
1 parent 58848c1 commit a52a442

7 files changed

Lines changed: 444 additions & 82 deletions

File tree

src/blockchain/soroban/soroban.service.ts

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Injectable, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import * as StellarSdk from 'stellar-sdk';
4+
import { TransactionBuilderService } from '../../stellar/transaction-builder.service';
45

56
/**
67
* Low-level Soroban RPC client for interacting with smart contracts on the Stellar network.
@@ -12,7 +13,10 @@ export class SorobanService {
1213
private readonly server: StellarSdk.SorobanRpc.Server;
1314
private readonly networkPassphrase: string;
1415

15-
constructor(private readonly configService: ConfigService) {
16+
constructor(
17+
private readonly configService: ConfigService,
18+
private readonly transactionBuilderService: TransactionBuilderService,
19+
) {
1620
const rpcUrl =
1721
this.configService.get<string>('STELLAR_SOROBAN_URL') ||
1822
'https://soroban-testnet.stellar.org';
@@ -55,28 +59,34 @@ export class SorobanService {
5559

5660
const account = new StellarSdk.Account(sourcePublic, '0');
5761

58-
const tx = new StellarSdk.TransactionBuilder(account, {
59-
fee: '100',
60-
networkPassphrase: this.networkPassphrase,
61-
})
62-
.addOperation(contract.call(method, ...args))
63-
.setTimeout(30)
64-
.build();
62+
return this.transactionBuilderService.buildWithFeeRetry(
63+
1,
64+
`${method} simulation`,
65+
async (fee) => {
66+
const tx = new StellarSdk.TransactionBuilder(account, {
67+
fee,
68+
networkPassphrase: this.networkPassphrase,
69+
})
70+
.addOperation(contract.call(method, ...args))
71+
.setTimeout(30)
72+
.build();
6573

66-
const simulation = await this.server.simulateTransaction(tx);
74+
const simulation = await this.server.simulateTransaction(tx);
6775

68-
if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) {
69-
const errorMsg =
70-
(simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error ||
71-
'Unknown simulation error';
72-
throw new Error(`Soroban simulation failed: ${errorMsg}`);
73-
}
76+
if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) {
77+
const errorMsg =
78+
(simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error ||
79+
'Unknown simulation error';
80+
throw new Error(`Soroban simulation failed: ${errorMsg}`);
81+
}
7482

75-
const successResult = simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse;
76-
if (!successResult.result) {
77-
throw new Error('Soroban simulation returned no result');
78-
}
83+
const successResult = simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionSuccessResponse;
84+
if (!successResult.result) {
85+
throw new Error('Soroban simulation returned no result');
86+
}
7987

80-
return successResult.result.retval;
88+
return successResult.result.retval;
89+
},
90+
);
8191
}
8292
}

src/modules/transactions/transactions.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ConfigService } from '@nestjs/config';
1212
import { Cache } from 'cache-manager';
1313
import * as StellarSdk from 'stellar-sdk';
1414
import { SupabaseService } from '../../database/supabase.client';
15+
import { TransactionBuilderService } from '../../stellar/transaction-builder.service';
1516
import { SubmitTransactionRequestDto, TransactionType } from './dto/submit-transaction-request.dto';
1617
import { SubmitTransactionResponseDto } from './dto/submit-transaction-response.dto';
1718
import {
@@ -71,6 +72,7 @@ export class TransactionsService {
7172
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
7273
private readonly configService: ConfigService,
7374
private readonly supabaseService: SupabaseService,
75+
private readonly transactionBuilderService: TransactionBuilderService,
7476
) {
7577
const horizonUrl =
7678
this.configService.get<string>('STELLAR_HORIZON_URL') ||
@@ -92,7 +94,10 @@ export class TransactionsService {
9294

9395
let transactionHash: string;
9496
try {
95-
const horizonResult = await this.horizonServer.submitTransaction(transaction);
97+
const horizonResult = await this.transactionBuilderService.submitWithFeeRetry(
98+
transaction,
99+
(retryTransaction) => this.horizonServer.submitTransaction(retryTransaction),
100+
);
96101
transactionHash = horizonResult.hash;
97102
} catch (error) {
98103
this.handleHorizonError(error);

src/stellar/contracts/clients/creditline.client.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import * as StellarSdk from 'stellar-sdk';
44
import { SorobanService } from '../../../blockchain/soroban/soroban.service';
5+
import { TransactionBuilderService } from '../../transaction-builder.service';
56
import { CreateLoanParams, CREDIT_LINE_CONTRACT_ID_KEY } from '../interfaces/creditline.interface';
67
import {
78
ContractNotConfiguredError,
@@ -15,6 +16,7 @@ export class CreditLineContractClient {
1516

1617
constructor(
1718
private readonly sorobanService: SorobanService,
19+
private readonly transactionBuilderService: TransactionBuilderService,
1820
private readonly configService: ConfigService,
1921
) {
2022
this.contractId =
@@ -43,26 +45,32 @@ export class CreditLineContractClient {
4345
const loanAmount = this.toContractAmount(params.loanAmount);
4446
const guarantee = this.toContractAmount(params.guarantee);
4547

46-
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
47-
fee: '100',
48-
networkPassphrase,
49-
})
50-
.addOperation(
51-
contract.call(
52-
'create_loan',
53-
StellarSdk.nativeToScVal(params.loanId, { type: 'string' }),
54-
StellarSdk.nativeToScVal(params.vendorId, { type: 'string' }),
55-
StellarSdk.nativeToScVal(amount, { type: 'i128' }),
56-
StellarSdk.nativeToScVal(loanAmount, { type: 'i128' }),
57-
StellarSdk.nativeToScVal(guarantee, { type: 'i128' }),
58-
StellarSdk.nativeToScVal(params.interestRate, { type: 'u32' }),
59-
StellarSdk.nativeToScVal(params.term, { type: 'u32' }),
60-
),
61-
)
62-
.setTimeout(30)
63-
.build();
48+
const prepared = await this.transactionBuilderService.buildWithFeeRetry(
49+
1,
50+
'create_loan transaction',
51+
async (fee) => {
52+
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
53+
fee,
54+
networkPassphrase,
55+
})
56+
.addOperation(
57+
contract.call(
58+
'create_loan',
59+
StellarSdk.nativeToScVal(params.loanId, { type: 'string' }),
60+
StellarSdk.nativeToScVal(params.vendorId, { type: 'string' }),
61+
StellarSdk.nativeToScVal(amount, { type: 'i128' }),
62+
StellarSdk.nativeToScVal(loanAmount, { type: 'i128' }),
63+
StellarSdk.nativeToScVal(guarantee, { type: 'i128' }),
64+
StellarSdk.nativeToScVal(params.interestRate, { type: 'u32' }),
65+
StellarSdk.nativeToScVal(params.term, { type: 'u32' }),
66+
),
67+
)
68+
.setTimeout(30)
69+
.build();
6470

65-
const prepared = await server.prepareTransaction(tx);
71+
return server.prepareTransaction(tx);
72+
},
73+
);
6674
return prepared.toXDR();
6775
}
6876

@@ -83,15 +91,21 @@ export class CreditLineContractClient {
8391
const sourceKeypair = StellarSdk.Keypair.random();
8492
const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0');
8593

86-
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
87-
fee: StellarSdk.BASE_FEE,
88-
networkPassphrase,
89-
})
90-
.addOperation(contract.call('repay_loan', userArg, loanIdArg, amountArg))
91-
.setTimeout(300)
92-
.build();
94+
const prepared = await this.transactionBuilderService.buildWithFeeRetry(
95+
1,
96+
'repay_loan transaction',
97+
async (fee) => {
98+
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
99+
fee,
100+
networkPassphrase,
101+
})
102+
.addOperation(contract.call('repay_loan', userArg, loanIdArg, amountArg))
103+
.setTimeout(300)
104+
.build();
93105

94-
const prepared = await server.prepareTransaction(tx);
106+
return server.prepareTransaction(tx);
107+
},
108+
);
95109
return prepared.toXDR();
96110
} catch (error) {
97111
if (error instanceof ContractNotConfiguredError) {

src/stellar/contracts/clients/liquidity-pool.client.ts

Lines changed: 62 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import * as StellarSdk from 'stellar-sdk';
44
import { SorobanService } from '../../../blockchain/soroban/soroban.service';
5+
import { TransactionBuilderService } from '../../transaction-builder.service';
56
import { PoolStats, LIQUIDITY_POOL_CONTRACT_ID_KEY } from '../interfaces/liquidity-pool.interface';
67
import {
78
ContractNotConfiguredError,
@@ -20,6 +21,7 @@ export class LiquidityPoolContractClient {
2021

2122
constructor(
2223
private readonly sorobanService: SorobanService,
24+
private readonly transactionBuilderService: TransactionBuilderService,
2325
private readonly configService: ConfigService,
2426
) {
2527
this.contractId =
@@ -172,23 +174,36 @@ export class LiquidityPoolContractClient {
172174
const sourceKeypair = StellarSdk.Keypair.random();
173175
const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0');
174176

175-
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
176-
fee: StellarSdk.BASE_FEE,
177-
networkPassphrase,
178-
})
179-
.addOperation(contract.call('deposit', userArg, amountArg))
180-
.setTimeout(300)
181-
.build();
182-
183-
const simulation = await server.simulateTransaction(tx);
184-
185-
if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) {
186-
const errorMsg =
187-
(simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error ||
188-
'Unknown simulation error';
189-
this.logger.error(`deposit simulation failed: ${errorMsg}`);
190-
throw new ContractSimulationError('deposit');
191-
}
177+
const buildTransaction = (fee: string): StellarSdk.Transaction =>
178+
new StellarSdk.TransactionBuilder(sourceAccount, {
179+
fee,
180+
networkPassphrase,
181+
})
182+
.addOperation(contract.call('deposit', userArg, amountArg))
183+
.setTimeout(300)
184+
.build();
185+
186+
const { tx, simulation } = await this.transactionBuilderService.buildWithFeeRetry(
187+
1,
188+
'deposit transaction',
189+
async (fee) => {
190+
const tx = buildTransaction(fee);
191+
const simulation = await server.simulateTransaction(tx);
192+
193+
if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) {
194+
const errorMsg =
195+
(simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error ||
196+
'Unknown simulation error';
197+
if (this.transactionBuilderService.isInsufficientFeeError(errorMsg)) {
198+
throw new Error(errorMsg);
199+
}
200+
this.logger.error(`deposit simulation failed: ${errorMsg}`);
201+
throw new ContractSimulationError('deposit');
202+
}
203+
204+
return { tx, simulation };
205+
},
206+
);
192207

193208
const assembledTx = StellarSdk.SorobanRpc.assembleTransaction(
194209
tx,
@@ -224,23 +239,36 @@ export class LiquidityPoolContractClient {
224239
const sourceKeypair = StellarSdk.Keypair.random();
225240
const sourceAccount = new StellarSdk.Account(sourceKeypair.publicKey(), '0');
226241

227-
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
228-
fee: StellarSdk.BASE_FEE,
229-
networkPassphrase,
230-
})
231-
.addOperation(contract.call('withdraw', userArg, sharesArg))
232-
.setTimeout(300)
233-
.build();
234-
235-
const simulation = await server.simulateTransaction(tx);
236-
237-
if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) {
238-
const errorMsg =
239-
(simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error ||
240-
'Unknown simulation error';
241-
this.logger.error(`withdraw simulation failed: ${errorMsg}`);
242-
throw new ContractSimulationError('withdraw');
243-
}
242+
const buildTransaction = (fee: string): StellarSdk.Transaction =>
243+
new StellarSdk.TransactionBuilder(sourceAccount, {
244+
fee,
245+
networkPassphrase,
246+
})
247+
.addOperation(contract.call('withdraw', userArg, sharesArg))
248+
.setTimeout(300)
249+
.build();
250+
251+
const { tx, simulation } = await this.transactionBuilderService.buildWithFeeRetry(
252+
1,
253+
'withdraw transaction',
254+
async (fee) => {
255+
const tx = buildTransaction(fee);
256+
const simulation = await server.simulateTransaction(tx);
257+
258+
if (StellarSdk.SorobanRpc.Api.isSimulationError(simulation)) {
259+
const errorMsg =
260+
(simulation as StellarSdk.SorobanRpc.Api.SimulateTransactionErrorResponse).error ||
261+
'Unknown simulation error';
262+
if (this.transactionBuilderService.isInsufficientFeeError(errorMsg)) {
263+
throw new Error(errorMsg);
264+
}
265+
this.logger.error(`withdraw simulation failed: ${errorMsg}`);
266+
throw new ContractSimulationError('withdraw');
267+
}
268+
269+
return { tx, simulation };
270+
},
271+
);
244272

245273
const assembledTx = StellarSdk.SorobanRpc.assembleTransaction(
246274
tx,

src/stellar/stellar.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { SorobanService } from '../blockchain/soroban/soroban.service';
4+
import { StellarService } from './stellar.service';
5+
import { TransactionBuilderService } from './transaction-builder.service';
46
import { CreditLineContractClient } from './contracts/clients/creditline.client';
57
import { ReputationContractClient } from './contracts/clients/reputation.client';
68
import { LiquidityPoolContractClient } from './contracts/clients/liquidity-pool.client';
@@ -10,6 +12,8 @@ import { ParametersContractClient } from './contracts/clients/parameters.client'
1012
@Module({
1113
imports: [ConfigModule],
1214
providers: [
15+
StellarService,
16+
TransactionBuilderService,
1317
SorobanService,
1418
CreditLineContractClient,
1519
ReputationContractClient,
@@ -18,6 +22,8 @@ import { ParametersContractClient } from './contracts/clients/parameters.client'
1822
ParametersContractClient,
1923
],
2024
exports: [
25+
StellarService,
26+
TransactionBuilderService,
2127
SorobanService,
2228
CreditLineContractClient,
2329
ReputationContractClient,

src/stellar/stellar.service.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import * as StellarSdk from 'stellar-sdk';
4+
5+
@Injectable()
6+
export class StellarService {
7+
private readonly logger = new Logger(StellarService.name);
8+
private readonly horizonServer: StellarSdk.Horizon.Server;
9+
private readonly networkPassphrase: string;
10+
11+
constructor(private readonly configService: ConfigService) {
12+
const horizonUrl =
13+
this.configService.get<string>('STELLAR_HORIZON_URL') ||
14+
'https://horizon-testnet.stellar.org';
15+
16+
this.networkPassphrase =
17+
this.configService.get<string>('STELLAR_NETWORK_PASSPHRASE') ||
18+
StellarSdk.Networks.TESTNET;
19+
20+
this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl);
21+
this.logger.log(`Stellar Horizon client initialized: ${horizonUrl}`);
22+
}
23+
24+
getHorizonServer(): StellarSdk.Horizon.Server {
25+
return this.horizonServer;
26+
}
27+
28+
getNetworkPassphrase(): string {
29+
return this.networkPassphrase;
30+
}
31+
}

0 commit comments

Comments
 (0)