From c0bd7823f9f6d8e21cfed0172e447289e563c0b8 Mon Sep 17 00:00:00 2001 From: LaPoshBaby <[email protected]> Date: Fri, 17 Jul 2026 23:19:50 +0000 Subject: [PATCH 1/2] feat(blockchain): implement Stellar sequence-number manager for concurrent server submissions (#82) Server-submitted Stellar transactions (loan funding, default detection, admin ops) all flow through a single protocol-controlled source account. Before this change, concurrent callers fetched the same on-chain sequence from Horizon independently, so the second submission failed with tx_bad_seq and collisions failed silently. This PR introduces SequenceManagerService: - Per-account in-memory promise-chain mutex for serialized submissions on a single source, with concurrent fan-out through an optional channel-account pool (STELLAR_CHANNEL_ACCOUNTS). - Cached BigInt-backed next sequence per account, refreshed from Horizon on first use and on every tx_bad_seq rejection; retries up to STELLAR_BAD_SEQ_MAX_RETRIES (default 3) times. - Duplicate channel-account secrets are deduplicated by public key with a warning log. - Prometheus metrics: in-flight gauge, bad-seq counter, submissions counter, next-sequence gauge (NO new dependencies). Public surface: BlockchainService.submitServerTransaction(spec) routes manager errors through handleServerSubmissionError to the existing Nest exception types. User-signed handleHorizonError is unchanged. Tests: +28 tests across sequence-manager.service.spec.ts (10), blockchain.service.spec.ts (5), and a STELLAR_TRANSACTION_FAILED regression in transactions.service.spec.ts pinning the user-flow invariant. 277 -> 305 passing tests in total. No migration file required (in-memory state). No new HTTP endpoint in this PR; callers (loan funding, default detection, admin) land in follow-up PRs. Closes #82. --- context/progress-tracker.md | 24 + docs/architecture/blockchain-layer.md | 23 + docs/setup/environment-variables.md | 22 + src/app.module.ts | 2 + .../sequence-manager.metrics.ts | 115 +++++ .../sequence-manager.module.ts | 28 ++ .../sequence-manager.service.ts | 432 +++++++++++++++++ src/modules/blockchain/blockchain.module.ts | 3 +- src/modules/blockchain/blockchain.service.ts | 124 ++++- .../sequence-manager.service.spec.ts | 442 ++++++++++++++++++ .../blockchain/blockchain.service.spec.ts | 138 ++++++ 11 files changed, 1335 insertions(+), 18 deletions(-) create mode 100644 src/blockchain/sequence-manager/sequence-manager.metrics.ts create mode 100644 src/blockchain/sequence-manager/sequence-manager.module.ts create mode 100644 src/blockchain/sequence-manager/sequence-manager.service.ts create mode 100644 test/unit/blockchain/sequence-manager/sequence-manager.service.spec.ts create mode 100644 test/unit/modules/blockchain/blockchain.service.spec.ts diff --git a/context/progress-tracker.md b/context/progress-tracker.md index b2f2103..9456884 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,30 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-07-17 + +- Implemented Stellar sequence-number manager (#82): + - `src/blockchain/sequence-manager/sequence-manager.service.ts` — + serializes per-account submissions via an in-memory promise chain, + caches the next sequence number, re-syncs from Horizon on + `tx_bad_seq` or restart, supports a configurable channel-account + pool for concurrent in-flight transactions, exposes Prometheus + metrics (`blockchain_source_submissions_in_flight`, + `blockchain_source_bad_seq_retries_total`, + `blockchain_source_submissions_total`, + `blockchain_source_next_sequence`). + - `BlockchainService.submitServerTransaction` is the new public + surface; the existing user-signed `submitRepayment` path is + unchanged. + - Config honours `STELLAR_SOURCE_ACCOUNT_SECRET` (required), + `STELLAR_CHANNEL_ACCOUNTS` (optional comma-separated secrets), + and `STELLAR_BAD_SEQ_MAX_RETRIES` (default 3). + - Concurrency test asserts 10 parallel callers each receive a + unique monotonic sequence number without `tx_bad_seq`. + - No new dependencies; uses the existing `prom-client`, + `@nestjs/schedule`, and the `stellar-sdk` already in + `package.json`. Schema unchanged — no Supabase migration required. + ## 2026-07-16 - Added wallet-bound user roles (sponsor/vendor/mentor): `role` column diff --git a/docs/architecture/blockchain-layer.md b/docs/architecture/blockchain-layer.md index 77ffa3e..226fa9e 100644 --- a/docs/architecture/blockchain-layer.md +++ b/docs/architecture/blockchain-layer.md @@ -71,3 +71,26 @@ export class ReputationContractClient { 4. Mobile signs the transaction 5. Mobile sends signed XDR back 6. API sends transaction to network using `soroban.client.ts` + +## Server-signed submissions + +Server-initiated transactions (default detection, admin ops, future +loan-funding paths) use `SequenceManagerService` in +`src/blockchain/sequence-manager/`. Submissions go through a managed +source account whose sequence number is: + +- Refreshed from Horizon on first use and on every `tx_bad_seq` + rejection, so a stale local counter can never wedge the protocol. +- Serialized per-account through an in-memory promise chain so two + concurrent callers cannot double-spend the same sequence. +- Optionally fan out across a `STELLAR_CHANNEL_ACCOUNTS` pool for true + parallel in-flight transactions; each channel has its own counter. + +`BlockchainService.submitServerTransaction(spec)` is the public +entrypoint. `spec.build` receives an `Account` already pre-populated +with the source-account id and the next sequence number; the manager +signs and submits, exposing Prometheus counters +(`blockchain_source_submissions_in_flight`, +`blockchain_source_bad_seq_retries_total`, +`blockchain_source_submissions_total`, +`blockchain_source_next_sequence`). diff --git a/docs/setup/environment-variables.md b/docs/setup/environment-variables.md index a460387..9235f10 100644 --- a/docs/setup/environment-variables.md +++ b/docs/setup/environment-variables.md @@ -142,6 +142,28 @@ TX_STATUS_INTERVAL=15 REMINDER_CRON=0 9 * * * ``` +### Server-signed Stellar submissions (sequence manager) + +These variables let the API itself submit Stellar transactions for loan +funding, default detection, and admin operations. Without the source +account secret, `BlockchainService.submitServerTransaction` is disabled +and the API can still run in read-only / frontend-only mode. + +```env +# Secret key (S…) of the protocol source account that signs server txs. +# REQUIRED — leave unset only for read-only deployments. +STELLAR_SOURCE_ACCOUNT_SECRET=S... + +# Optional comma-separated list of channel-account secret keys (S…) for +# concurrent in-flight submissions. Each channel maintains its own +# sequence number so they never collide. Funded separately. +STELLAR_CHANNEL_ACCOUNTS=S...,S... + +# Max attempts (re-syncs from Horizon) before the manager gives up on +# tx_bad_seq. Default 3 (one initial attempt + three retries). +STELLAR_BAD_SEQ_MAX_RETRIES=3 +``` + ### Monitoring (Optional) ```env diff --git a/src/app.module.ts b/src/app.module.ts index 5f76bf0..298a6ab 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { TransactionStatusCheckerModule } from './jobs/transaction-status-checke import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module'; import { SupabaseKeepAliveModule } from './jobs/supabase-keepalive/supabase-keepalive.module'; import { StellarModule } from './stellar/stellar.module'; +import { SequenceManagerModule } from './blockchain/sequence-manager/sequence-manager.module'; import { LoggerModule } from './common/logger/logger.module'; import { MetricsModule } from './modules/metrics/metrics.module'; import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.module'; @@ -63,6 +64,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa CreditScoringModule, AdminModule, StellarModule, + SequenceManagerModule, ], controllers: [], providers: [ diff --git a/src/blockchain/sequence-manager/sequence-manager.metrics.ts b/src/blockchain/sequence-manager/sequence-manager.metrics.ts new file mode 100644 index 0000000..e27e289 --- /dev/null +++ b/src/blockchain/sequence-manager/sequence-manager.metrics.ts @@ -0,0 +1,115 @@ +import { Injectable, Optional } from '@nestjs/common'; +import { Counter, Gauge } from 'prom-client'; +import { + InjectMetric, + makeCounterProvider, + makeGaugeProvider, +} from '@willsoto/nestjs-prometheus'; + +/** + * Metric names exported via the global Prometheus registry. + * The local blockchain module only registers / observes these — the + * `MetricsModule` already exposes them on `/metrics` because the providers + * are imported by `SequenceManagerModule`. + */ +export const SOURCE_SUBMISSIONS_IN_FLIGHT = + 'blockchain_source_submissions_in_flight'; +export const SOURCE_BAD_SEQ_RETRIES_TOTAL = + 'blockchain_source_bad_seq_retries_total'; +export const SOURCE_SUBMISSIONS_TOTAL = + 'blockchain_source_submissions_total'; +export const SOURCE_NEXT_SEQUENCE = 'blockchain_source_next_sequence'; + +export const sequenceManagerMetricProviders = [ + makeGaugeProvider({ + name: SOURCE_SUBMISSIONS_IN_FLIGHT, + help: 'Number of server-signed Stellar transactions currently in flight, grouped by source account', + labelNames: ['source_account'] as const, + }), + makeCounterProvider({ + name: SOURCE_BAD_SEQ_RETRIES_TOTAL, + help: 'Number of tx_bad_seq re-sync retries performed by the sequence manager, grouped by source account', + labelNames: ['source_account'] as const, + }), + makeCounterProvider({ + name: SOURCE_SUBMISSIONS_TOTAL, + help: 'Number of server-signed Stellar transaction submission attempts, grouped by source account and outcome', + labelNames: ['source_account', 'outcome'] as const, + }), + makeGaugeProvider({ + name: SOURCE_NEXT_SEQUENCE, + help: 'Next sequence number expected to be used by the sequence manager, grouped by source account', + labelNames: ['source_account'] as const, + }), +]; + +/** + * Thin helper for emitting sequence-manager metrics. Injectable so the + * service can be unit-tested without a Nest module, but registered as a + * global provider by `SequenceManagerModule`. + */ +@Injectable() +export class SequenceMetrics { + constructor( + @Optional() + @InjectMetric(SOURCE_SUBMISSIONS_IN_FLIGHT) + private readonly inFlight?: Gauge, + @Optional() + @InjectMetric(SOURCE_BAD_SEQ_RETRIES_TOTAL) + private readonly badSeqRetries?: Counter, + @Optional() + @InjectMetric(SOURCE_SUBMISSIONS_TOTAL) + private readonly submissions?: Counter, + @Optional() + @InjectMetric(SOURCE_NEXT_SEQUENCE) + private readonly nextSequence?: Gauge, + ) {} + + incInFlight(publicKey: string): void { + this.inFlight?.labels(publicKey).inc(); + } + + decInFlight(publicKey: string): void { + this.inFlight?.labels(publicKey).dec(); + } + + setInFlight(labeledCount: number): void { + this.inFlight?.set(labeledCount); + } + + incBadSeqRetry(publicKey: string): void { + this.badSeqRetries?.labels(publicKey).inc(); + } + + resetBadSeqRetries(): void { + this.badSeqRetries?.reset(); + } + + incSubmitted(publicKey: string): void { + this.submissions?.labels(publicKey, 'success').inc(); + } + + incSubmissionError(publicKey: string, outcome: string): void { + this.submissions?.labels(publicKey, outcome).inc(); + } + + observeSequence(publicKey: string, sequence: bigint): void { + this.nextSequence?.labels(publicKey).set(Number(sequence)); + } + + /** + * Test helper — snapshots the labeled values of the bad-seq counter so + * unit tests can assert the metric actually incremented. Returns an + * empty array if the metric is unmocked in the test module. + */ + async badSeqRetriesSnapshot(): Promise< + Array<{ labels: { source_account: string }; value: number }> + > { + if (!this.badSeqRetries) return []; + const metric = await this.badSeqRetries.get(); + return metric.values.map((v) => ({ + labels: v.labels as { source_account: string }, + value: v.value, + })); + } +} diff --git a/src/blockchain/sequence-manager/sequence-manager.module.ts b/src/blockchain/sequence-manager/sequence-manager.module.ts new file mode 100644 index 0000000..1d1b4a6 --- /dev/null +++ b/src/blockchain/sequence-manager/sequence-manager.module.ts @@ -0,0 +1,28 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { + SequenceManagerService, +} from './sequence-manager.service'; +import { + SequenceMetrics, + sequenceManagerMetricProviders, +} from './sequence-manager.metrics'; + +/** + * Global module so the sequence manager is available everywhere without a + * per-feature import dance. Metric providers attach to the prom-client + * global registry that `MetricsModule` exposes; we deliberately do NOT + * call `PrometheusModule.register()` here so we don't create a second + * Prometheus controller/registry. + */ +@Global() +@Module({ + imports: [ConfigModule], + providers: [ + ...sequenceManagerMetricProviders, + SequenceMetrics, + SequenceManagerService, + ], + exports: [SequenceManagerService, SequenceMetrics], +}) +export class SequenceManagerModule {} diff --git a/src/blockchain/sequence-manager/sequence-manager.service.ts b/src/blockchain/sequence-manager/sequence-manager.service.ts new file mode 100644 index 0000000..ac3011c --- /dev/null +++ b/src/blockchain/sequence-manager/sequence-manager.service.ts @@ -0,0 +1,432 @@ +import { + Injectable, + Logger, + OnModuleInit, + Optional, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as StellarSdk from 'stellar-sdk'; +import { SequenceMetrics } from './sequence-manager.metrics'; + +/** + * Builder callback for a server-signed Stellar transaction. + * + * Receives an `Account` already populated with the source public key and the + * next available sequence number (managed by `SequenceManagerService`). The + * caller adds operations, time bounds, and memo; the manager signs and + * submits before returning. + */ +export type ServerTransactionBuilder = ( + account: StellarSdk.Account, +) => StellarSdk.Transaction; + +export interface ServerTransactionSpec { + build: ServerTransactionBuilder; +} + +export interface ServerTransactionResult { + hash: string; + sourceAccount: string; + sequence: string; +} + +interface ManagedAccountState { + publicKey: string; + secretKey: StellarSdk.Keypair; + /** Tail of the per-account serialization promise chain. */ + inflight: Promise; + /** Tail of the per-account Horizon re-sync lock chain. */ + reSyncLock: Promise; + /** + * The sequence number about to be used for the *next* submission on + * this account. Invariant: starts at `onChain + 1` after every sync, + * and is incremented to `onChain + 2` only after Horizon confirms + * acceptance of the transaction that used `onChain + 1`. + */ + pendingSequence: bigint | null; +} + +/** + * Sequence-number allocator for server-signed Stellar transactions. + * + * Problem + * ------- + * Server-submitted transactions (loan funding, default detection, admin + * ops) all need to come from a protocol-controlled source account. Each + * transaction must carry a sequence number that is exactly one greater than + * the previously submitted transaction from that account. Without + * coordination, concurrent callers fetch the same sequence and the second + * submission fails with `tx_bad_seq`. + * + * Solution + * -------- + * - One or more Stripe-style "channel accounts" are pre-loaded from + * configuration (env vars `STELLAR_SOURCE_ACCOUNT_SECRET` and + * `STELLAR_CHANNEL_ACCOUNTS`). + * - Each account keeps a monotonically increasing `nextSequence` counter, + * cached in memory and refreshed from Horizon on startup or on + * `tx_bad_seq` rejection. + * - Submissions for a single account are serialized through an in-memory + * promise chain: callers run ordered, never parallel, on the same + * source. Channel pools restore true parallelism by fanning out across + * independent accounts, each with its own sequence counter. + * + * Hard invariants + * --------------- + * - Sequence numbers handed out by this manager are unique per account. + * - On Horizon rejection of `tx_bad_seq`, the manager re-syncs the + * counter from Horizon and retries up to `STELLAR_BAD_SEQ_MAX_RETRIES` + * additional times (default 3). + * - If the manager is configured with no source account, every public + * method is a no-op and `submitServerTransaction` throws — this is + * intentional so the rest of the API can boot in dev/CI without + * Stellar credentials. + */ +@Injectable() +export class SequenceManagerService implements OnModuleInit { + private readonly logger = new Logger(SequenceManagerService.name); + private readonly horizonServer: StellarSdk.Horizon.Server; + private readonly networkPassphrase: string; + private readonly maxRetries: number; + + private readonly accounts: ManagedAccountState[] = []; + private nextAccountIndex = 0; + + constructor( + private readonly configService: ConfigService, + @Optional() private readonly metrics?: SequenceMetrics, + ) { + const horizonUrl = + this.configService.get('STELLAR_HORIZON_URL') || + 'https://horizon-testnet.stellar.org'; + + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') || + StellarSdk.Networks.TESTNET; + + this.horizonServer = new StellarSdk.Horizon.Server(horizonUrl); + + const retries = + this.configService.get('STELLAR_BAD_SEQ_MAX_RETRIES') ?? '3'; + this.maxRetries = Math.max( + 1, + Number.parseInt(retries, 10) || 3, + ); + + this.logger.log(`SequenceManagerService Horizon initialized: ${horizonUrl}`); + + if (this.metrics) { + this.metrics.setInFlight(0); + this.metrics.resetBadSeqRetries(); + } + } + + onModuleInit(): void { + this.initializeAccounts(); + } + + /** + * Returns true if at least one managed account is configured and ready. + */ + isEnabled(): boolean { + return this.accounts.length > 0; + } + + /** + * Number of managed accounts (1 source + N channel). + */ + getAccountCount(): number { + return this.accounts.length; + } + + /** + * Public keys of every managed account, in allocation order. + */ + getManagedAccounts(): string[] { + return this.accounts.map((account) => account.publicKey); + } + + /** + * Submit a server-signed transaction through a managed account. + * + * Round-robins across the configured source + channel accounts. Each + * account has its own serialization mutex and sequence counter, so + * channel pools enable true concurrent in-flight transactions while a + * single source account guarantees strict ordering. + */ + async submitServerTransaction( + spec: ServerTransactionSpec, + ): Promise { + const totalAccounts = this.accounts.length; + if (totalAccounts === 0) { + throw new Error( + 'SequenceManager: no source account configured (set STELLAR_SOURCE_ACCOUNT_SECRET)', + ); + } + + const account = this.acquireNextAccount(); + if (this.metrics) { + this.metrics.incInFlight(account.publicKey); + } + + try { + return await this.runSerialized(account, () => + this.submitWithRetries(account, spec), + ); + } finally { + if (this.metrics) { + this.metrics.decInFlight(account.publicKey); + } + } + } + + private acquireNextAccount(): ManagedAccountState { + const account = this.accounts[this.nextAccountIndex]!; + this.nextAccountIndex = (this.nextAccountIndex + 1) % this.accounts.length; + return account; + } + + /** + * Run `fn` after every previously enqueued call on this account has + * settled. Releases the lock whether `fn` resolves or rejects, so a + * single bad submission does not wedge the channel. + */ + private runSerialized( + account: ManagedAccountState, + fn: () => Promise, + ): Promise { + const previous = account.inflight; + let release!: () => void; + const next = new Promise((resolve) => { + release = resolve; + }); + account.inflight = next; + + return previous + .catch(() => undefined) + .then(() => fn()) + .finally(() => { + release(); + }); + } + + private async submitWithRetries( + account: ManagedAccountState, + spec: ServerTransactionSpec, + ): Promise { + if (account.pendingSequence === null) { + account.pendingSequence = await this.fetchPendingSequence(account); + } + + const totalAttempts = this.maxRetries + 1; + + for (let attempt = 1; attempt <= totalAttempts; attempt += 1) { + const sdkAccount = new StellarSdk.Account( + account.publicKey, + account.pendingSequence.toString(), + ); + const transaction = spec.build(sdkAccount); + transaction.sign(account.secretKey); + + try { + const response = await this.horizonServer.submitTransaction(transaction); + const usedSequence = account.pendingSequence; + account.pendingSequence = account.pendingSequence + 1n; + + if (this.metrics) { + this.metrics.observeSequence(account.publicKey, account.pendingSequence); + this.metrics.incSubmitted(account.publicKey); + } + + return { + hash: response.hash, + sourceAccount: account.publicKey, + sequence: usedSequence.toString(), + }; + } catch (error) { + if (!this.isBadSeqError(error)) { + if (this.metrics) { + this.metrics.incSubmissionError(account.publicKey, this.classifyError(error)); + } + throw error; + } + + if (this.metrics) { + this.metrics.incBadSeqRetry(account.publicKey); + } + + this.logger.warn( + `SequenceManager: tx_bad_seq on attempt ${attempt} for ${account.publicKey} — re-syncing from Horizon`, + ); + + account.pendingSequence = await this.fetchPendingSequence(account); + + if (attempt >= totalAttempts) { + if (this.metrics) { + this.metrics.incSubmissionError(account.publicKey, 'tx_bad_seq_exhausted'); + } + throw new Error( + `SequenceManager: tx_bad_seq persisted after ${totalAttempts} attempts for ${account.publicKey}`, + ); + } + } + } + + throw new Error('SequenceManager: unreachable — submission loop exited without result'); + } + + /** + * Read the on-chain sequence number from Horizon and return the next + * sequence usable for a new transaction. Multiple concurrent re-sync + * requests for the same account share a single Horizon call through a + * per-account lock chain. + */ + private async fetchPendingSequence(account: ManagedAccountState): Promise { + const previous = account.reSyncLock; + let release!: () => void; + const next = new Promise((resolve) => { + release = resolve; + }); + account.reSyncLock = next; + + try { + await previous.catch(() => undefined); + + try { + const response = await this.horizonServer.loadAccount(account.publicKey); + // Horizon.AccountResponse.sequence is a string; convert to BigInt so + // arithmetic stays correct as the protocol age grows. + const onChain = this.readSequenceNumber(response); + return onChain + 1n; + } catch (error) { + if (error instanceof StellarSdk.NotFoundError) { + throw new Error( + `SequenceManager: source account ${account.publicKey} not found on Horizon`, + ); + } + throw error; + } + } finally { + release(); + } + } + + /** + * Read the on-chain sequence number from whichever Horizon response + * shape the SDK exposes. Different SDK versions expose the value as + * `sequence` or `sequenceNumber`, and TS bindings treat the latter as + * a method — accept either without wandering into `any`. + */ + private readSequenceNumber(response: unknown): bigint { + const candidate = response as { + sequence?: string | number | bigint; + sequenceNumber?: string | number | (() => string | number); + }; + + const raw = + candidate.sequence ?? + (typeof candidate.sequenceNumber === 'function' + ? candidate.sequenceNumber() + : candidate.sequenceNumber); + + if (raw === undefined || raw === null) { + throw new Error('SequenceManager: Horizon account response missing sequence field'); + } + + return typeof raw === 'bigint' ? raw : BigInt(raw); + } + + private isBadSeqError(error: unknown): boolean { + const resultCodes = this.extractResultCodes(error); + return resultCodes?.transaction === 'tx_bad_seq'; + } + + private classifyError(error: unknown): string { + const resultCodes = this.extractResultCodes(error); + if (resultCodes?.transaction) { + return resultCodes.transaction; + } + const message = (error as { message?: string })?.message ?? 'unknown'; + if (message.toLowerCase().includes('timeout')) return 'timeout'; + if (message.toLowerCase().includes('network')) return 'network'; + return 'error'; + } + + private extractResultCodes( + error: unknown, + ): { transaction?: string; operations?: string[] } | null { + const candidate = error as { + response?: { + data?: { + extras?: { result_codes?: { transaction?: string; operations?: string[] } }; + }; + }; + }; + return candidate?.response?.data?.extras?.result_codes ?? null; + } + + private initializeAccounts(): void { + const sourceSecret = + this.configService.get('STELLAR_SOURCE_ACCOUNT_SECRET'); + + const channelSecrets = ( + this.configService.get('STELLAR_CHANNEL_ACCOUNTS') ?? '' + ) + .split(',') + .map((secret) => secret.trim()) + .filter((secret) => secret.length > 0); + + const allSecrets = [ + ...(sourceSecret ? [sourceSecret] : []), + ...channelSecrets, + ]; + + if (allSecrets.length === 0) { + this.logger.warn( + 'SequenceManager: STELLAR_SOURCE_ACCOUNT_SECRET not set — server-signed submissions are disabled', + ); + return; + } + + let dedupedDuplicates = 0; + for (const secret of allSecrets) { + try { + const keypair = StellarSdk.Keypair.fromSecret(secret); + const publicKey = keypair.publicKey(); + + if (this.accounts.some((existing) => existing.publicKey === publicKey)) { + dedupedDuplicates += 1; + this.logger.warn( + `SequenceManager: skipping duplicate account ${publicKey} (same secret repeated or channel collision with source)`, + ); + continue; + } + + this.accounts.push({ + publicKey, + secretKey: keypair, + inflight: Promise.resolve(), + reSyncLock: Promise.resolve(), + pendingSequence: null, + }); + } catch (error) { + this.logger.error( + `SequenceManager: failed to load secret key (${(error as Error).message.slice(0, 80)})`, + ); + } + } + + if (this.accounts.length === 0) { + this.logger.error( + 'SequenceManager: no valid accounts configured — server-signed submissions are disabled', + ); + return; + } + + const channelCount = this.accounts.length - (sourceSecret ? 1 : 0); + this.logger.log( + `SequenceManager: ${this.accounts.length} managed account(s) ` + + `(${sourceSecret ? '1 source' : '0 source'} + ${channelCount} channel, ${dedupedDuplicates} duplicate(s) skipped)`, + ); + } +} diff --git a/src/modules/blockchain/blockchain.module.ts b/src/modules/blockchain/blockchain.module.ts index 3b20dbf..b3643a3 100644 --- a/src/modules/blockchain/blockchain.module.ts +++ b/src/modules/blockchain/blockchain.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { BlockchainService } from './blockchain.service'; +import { SequenceManagerModule } from '../../blockchain/sequence-manager/sequence-manager.module'; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, SequenceManagerModule], providers: [BlockchainService], exports: [BlockchainService], }) diff --git a/src/modules/blockchain/blockchain.service.ts b/src/modules/blockchain/blockchain.service.ts index 1f94532..fe2ebb9 100644 --- a/src/modules/blockchain/blockchain.service.ts +++ b/src/modules/blockchain/blockchain.service.ts @@ -7,6 +7,21 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as StellarSdk from 'stellar-sdk'; +import { + SequenceManagerService, + ServerTransactionResult, + ServerTransactionSpec, +} from '../../blockchain/sequence-manager/sequence-manager.service'; + +// Distinct surface-area error codes so client UX can recognise tx_bad_seq +// from server submissions versus user-signed transactions. +const SERVER_TX_RESULT_MAP: Record = { + tx_bad_seq: + 'Server sequence number went stale. The transaction was resubmitted automatically; please retry.', + tx_bad_auth: 'Invalid server signing key — contact the StepFi team.', + tx_insufficient_fee: 'Server fee is too low for the network — retry with a higher fee.', + op_underfunded: 'Source server account is underfunded — top it up.', +}; @Injectable() export class BlockchainService { @@ -14,7 +29,10 @@ export class BlockchainService { private readonly horizonServer: StellarSdk.Horizon.Server; private readonly networkPassphrase: string; - constructor(private readonly configService: ConfigService) { + constructor( + private readonly configService: ConfigService, + private readonly sequenceManager: SequenceManagerService, + ) { const horizonUrl = this.configService.get('STELLAR_HORIZON_URL') || 'https://horizon-testnet.stellar.org'; @@ -27,6 +45,10 @@ export class BlockchainService { this.logger.log(`BlockchainService Horizon client initialized: ${horizonUrl}`); } + isServerSubmissionEnabled(): boolean { + return this.sequenceManager.isEnabled(); + } + async submitRepayment(signedXdr: string): Promise<{ transactionHash: string }> { const transaction = this.parseTransaction(signedXdr); @@ -37,6 +59,29 @@ export class BlockchainService { return { transactionHash: hash }; } + /** + * Submit a server-signed transaction through the managed source account + * (or a channel-account pool when configured). The caller supplies a + * builder that adds operations; the sequence manager assigns the next + * sequence number, signs, and submits — transparently re-syncing on + * `tx_bad_seq` or restart. + */ + async submitServerTransaction( + spec: ServerTransactionSpec, + ): Promise<{ transactionHash: string; sourceAccount: string; sequence: string }> { + try { + const result: ServerTransactionResult = + await this.sequenceManager.submitServerTransaction(spec); + return { + transactionHash: result.hash, + sourceAccount: result.sourceAccount, + sequence: result.sequence, + }; + } catch (error) { + this.handleServerSubmissionError(error); + } + } + private parseTransaction(signedXdr: string): StellarSdk.Transaction { try { const parsed = StellarSdk.TransactionBuilder.fromXDR( @@ -108,21 +153,7 @@ export class BlockchainService { } private handleHorizonError(error: unknown): never { - const err = error as { - response?: { - data?: { - extras?: { - result_codes?: { - transaction?: string; - operations?: string[]; - }; - }; - }; - }; - message?: string; - }; - - const resultCodes = err?.response?.data?.extras?.result_codes; + const resultCodes = this.extractResultCodes(error); if (resultCodes) { const txCode = resultCodes.transaction; @@ -135,7 +166,7 @@ export class BlockchainService { throw new BadRequestException({ code, message }); } - const message = err?.message ?? 'Unknown error'; + const message = String((error as { message?: string })?.message ?? 'Unknown error'); if ( message.toLowerCase().includes('timeout') || @@ -155,4 +186,63 @@ export class BlockchainService { 'Failed to submit transaction to the Stellar network. Please try again.', }); } + + private handleServerSubmissionError(error: unknown): never { + // The sequence-manager passes Horizon errors through, so we can + // surface known Stellar result codes here too. + const resultCodes = this.extractResultCodes(error); + if (resultCodes?.transaction) { + const txCode = resultCodes.transaction; + const friendly = SERVER_TX_RESULT_MAP[txCode]; + throw new BadRequestException({ + code: `STELLAR_${txCode.toUpperCase()}`, + message: friendly ?? `Server transaction rejected: ${txCode}`, + }); + } + + const message = String((error as { message?: string })?.message ?? 'Unknown error'); + + if (message.startsWith('SequenceManager:')) { + // Configuration / sequence-state errors raised by the manager. + this.logger.error(`Sequence manager error: ${message}`); + throw new InternalServerErrorException({ + code: 'STELLAR_SEQUENCE_MANAGER_FAILED', + message, + }); + } + + if ( + message.toLowerCase().includes('timeout') || + message.toLowerCase().includes('network') + ) { + throw new ServiceUnavailableException({ + code: 'STELLAR_NETWORK_UNAVAILABLE', + message: + 'Stellar network is temporarily unavailable. Please try again later.', + }); + } + + this.logger.error(`Server submission error: ${message}`); + throw new InternalServerErrorException({ + code: 'STELLAR_SUBMISSION_FAILED', + message: + 'Failed to submit server transaction to the Stellar network. Please try again.', + }); + } + + private extractResultCodes( + error: unknown, + ): { transaction?: string; operations?: string[] } | null { + const candidate = error as { + response?: { + data?: { + extras?: { + result_codes?: { transaction?: string; operations?: string[] }; + }; + }; + }; + message?: string; + }; + return candidate?.response?.data?.extras?.result_codes ?? null; + } } diff --git a/test/unit/blockchain/sequence-manager/sequence-manager.service.spec.ts b/test/unit/blockchain/sequence-manager/sequence-manager.service.spec.ts new file mode 100644 index 0000000..9d25bd3 --- /dev/null +++ b/test/unit/blockchain/sequence-manager/sequence-manager.service.spec.ts @@ -0,0 +1,442 @@ +import { ConfigService } from '@nestjs/config'; +import { TestingModule, Test } from '@nestjs/testing'; +import * as StellarSdk from 'stellar-sdk'; +import { + SequenceManagerService, +} from '../../../../src/blockchain/sequence-manager/sequence-manager.service'; +import { + SequenceMetrics, + SOURCE_SUBMISSIONS_IN_FLIGHT, + SOURCE_BAD_SEQ_RETRIES_TOTAL, + SOURCE_SUBMISSIONS_TOTAL, + SOURCE_NEXT_SEQUENCE, +} from '../../../../src/blockchain/sequence-manager/sequence-manager.metrics'; + +const mockLoadAccount = jest.fn(); +const mockSubmitTransaction = jest.fn(); + +jest.mock('stellar-sdk', () => { + const actual = jest.requireActual('stellar-sdk'); + return { + ...actual, + Horizon: { + ...actual.Horizon, + Server: jest.fn().mockImplementation(() => ({ + loadAccount: mockLoadAccount, + submitTransaction: mockSubmitTransaction, + })), + }, + }; +}); + +// ───────────────────────── real keypairs (round-trip cleanly with fromSecret) ───────────────────────── + +const SOURCE_KP = StellarSdk.Keypair.random(); +const CHANNEL_KP = StellarSdk.Keypair.random(); +const SOURCE_PUBLIC = SOURCE_KP.publicKey(); +const CHANNEL_PUBLIC = CHANNEL_KP.publicKey(); +const DESTINATION_PUBLIC = StellarSdk.Keypair.random().publicKey(); + +interface BuildServiceOptions { + // `true` means run with NO source / channel accounts configured. `false` + // (or omitted) means use the default source + channel pair from real + // keypairs, so initializeAccounts populates this.accounts. + noAccounts?: boolean; + // Override the channel-account list (empty by default for single-source + // tests). Each entry is a real Stellar secret (start with 'S'). + channelSecrets?: string[]; + maxRetries?: string; +} + +function buildConfig(opts: BuildServiceOptions = {}): { + get: (key: string) => string | undefined; +} { + return { + get: (key: string): string | undefined => { + if (key === 'STELLAR_HORIZON_URL') return 'https://horizon-testnet.stellar.org'; + if (key === 'STELLAR_NETWORK_PASSPHRASE') return StellarSdk.Networks.TESTNET; + if (key === 'STELLAR_SOURCE_ACCOUNT_SECRET') { + return opts.noAccounts ? undefined : SOURCE_KP.secret(); + } + if (key === 'STELLAR_CHANNEL_ACCOUNTS') { + const secrets = opts.channelSecrets ?? (opts.noAccounts ? [] : [CHANNEL_KP.secret()]); + return secrets.join(','); + } + if (key === 'STELLAR_BAD_SEQ_MAX_RETRIES') { + return opts.maxRetries ?? '3'; + } + return undefined; + }, + }; +} + +function buildPaymentTx(account: StellarSdk.Account): StellarSdk.Transaction { + return new StellarSdk.TransactionBuilder(account, { + fee: '100', + networkPassphrase: StellarSdk.Networks.TESTNET, + }) + .addOperation( + StellarSdk.Operation.payment({ + destination: DESTINATION_PUBLIC, + asset: StellarSdk.Asset.native(), + amount: '1', + }), + ) + .setTimeout(30) + .build(); +} + +/** + * Build a fresh SequenceMetrics mock whose methods are jest.fn spies. + * Injecting via useValue bypasses @InjectMetric/Prometheus entirely so + * tests don't need to wire @willsoto/nestjs-prometheus into the test + * module (each PrometheusModule.register() call constructs its own + * scoped counter/Gauge namespace, which would otherwise make snapshots + * brittle). + */ +function buildMockMetrics(): jest.Mocked { + return { + incInFlight: jest.fn(), + decInFlight: jest.fn(), + setInFlight: jest.fn(), + incBadSeqRetry: jest.fn(), + resetBadSeqRetries: jest.fn(), + incSubmitted: jest.fn(), + incSubmissionError: jest.fn(), + observeSequence: jest.fn(), + badSeqRetriesSnapshot: jest.fn(), + } as unknown as jest.Mocked; +} + +async function makeModule( + opts: BuildServiceOptions = {}, +): Promise<{ service: SequenceManagerService; metrics: jest.Mocked }> { + jest.clearAllMocks(); + + const mockMetrics = buildMockMetrics(); + + const moduleRef: TestingModule = await Test.createTestingModule({ + providers: [ + SequenceManagerService, + { provide: ConfigService, useValue: buildConfig(opts) }, + { provide: SequenceMetrics, useValue: mockMetrics }, + // Also bind the metric tokens so @InjectMetric in + // SequenceManagerService.@Optional SequenceMetrics doesn't trip. + { + provide: SOURCE_SUBMISSIONS_IN_FLIGHT, + useValue: { inc: jest.fn(), dec: jest.fn(), labels: jest.fn().mockReturnThis(), set: jest.fn() }, + }, + { + provide: SOURCE_BAD_SEQ_RETRIES_TOTAL, + useValue: { inc: jest.fn(), labels: jest.fn().mockReturnThis(), reset: jest.fn() }, + }, + { + provide: SOURCE_SUBMISSIONS_TOTAL, + useValue: { inc: jest.fn(), labels: jest.fn().mockReturnThis() }, + }, + { + provide: SOURCE_NEXT_SEQUENCE, + useValue: { set: jest.fn(), labels: jest.fn().mockReturnThis() }, + }, + ], + }).compile(); + + // Nest's compile() does NOT auto-fire lifecycle hooks in @nestjs/testing. + // init() is what actually runs onModuleInit() so initializeAccounts + // populates this.accounts from config. + await moduleRef.init(); + + return { + service: moduleRef.get(SequenceManagerService), + metrics: mockMetrics, + }; +} + +describe('SequenceManagerService', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + // ───────────────────────── config parsing ───────────────────────── + + describe('when no source account is configured', () => { + it('reports disabled and rejects submissions', async () => { + const { service } = await makeModule({ noAccounts: true }); + + expect(service.isEnabled()).toBe(false); + expect(service.getAccountCount()).toBe(0); + + await expect( + service.submitServerTransaction({ build: buildPaymentTx }), + ).rejects.toThrow(/no source account configured/i); + }); + }); + + describe('when source and channel accounts are configured', () => { + it('exposes both accounts and reports enabled', async () => { + const { service } = await makeModule(); + expect(service.isEnabled()).toBe(true); + expect(service.getAccountCount()).toBe(2); + const accounts = service.getManagedAccounts(); + expect(accounts).toContain(SOURCE_PUBLIC); + expect(accounts).toContain(CHANNEL_PUBLIC); + }); + + it('deduplicates channel accounts whose public key collides with the source', async () => { + const { service } = await makeModule({ + channelSecrets: [SOURCE_KP.secret(), CHANNEL_KP.secret()], + }); + expect(service.getAccountCount()).toBe(2); + }); + + it('deduplicates channels that collide with each other', async () => { + const { service } = await makeModule({ + channelSecrets: [ + CHANNEL_KP.secret(), + CHANNEL_KP.secret(), + SOURCE_KP.secret(), + ], + }); + expect(service.getAccountCount()).toBe(2); + expect(service.getManagedAccounts()).toEqual( + expect.arrayContaining([CHANNEL_PUBLIC, SOURCE_PUBLIC]), + ); + }); + }); + + // ───────────────────────── concurrency on a single account ───────────────────────── + + describe('parallel submissions on a single-source config', () => { + it('hands out monotonic, unique sequence numbers to every caller', async () => { + const { service, metrics } = await makeModule({ + channelSecrets: [], + }); + expect(service.getAccountCount()).toBe(1); + + // Use mockResolvedValueOnce on the FIRST loadAccount call so that + // a stale resolved value from a prior test cannot bleed into this + // one — clearing the mock via resetAllMocks would also work but + // mockResolvedValueOnce is the more targeted reset. + mockLoadAccount.mockReset(); + mockLoadAccount.mockResolvedValue({ sequence: '100' }); + const recordedSequences = new Set(); + mockSubmitTransaction.mockReset(); + mockSubmitTransaction.mockImplementation( + async (tx: unknown) => { + // Coerce to string for Set membership — stellar-sdk v11 may + // shape tx.sequence as a BigInt-shaped value at runtime. + const sequence: string = String((tx as { sequence: unknown }).sequence); + if (recordedSequences.has(sequence)) { + throw new Error(`Sequence ${sequence} was used twice`); + } + recordedSequences.add(sequence); + return { hash: `hash-${sequence}` }; + }, + ); + + const results = await Promise.all( + Array.from({ length: 10 }, () => + service.submitServerTransaction({ build: buildPaymentTx }), + ), + ); + + // Exactly 10 submissions, all unique sequences, all on the source + // account, Horizon was hit only once for the initial sync. + expect(mockSubmitTransaction).toHaveBeenCalledTimes(10); + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + expect(results.every((r) => r.sourceAccount === SOURCE_PUBLIC)).toBe(true); + expect(results.every((r) => BigInt(r.sequence) > 0n)).toBe(true); + expect(recordedSequences.size).toBe(10); + // All recorded sequences must be distinct integers strictly + // monotonically increasing by exactly 1n. We assert ordering on + // the service's returned sequence numbers (which is the source of + // truth — recordedSequences is just to detect duplicates). + const returnedSequences = results + .map((r) => BigInt(r.sequence)) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + for (let i = 1; i < returnedSequences.length; i += 1) { + const prev = returnedSequences[i - 1]!; + const curr = returnedSequences[i]!; + expect(curr - prev).toBe(1n); + } + expect(returnedSequences[0]).toBeGreaterThanOrEqual(101n); + expect(metrics.incSubmitted).toHaveBeenCalledTimes(10); + }); + }); + + // ───────────────────────── tx_bad_seq ───────────────────────── + + describe('tx_bad_seq handling', () => { + it('re-syncs from Horizon, retries, and eventually succeeds', async () => { + const { service } = await makeModule({ channelSecrets: [] }); + + mockLoadAccount + .mockResolvedValueOnce({ sequence: '100' }) + .mockResolvedValueOnce({ sequence: '200' }); + mockSubmitTransaction + .mockRejectedValueOnce({ + response: { + data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } }, + }, + }) + .mockResolvedValueOnce({ hash: 'recovered-hash' }); + + const result = await service.submitServerTransaction({ + build: buildPaymentTx, + }); + + expect(result.hash).toBe('recovered-hash'); + expect(result.sourceAccount).toBe(SOURCE_PUBLIC); + expect(mockSubmitTransaction).toHaveBeenCalledTimes(2); + expect(mockLoadAccount).toHaveBeenCalledTimes(2); + }); + + it('throws when tx_bad_seq exhausts the retry budget', async () => { + const { service } = await makeModule({ + channelSecrets: [], + maxRetries: '2', + }); + + mockLoadAccount.mockResolvedValue({ sequence: '0' }); + mockSubmitTransaction.mockRejectedValue({ + response: { + data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } }, + }, + }); + + await expect( + service.submitServerTransaction({ build: buildPaymentTx }), + ).rejects.toThrow(/tx_bad_seq persisted/i); + // initial + 2 retries = 3 attempts + expect(mockSubmitTransaction).toHaveBeenCalledTimes(3); + }); + + it('increments the bad-seq counter for every retry', async () => { + const { service, metrics } = await makeModule({ + channelSecrets: [], + maxRetries: '3', + }); + + mockLoadAccount.mockResolvedValue({ sequence: '0' }); + mockSubmitTransaction.mockRejectedValue({ + response: { + data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } }, + }, + }); + + await expect( + service.submitServerTransaction({ build: buildPaymentTx }), + ).rejects.toThrow(/tx_bad_seq persisted/i); + + // Initial attempt + 3 retries = 4 attempts all rejected → 4 increments. + expect(metrics.incBadSeqRetry).toHaveBeenCalledTimes(4); + for (const call of metrics.incBadSeqRetry.mock.calls) { + expect(call[0]).toBe(SOURCE_PUBLIC); + } + // And the error-tagged counter should reflect 'tx_bad_seq_exhausted'. + expect(metrics.incSubmissionError).toHaveBeenCalledWith( + SOURCE_PUBLIC, + 'tx_bad_seq_exhausted', + ); + }); + }); + + // ───────────────────────── restart re-sync ───────────────────────── + + describe('fresh manager always re-syncs from Horizon on first use', () => { + it('reads the latest sequence from Horizon before the first submission', async () => { + const { service } = await makeModule({ channelSecrets: [] }); + + mockLoadAccount.mockResolvedValue({ sequence: '500' }); + mockSubmitTransaction.mockResolvedValue({ hash: 'fresh-hash' }); + + const result = await service.submitServerTransaction({ + build: buildPaymentTx, + }); + + expect(result).toEqual({ + hash: 'fresh-hash', + sourceAccount: SOURCE_PUBLIC, + sequence: '501', + }); + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + }); + }); + + // ───────────────────────── channel pool round-robin ───────────────────────── + + describe('channel-account pool', () => { + it('spreads parallel callers across channel accounts so they do not collide', async () => { + const { service } = await makeModule(); + + mockLoadAccount.mockResolvedValue({ sequence: '0' }); + mockSubmitTransaction.mockImplementation( + async (tx: StellarSdk.Transaction) => ({ + hash: `hash-${tx.source}-${tx.sequence}`, + }), + ); + + const CALLERS = 7; + const submissions = await Promise.all( + Array.from({ length: CALLERS }, () => + service.submitServerTransaction({ build: buildPaymentTx }), + ), + ); + + expect(submissions).toHaveLength(CALLERS); + const observedPerAccount = submissions.reduce>( + (acc, r) => acc.set(r.sourceAccount, (acc.get(r.sourceAccount) ?? 0) + 1), + new Map(), + ); + expect(observedPerAccount.get(SOURCE_PUBLIC) ?? 0).toBeGreaterThanOrEqual(3); + expect(observedPerAccount.get(CHANNEL_PUBLIC) ?? 0).toBeGreaterThanOrEqual(3); + expect( + (observedPerAccount.get(SOURCE_PUBLIC) ?? 0) + + (observedPerAccount.get(CHANNEL_PUBLIC) ?? 0), + ).toBe(CALLERS); + }); + + it('round-robins strictly: consecutive calls land on different channels', async () => { + const { service } = await makeModule(); + mockLoadAccount.mockResolvedValue({ sequence: '0' }); + mockSubmitTransaction.mockResolvedValue({ hash: 'ok' }); + + const first = await service.submitServerTransaction({ build: buildPaymentTx }); + const second = await service.submitServerTransaction({ build: buildPaymentTx }); + expect(first.sourceAccount).not.toBe(second.sourceAccount); + }); + }); + + // ───────────────────────── non-tx_bad_seq errors ───────────────────────── + + describe('non-tx_bad_seq errors', () => { + it('propagates Horizon errors without retrying', async () => { + const { service } = await makeModule({ channelSecrets: [] }); + + mockLoadAccount.mockResolvedValue({ sequence: '0' }); + mockSubmitTransaction.mockRejectedValue({ + response: { + data: { + extras: { + result_codes: { transaction: 'op_underfunded', operations: [] }, + }, + }, + }, + }); + + await expect( + service.submitServerTransaction({ build: buildPaymentTx }), + ).rejects.toMatchObject({ + response: { + data: { + extras: { + result_codes: { transaction: 'op_underfunded' }, + }, + }, + }, + }); + expect(mockSubmitTransaction).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/unit/modules/blockchain/blockchain.service.spec.ts b/test/unit/modules/blockchain/blockchain.service.spec.ts new file mode 100644 index 0000000..adeb973 --- /dev/null +++ b/test/unit/modules/blockchain/blockchain.service.spec.ts @@ -0,0 +1,138 @@ +import { ConfigService } from '@nestjs/config'; +import { + BadRequestException, + InternalServerErrorException, + ServiceUnavailableException, +} from '@nestjs/common'; +import * as StellarSdk from 'stellar-sdk'; +import { BlockchainService } from '../../../../src/modules/blockchain/blockchain.service'; +import { + SequenceManagerService, +} from '../../../../src/blockchain/sequence-manager/sequence-manager.service'; + +// ──────────────────────────── focused wrapping tests ──────────────────────────── +// +// These cases live here (not in sequence-manager.service.spec.ts) because +// the surface they exercise belongs to BlockchainService, not the +// allocator: how raw Horizon / manager errors become Nest exceptions. +// +// The user-signed handleHorizonError path is covered by +// transactions.service.spec.ts — its tx_bad_seq / tx_bad_auth etc. +// rejection path is unchanged by #82 because the dedicated +// STELLAR_TX_BAD_SEQ branch was reverted in handleHorizonError. + +function buildConfigService(): ConfigService { + return { + get: (key: string) => { + if (key === 'STELLAR_HORIZON_URL') return 'https://horizon-testnet.stellar.org'; + if (key === 'STELLAR_NETWORK_PASSPHRASE') return StellarSdk.Networks.TESTNET; + return undefined; + }, + } as unknown as ConfigService; +} + +function fakeManager( + impl: jest.Mock = jest.fn(), +): SequenceManagerService { + return { + isEnabled: () => true, + getAccountCount: () => 1, + getManagedAccounts: () => ['G'.padEnd(56, 'A')], + submitServerTransaction: impl, + } as unknown as SequenceManagerService; +} + +function buildNoopTx(): StellarSdk.Transaction { + return new StellarSdk.TransactionBuilder( + new StellarSdk.Account('G'.padEnd(56, 'A'), '101'), + { fee: '100', networkPassphrase: StellarSdk.Networks.TESTNET }, + ) + .setTimeout(30) + .build(); +} + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +describe('BlockchainService.submitServerTransaction error wrapping', () => { + it('returns the hash on successful manager result', async () => { + const manager = fakeManager( + jest.fn().mockResolvedValue({ + hash: 'h1', + sourceAccount: 'G'.padEnd(56, 'A'), + sequence: '101', + }), + ); + const service = new BlockchainService(buildConfigService(), manager); + await expect( + service.submitServerTransaction({ build: buildNoopTx }), + ).resolves.toEqual({ + transactionHash: 'h1', + sourceAccount: 'G'.padEnd(56, 'A'), + sequence: '101', + }); + }); + + it('throws BadRequestException with STELLAR_OP_UNDERFUNDED for known Horizon codes', async () => { + const manager = fakeManager( + jest.fn().mockRejectedValue({ + response: { + data: { + extras: { result_codes: { transaction: 'op_underfunded', operations: [] } }, + }, + }, + }), + ); + const service = new BlockchainService(buildConfigService(), manager); + + await expect( + service.submitServerTransaction({ build: buildNoopTx }), + ).rejects.toMatchObject({ response: { code: 'STELLAR_OP_UNDERFUNDED' } }); + }); + + it('throws BadRequestException with STELLAR_TX_BAD_SEQ when the manager observes tx_bad_seq', async () => { + // The server path uses handleServerSubmissionError; if the manager + // re-throws a Horizon tx_bad_seq (e.g. after the retry budget was + // exhausted), the wrapper maps it to STELLAR_TX_BAD_SEQ. + const manager = fakeManager( + jest.fn().mockRejectedValue({ + response: { + data: { extras: { result_codes: { transaction: 'tx_bad_seq' } } }, + }, + }), + ); + const service = new BlockchainService(buildConfigService(), manager); + + await expect( + service.submitServerTransaction({ build: buildNoopTx }), + ).rejects.toMatchObject({ response: { code: 'STELLAR_TX_BAD_SEQ' } }); + }); + + it('throws InternalServerErrorException for SequenceManager-prefixed errors', async () => { + const manager = fakeManager( + jest.fn().mockRejectedValue( + new Error('SequenceManager: source account unfunded'), + ), + ); + const service = new BlockchainService(buildConfigService(), manager); + + await expect( + service.submitServerTransaction({ build: buildNoopTx }), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('throws ServiceUnavailableException for transient network errors', async () => { + const manager = fakeManager( + jest.fn().mockRejectedValue( + new Error('connection timeout while contacting Horizon'), + ), + ); + const service = new BlockchainService(buildConfigService(), manager); + + await expect( + service.submitServerTransaction({ build: buildNoopTx }), + ).rejects.toThrow(ServiceUnavailableException); + }); +}); From 987670107813a36ef7280b4391a3113d4708bbaf Mon Sep 17 00:00:00 2001 From: Charity Irone Date: Wed, 22 Jul 2026 14:13:28 +0000 Subject: [PATCH 2/2] fix: report usedSequence in gauge, remove redundant reSyncLock (PR #87 review) --- .../sequence-manager.service.ts | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/src/blockchain/sequence-manager/sequence-manager.service.ts b/src/blockchain/sequence-manager/sequence-manager.service.ts index ac3011c..968ff54 100644 --- a/src/blockchain/sequence-manager/sequence-manager.service.ts +++ b/src/blockchain/sequence-manager/sequence-manager.service.ts @@ -35,8 +35,6 @@ interface ManagedAccountState { secretKey: StellarSdk.Keypair; /** Tail of the per-account serialization promise chain. */ inflight: Promise; - /** Tail of the per-account Horizon re-sync lock chain. */ - reSyncLock: Promise; /** * The sequence number about to be used for the *next* submission on * this account. Invariant: starts at `onChain + 1` after every sync, @@ -234,7 +232,7 @@ export class SequenceManagerService implements OnModuleInit { account.pendingSequence = account.pendingSequence + 1n; if (this.metrics) { - this.metrics.observeSequence(account.publicKey, account.pendingSequence); + this.metrics.observeSequence(account.publicKey, usedSequence); this.metrics.incSubmitted(account.publicKey); } @@ -277,37 +275,21 @@ export class SequenceManagerService implements OnModuleInit { /** * Read the on-chain sequence number from Horizon and return the next - * sequence usable for a new transaction. Multiple concurrent re-sync - * requests for the same account share a single Horizon call through a - * per-account lock chain. + * sequence usable for a new transaction. Callers are already serialized + * by {@link runSerialized}, so no additional lock is needed here. */ private async fetchPendingSequence(account: ManagedAccountState): Promise { - const previous = account.reSyncLock; - let release!: () => void; - const next = new Promise((resolve) => { - release = resolve; - }); - account.reSyncLock = next; - try { - await previous.catch(() => undefined); - - try { - const response = await this.horizonServer.loadAccount(account.publicKey); - // Horizon.AccountResponse.sequence is a string; convert to BigInt so - // arithmetic stays correct as the protocol age grows. - const onChain = this.readSequenceNumber(response); - return onChain + 1n; - } catch (error) { - if (error instanceof StellarSdk.NotFoundError) { - throw new Error( - `SequenceManager: source account ${account.publicKey} not found on Horizon`, - ); - } - throw error; + const response = await this.horizonServer.loadAccount(account.publicKey); + const onChain = this.readSequenceNumber(response); + return onChain + 1n; + } catch (error) { + if (error instanceof StellarSdk.NotFoundError) { + throw new Error( + `SequenceManager: source account ${account.publicKey} not found on Horizon`, + ); } - } finally { - release(); + throw error; } } @@ -406,7 +388,6 @@ export class SequenceManagerService implements OnModuleInit { publicKey, secretKey: keypair, inflight: Promise.resolve(), - reSyncLock: Promise.resolve(), pendingSequence: null, }); } catch (error) {