Skip to content

Deposit Account Pool Service (Stellar Keypair Management for Deposit-Based Payments) #27

Description

@codebestia

Background

Not every payer will pay an invoice by having their wallet call the contract directly. An alternative flow is a dedicated deposit address: the payer sends funds to a Stellar address the platform controls, and something downstream (a future indexer/reconciliation issue) watches that address for the incoming payment. That requires a managed pool of Stellar accounts to hand out as deposit addresses, track whether each one is currently tied to an invoice, and free it up for reuse afterward.

This issue is the account pool service only — a class-based module with no HTTP surface. Detecting deposits, sweeping received funds, and wiring assignment into the invoice/payment flow are separate, later issues.

The following design decisions were clarified before writing this issue:

  • Rotation reuses the same keypair. An account's Stellar address is not single-use — once its invoice is done, the same address is freed and later handed to a different invoice. This avoids paying the ~1 XLM base reserve to create a new on-chain account for every single invoice.
  • Release is explicit, not computed from invoice status at query time. That implies a matching assign step for the state to ever become "in use" in the first place — assignAccount is added alongside the releaseAccount.
  • The private key is encrypted at rest, not stored raw, since real funds will move through these addresses.
  • createAccount only generates a keypair — it does not submit a funding transaction. Keypair.random() produces an address that isn't a real Stellar ledger entry until it receives its first payment (which itself doubles as the on-chain CreateAccount operation). No treasury/funding account is needed for this issue.

Proposed Steps

  1. Encryption utility — add src/utils/encryption.ts with encrypt(plaintext: string): string / decrypt(ciphertext: string): string using AES-256-GCM, keyed from a new DEPOSIT_ACCOUNT_ENCRYPTION_KEY env var (32-byte key, hex-encoded; fail fast at startup if it's missing or the wrong length). Store iv, auth tag, and ciphertext combined in one string column. decrypt will have no caller within this issue — it exists for the future sweep/withdraw work that will need to sign with these keys — but it must exist and be correct now since the encryption format is what that future code will depend on.

  2. Env config — add to environment.ts / .env.example:

    STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
    DEPOSIT_ACCOUNT_ENCRYPTION_KEY=
    

    (Horizon, not Soroban RPC — balance lookups for classic/SAC-wrapped asset balances on a plain G... account go through Horizon.Server, not rpc.Server.)

  3. Schema — add a DepositAccount model (see Schema Changes) and run prisma migrate dev.

  4. src/services/deposit-account.service.ts — a class (DepositAccountService), not the plain-exported-functions style used by the rest of src/services/ — that's a deliberate, explicitly requested deviation for this module; don't "fix" it to match the other services. Constructed with a Horizon.Server instance. Methods:

    • createAccount()Keypair.random(), encrypt(keypair.secret()), persist { address: keypair.publicKey(), encryptedSecret, inUse: false, invoiceId: null }. Returns a summary that never includes the encrypted or raw secret.
    • getAllAccounts() — returns every DepositAccount, same secret-free shape.
    • getAccountBalance(address: string)horizon.loadAccount(address) → return .balances. Catch the 404 case (NotFoundError) and return an empty/zero balance rather than throwing — a pooled account with no deposits yet is a normal, expected state, not an error.
    • getAvailableAccounts()findMany({ where: { inUse: false } }).
    • assignAccount(invoiceId: string) — claims exactly one available account and links it to invoiceId. Must be safe under concurrent calls: two invoices must never be handed the same account. Implement as a conditional update (updateMany({ where: { id: candidateId, inUse: false }, data: { inUse: true, invoiceId, lastUsedAt: now } }), checking the affected row count, retrying against the next candidate on a lost race) rather than a plain read-then-write.
    • releaseAccount(accountId: string) — clears invoiceId to null, sets inUse: false, updates lastUsedAt. Whatever decides when an invoice is "done" (paid, cancelled, expired) and calls this is out of scope here — this issue only provides the primitive.

Schema Changes

DepositAccount (new model)

id              String    (uuid, PK)
address         String    (unique — Stellar public key, G...)
encryptedSecret String    (AES-256-GCM ciphertext of the raw secret seed; never the plaintext key)
invoiceId       String?   (unique, nullable — FK -> Invoice; null when free)
inUse           Boolean   (default false)
lastUsedAt      DateTime?
createdAt       DateTime
updatedAt       DateTime

Invoice gains the reciprocal relation field (depositAccount DepositAccount?).

Acceptance Criteria

  • DepositAccount model added with invoiceId unique + nullable; prisma migrate dev runs cleanly
  • DepositAccountService is implemented as a class, constructed with a Horizon.Server
  • createAccount() persists an encrypted secret, never a plaintext one, and submits no on-chain transaction
  • getAllAccounts() and getAvailableAccounts() never include the encrypted or decrypted secret in their return values
  • getAccountBalance(address) returns a zero/empty result for an address that doesn't exist on-chain yet, instead of throwing
  • getAvailableAccounts() returns only accounts where inUse is false
  • assignAccount(invoiceId), called concurrently for two different invoices, never assigns the same account to both
  • releaseAccount(accountId) clears invoiceId/inUse, and a released account's address can subsequently be handed out again by assignAccount for a different invoice
  • encrypt/decrypt round-trip correctly; decrypt is implemented and exported but has no caller in this issue
  • No routes, controllers, or endpoints are added anywhere in this issue
  • Sweeping or withdrawing funds out of a deposit account is explicitly out of scope

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions