Skip to content

Repository files navigation

Nebula — Liquid Yield for XLM

Deposit XLM, receive nXLM, keep full liquidity while the protocol earns yield on your behalf.

nXLM is a value-accruing token: your balance never changes, but each nXLM becomes redeemable for more XLM over time. It trades on SDEX, moves through path payments, and works as collateral in Soroban protocols — all while earning.

Status: live on Stellar testnet, earning real interest from a Blend pool. Contracts, indexer and web app are all built; the interface is deployed at nebula.thesolenoid.space and the indexer syncs on a schedule into hosted Postgres. The contracts below are the post-security-pass build, deployed 2026-08-14. Unaudited — testnet only, and see Security for what would have to change before it held real money.

▶ Live app · Contracts on Stellar Expert · On-chain record · Give feedback · Security

Live app nebula.thesolenoid.space
Watch it work, no wallet needed The landing page reads live TVL, share price and the price history straight off testnet — connecting is only needed to deposit
Try it yourself Fund a testnet wallet at friendbot, connect, deposit XLM, watch the share price rise on the next harvest, redeem
On-chain record evidence/ — every depositor and transaction as CSV, each row with its own explorer link
Used it? Tell us what broke In the app if your wallet is connected, or this short form if it is not — including especially if you never got as far as depositing

The problem

XLM is dead capital. Real yield exists on Stellar — Blend lending markets, Aquarius liquidity incentives — but using it means supplying to pools by hand, claiming AQUA emissions, swapping them, and re-depositing. Most holders never do, so their realized yield is far below the headline number, and the position they end up with isn't liquid or spendable.

Nebula collapses that into one token.

A note on "staking"

Nebula is not a liquid staking protocol, because Stellar has no protocol staking to wrap. The Stellar Consensus Protocol is Federated Byzantine Agreement, not proof-of-stake: validators bond nothing, earn no block rewards, and cannot be slashed. The inflation mechanism was disabled in Protocol 12 in October 2019.

Yield here comes from real DeFi venues, and the vocabulary reflects that — strategies, not validators. See NEBULA.md §0 for the full reasoning.

Why Stellar

Not incidental to the design — three properties of the network are load-bearing here:

  • Sub-cent, deterministic fees make harvesting viable. A vault's yield is the gross return minus the cost of collecting it. The harvests below realized amounts in the hundreds of stroops; on a chain where a keeper transaction costs a dollar, every one of them would have been a net loss, and the whole compounding loop would only work at a size Nebula does not have yet.
  • nXLM is liquid the moment it exists. SEP-41 tokens trade on SDEX and route through path payments without a pool being bootstrapped first, so "keep your liquidity" is a property of the network rather than a promise about a future listing.
  • XLM is the largest pool of idle capital on the network, and unlike a proof-of-stake chain Stellar offers its holders nothing for holding it — no staking, no block rewards, no inflation since Protocol 12. The gap this fills exists specifically because of how consensus works here.

How it works

   Deposit XLM ──▶ Vault mints nXLM at the current share price
                        │
                        ▼
              Keeper allocates above the reserve target
              ┌────────────────────┐
              ▼                    ▼
           Blend                Reserve
         (lending)           (instant exit)
              │                    │
              └─────────┬──────────┘
                        ▼
        Harvest: sweep interest, mark each venue
        to market, credit the rest → share price ↕
                        │
      ┌─────────────────┼─────────────────┐
      ▼                 ▼                 ▼
  Hold & accrue    Trade on SDEX     Redeem for XLM
                   / path payment     (+ accrued yield)

One strategy ships today. The seam takes more (see Adding a venue), and Aquarius was evaluated and deliberately left out: it is an AMM, so supplying to it means taking on impermanent loss, and a vault whose pitch is "deposit XLM, get more XLM" should not quietly become one that can return less of it than you put in. That belongs in a second, clearly-labelled vault rather than behind the same token.

Share price

share_price = total_assets / total_supply     (XLM per nXLM)

mint:    shares_out = assets_in * total_supply / total_assets
redeem:  assets_out = shares_in * total_assets / total_supply

Both conversions round down, so every rounding remainder accrues to the vault and therefore to the remaining holders. An off-by-one favouring the caller is drainable in a loop, which is why the direction is load-bearing rather than cosmetic.

The invariant

total_assets == idle + Σ strategy.deployed

total_assets is tracked in contract state and is never read from a live token balance. That one choice makes the classic first-depositor ("inflation") attack structurally impossible: XLM donated directly to the vault address does not enter total_assets, so it cannot move the share price. Yield is recognized only through harvest, and only in the amount the vault measured arriving — a strategy that reports a gain it did not deliver is rejected.

Belt and braces on top of that: a virtual offset in every conversion, and 1000 dead shares locked at first deposit so total supply can never sit low enough for share rounding to be exploitable.

Losses are recognized, not deferred

deployed is a cost basis, so on its own the vault cannot tell a strategy sitting on its principal apart from one that has lost half of it. mark_to_market runs at the top of deposit, redeem and harvest: any shortfall between what a venue holds and what the vault deployed there is written off against total_assets before the share price is quoted.

Doing it on every priced action is the point. If a loss were only recognized at harvest, the first holders to redeem after a drawdown would be paid at the old price out of everyone else's principal, and the stragglers would find the vault empty. Charging it to every share at the same instant means leaving early buys nothing. It also closes the case where a strategy hands back principal and calls it yield — both are the same write-down, so the vault never has to trust a venue to classify its own shortfall honestly. The share price can therefore fall, and a strategy_loss event says why.


Architecture

Four pieces, deployed independently:

contracts/             Soroban, Rust — the protocol itself
├── interfaces/        Strategy + ShareToken traits — the seam between vault and venues
├── nxlm-token/        SEP-41 share token. Minter fixed to the vault at construction.
├── vault/             Deposits, redemptions, share price, allocation, harvest, registry
└── strategies/
    ├── blend/         Supplies XLM to a Blend lending pool, harvests borrower interest
    └── mock/          Controllable test double. Its levers are behind a `testutils`
                       feature so they cannot reach a deployable wasm.

indexer/               TypeScript — reads vault events from Soroban RPC into Postgres.
                       Runs on a schedule from GitHub Actions, not a server.

web/                   Next.js 16 — the interface. Reads the chain directly for live
                       figures and the indexer for history. Deployed to Vercel.

scripts/               Deploy, register a strategy, run the keeper, seed testnet.

Adding a venue

Every yield source implements one trait, so a new venue is a new contract rather than a change to the vault:

pub trait Strategy {
    /// The asset this venue takes. The vault refuses to register a mismatch.
    fn underlying(env: Env) -> Address;
    /// The one vault allowed to instruct it. Checked at registration, so assets
    /// cannot be pushed somewhere this vault could never pull them back from.
    fn vault(env: Env) -> Address;

    fn deposit(env: Env, amount: i128);
    fn withdraw(env: Env, amount: i128) -> i128;
    fn harvest(env: Env) -> i128;
    fn total_assets(env: Env) -> i128;
    fn max_withdrawable(env: Env) -> i128;
}

Semantics are push: the vault transfers the asset first and then instructs the strategy, so a venue never pulls from the vault and never holds an allowance against it.

Roles

Role Can Cannot
User Deposit, redeem, transfer nXLM
Keeper allocate, harvest, unwind Register strategies, send funds anywhere but a registered venue
Admin Register/pause strategies, set fee (capped at 20%) and reserve, pause deposits, sweep donations, rotate the keeper Mint nXLM, block redemptions, touch the dead-share lock, take the reserve directly

Redemptions are never pausable. A vault that can trap funds is a custodian, and Nebula is not one. sweep reaches only the surplus above idle, and refuses the share token outright, so it cannot be used to reach accounted funds or unwind the dead-share lock.

The admin key is still the largest trust assumption here, and the table above should not be read as saying otherwise. Registering a strategy is by definition the power to send vault assets to a contract of the admin's choosing, and the registration checks — matching underlying, matching vault — constrain which contract, not whose. There is no timelock, so a parameter change or a new venue lands in the same ledger it is signed in. On testnet that is a reasonable trade for iteration speed. Before real money it needs a timelock on add_strategy and set_keeper, a multisig on the admin key, and an external audit. See Security.

The Blend strategy

Supplies XLM to a Blend lending pool and earns borrower interest. Two deliberate limits:

  • Supply, never collateral. Blend distinguishes Supply from SupplyCollateral; only the latter backs borrowing and carries a health factor. Nebula never borrows, so its position cannot be liquidated and is withdrawable whenever the pool holds cash.

  • Harvest realizes interest, not emissions. Interest accrues in XLM itself — the bToken rate rises — so harvest withdraws exactly the surplus above cost basis and leaves the principal working. BLND emissions are a different asset needing a DEX route to become XLM, and counting an asset the vault cannot redeem into would inflate the price against XLM it does not hold.

    claim_emissions exists on the strategy but is currently unreachable, and the honest reading is that BLND is accruing to the position with no way to collect it. It authorizes against the vault, and a contract can only be authorized for calls it makes itself — so the only possible caller is the vault, which has no entry point that forwards to it. Collecting emissions needs either a keeper-gated pass-through on the vault or a strategy-local treasury address set at construction. It affects yield, not safety: nothing depends on it, and the share price already ignores emissions by design.

Blend publishes blend-contract-sdk, but it pins soroban-sdk 25 against Nebula's 26 — two major SDK versions cannot link into one contract. The adapter mirrors the handful of Blend types it touches instead, which also avoids coupling Nebula to Blend's release cadence.


Getting started

Prerequisites

  • Rust 1.85+ with the wasm32v1-none target
  • Stellar CLI 23+
  • Node 22+ and a Postgres database, for the indexer and the web app
rustup target add wasm32v1-none
cargo install --locked stellar-cli

Build and test

cargo test --workspace     # 64 tests
stellar contract build     # release wasm for all contracts

Deploy to testnet

stellar keys generate --network testnet nebula-deployer
SOURCE=nebula-deployer ./scripts/deploy.sh

The script derives the vault's address from a salt, deploys the share token bound to that address, then deploys the vault to it — the token's minter is immutable, so the pair must be deployed in that order. The vault verifies the binding on-chain and the deployment fails if it does not match.

Addresses are written to deployments/testnet.json.

Register the Blend strategy

SOURCE=nebula-deployer BLEND_POOL=<pool address> ./scripts/add-blend-strategy.sh

Deploys the strategy against a specific Blend pool and registers it with the vault. BLEND_POOL is deliberately not defaulted — there is no canonical testnet pool, and pointing the vault at the wrong one should be a conscious act. The vault verifies both that the strategy's underlying matches its own and that the strategy names this vault as its owner before accepting it.

Run the web app

cd web && npm install
cp .env.example .env.local     # fill in DATABASE_URL and ADMIN_PASSWORD at minimum
npm run dev

Live vault figures are read straight from the contracts by simulating a transaction — Soroban has no read endpoint, so a "read" is a simulation whose result is discarded. History, profiles and feedback come from the indexer's Postgres. Both degrade independently: if RPC is unreachable the page says so rather than showing a stale number, and if the database is down the live figures still render.

Run the keeper

SOURCE=nebula-keeper WATCH=300 ./scripts/keeper.sh

Allocates idle XLM above the reserve target, then harvests. Safe to run unattended: the keeper can only move funds between the vault and already-registered strategies.


Contract addresses

Stellar testnet. Machine-readable copy in deployments/testnet.json.

Contract Address
Vault CDONRBWS…IXDLBHMQX
nXLM share token CAEEI27X…K5NI464CT
Blend strategy CATKCADB…N5DO2FI5DU
Blend pool (upstream) CCEBVDYM…KHPQ44HGF
Underlying — native XLM SAC CDLZFC3S…VU2HHGCYSC

Verified live on testnet

