Skip to content
Merged
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
67 changes: 60 additions & 7 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,66 @@ pure chore/docs commits). Direct pushes to main must also be logged here.

---

## 2026-08-27

- Closed the audit gaps on `POST /transactions/submit` (#117):
- **Fail-closed contract allowlist** β€” the contract ID for the declared
type must be configured and the XDR must target it. Function-name-only
matching was removed: an unset contract ID now rejects with
`TRANSACTION_CONTRACT_NOT_CONFIGURED`, and an invocation whose target
contract cannot be determined from the XDR rejects with
`TRANSACTION_TYPE_MISMATCH` instead of silently skipping the check.
- **No stale pending rows on submission failure** β€” when Horizon rejects
the transaction (or submission fails unexpectedly), the persisted record
is marked `failed` with the mapped error message and `completed_at`, so
the row no longer lingers as `pending` attributable to the submitting
wallet. Transient network unavailability (503) leaves the row `pending`
for the status checker to reconcile, since the transaction may still be
in flight.
- Resolved the committed merge-conflict markers in this file (stale
StepFi-Contracts content from the wrong repo removed; StepFi-API history
retained).

## 2026-08-26

- 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`).
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.

## 2026-08-25

- Secured `POST /transactions/submit` (#117):
- **Source binding** β€” the authenticated wallet must be the transaction
source account (or the inner source for fee-bump transactions), or must
appear as an authorized address in the Soroban invocation auth. Third-party
XDR where the wallet is neither source nor authorizer is rejected with
`TRANSACTION_SOURCE_MISMATCH`. (Deposit/withdraw/repay/vendor XDRs built
by this API use a random source account and authorize via Soroban auth, so
the auth check keeps those flows working.)
- **Operation allowlist per type** β€” every operation must be a Soroban
`invokeHostFunction` whose function name matches the declared type
(`create_loan`, `repay_loan`/`repay_installment`, `deposit`, `withdraw`,
`approve_vendor`, `suspend_vendor`) and must target the contract owned by
that flow. Rejections use `TRANSACTION_TYPE_MISMATCH` /
`TRANSACTION_OPERATION_NOT_ALLOWED`.
- **Idempotency** β€” migration
`20260825000001_add_unique_transaction_hash.sql` adds partial unique
indexes on `transaction_hash` and `hash` (with pre-existing row dedupe);
the service checks for an existing record before submitting and returns it
(`duplicate: true`) instead of re-submitting, with the unique-constraint
violation as the concurrency backstop.
- **Rate limits** β€” `WalletThrottlerGuard` keys `@nestjs/throttler` on the
authenticated wallet; the submit route is limited to 10 req / 60 s per
wallet AND per IP (global guard), matching the auth-endpoint pattern.
- **Persistence-first** β€” the local record is written (await) before the
Horizon submission, so persistence failures surface as
`TRANSACTION_PERSISTENCE_FAILED` instead of being silently dropped, and
the transaction hash is always known to the status checker / indexer.
- Updated `SubmitTransactionResponseDto` (`status` may reflect the recorded
status, plus `duplicate` flag), controller Swagger, and unit tests covering
every rejection branch plus the happy path.

## 2026-08-24

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

## 2026-08-26

- 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`).
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.

## 2026-07-23

- 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.
Expand Down
25 changes: 25 additions & 0 deletions src/modules/transactions/dto/submit-transaction-request.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,31 @@ export enum TransactionType {

/**
* DTO for submitting a signed Stellar XDR transaction to the network.
*
* POST /transactions/submit enforces the following guarantees:
* - The transaction source account (or, for fee-bump transactions, the inner
* source account) must equal the authenticated wallet, or the wallet must
* appear as an authorized address in the Soroban invocation auth. XDR in
* which the wallet is neither source nor authorizer is rejected with
* `TRANSACTION_SOURCE_MISMATCH`.
* - The declared `type` must match the operations contained in the XDR
* (e.g. `deposit` must be a Soroban `deposit` invocation on the liquidity
* pool contract). Mismatches are rejected with `TRANSACTION_TYPE_MISMATCH`
* or `TRANSACTION_OPERATION_NOT_ALLOWED`. The allowlist is fail-closed on
* the contract ID: when the contract for the flow is not configured on the
* server the submission is rejected with `TRANSACTION_CONTRACT_NOT_CONFIGURED`
* (never falling back to function-name-only matching), and an invocation
* whose target contract cannot be determined from the XDR is rejected with
* `TRANSACTION_TYPE_MISMATCH`.
* - Submission is idempotent per transaction hash: re-submitting an already
* recorded hash returns the original record without a second Horizon
* submission (`duplicate: true` on the response).
* - The local record is persisted before the Horizon submission, so
* persistence failures surface as errors rather than being silently dropped.
* When Horizon rejects the transaction (or submission fails unexpectedly),
* the persisted record is marked `failed` with the mapped error message;
* transient network unavailability leaves it `pending` for the status
* checker to reconcile.
*/
export class SubmitTransactionRequestDto {
@ApiProperty({
Expand Down
19 changes: 16 additions & 3 deletions src/modules/transactions/dto/submit-transaction-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

/**
* DTO returned after successfully submitting a transaction to the Stellar network.
*
* Submission is idempotent per transaction hash: when the same hash was already
* recorded locally, the original record is returned (with `duplicate: true`)
* instead of submitting to Horizon a second time.
*/
export class SubmitTransactionResponseDto {
@ApiProperty({
Expand All @@ -11,8 +15,17 @@ export class SubmitTransactionResponseDto {
transactionHash: string;

@ApiProperty({
description: 'Transaction status immediately after submission',
description:
'Transaction status. Fresh submissions return `pending`; duplicate submissions return the recorded status of the original record.',
enum: ['pending', 'success', 'failed'],
example: 'pending',
})
status: 'pending';
status: 'pending' | 'success' | 'failed';

@ApiPropertyOptional({
description:
'True when the hash was already recorded locally and the original record is returned without re-submitting to Horizon.',
example: false,
})
duplicate?: boolean;
}
23 changes: 18 additions & 5 deletions src/modules/transactions/transactions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { SubmitTransactionResponseDto } from './dto/submit-transaction-response.
import { TransactionStatusResponseDto } from './dto/transaction-status-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Throttle } from '@nestjs/throttler';
import { WalletThrottlerGuard } from './wallet-throttler.guard';

const SUBMIT_RATE_LIMIT = 10;
const SUBMIT_RATE_TTL_MS = 60000;

@ApiTags('transactions')
@Controller('transactions')
Expand All @@ -31,21 +36,29 @@ export class TransactionsController {

@Post('submit')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard)
@Throttle({ default: { limit: SUBMIT_RATE_LIMIT, ttl: SUBMIT_RATE_TTL_MS } })
@UseGuards(JwtAuthGuard, WalletThrottlerGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Submit a signed XDR transaction to the Stellar network',
description:
'Validates the XDR format, submits the signed transaction to the Stellar network via Horizon API, stores the transaction hash with pending status in the database, and returns the hash immediately without waiting for confirmation.',
'Validates that the XDR source account (or Soroban authorization) matches the authenticated wallet and that the operations match the declared type, then persists the transaction record and submits the signed transaction to the Stellar network via Horizon. Returns the hash immediately without waiting for confirmation. Submission is idempotent per transaction hash: re-submitting an already recorded hash returns the original record. Rate limited per wallet and per IP.',
})
@ApiResponse({
status: 200,
description: 'Transaction submitted successfully β€” hash returned with pending status',
description:
'Transaction submitted successfully β€” hash returned with pending status (or the original record when the hash was already submitted)',
type: SubmitTransactionResponseDto,
})
@ApiResponse({ status: 400, description: 'Malformed XDR, invalid signature, or Stellar rejection' })
@ApiResponse({
status: 400,
description:
'Malformed XDR, source account mismatch (TRANSACTION_SOURCE_MISMATCH), operation/type mismatch (TRANSACTION_TYPE_MISMATCH, TRANSACTION_OPERATION_NOT_ALLOWED), or Stellar rejection',
})
@ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' })
@ApiResponse({ status: 503, description: 'Stellar network temporarily unavailable' })
@ApiResponse({ status: 429, description: 'Too many requests - rate limit exceeded (per wallet or per IP)' })
@ApiResponse({ status: 500, description: 'Failed to persist the transaction record locally (TRANSACTION_PERSISTENCE_FAILED) or an unexpected Stellar submission failure (STELLAR_SUBMISSION_FAILED)' })
@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)' })
async submitTransaction(
@CurrentUser() user: { wallet: string },
@Body() dto: SubmitTransactionRequestDto,
Expand Down
Loading
Loading