Skip to content

Commit e86fd40

Browse files
fix: fail closed on contract allowlist and mark records failed on Horizon rejection (#117)
Closes #117 Addresses the audit gaps on the secured submit endpoint: the per-type contract check no longer degrades to function-name-only matching when the contract ID is unset or unextractable, and persisted records are marked failed when Horizon rejects the transaction instead of lingering as stale pending rows. Also resolves the committed merge-conflict markers in the progress tracker. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 7bfffc4 commit e86fd40

5 files changed

Lines changed: 286 additions & 28 deletions

File tree

context/progress-tracker.md

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,66 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
66

77
---
88

9+
## 2026-08-27
10+
11+
- Closed the audit gaps on `POST /transactions/submit` (#117):
12+
- **Fail-closed contract allowlist** — the contract ID for the declared
13+
type must be configured and the XDR must target it. Function-name-only
14+
matching was removed: an unset contract ID now rejects with
15+
`TRANSACTION_CONTRACT_NOT_CONFIGURED`, and an invocation whose target
16+
contract cannot be determined from the XDR rejects with
17+
`TRANSACTION_TYPE_MISMATCH` instead of silently skipping the check.
18+
- **No stale pending rows on submission failure** — when Horizon rejects
19+
the transaction (or submission fails unexpectedly), the persisted record
20+
is marked `failed` with the mapped error message and `completed_at`, so
21+
the row no longer lingers as `pending` attributable to the submitting
22+
wallet. Transient network unavailability (503) leaves the row `pending`
23+
for the status checker to reconcile, since the transaction may still be
24+
in flight.
25+
- Resolved the committed merge-conflict markers in this file (stale
26+
StepFi-Contracts content from the wrong repo removed; StepFi-API history
27+
retained).
28+
29+
## 2026-08-26
30+
31+
- Fixed registration race conditions in `AuthService.register()` by eliminating application-side pre-checks (`findByWallet`, `checkUsernameExists`) and relying directly on DB-level UNIQUE constraints (`users.wallet_address`, `users.username`).
32+
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
33+
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
34+
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.
35+
36+
## 2026-08-25
37+
38+
- Secured `POST /transactions/submit` (#117):
39+
- **Source binding** — the authenticated wallet must be the transaction
40+
source account (or the inner source for fee-bump transactions), or must
41+
appear as an authorized address in the Soroban invocation auth. Third-party
42+
XDR where the wallet is neither source nor authorizer is rejected with
43+
`TRANSACTION_SOURCE_MISMATCH`. (Deposit/withdraw/repay/vendor XDRs built
44+
by this API use a random source account and authorize via Soroban auth, so
45+
the auth check keeps those flows working.)
46+
- **Operation allowlist per type** — every operation must be a Soroban
47+
`invokeHostFunction` whose function name matches the declared type
48+
(`create_loan`, `repay_loan`/`repay_installment`, `deposit`, `withdraw`,
49+
`approve_vendor`, `suspend_vendor`) and must target the contract owned by
50+
that flow. Rejections use `TRANSACTION_TYPE_MISMATCH` /
51+
`TRANSACTION_OPERATION_NOT_ALLOWED`.
52+
- **Idempotency** — migration
53+
`20260825000001_add_unique_transaction_hash.sql` adds partial unique
54+
indexes on `transaction_hash` and `hash` (with pre-existing row dedupe);
55+
the service checks for an existing record before submitting and returns it
56+
(`duplicate: true`) instead of re-submitting, with the unique-constraint
57+
violation as the concurrency backstop.
58+
- **Rate limits**`WalletThrottlerGuard` keys `@nestjs/throttler` on the
59+
authenticated wallet; the submit route is limited to 10 req / 60 s per
60+
wallet AND per IP (global guard), matching the auth-endpoint pattern.
61+
- **Persistence-first** — the local record is written (await) before the
62+
Horizon submission, so persistence failures surface as
63+
`TRANSACTION_PERSISTENCE_FAILED` instead of being silently dropped, and
64+
the transaction hash is always known to the status checker / indexer.
65+
- Updated `SubmitTransactionResponseDto` (`status` may reflect the recorded
66+
status, plus `duplicate` flag), controller Swagger, and unit tests covering
67+
every rejection branch plus the happy path.
68+
969
## 2026-08-24
1070

1171
- **Session families + refresh-token replay detection** (`sessions.family_id`
@@ -27,13 +87,6 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
2787
event, blocked-user denial within TTL bound, cache expiry re-query,
2888
cleanup job deletes-only-expired.
2989

30-
## 2026-08-26
31-
32-
- Fixed registration race conditions in `AuthService.register()` by eliminating application-side pre-checks (`findByWallet`, `checkUsernameExists`) and relying directly on DB-level UNIQUE constraints (`users.wallet_address`, `users.username`).
33-
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
34-
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
35-
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.
36-
3790
## 2026-07-23
3891

3992
- Added GitHub Actions health check workflow (`health-check.yml`) to ping the Render API every 6 hours to prevent the free tier instance from sleeping. Auto-creates or comments on issues with the `incident` label if the ping fails, preventing silent outages.

src/modules/transactions/dto/submit-transaction-request.dto.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,21 @@ export enum TransactionType {
2323
* - The declared `type` must match the operations contained in the XDR
2424
* (e.g. `deposit` must be a Soroban `deposit` invocation on the liquidity
2525
* pool contract). Mismatches are rejected with `TRANSACTION_TYPE_MISMATCH`
26-
* or `TRANSACTION_OPERATION_NOT_ALLOWED`.
26+
* or `TRANSACTION_OPERATION_NOT_ALLOWED`. The allowlist is fail-closed on
27+
* the contract ID: when the contract for the flow is not configured on the
28+
* server the submission is rejected with `TRANSACTION_CONTRACT_NOT_CONFIGURED`
29+
* (never falling back to function-name-only matching), and an invocation
30+
* whose target contract cannot be determined from the XDR is rejected with
31+
* `TRANSACTION_TYPE_MISMATCH`.
2732
* - Submission is idempotent per transaction hash: re-submitting an already
2833
* recorded hash returns the original record without a second Horizon
2934
* submission (`duplicate: true` on the response).
3035
* - The local record is persisted before the Horizon submission, so
3136
* persistence failures surface as errors rather than being silently dropped.
37+
* When Horizon rejects the transaction (or submission fails unexpectedly),
38+
* the persisted record is marked `failed` with the mapped error message;
39+
* transient network unavailability leaves it `pending` for the status
40+
* checker to reconcile.
3241
*/
3342
export class SubmitTransactionRequestDto {
3443
@ApiProperty({

src/modules/transactions/transactions.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ export class TransactionsController {
5757
})
5858
@ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' })
5959
@ApiResponse({ status: 429, description: 'Too many requests - rate limit exceeded (per wallet or per IP)' })
60-
@ApiResponse({ status: 500, description: 'Failed to persist the transaction record locally (TRANSACTION_PERSISTENCE_FAILED)' })
61-
@ApiResponse({ status: 503, description: 'Stellar network temporarily unavailable' })
60+
@ApiResponse({ status: 500, description: 'Failed to persist the transaction record locally (TRANSACTION_PERSISTENCE_FAILED) or an unexpected Stellar submission failure (STELLAR_SUBMISSION_FAILED)' })
61+
@ApiResponse({ status: 503, description: 'Stellar network temporarily unavailable, or the contract for the declared type is not configured on the server (TRANSACTION_CONTRACT_NOT_CONFIGURED)' })
6262
async submitTransaction(
6363
@CurrentUser() user: { wallet: string },
6464
@Body() dto: SubmitTransactionRequestDto,

src/modules/transactions/transactions.service.ts

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,9 @@ export class TransactionsService {
165165
// 4. Persist first so persistence failures surface instead of being
166166
// silently dropped. The unique hash indexes backstop the check above
167167
// against concurrent duplicate submissions.
168+
let lookupColumn: TransactionLookupColumn | null = null;
168169
try {
169-
await this.persistTransactionRecord(wallet, transactionHash, dto.type, dto.xdr);
170+
lookupColumn = await this.persistTransactionRecord(wallet, transactionHash, dto.type, dto.xdr);
170171
} catch (error) {
171172
if (this.isUniqueViolationError(error)) {
172173
const existingAfterRace = await this.findTransactionRecord(transactionHash);
@@ -187,9 +188,14 @@ export class TransactionsService {
187188

188189
// 5. Submit to Horizon only after the local record exists, so the
189190
// transaction hash is always known to the status checker / indexer.
191+
// When Horizon rejects the transaction (or submission fails
192+
// unexpectedly), the persisted record is marked failed so it does not
193+
// linger as a stale `pending` row attributable to the submitting
194+
// wallet.
190195
try {
191196
await this.horizonServer.submitTransaction(transaction);
192197
} catch (error) {
198+
await this.markTransactionFailed(lookupColumn, transactionHash, error);
193199
this.handleHorizonError(error);
194200
}
195201

@@ -291,8 +297,29 @@ export class TransactionsService {
291297
});
292298
}
293299

300+
// Fail closed on the contract ID: the allowlist never degrades to
301+
// function-name-only matching. If the contract for this flow is not
302+
// configured, submissions of this type are disabled rather than
303+
// accepting invocations of attacker-deployed contracts whose functions
304+
// share StepFi names.
294305
const expectedContractId = this.configService.get<string>(allowlist.contractIdKey);
295-
if (expectedContractId && invocation.contractId && invocation.contractId !== expectedContractId.trim()) {
306+
if (!expectedContractId) {
307+
throw new ServiceUnavailableException({
308+
code: 'TRANSACTION_CONTRACT_NOT_CONFIGURED',
309+
message: `The contract for transaction type '${type}' is not configured on the server. Submission is disabled until the contract ID is set.`,
310+
});
311+
}
312+
313+
// The target contract must be determinable from the XDR — a matching
314+
// function name is not enough.
315+
if (!invocation.contractId) {
316+
throw new BadRequestException({
317+
code: 'TRANSACTION_TYPE_MISMATCH',
318+
message: `Could not determine the target contract address for transaction type '${type}'.`,
319+
});
320+
}
321+
322+
if (invocation.contractId !== expectedContractId.trim()) {
296323
throw new BadRequestException({
297324
code: 'TRANSACTION_TYPE_MISMATCH',
298325
message: `Transaction type '${type}' must invoke contract ${expectedContractId}, but the XDR targets ${invocation.contractId}.`,
@@ -426,7 +453,17 @@ export class TransactionsService {
426453
return code === '23505' || message.includes('duplicate key value violates unique constraint');
427454
}
428455

429-
private handleHorizonError(error: unknown): never {
456+
/**
457+
* Maps a Horizon submission error to the HTTP status, typed code, and
458+
* user-facing message that should be returned to the client. Shared by
459+
* `handleHorizonError` (which throws) and `markTransactionFailed` (which
460+
* records the same message on the local row).
461+
*/
462+
private describeHorizonError(error: unknown): {
463+
httpStatus: number;
464+
code: string;
465+
message: string;
466+
} {
430467
const err = error as {
431468
response?: { data?: { extras?: { result_codes?: { transaction?: string; operations?: string[] } } } };
432469
message?: string;
@@ -441,40 +478,102 @@ export class TransactionsService {
441478

442479
for (const code of allCodes) {
443480
if (code && HORIZON_ERROR_MAP[code]) {
444-
throw new BadRequestException({
481+
return {
482+
httpStatus: 400,
445483
code: `STELLAR_${code.toUpperCase()}`,
446484
message: HORIZON_ERROR_MAP[code],
447-
});
485+
};
448486
}
449487
}
450488

451-
throw new BadRequestException({
489+
return {
490+
httpStatus: 400,
452491
code: 'STELLAR_TRANSACTION_FAILED',
453492
message: `Transaction rejected by the Stellar network: ${allCodes.join(', ')}`,
454-
});
493+
};
455494
}
456495

457496
const message = err?.message ?? 'Unknown error';
458497
if (message.toLowerCase().includes('timeout') || message.toLowerCase().includes('network')) {
459-
throw new ServiceUnavailableException({
498+
return {
499+
httpStatus: 503,
460500
code: 'STELLAR_NETWORK_UNAVAILABLE',
461501
message: 'Stellar network is temporarily unavailable. Please try again later.',
462-
});
502+
};
463503
}
464504

465505
this.logger.error(`Horizon submission error: ${message}`);
466-
throw new InternalServerErrorException({
506+
return {
507+
httpStatus: 500,
467508
code: 'STELLAR_SUBMISSION_FAILED',
468509
message: 'Failed to submit transaction to the Stellar network. Please try again.',
469-
});
510+
};
511+
}
512+
513+
private handleHorizonError(error: unknown): never {
514+
const details = this.describeHorizonError(error);
515+
516+
if (details.httpStatus === 503) {
517+
throw new ServiceUnavailableException({ code: details.code, message: details.message });
518+
}
519+
if (details.httpStatus === 500) {
520+
throw new InternalServerErrorException({ code: details.code, message: details.message });
521+
}
522+
throw new BadRequestException({ code: details.code, message: details.message });
523+
}
524+
525+
/**
526+
* Best-effort update of the persisted record when Horizon rejected the
527+
* transaction or submission failed unexpectedly, so the row does not linger
528+
* as a stale `pending` record attributable to the submitting wallet. On
529+
* transient network unavailability (503) the outcome is unknown — the
530+
* transaction may still be in flight — so the row is left `pending` for the
531+
* status checker to reconcile. Never throws: the original Horizon error is
532+
* what surfaces to the client.
533+
*/
534+
private async markTransactionFailed(
535+
lookupColumn: TransactionLookupColumn | null,
536+
hash: string,
537+
error: unknown,
538+
): Promise<void> {
539+
if (!lookupColumn) {
540+
return;
541+
}
542+
543+
const details = this.describeHorizonError(error);
544+
if (details.httpStatus === 503) {
545+
return;
546+
}
547+
548+
try {
549+
const failedAt = new Date().toISOString();
550+
const client = this.supabaseService.getServiceRoleClient();
551+
const { error: updateError } = await client
552+
.from('transactions')
553+
.update({
554+
status: 'failed',
555+
error: details.message,
556+
completed_at: failedAt,
557+
updated_at: failedAt,
558+
})
559+
.eq(lookupColumn, hash);
560+
561+
if (updateError) {
562+
this.logger.warn(`Failed to mark transaction ${hash} as failed: ${updateError.message}`);
563+
}
564+
} catch (persistError) {
565+
this.logger.warn(
566+
`Failed to mark transaction ${hash} as failed: ${(persistError as Error).message}`,
567+
);
568+
}
470569
}
471570

472571
private async persistTransactionRecord(
473572
wallet: string,
474573
hash: string,
475574
type: TransactionType,
476575
xdr: string,
477-
): Promise<void> {
576+
): Promise<TransactionLookupColumn> {
478577
const client = this.supabaseService.getServiceRoleClient();
479578
const submittedAt = new Date().toISOString();
480579
const transactionHashPayload: Record<string, unknown> = {
@@ -491,7 +590,7 @@ export class TransactionsService {
491590
.insert(transactionHashPayload);
492591

493592
if (!transactionHashError) {
494-
return;
593+
return 'transaction_hash';
495594
}
496595

497596
if (!this.isUnknownColumnError(transactionHashError)) {
@@ -512,6 +611,8 @@ export class TransactionsService {
512611
if (legacyHashError) {
513612
throw new Error(legacyHashError.message ?? 'Supabase insert failed');
514613
}
614+
615+
return 'hash';
515616
}
516617

517618
private async findTransactionRecord(hash: string): Promise<TransactionRecord | null> {

0 commit comments

Comments
 (0)