Every path exercised against the real Blend pool, not a stand-in. Reproduce with ./scripts/smoke-test.sh — the figures below are one run of it against the contracts above:

Path Result
Deposit 100 XLM in → 99.9999000 nXLM minted, 1,000 dead shares locked in the vault
Allocate 89.9999999 XLM supplied to Blend, 10 XLM held back as the redemption reserve
Harvest Real borrower interest: 0.0000607 XLM credited net of the 10% fee → share price 1.0000000 → 1.0000006
Redeem forcing unwind Full position returned 99.9999606 XLM, pulled back out of Blend mid-redemption
Invariant total_assets == idle + Σ deployed held after every operation

That last row is worth reading honestly: a round trip taken minutes apart comes back 0.0000394 XLM short, because share rounding always resolves against the redeemer and a few minutes in the pool does not out-earn it. The vault is not a place to park money for an hour, and the smoke test prints the negative rather than hiding it.

The allowance-based deposit path — the one thing local tests could not prove, because mock_all_auths makes every require_auth succeed — works on-chain. The transfer, approve, and Blend supply events all fired in one transaction with no authorize_as_current_contract.


Screenshots

The Nebula dashboard: XLM balance, nXLM position, current share price and vault TVL

The deposit card with an amount entered and the estimated nXLM shown Share price history, rising across the window
Deposit — estimated nXLM before you sign Share price — the whole product, in one line

Nebula at 375px: the dashboard and the deposit flow

375px. Tab strips collapse behind a menu below md, and the shader heroes render as a still on touch devices — they are fragment-bound, and animating them cost the frame budget on a phone.

More, including the admin and monitoring views, in docs/screenshots/ — which also lists what each one has to show and why the analytics shot is deliberately taken last.


Traction

The contracts were redeployed on 2026-08-14 to pick up the security pass, and the event history starts again from there. The figures below are what the new vault has done since — which is one end-to-end smoke test and nothing else yet.

Exported from the indexer. Regenerate with cd indexer && npm run export.

Depositing addresses 1 — the project's own test account
External users 0
Contract tests 64 passing
Full cycle verified on-chain Yes — deposit, allocate, harvest, redeem, see above

The previous deployment's record is kept, in full

evidence/retired-vault-2026-07/ — 11 depositors, 17 transactions, 4,692 XLM, every row with its own transaction hash and Stellar Expert link.

It is archived rather than deleted, and archived rather than merged, and both halves of that are deliberate.

Kept, because it happened. Those transactions are on a public ledger and cannot be un-made by becoming inconvenient. They also proved the parts of this system that are hardest to prove any other way — the event decoding, the accounting, the harvest loop, and the invariant total_assets == idle + Σ deployed holding across 17 real transactions. None of that is invalidated by a redeploy.

Separate, because those rows belong to a different contract. summary.csv in the live export names the vault it came from. Folding a retired vault's depositors into files that name the live contract would make every explorer link in them resolve to a contract the file does not claim — checkable by a reviewer in about ten seconds. So the two are kept apart, and every indexer query is scoped by events.contract_id so they can never be silently summed. That scoping is not housekeeping: before it existed, the stats page showed the live vault's share price beside 0.0736505 XLM of yield the live vault never earned.

And it was never evidence of users. All 11 addresses were self-generated, funded minutes apart from the same faucet while the deposit path was being tested, and days_active reads 1 for every one — exactly what a scripted batch looks like. That is stated in the archive's own README rather than left for someone to work out.

The live record is in evidence/ as CSV — one file per wallet, one per transaction, one per harvest — and every activity row carries its own transaction hash and Stellar Expert link. Nothing there is typed in by hand; it is a rendering of decoded Soroban events, so any single row can be checked against a ledger this project does not control, and there is no code path that can add a row the chain did not produce.

Identity is the half no ledger can carry. An address is free to create and a funded testnet wallet costs nothing, so the export deliberately reports days_active rather than filtering on it — a batch driven from one script in one sitting is visible as such. docs/USER_SURVEY.md is the other half: it collects a wallet address alongside a person, so each response joins to a row in depositors.csv and a claim that does not match the chain is visible on sight.

