Skip to content

Sponsored Reserves & Trustline Lifecycle Management #339

Description

@devsimze

Problem Statement

Every Stellar account the platform creates for a user (createCustodialWallet in src/stellar/wallet.ts) needs a base reserve of XLM to exist, plus 0.5 XLM per additional entry (trustline, offer, data entry). A user who onboards with a stablecoin and never touches XLM cannot actually hold anything: their account is either unfunded (operations fail with tx_insufficient_balance / op_no_trust) or the platform has to quietly gift them XLM with no accounting. Stellar solves this natively with sponsored reserves (BeginSponsoringFutureReserves / EndSponsoringFutureReserves and RevokeSponsorship): a sponsor account pays the reserve, the sponsored account owns the entry, and the sponsor reclaims the XLM when the entry is removed. This issue makes the platform a first-class reserve sponsor with a full lifecycle — sponsor on create, track what is owed, revoke and reclaim on close — so a user never needs XLM and the platform never loses track of reserve capital.

Current State

  • src/stellar/wallet.tscreateCustodialWallet generates a Keypair.random(), encrypts the secret, and writes a CustodialWallet row. Nothing funds or sponsors the account. There is no trustline creation here.
  • src/stellar/client.tsgetAgentKeypair() exposes the platform's STELLAR_AGENT_SECRET_KEY; submitTransaction / prepareTransaction / getAccount go through ResilientRpcClient.
  • src/stellar/contract.ts — vault writes assume the user account already trusts the asset it deposits.
  • prisma/schema.prismaCustodialWallet has publicKey, encryptedSecret, iv, authTag, keyVersion, encryptionKeyId. No reserve/sponsorship bookkeeping.
  • src/jobs/ — has scheduled jobs (poolMetrics, sessionCleanup, dataRetention, …) but nothing reconciling on-chain reserve state.
  • deploy/monitoring/prometheus/alert-rules.yaml — operator alerting exists and is the right place for a "sponsor account low on XLM" rule.

Proposed Solution

1. Sponsorship on account provisioning

  • New src/stellar/sponsorship.ts:
    • buildSponsoredCreateAccount({ newAccountId, sponsorKeypair, startingBalance: 0 }) — wraps CreateAccount (0 starting balance) between BeginSponsoringFutureReserves(sponsor)EndSponsoringFutureReserves(newAccount), so the sponsor pays base reserve.
    • buildSponsoredTrustline({ accountId, asset, sponsorKeypair }) — same sandwich around ChangeTrust, so each asset a user holds costs the sponsor 0.5 XLM, not the user.
    • buildRevokeSponsorship({ sponsorKeypair, accountId, ledgerKey }) — for account close.
  • createCustodialWallet calls the sponsored-create path (behind SPONSORED_RESERVES_ENABLED, default on for new accounts). Submission goes through the outbox (OutboxOpKind gains ACCOUNT_PROVISION) so it is durable, retriable, and serialized on the sponsor signer like every other money move.

2. Reserve ledger

New model:

model ReserveSponsorship {
  id             String   @id @default(uuid())
  sponsoredId    String   // CustodialWallet.id
  sponsorAccount String   // which platform sponsor key paid
  entryType      String   // ACCOUNT | TRUSTLINE | OFFER
  ledgerKey      String   // the sponsored ledger entry, for revoke
  xlmReserved    Decimal  @db.Decimal(36, 18)
  status         String   // ACTIVE | REVOKED | RECLAIMED
  createdAt      DateTime @default(now())
  revokedAt      DateTime?
  @@index([sponsoredId])
  @@index([sponsorAccount, status])
}
  • Every sponsored entry is one row. The sum of xlmReserved where status = ACTIVE is the platform's outstanding reserve liability, exported as a Prometheus gauge.

3. Multi-sponsor + capacity

  • Support a pool of sponsor accounts (STELLAR_SPONSOR_KEYS, comma-separated, resolved through src/keys/registry.ts so each is hash-tracked). Provisioning picks the sponsor with the most available XLM above a floor.
  • A sponsorCapacityGuard refuses new provisioning (named 503 sponsor_capacity_exhausted) when no sponsor is above SPONSOR_MIN_XLM_FLOOR, and emits a critical alert — the platform degrades loudly, never silently under-reserves.

4. Reclamation on close

  • Account-close / wallet-decommission flow submits RevokeSponsorship for each ACTIVE row (trustlines first, account last), moves rows to RECLAIMED, and records the XLM returned to the sponsor.
  • A reserveReconciliation job (new, in src/jobs/) walks ReserveSponsorship against on-chain sponsor fields (getAccount / ledger entry inspection): flags rows the ledger says are sponsored-by-someone-else, rows we think are active but the entry is gone, and drift between xlmReserved and the protocol's current base reserve.

5. API + docs

  • GET /api/v1/admin/reserves — outstanding liability, per-sponsor balances, reconciliation drift. Admin-scoped (src/middleware/adminAuth.ts), audit-logged.
  • docs/NON_CUSTODIAL_ARCHITECTURE.md gains a "Reserve sponsorship" section; docs/RUNBOOK.md gets a "sponsor account top-up" procedure; deploy/monitoring/prometheus/alert-rules.yaml gains the low-sponsor rule.

Edge Cases & Failure Modes

  • Sponsor runs dry mid-provision: the sponsored transaction fails atomically; the outbox retries against a different sponsor on the next attempt (signer re-resolved), and the ReserveSponsorship row is only written on confirmation.
  • User acquires their own XLM and no longer needs sponsorship: allowed; reconciliation notes the account could self-fund but does not auto-revoke (revoking a still-in-use trustline would break the user). A separate opt-in "migrate reserves to user" flow is out of scope but the ledger supports it.
  • Partial sandwich: assert every Begin… has a matching End… in the same transaction — a malformed builder must fail a unit test, not reach the network.
  • Revoke ordering: revoking the account sponsorship while trustlines are still sponsored by us is illegal on-chain; the close flow orders revokes leaf-first and asserts it.
  • Reconciliation vs. a pending outbox op: an entry that is "missing on-chain" but has a PENDING/SUBMITTED outbox provision op is not drift — reconciliation must join against the outbox before alerting.
  • Base reserve change (network protocol upgrade): xlmReserved is recorded at creation time; reconciliation reports the delta rather than rewriting history.

Security & Privacy Considerations

  • Sponsor keys are platform secrets — resolved only through src/keys/registry.ts, never logged, never returned by any endpoint.
  • Sponsoring an account gives the sponsor no authority over it (that is the point of the primitive) — but RevokeSponsorship is powerful; only the account-close flow and admin tooling may build one, and every revoke is audit-logged with the initiating admin/session.
  • The admin reserves endpoint exposes aggregate financial state — admin-scoped, rate-limited, and in the admin audit log.
  • No user-facing endpoint can trigger sponsorship of an arbitrary account id; provisioning is bound to the caller's own wallet creation.

Out of Scope

  • Letting users sponsor each other.
  • Reserve sponsorship for offers/data entries the platform does not itself create.
  • Automatic XLM top-up of sponsor accounts from a fiat rail (operational runbook only).
  • Migrating existing unsponsored accounts in bulk (a follow-up; the ledger model is designed to allow it).

Suggested Implementation Plan

  1. src/stellar/sponsorship.ts builders + unit tests asserting balanced Begin/End and leaf-first revoke ordering.
  2. Schema: ReserveSponsorship + OutboxOpKind.ACCOUNT_PROVISION + migration/rollback.
  3. Wire sponsored-create into createCustodialWallet via the outbox; feature flag.
  4. Sponsored trustline path invoked before first deposit of a new asset.
  5. reserveReconciliation job + Prometheus gauges + alert rule.
  6. Admin endpoint + docs/runbook updates.

Acceptance Criteria

  • New custodial wallets are created with 0 starting balance and a platform-sponsored base reserve; no user needs XLM to onboard
  • Each asset trustline a user needs is sponsored by the platform and recorded as a ReserveSponsorship row
  • Outstanding reserve liability and per-sponsor XLM balances are exported as Prometheus gauges with an operator alert rule
  • Provisioning fails loudly (503 sponsor_capacity_exhausted + critical alert) when no sponsor is above the floor — never silently under-reserves
  • Account close revokes sponsorships leaf-first and records reclaimed XLM
  • reserveReconciliation job detects on-chain vs. ledger drift and joins against pending outbox ops before alerting
  • GET /api/v1/admin/reserves is admin-scoped and audit-logged; docs + runbook + alert rule updated; tests green

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave program

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions