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

---

## 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, when configured, 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-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
16 changes: 16 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,22 @@ 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`.
* - 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.
*/
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;
}
21 changes: 17 additions & 4 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,20 +36,28 @@ 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: 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)' })
@ApiResponse({ status: 503, description: 'Stellar network temporarily unavailable' })
async submitTransaction(
@CurrentUser() user: { wallet: string },
Expand Down
Loading
Loading