It is collected two ways, and the difference is deliberate:

  • /feedback — the same fifteen questions, in the app. The address is taken from a wallet that signed a session challenge rather than typed, so every response joins to the on-chain record automatically, cannot claim someone else's deposits, and cannot be mistyped. The admin view reads corroborated off the chain rather than off the form.
  • The Google Form — the same questions, worse data, far better reach. It works in a Discord message and needs no wallet, which is the only way to hear from the person who bounced at the wallet install or read the landing page and left. That person is often the most useful respondent there is, and the in-app form structurally cannot reach them.

Tried Nebula? Tell us what to fix next →


Testing

64 tests covering the accounting, the access control, and the attacks:

Area Covered
Deposit / redeem Round trip, dilution, dust rejection, dead-share lock
Inflation attack Donation cannot move the share price; donations are sweepable, not stranded
Rounding Never favours the caller, at a deliberately awkward share price
Strategies Weight splitting, caps, pausing, asset mismatch, over-100% weights
Yield Share price rises on harvest, fee taken off the top, over-reporting rejected
Liquidity Redemption unwinds strategies; fails cleanly and atomically when illiquid
Access control Admin/keeper separation, non-removable funded strategies, strategy bound to another vault rejected, share token not sweepable, burn requires the vault
Drawdowns Harvest writes a venue loss down, a loss is split across holders instead of paid to whoever redeems first, depositing after an unreported loss buys in at the lowered price, no fee on a losing period
Lifecycle Two depositors across two harvests, late joiner cannot claim earlier yield
Blend adapter Supply, interest accrual, harvest leaving principal working, partial withdrawal when the pool is short on cash, liquidity-bounded max_withdrawable

Every state-changing vault test asserts the total_assets == idle + Σ deployed invariant afterwards.

The Blend adapter is tested against a local stand-in that models a rising bToken rate. That covers the accounting but not the authorization path — mock_all_auths makes every require_auth succeed, so the allowance grant in deposit is exercised for its token effects, not its auth semantics. That gap is closed by the live testnet run above, where the real pool enforced real auth.

cargo test --workspace

Analytics & monitoring

PostHog, self-proxied, covering both product analytics and error tracking.

One vendor, deliberately. The obvious alternative is PostHog for funnels and Sentry for errors. PostHog captures $exception events with stack traces and issue grouping, and putting both on one timeline is worth more here than Sentry's deeper error tooling: the question this project actually needs answered is "the deposit funnel leaks at signing — what threw?", and with two vendors that is a manual identity reconciliation across two dashboards. With one it is a click. A second SDK would also be a second script on a page whose users disproportionately run blockers.

Piece Where
Browser events + pageviews web/instrumentation-client.ts
Browser exceptions same file — capture_exceptions, unhandled errors and rejections
Server exceptions web/instrumentation.ts — Next's onRequestError
Typed event names web/lib/analytics.ts
Funnel drop-off panel /asdfg/admin — joins PostHog's funnel to on-chain depositors

Requests go to /ingest on our own origin and are rewritten to PostHog in web/next.config.ts. A direct call to a posthog.com host is blocked by uBlock Origin and Brave's shields, and this audience runs those far above the general web's rate — so the missing data would not be missing at random, it would be exactly the privacy-minded users, which is most of the point of measuring a crypto product.

Event names are a compile-checked union, so a typo cannot silently split one funnel into two:

wallet_connect_started / wallet_connected / wallet_connect_failed   { wallet, reason }
deposit_submitted / deposit_confirmed / deposit_failed              { size, phase, reason }
withdraw_submitted / withdraw_confirmed / withdraw_failed           { size, phase, reason }
username_set · review_submitted                                     { rating }

Two deliberate departures from what a default install would send. No wallet address and no exact amount — the privacy page promises analytics is not tied to your address, and an exact figure plus a timestamp identifies one transaction on a public ledger, so amounts go as bands (<10, 10-100, 100-1k). deposit_failed carries the phase it died insimulating, signing, submitting, confirming. Backing out at the wallet prompt and the contract rejecting you look identical in a funnel and need opposite responses.

