Skip to content

Commit c17eaa1

Browse files
authored
Merge pull request #738 from Timrossid/fix/669-contract-invocation-replay-protection
fix: add durable contract invocation replay protection
2 parents 058bd60 + bb1a76f commit c17eaa1

5 files changed

Lines changed: 170 additions & 30 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Injectable } from "@nestjs/common";
2+
import { SupabaseService } from "../supabase/supabase.service";
3+
4+
export type ReplayClaim =
5+
| { kind: "claimed" }
6+
| { kind: "cached"; response: unknown }
7+
| { kind: "conflict" }
8+
| { kind: "in_flight" };
9+
10+
@Injectable()
11+
export class InvocationReplayService {
12+
constructor(private readonly supabase: SupabaseService) {}
13+
14+
async claim(
15+
scope: string,
16+
key: string,
17+
fingerprint: string,
18+
): Promise<ReplayClaim> {
19+
const { data, error } = await this.supabase.getClient().rpc("claim_transaction_invocation", {
20+
p_scope: scope,
21+
p_key: key,
22+
p_fingerprint: fingerprint,
23+
});
24+
if (error) throw error;
25+
return data as ReplayClaim;
26+
}
27+
28+
async complete(scope: string, key: string, response: unknown): Promise<void> {
29+
const { error } = await this.supabase.getClient().rpc("complete_transaction_invocation", {
30+
p_scope: scope,
31+
p_key: key,
32+
p_response: response,
33+
});
34+
if (error) throw error;
35+
}
36+
37+
async release(scope: string, key: string): Promise<void> {
38+
const { error } = await this.supabase.getClient().rpc("release_transaction_invocation", {
39+
p_scope: scope,
40+
p_key: key,
41+
});
42+
if (error) throw error;
43+
}
44+
}

app/backend/src/transactions/transaction.service.ts

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Injectable,
33
Logger,
44
BadRequestException,
5+
ConflictException,
56
InternalServerErrorException,
67
} from "@nestjs/common";
78
import { createHash } from "crypto";
@@ -18,20 +19,18 @@ import { buildScVal } from "./utils/param-builder";
1819
import { SorobanRpcService } from "./soroban-rpc.service";
1920
import { mapSorobanError } from "../common/soroban-errors";
2021
import { SorobanErrorCode } from "../common/soroban-errors";
22+
import { InvocationReplayService } from "./invocation-replay.service";
2123

2224
const STROOPS_PER_XLM = 10_000_000;
2325
const BASE_FEE = 100; // stroops
2426

2527
@Injectable()
2628
export class TransactionsService {
2729
private readonly logger = new Logger(TransactionsService.name);
28-
private readonly idempotencyResponses = new Map<
29-
string,
30-
ComposeTransactionResponse | ComposeTransactionError
31-
>();
32-
private readonly idempotencyFingerprints = new Map<string, string>();
33-
34-
constructor(private readonly sorobanRpcService: SorobanRpcService) {}
30+
constructor(
31+
private readonly sorobanRpcService: SorobanRpcService,
32+
private readonly invocationReplayService: InvocationReplayService,
33+
) {}
3534

3635
async composeTransaction(
3736
dto: ComposeTransactionDto,
@@ -40,16 +39,21 @@ export class TransactionsService {
4039

4140
const payloadFingerprint = this.buildFingerprint(dto);
4241
const idempotencyKey = dto.idempotencyKey ?? payloadFingerprint;
43-
const fingerprintForKey = this.idempotencyFingerprints.get(idempotencyKey);
44-
if (fingerprintForKey && fingerprintForKey !== payloadFingerprint) {
45-
throw new BadRequestException(
42+
const replay = await this.invocationReplayService.claim(
43+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
44+
idempotencyKey,
45+
payloadFingerprint,
46+
);
47+
if (replay.kind === "conflict") {
48+
throw new ConflictException(
4649
"This idempotency key was already used with a different payload.",
4750
);
4851
}
49-
50-
const cached = this.idempotencyResponses.get(idempotencyKey);
51-
if (cached) {
52-
return cached;
52+
if (replay.kind === "in_flight") {
53+
throw new ConflictException("This invocation is already in progress.");
54+
}
55+
if (replay.kind === "cached") {
56+
return replay.response as ComposeTransactionResponse | ComposeTransactionError;
5357
}
5458

5559
const startTime = Date.now();
@@ -64,6 +68,10 @@ export class TransactionsService {
6468
try {
6569
account = await this.sorobanRpcService.getAccount(dto.sourceAccount);
6670
} catch (err) {
71+
await this.invocationReplayService.release(
72+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
73+
idempotencyKey,
74+
);
6775
return {
6876
success: false,
6977
error: err.message,
@@ -76,6 +84,10 @@ export class TransactionsService {
7684
try {
7785
scParams = dto.params.map(buildScVal);
7886
} catch (err) {
87+
await this.invocationReplayService.release(
88+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
89+
idempotencyKey,
90+
);
7991
throw new BadRequestException(`Invalid parameter: ${err.message}`);
8092
}
8193

@@ -102,6 +114,10 @@ export class TransactionsService {
102114
simulationResult = await this.sorobanRpcService.simulateTransaction(tx);
103115
} catch (err) {
104116
this.logger.error("RPC simulation request failed", err);
117+
await this.invocationReplayService.release(
118+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
119+
idempotencyKey,
120+
);
105121
throw new InternalServerErrorException(
106122
"Failed to reach Soroban RPC provider.",
107123
);
@@ -119,7 +135,10 @@ export class TransactionsService {
119135
userMessage: mapped.message,
120136
details: mapped.details,
121137
};
122-
this.rememberResponse(idempotencyKey, payloadFingerprint, failedResponse);
138+
await this.invocationReplayService.release(
139+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
140+
idempotencyKey,
141+
);
123142
return failedResponse;
124143
}
125144

@@ -134,7 +153,10 @@ export class TransactionsService {
134153
restorePreamble: simulationResult.restorePreamble,
135154
},
136155
} as ComposeTransactionError;
137-
this.rememberResponse(idempotencyKey, payloadFingerprint, restoreResponse);
156+
await this.invocationReplayService.release(
157+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
158+
idempotencyKey,
159+
);
138160
return restoreResponse;
139161
}
140162

@@ -203,7 +225,11 @@ export class TransactionsService {
203225
},
204226
},
205227
};
206-
this.rememberResponse(idempotencyKey, payloadFingerprint, response);
228+
await this.invocationReplayService.complete(
229+
`${dto.sourceAccount}:${dto.networkPassphrase ?? "__default__"}`,
230+
idempotencyKey,
231+
response,
232+
);
207233
return response;
208234
}
209235

@@ -230,12 +256,4 @@ export class TransactionsService {
230256
return createHash("sha256").update(normalized).digest("hex");
231257
}
232258

233-
private rememberResponse(
234-
idempotencyKey: string,
235-
fingerprint: string,
236-
response: ComposeTransactionResponse | ComposeTransactionError,
237-
): void {
238-
this.idempotencyFingerprints.set(idempotencyKey, fingerprint);
239-
this.idempotencyResponses.set(idempotencyKey, response);
240-
}
241259
}

app/backend/src/transactions/transaction.service.unit.spec.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,20 @@ jest.mock('@stellar/stellar-sdk', () => {
7676
};
7777
});
7878

79-
import { BadRequestException } from '@nestjs/common';
79+
jest.mock('../supabase/supabase.service', () => ({
80+
SupabaseService: class SupabaseService {},
81+
}));
82+
83+
import { ConflictException } from '@nestjs/common';
8084

8185
import { SorobanRpcService } from './soroban-rpc.service';
8286
import { TransactionsService } from './transaction.service';
87+
import { InvocationReplayService } from './invocation-replay.service';
8388

8489
describe('TransactionsService', () => {
8590
let service: TransactionsService;
8691
let mockSorobanRpcService: jest.Mocked<Partial<SorobanRpcService>>;
92+
let mockInvocationReplayService: jest.Mocked<Partial<InvocationReplayService>>;
8793

8894
beforeEach(() => {
8995
mockSorobanRpcService = {
@@ -110,8 +116,16 @@ describe('TransactionsService', () => {
110116
},
111117
}),
112118
};
119+
mockInvocationReplayService = {
120+
claim: jest.fn().mockResolvedValue({ kind: 'claimed' }),
121+
complete: jest.fn().mockResolvedValue(undefined),
122+
release: jest.fn().mockResolvedValue(undefined),
123+
};
113124

114-
service = new TransactionsService(mockSorobanRpcService as unknown as SorobanRpcService);
125+
service = new TransactionsService(
126+
mockSorobanRpcService as unknown as SorobanRpcService,
127+
mockInvocationReplayService as unknown as InvocationReplayService,
128+
);
115129
});
116130

117131
it('returns a simulation summary and idempotency key', async () => {
@@ -130,7 +144,7 @@ describe('TransactionsService', () => {
130144
}
131145
});
132146

133-
it('reuses the cached response for the same idempotency key', async () => {
147+
it('returns a durable cached response for the same idempotency key', async () => {
134148
const payload = {
135149
contractId: 'C123',
136150
method: 'health_check',
@@ -139,14 +153,20 @@ describe('TransactionsService', () => {
139153
idempotencyKey: 'same-key',
140154
};
141155

156+
mockInvocationReplayService.claim!.mockResolvedValueOnce({ kind: 'claimed' });
142157
const first = await service.composeTransaction(payload);
158+
mockInvocationReplayService.claim!.mockResolvedValueOnce({
159+
kind: 'cached',
160+
response: first,
161+
});
143162
const second = await service.composeTransaction(payload);
144163

145164
expect(second).toEqual(first);
146165
expect(mockSorobanRpcService.simulateTransaction).toHaveBeenCalledTimes(1);
147166
});
148167

149168
it('rejects reusing an idempotency key with a different payload', async () => {
169+
mockInvocationReplayService.claim!.mockResolvedValueOnce({ kind: 'claimed' });
150170
await service.composeTransaction({
151171
contractId: 'C123',
152172
method: 'health_check',
@@ -155,6 +175,7 @@ describe('TransactionsService', () => {
155175
idempotencyKey: 'same-key',
156176
});
157177

178+
mockInvocationReplayService.claim!.mockResolvedValueOnce({ kind: 'conflict' });
158179
await expect(
159180
service.composeTransaction({
160181
contractId: 'C123',
@@ -163,6 +184,8 @@ describe('TransactionsService', () => {
163184
sourceAccount: 'G123',
164185
idempotencyKey: 'same-key',
165186
}),
166-
).rejects.toThrow(BadRequestException);
187+
).rejects.toThrow(ConflictException);
188+
189+
expect(mockInvocationReplayService.complete).toHaveBeenCalledTimes(1);
167190
});
168191
});

app/backend/src/transactions/transactions.module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@ import { ApiKeysModule } from "../api-keys/api-keys.module";
88
import { ApiKeyGuard } from "../auth/guards/api-key.guard";
99
import { MetricsModule } from "../metrics/metrics.module";
1010
import { FeatureFlagsModule } from "../feature-flags/feature-flags.module";
11+
import { SupabaseModule } from "../supabase/supabase.module";
12+
import { InvocationReplayService } from "./invocation-replay.service";
1113

1214
@Module({
13-
imports: [AppConfigModule, ApiKeysModule, MetricsModule, FeatureFlagsModule],
15+
imports: [AppConfigModule, ApiKeysModule, MetricsModule, FeatureFlagsModule, SupabaseModule],
1416
controllers: [TransactionsController],
1517
providers: [
1618
HorizonService,
1719
TransactionsService,
1820
SorobanRpcService,
1921
ApiKeyGuard,
22+
InvocationReplayService,
2023
],
2124
exports: [HorizonService, TransactionsService, SorobanRpcService],
2225
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
CREATE TABLE IF NOT EXISTS transaction_invocation_replays (
2+
scope TEXT NOT NULL,
3+
idempotency_key TEXT NOT NULL,
4+
fingerprint TEXT NOT NULL,
5+
status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
6+
response JSONB,
7+
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9+
PRIMARY KEY (scope, idempotency_key)
10+
);
11+
12+
CREATE OR REPLACE FUNCTION claim_transaction_invocation(
13+
p_scope TEXT, p_key TEXT, p_fingerprint TEXT
14+
) RETURNS JSONB LANGUAGE plpgsql AS $$
15+
DECLARE existing transaction_invocation_replays;
16+
BEGIN
17+
INSERT INTO transaction_invocation_replays(scope, idempotency_key, fingerprint, status)
18+
VALUES (p_scope, p_key, p_fingerprint, 'pending')
19+
ON CONFLICT (scope, idempotency_key) DO NOTHING;
20+
21+
SELECT * INTO existing FROM transaction_invocation_replays
22+
WHERE scope = p_scope AND idempotency_key = p_key FOR UPDATE;
23+
24+
IF existing.fingerprint <> p_fingerprint THEN
25+
RETURN jsonb_build_object('kind', 'conflict');
26+
END IF;
27+
IF existing.status = 'completed' THEN
28+
RETURN jsonb_build_object('kind', 'cached', 'response', existing.response);
29+
END IF;
30+
IF existing.claimed_at < now() - interval '5 minutes' THEN
31+
UPDATE transaction_invocation_replays SET claimed_at = now(), updated_at = now()
32+
WHERE scope = p_scope AND idempotency_key = p_key;
33+
RETURN jsonb_build_object('kind', 'claimed');
34+
END IF;
35+
RETURN jsonb_build_object('kind', 'in_flight');
36+
END;
37+
$$;
38+
39+
CREATE OR REPLACE FUNCTION complete_transaction_invocation(
40+
p_scope TEXT, p_key TEXT, p_response JSONB
41+
) RETURNS VOID LANGUAGE SQL AS $$
42+
UPDATE transaction_invocation_replays
43+
SET status = 'completed', response = p_response, updated_at = now()
44+
WHERE scope = p_scope AND idempotency_key = p_key AND status = 'pending';
45+
$$;
46+
47+
CREATE OR REPLACE FUNCTION release_transaction_invocation(
48+
p_scope TEXT, p_key TEXT
49+
) RETURNS VOID LANGUAGE SQL AS $$
50+
DELETE FROM transaction_invocation_replays
51+
WHERE scope = p_scope AND idempotency_key = p_key AND status = 'pending';
52+
$$;

0 commit comments

Comments
 (0)