Skip to content

Commit 532b6fc

Browse files
authored
Merge branch 'main' into Event_Emitter
2 parents fe40a09 + 31e0b21 commit 532b6fc

33 files changed

Lines changed: 6831 additions & 36 deletions

backend/src/app.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ const envValidationSchema = Joi.object({
7979
RPC_MAX_RETRIES: Joi.number().integer().min(0).default(3),
8080
RPC_RETRY_DELAY: Joi.number().integer().min(0).default(1000),
8181
RPC_TIMEOUT: Joi.number().integer().min(0).default(10000),
82+
TRANSACTION_BATCH_MAX_SIZE: Joi.number()
83+
.integer()
84+
.min(1)
85+
.max(100)
86+
.default(25),
8287

8388
REDIS_URL: Joi.string().uri().optional(),
8489
MAIL_HOST: Joi.string().optional(),

backend/src/config/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export default () => ({
3838
rpcMaxRetries: parseInt(process.env.RPC_MAX_RETRIES || '3', 10),
3939
rpcRetryDelay: parseInt(process.env.RPC_RETRY_DELAY || '1000', 10),
4040
rpcTimeout: parseInt(process.env.RPC_TIMEOUT || '10000', 10),
41+
batchMaxSize: parseInt(process.env.TRANSACTION_BATCH_MAX_SIZE || '25', 10),
4142
},
4243
redis: {
4344
url: process.env.REDIS_URL,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class CreateTransactionBatchTables1799300000000 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query(`
6+
DO $$
7+
BEGIN
8+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_batches_status_enum') THEN
9+
CREATE TYPE "transaction_batches_status_enum" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'PARTIAL_FAILED', 'FAILED');
10+
END IF;
11+
END
12+
$$;
13+
`);
14+
15+
await queryRunner.query(`
16+
DO $$
17+
BEGIN
18+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_batch_operations_status_enum') THEN
19+
CREATE TYPE "transaction_batch_operations_status_enum" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
20+
END IF;
21+
END
22+
$$;
23+
`);
24+
25+
await queryRunner.query(`
26+
CREATE TABLE IF NOT EXISTS "transaction_batches" (
27+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
28+
"status" "transaction_batches_status_enum" NOT NULL DEFAULT 'PENDING',
29+
"requestedOperationCount" integer NOT NULL,
30+
"completedCount" integer NOT NULL DEFAULT 0,
31+
"failedCount" integer NOT NULL DEFAULT 0,
32+
"maxBatchSize" integer NOT NULL,
33+
"transactionHashes" text[] NOT NULL DEFAULT ARRAY[]::text[],
34+
"estimatedIndividualFee" varchar NOT NULL DEFAULT '0',
35+
"actualBatchFee" varchar NOT NULL DEFAULT '0',
36+
"estimatedCostSavings" varchar NOT NULL DEFAULT '0',
37+
"savingsPercentage" numeric(9,4) NOT NULL DEFAULT 0,
38+
"costMetricSource" varchar NOT NULL DEFAULT 'base_fee_estimate',
39+
"errorMessage" varchar,
40+
"metadata" jsonb,
41+
"startedAt" timestamp,
42+
"completedAt" timestamp,
43+
"createdAt" timestamp NOT NULL DEFAULT now(),
44+
"updatedAt" timestamp NOT NULL DEFAULT now()
45+
);
46+
`);
47+
48+
await queryRunner.query(`
49+
CREATE TABLE IF NOT EXISTS "transaction_batch_operations" (
50+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
51+
"batchId" uuid NOT NULL,
52+
"operationIndex" integer NOT NULL,
53+
"contractId" varchar NOT NULL,
54+
"functionName" varchar NOT NULL,
55+
"args" jsonb,
56+
"metadata" jsonb,
57+
"idempotencyKey" varchar,
58+
"status" "transaction_batch_operations_status_enum" NOT NULL DEFAULT 'PENDING',
59+
"txHash" varchar,
60+
"errorMessage" varchar,
61+
"startedAt" timestamp,
62+
"completedAt" timestamp,
63+
"createdAt" timestamp NOT NULL DEFAULT now(),
64+
"updatedAt" timestamp NOT NULL DEFAULT now()
65+
);
66+
`);
67+
68+
await queryRunner.query(`
69+
DO $$
70+
BEGIN
71+
IF NOT EXISTS (
72+
SELECT 1 FROM pg_constraint WHERE conname = 'FK_transaction_batch_operations_batch'
73+
) THEN
74+
ALTER TABLE "transaction_batch_operations"
75+
ADD CONSTRAINT "FK_transaction_batch_operations_batch"
76+
FOREIGN KEY ("batchId") REFERENCES "transaction_batches"("id") ON DELETE CASCADE;
77+
END IF;
78+
END
79+
$$;
80+
`);
81+
82+
await queryRunner.query(`
83+
CREATE INDEX IF NOT EXISTS "idx_transaction_batches_status"
84+
ON "transaction_batches" ("status");
85+
`);
86+
87+
await queryRunner.query(`
88+
CREATE INDEX IF NOT EXISTS "idx_transaction_batch_operations_status"
89+
ON "transaction_batch_operations" ("status");
90+
`);
91+
92+
await queryRunner.query(`
93+
CREATE INDEX IF NOT EXISTS "idx_transaction_batch_operations_batch_order"
94+
ON "transaction_batch_operations" ("batchId", "operationIndex");
95+
`);
96+
}
97+
98+
public async down(queryRunner: QueryRunner): Promise<void> {
99+
await queryRunner.query(`
100+
DROP INDEX IF EXISTS "idx_transaction_batch_operations_batch_order";
101+
`);
102+
103+
await queryRunner.query(`
104+
DROP INDEX IF EXISTS "idx_transaction_batch_operations_status";
105+
`);
106+
107+
await queryRunner.query(`
108+
DROP INDEX IF EXISTS "idx_transaction_batches_status";
109+
`);
110+
111+
await queryRunner.query(`
112+
ALTER TABLE "transaction_batch_operations"
113+
DROP CONSTRAINT IF EXISTS "FK_transaction_batch_operations_batch";
114+
`);
115+
116+
await queryRunner.query(`
117+
DROP TABLE IF EXISTS "transaction_batch_operations";
118+
`);
119+
120+
await queryRunner.query(`
121+
DROP TABLE IF EXISTS "transaction_batches";
122+
`);
123+
124+
await queryRunner.query(`
125+
DO $$
126+
BEGIN
127+
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_batch_operations_status_enum') THEN
128+
DROP TYPE "transaction_batch_operations_status_enum";
129+
END IF;
130+
END
131+
$$;
132+
`);
133+
134+
await queryRunner.query(`
135+
DO $$
136+
BEGIN
137+
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'transaction_batches_status_enum') THEN
138+
DROP TYPE "transaction_batches_status_enum";
139+
END IF;
140+
END
141+
$$;
142+
`);
143+
}
144+
}

backend/src/modules/blockchain/blockchain.controller.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { BlockchainController } from './blockchain.controller';
33
import { StellarService } from './stellar.service';
44
import { BalanceSyncService } from './balance-sync.service';
55
import { TransactionDto } from './dto/transaction.dto';
6+
import { TransactionBatchingService } from './transaction-batching.service';
7+
import { TransactionBatchStatus } from './entities/transaction-batch.entity';
68

79
const MOCK_PUBLIC_KEY =
810
'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
@@ -25,6 +27,7 @@ const MOCK_TRANSACTIONS: TransactionDto[] = [
2527
describe('BlockchainController', () => {
2628
let controller: BlockchainController;
2729
let stellarService: jest.Mocked<StellarService>;
30+
let transactionBatchingService: jest.Mocked<TransactionBatchingService>;
2831

2932
beforeEach(async () => {
3033
const mockStellarService: Partial<jest.Mocked<StellarService>> = {
@@ -37,18 +40,43 @@ describe('BlockchainController', () => {
3740

3841
const mockBalanceSyncService = {
3942
// Add any methods if needed, but since the controller doesn't use it in tests, empty is fine
43+
getMetricsSummary: jest.fn().mockReturnValue({}),
44+
};
45+
46+
const mockTransactionBatchingService = {
47+
createAndProcessBatch: jest.fn().mockResolvedValue({
48+
id: 'batch-1',
49+
status: TransactionBatchStatus.COMPLETED,
50+
requestedOperationCount: 1,
51+
completedCount: 1,
52+
failedCount: 0,
53+
operations: [],
54+
}),
55+
getBatchStatus: jest.fn().mockResolvedValue({
56+
id: 'batch-1',
57+
status: TransactionBatchStatus.COMPLETED,
58+
requestedOperationCount: 1,
59+
completedCount: 1,
60+
failedCount: 0,
61+
operations: [],
62+
}),
4063
};
4164

4265
const module: TestingModule = await Test.createTestingModule({
4366
controllers: [BlockchainController],
4467
providers: [
4568
{ provide: StellarService, useValue: mockStellarService },
4669
{ provide: BalanceSyncService, useValue: mockBalanceSyncService },
70+
{
71+
provide: TransactionBatchingService,
72+
useValue: mockTransactionBatchingService,
73+
},
4774
],
4875
}).compile();
4976

5077
controller = module.get<BlockchainController>(BlockchainController);
5178
stellarService = module.get(StellarService);
79+
transactionBatchingService = module.get(TransactionBatchingService);
5280
});
5381

5482
describe('getWalletTransactions', () => {
@@ -98,4 +126,40 @@ describe('BlockchainController', () => {
98126
});
99127
});
100128
});
129+
130+
describe('transaction batches', () => {
131+
it('should submit a batch without returning or persisting the source secret key in the controller', async () => {
132+
const dto = {
133+
sourceSecretKey: 'S_SECRET',
134+
maxBatchSize: 10,
135+
operations: [
136+
{
137+
contractId: 'C1',
138+
functionName: 'deposit',
139+
args: ['100'],
140+
idempotencyKey: 'op-1',
141+
},
142+
],
143+
};
144+
145+
const result = await controller.createBatch(dto);
146+
147+
expect(
148+
transactionBatchingService.createAndProcessBatch,
149+
).toHaveBeenCalledWith('S_SECRET', dto.operations, {
150+
maxBatchSize: 10,
151+
metadata: undefined,
152+
});
153+
expect(JSON.stringify(result)).not.toContain('S_SECRET');
154+
});
155+
156+
it('should fetch batch status by id', async () => {
157+
const result = await controller.getBatch('batch-1');
158+
159+
expect(transactionBatchingService.getBatchStatus).toHaveBeenCalledWith(
160+
'batch-1',
161+
);
162+
expect(result).toMatchObject({ id: 'batch-1' });
163+
});
164+
});
101165
});

backend/src/modules/blockchain/blockchain.controller.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
1+
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
22
import {
33
ApiOperation,
44
ApiParam,
@@ -9,13 +9,17 @@ import {
99
import { StellarService } from './stellar.service';
1010
import { BalanceSyncService } from './balance-sync.service';
1111
import { TransactionDto } from './dto/transaction.dto';
12+
import { TransactionBatchingService } from './transaction-batching.service';
13+
import { CreateTransactionBatchDto } from './dto/create-transaction-batch.dto';
14+
import { TransactionBatchResponseDto } from './dto/transaction-batch-response.dto';
1215

1316
@ApiTags('Blockchain')
1417
@Controller('blockchain')
1518
export class BlockchainController {
1619
constructor(
1720
private readonly stellarService: StellarService,
1821
private readonly balanceSyncService: BalanceSyncService,
22+
private readonly transactionBatchingService: TransactionBatchingService,
1923
) {}
2024

2125
@Post('wallets/generate')
@@ -99,6 +103,46 @@ export class BlockchainController {
99103
return this.stellarService.getEndpointsStatus();
100104
}
101105

106+
@Post('batches')
107+
@ApiOperation({
108+
summary: 'Create and process a Soroban transaction batch',
109+
description:
110+
'Executes compatible contract operations using one source signer, tracks partial failures, and returns fee savings metrics. The sourceSecretKey is never persisted or returned.',
111+
})
112+
@ApiResponse({
113+
status: 201,
114+
description: 'Persisted batch status including per-operation results',
115+
type: TransactionBatchResponseDto,
116+
})
117+
@ApiResponse({ status: 400, description: 'Invalid batch request' })
118+
async createBatch(
119+
@Body() dto: CreateTransactionBatchDto,
120+
): Promise<TransactionBatchResponseDto> {
121+
return this.transactionBatchingService.createAndProcessBatch(
122+
dto.sourceSecretKey,
123+
dto.operations,
124+
{ maxBatchSize: dto.maxBatchSize, metadata: dto.metadata },
125+
);
126+
}
127+
128+
@Get('batches/:id')
129+
@ApiOperation({
130+
summary:
131+
'Get transaction batch status, operation results, and cost metrics',
132+
})
133+
@ApiParam({ name: 'id', description: 'Transaction batch UUID' })
134+
@ApiResponse({
135+
status: 200,
136+
description: 'Batch status including operations and cost-savings metrics',
137+
type: TransactionBatchResponseDto,
138+
})
139+
@ApiResponse({ status: 404, description: 'Transaction batch not found' })
140+
async getBatch(
141+
@Param('id') id: string,
142+
): Promise<TransactionBatchResponseDto> {
143+
return this.transactionBatchingService.getBatchStatus(id);
144+
}
145+
102146
@Get('balance-sync/metrics')
103147
@ApiOperation({
104148
summary: 'Get WebSocket connection health metrics for balance sync',

backend/src/modules/blockchain/blockchain.module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import { YieldHandler } from './event-handlers/yield.handler';
2323
import { IndexerService } from './indexer.service';
2424
import { BalanceSyncService } from './balance-sync.service';
2525
import { ProtocolMetrics } from '../admin-analytics/entities/protocol-metrics.entity';
26+
import { TransactionBatchingService } from './transaction-batching.service';
27+
import { TransactionBatch } from './entities/transaction-batch.entity';
28+
import { TransactionBatchOperation } from './entities/transaction-batch-operation.entity';
2629

2730
@Global()
2831
@Module({
@@ -43,6 +46,8 @@ import { ProtocolMetrics } from '../admin-analytics/entities/protocol-metrics.en
4346
UserSubscription,
4447
SavingsProduct,
4548
ProtocolMetrics,
49+
TransactionBatch,
50+
TransactionBatchOperation,
4651
]),
4752
],
4853
controllers: [BlockchainController, StellarEventListenerController],
@@ -56,6 +61,7 @@ import { ProtocolMetrics } from '../admin-analytics/entities/protocol-metrics.en
5661
WithdrawHandler,
5762
YieldHandler,
5863
BalanceSyncService,
64+
TransactionBatchingService,
5965
],
6066
exports: [
6167
StellarService,
@@ -67,6 +73,7 @@ import { ProtocolMetrics } from '../admin-analytics/entities/protocol-metrics.en
6773
WithdrawHandler,
6874
YieldHandler,
6975
BalanceSyncService,
76+
TransactionBatchingService,
7077
],
7178
})
7279
export class BlockchainModule {}

0 commit comments

Comments
 (0)