Session recording is off. On a page where people type amounts and approve wallet prompts it is a lot of exposure for a question the funnel already answers.


Security

Nebula is unaudited and on testnet. Everything below is the current posture, not a claim that it is ready for real money.

On chain

  • Redemption is never pausable, and mark_to_market means the price it pays already reflects any venue loss, so exiting first is not an advantage.
  • The minter is immutable. nXLM binds it at construction with no rotation entry point, so holders trust the vault contract rather than an operator. Burning requires the vault too — a bypassing burn would leave total_shares counting shares nobody holds and strand the XLM behind them.
  • Fees are capped at 20% and validated in both the constructor and set_params. They apply only to harvested gains, never to principal, and not at all in a period that lost money.
  • Overflow-checked arithmetic throughout, with explicit checked helpers and no unchecked casts.

Off chain

  • The admin surface sits behind an obscure prefix, an address allowlist, and a password compared server-side. The session cookie is HMAC-signed with the expiry inside the signed payload, so it cannot be forged by sending a header. Requests are rewritten away before the page renders, because gating in a layout still runs the page and serializes its data into the RSC payload.
  • Writes keyed by wallet address require proof of that wallet. An address is a public identifier, so anything that trusted one as an argument could be spoofed. Setting a username or leaving a review means signing a challenge transaction built with sequence 0 — unsubmittable by construction, since a valid sequence must be the account's current one plus one. Nonces are single-use, enforced by a primary key rather than by clearing a cookie.
  • Every SQL statement is parameterized, in both the web app and the indexer.

Known gaps before mainnet

Gap Why it matters
No external audit The contracts have been reviewed only by their author
No timelock on add_strategy / set_keeper A new venue or a rotated keeper lands instantly
Single admin key, not a multisig One key is one point of failure
Shared admin password Fine for one operator on testnet; not an accountable identity
Yield credited atomically at harvest A deposit timed just before a harvest captures a share of yield it was not exposed to. Streaming it over the harvest interval is the fix
claim_emissions unreachable BLND accrues with no way to collect it — yield left on the table, not a safety issue

Documentation

Document What's in it
NEBULA.md Source of truth — mechanism design, yield sources, decisions, risks
docs/SUCCESS_METRICS.md Level 4 requirement tracker and build plan
docs/USER_SURVEY.md The tester survey — every field, and why each one is there
evidence/README.md The exported on-chain record: what is in it, how to check it, what it cannot show
docs/FRONTEND_PLAN.md Page inventory and the landing-page section breakdown
web/README.md The interface — stack, design system, auth, environment
indexer/README.md Event indexer — setup, commands, and the two RPC gotchas

Indexer

indexer/ ingests vault events into Postgres and is what makes the dashboard and the usage evidence possible:

cd indexer && npm install && cp .env.example .env
npm run migrate && npm run sync
npm run stats        # TVL, share price, depositor count, realized APY
npm run depositors   # every depositing address, with tx hashes
npm run export       # the whole record to evidence/*.csv

It runs every 10 minutes from GitHub Actions, which needs DATABASE_URL set as a repository secret. That schedule matters more than it looks: Soroban RPC discards events after roughly a week, and a gap cannot be backfilled once they are gone — so the record of who used the protocol has to be captured as it happens. A run exits non-zero if it detects a retention gap or fails to store a row, so an incomplete sync goes red rather than quietly reporting success.

Ingestion is idempotent on the RPC event id, so re-reading a range is harmless, and events are accepted only from the configured vault contract — a filter enforced at the request and re-checked on arrival, since the RPC applying it is not one we run.

On hosted Postgres, use a pooler connection string rather than the direct host. Supabase's direct endpoint is IPv6-only and GitHub Actions runners are IPv4-only, so the direct URL cannot connect from CI at all.


License

Apache-2.0

About

Testnet XLM yield vault with Soroban contracts, liquid nXLM shares, strategy accounting and an event indexer.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages