Skip to content

Implement Horizon polling worker for inbound payment detection #98

@phertyameen

Description

@phertyameen

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:

  1. Account created: status PENDING_PAYMENT ✅ exists
  2. Sender transfers funds to ephemeral address :currently undetected
  3. StellarService.recordPayment() called: updates contract state
  4. Account status updated to PENDING_CLAIM: never happens today
  5. 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

  • A PaymentMonitorModule exists in src/modules/payment-monitor/
  • The worker polls Horizon for all PENDING_PAYMENT accounts on a configurable interval
  • Only payments received after the account's createdAt are considered
  • When a payment is detected, StellarService.recordPayment() is called and account status is updated to PENDING_CLAIM
  • Error::DuplicateAsset from the contract is handled as idempotent, not a failure
  • A single account's polling failure does not stop other accounts from being checked
  • Expired accounts are not polled
  • PAYMENT_POLL_INTERVAL_MS is configurable via environment variable and documented in .env.example
  • Unit tests cover: payment detected and status updated, duplicate detection handled, expired account skipped, Horizon error on single account does not abort the tick

Metadata

Metadata

Assignees

No one assigned

    Labels

    criticalVery important for the project to function.enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions