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.ts — createCustodialWallet 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.ts — getAgentKeypair() 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.prisma — CustodialWallet 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
src/stellar/sponsorship.ts builders + unit tests asserting balanced Begin/End and leaf-first revoke ordering.
- Schema:
ReserveSponsorship + OutboxOpKind.ACCOUNT_PROVISION + migration/rollback.
- Wire sponsored-create into
createCustodialWallet via the outbox; feature flag.
- Sponsored trustline path invoked before first deposit of a new asset.
reserveReconciliation job + Prometheus gauges + alert rule.
- Admin endpoint + docs/runbook updates.
Acceptance Criteria
Problem Statement
Every Stellar account the platform creates for a user (
createCustodialWalletinsrc/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 withtx_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/EndSponsoringFutureReservesandRevokeSponsorship): 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.ts—createCustodialWalletgenerates aKeypair.random(), encrypts the secret, and writes aCustodialWalletrow. Nothing funds or sponsors the account. There is no trustline creation here.src/stellar/client.ts—getAgentKeypair()exposes the platform'sSTELLAR_AGENT_SECRET_KEY;submitTransaction/prepareTransaction/getAccountgo throughResilientRpcClient.src/stellar/contract.ts— vault writes assume the user account already trusts the asset it deposits.prisma/schema.prisma—CustodialWallethaspublicKey,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
src/stellar/sponsorship.ts:buildSponsoredCreateAccount({ newAccountId, sponsorKeypair, startingBalance: 0 })— wrapsCreateAccount(0 starting balance) betweenBeginSponsoringFutureReserves(sponsor)…EndSponsoringFutureReserves(newAccount), so the sponsor pays base reserve.buildSponsoredTrustline({ accountId, asset, sponsorKeypair })— same sandwich aroundChangeTrust, so each asset a user holds costs the sponsor 0.5 XLM, not the user.buildRevokeSponsorship({ sponsorKeypair, accountId, ledgerKey })— for account close.createCustodialWalletcalls the sponsored-create path (behindSPONSORED_RESERVES_ENABLED, default on for new accounts). Submission goes through the outbox (OutboxOpKindgainsACCOUNT_PROVISION) so it is durable, retriable, and serialized on the sponsor signer like every other money move.2. Reserve ledger
New model:
xlmReservedwherestatus = ACTIVEis the platform's outstanding reserve liability, exported as a Prometheus gauge.3. Multi-sponsor + capacity
STELLAR_SPONSOR_KEYS, comma-separated, resolved throughsrc/keys/registry.tsso each is hash-tracked). Provisioning picks the sponsor with the most available XLM above a floor.sponsorCapacityGuardrefuses new provisioning (named503 sponsor_capacity_exhausted) when no sponsor is aboveSPONSOR_MIN_XLM_FLOOR, and emits a critical alert — the platform degrades loudly, never silently under-reserves.4. Reclamation on close
RevokeSponsorshipfor eachACTIVErow (trustlines first, account last), moves rows toRECLAIMED, and records the XLM returned to the sponsor.reserveReconciliationjob (new, insrc/jobs/) walksReserveSponsorshipagainst on-chainsponsorfields (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 betweenxlmReservedand 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.mdgains a "Reserve sponsorship" section;docs/RUNBOOK.mdgets a "sponsor account top-up" procedure;deploy/monitoring/prometheus/alert-rules.yamlgains the low-sponsor rule.Edge Cases & Failure Modes
ReserveSponsorshiprow is only written on confirmation.Begin…has a matchingEnd…in the same transaction — a malformed builder must fail a unit test, not reach the network.PENDING/SUBMITTEDoutbox provision op is not drift — reconciliation must join against the outbox before alerting.xlmReservedis recorded at creation time; reconciliation reports the delta rather than rewriting history.Security & Privacy Considerations
src/keys/registry.ts, never logged, never returned by any endpoint.RevokeSponsorshipis powerful; only the account-close flow and admin tooling may build one, and every revoke is audit-logged with the initiating admin/session.Out of Scope
Suggested Implementation Plan
src/stellar/sponsorship.tsbuilders + unit tests asserting balanced Begin/End and leaf-first revoke ordering.ReserveSponsorship+OutboxOpKind.ACCOUNT_PROVISION+ migration/rollback.createCustodialWalletvia the outbox; feature flag.reserveReconciliationjob + Prometheus gauges + alert rule.Acceptance Criteria
ReserveSponsorshiprow503 sponsor_capacity_exhausted+ critical alert) when no sponsor is above the floor — never silently under-reservesreserveReconciliationjob detects on-chain vs. ledger drift and joins against pending outbox ops before alertingGET /api/v1/admin/reservesis admin-scoped and audit-logged; docs + runbook + alert rule updated; tests green