Summary
There is currently no mechanism to detect when funds arrive at an ephemeral account's Stellar address. Without this, an account created via POST /accounts will stay in PENDING_PAYMENT forever regardless of what happens on-chain, and no claim can ever be redeemed. This issue implements a background polling worker that monitors active ephemeral accounts for inbound payments and transitions them to PENDING_CLAIM when a payment is detected.
Architectural Decision
The chosen approach is Horizon polling, a scheduled background worker that periodically queries Horizon's payments endpoint for each account in PENDING_PAYMENT status. This was chosen over Horizon SSE streaming because it is stateless, easier to recover from failures, and avoids managing hundreds of persistent connections at scale. The tradeoff is latency equal to the polling interval, which is acceptable for the MVP.
Background
The gap this fills
When AccountsService.create() completes, the account exists on Stellar and the contract is initialized, but the SDK has no way to know when the sender actually transfers funds to that address. The full happy path requires:
- Account created: status PENDING_PAYMENT ✅ exists
- Sender transfers funds to ephemeral address :currently undetected
- StellarService.recordPayment() called: updates contract state
- Account status updated to PENDING_CLAIM: never happens today
- Recipient redeems claim: blocked until step 4
This worker closes the gap between steps 2 and 4.How the worker should behave
The worker runs on a fixed interval - a reasonable starting point for MVP is every 30 seconds, configurable via environment variable.
On each tick it should:
- Query the database for all accounts in PENDING_PAYMENT status whose expiresAt is still in the future
- For each account, query Horizon's payments endpoint for that account's public key, filtering for payments received after the account's createdAt timestamp to avoid reprocessing
- If a payment is found, call StellarService.recordPayment() to register it on the Soroban contract
- Update the account status to PENDING_CLAIM in the database
- Handle the case where the same payment is detected on multiple ticks. idempotency is critical here
Idempotency requirement
The worker will run repeatedly and may detect the same payment on multiple ticks before the status update is persisted. The worker must not call recordPayment() more than once for the same asset on the same account. The contract will return Error::DuplicateAsset if called twice for the same asset, the worker should treat this as a signal that the payment was already recorded, not as a failure. The database status update should be guarded with a conditional check or an atomic update that only transitions from PENDING_PAYMENT to PENDING_CLAIM, never backwards.
Where to build this
NestJS has a built-in @nestjs/schedule package for cron jobs and intervals. Look at how other modules are structured in src/modules/ for the right patterns. The worker should live in its own module: something like src/modules/payment-monitor/ — with a clear separation between the scheduling logic and the Horizon querying logic. It will need access to the Account repository and StellarService.
Failure handling
If Horizon is temporarily unavailable during a tick, the worker should log the error and wait for the next tick rather than crashing. A single account failing to poll should not stop the rest of the accounts from being checked that tick. Failures should be logged with enough context (account ID, public key, error message) to diagnose issues without manual Horizon lookups.Environment configuration
Add a PAYMENT_POLL_INTERVAL_MS environment variable (default 30000) to control the polling frequency. Document it in .env.example.
Where to look
src/modules/stellar/stellar.service.ts: recordPayment() method added in Issue 5
src/modules/accounts/entities/account.entity.ts: AccountStatus enum and account fields
@nestjs/schedule documentation: the scheduling primitive to use
Stellar Horizon documentation for the payments endpoint: specifically filtering by cursor or created_at to avoid reprocessing old payments
Acceptance Criteria
Summary
There is currently no mechanism to detect when funds arrive at an ephemeral account's Stellar address. Without this, an account created via POST /accounts will stay in PENDING_PAYMENT forever regardless of what happens on-chain, and no claim can ever be redeemed. This issue implements a background polling worker that monitors active ephemeral accounts for inbound payments and transitions them to PENDING_CLAIM when a payment is detected.
Architectural Decision
The chosen approach is Horizon polling, a scheduled background worker that periodically queries Horizon's payments endpoint for each account in PENDING_PAYMENT status. This was chosen over Horizon SSE streaming because it is stateless, easier to recover from failures, and avoids managing hundreds of persistent connections at scale. The tradeoff is latency equal to the polling interval, which is acceptable for the MVP.
Background
The gap this fills
When AccountsService.create() completes, the account exists on Stellar and the contract is initialized, but the SDK has no way to know when the sender actually transfers funds to that address. The full happy path requires:
This worker closes the gap between steps 2 and 4.How the worker should behave
The worker runs on a fixed interval - a reasonable starting point for MVP is every 30 seconds, configurable via environment variable.
On each tick it should:
Idempotency requirement
The worker will run repeatedly and may detect the same payment on multiple ticks before the status update is persisted. The worker must not call recordPayment() more than once for the same asset on the same account. The contract will return Error::DuplicateAsset if called twice for the same asset, the worker should treat this as a signal that the payment was already recorded, not as a failure. The database status update should be guarded with a conditional check or an atomic update that only transitions from PENDING_PAYMENT to PENDING_CLAIM, never backwards.
Where to build this
NestJS has a built-in @nestjs/schedule package for cron jobs and intervals. Look at how other modules are structured in src/modules/ for the right patterns. The worker should live in its own module: something like src/modules/payment-monitor/ — with a clear separation between the scheduling logic and the Horizon querying logic. It will need access to the Account repository and StellarService.
Failure handling
If Horizon is temporarily unavailable during a tick, the worker should log the error and wait for the next tick rather than crashing. A single account failing to poll should not stop the rest of the accounts from being checked that tick. Failures should be logged with enough context (account ID, public key, error message) to diagnose issues without manual Horizon lookups.Environment configuration
Add a PAYMENT_POLL_INTERVAL_MS environment variable (default 30000) to control the polling frequency. Document it in .env.example.
Where to look
src/modules/stellar/stellar.service.ts: recordPayment() method added in Issue 5
src/modules/accounts/entities/account.entity.ts: AccountStatus enum and account fields
@nestjs/schedule documentation: the scheduling primitive to use
Stellar Horizon documentation for the payments endpoint: specifically filtering by cursor or created_at to avoid reprocessing old payments
Acceptance Criteria