Soroban fullstack POC
Documentation
This page describes what the project is for, how the pieces fit together, what each part of the Home screen means, and how testing is wired. For a full command table and install steps, see the repository README.md and docs/POC_DELIVERABLES.md.
What this project is
This repository is a minimal end-to-end proof of concept on Stellar testnet: a small Soroban contract written in Rust, deployed with the Stellar CLI, and a Next.js frontend that reads contract state via Soroban RPC (simulation) and writes with a pluggable signer from Stellar Wallets Kit (@creit-tech/stellar-wallets-kit): one modal lists Freighter, xBull, Albedo, LOBSTR, and other supported wallets on Stellar testnet. Set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID from Reown (WalletConnect Cloud) to add WalletConnect inside the same kit flow (still stellar:testnet).
The goal is the developer lifecycle—build, test, deploy, invoke from a browser—not product features. The contract stays tiny on purpose but touches several Soroban types (unsigned and signed integers, string, u64) and emits one contract event per write so you can practice an observability story later.
How the pieces fit together
contracts/basic-storage/— Soroban contract source.set*functions write persistent storage and publish events;get*functions read it back.frontend/lib/stellar.ts— Creates the Stellar SDK contract client pointed at yourNEXT_PUBLIC_CONTRACT_ID, uses public Soroban testnet RPC, and accepts a signTransaction callback fromWalletProvider(Stellar Wallets Kit signer).frontend/app/page.tsx— Home UI: snapshot reads, four write forms, transaction log, and wallet state fromWalletProvider(header).NEXT_PUBLIC_CONTRACT_IDinfrontend/.env.local— The C… contract address returned aftermake deploy. Next.js only reads this at build time for production hosts; local dev picks it up when the dev server starts.NEXT_PUBLIC_WALLETCONNECT_PROJECT_IDinfrontend/.env.local— From Reown (WalletConnect Cloud). Enables WalletConnect inside the kit; omit for extension-only wallets.
Testing and the /tests page
Contract logic in contracts/basic-storage/ is exercised with unit tests, integration tests (separate test binary), Proptest (random and deterministic cases), invariant-style properties, and an optional libFuzzer harness under fuzz/. The interactive /tests page reads static JSON generated from the last cargo test run and shows line/function coverage when coverage-summary.json is present.
Makefile commands (repo root)
make sync-tests(same asmake export-test-results) — runsmake test-all-contract, then writesfrontend/public/test-results.jsonfor /tests. Use this after changing Rust tests so the UI matches reality.make test-all-contractandmake test-all— same recipe: fullcargo testfor the crate, then a shortcargo fuzzsmoke whencargo-fuzzand anightlytoolchain are installed. Does not update the frontend JSON by itself.make coverage— LLVM coverage (HTML, LCOV, andcoverage-summary.json). Requirescargo install cargo-llvm-cov. On /tests, branch totals often show as n/a because the LLVM JSON export has no branch counters for this small crate; line and function percentages are the meaningful headline.make fuzz/make contract-fuzz-smoke— libFuzzer only; needs nightly andcargo-fuzz. On macOS the Makefile passes-s none(no AddressSanitizer) so linking succeeds; Linux uses the default ASAN-backed fuzz build. If fuzz still fails,make test-all-contractprints a warning and continues somake sync-testscan finish.make ci— format check, Clippy, tests, WASM build, and Next.js production build (see README).
Without Make
From frontend/, npm run sync-tests and npm run export-test-results run the same export script as make sync-tests but invoke cargo test inside the script (they do not run test-all-contract first). For a single manual export from the repo root: node scripts/export-test-results.mjs.
The contract: storage, entrypoints, and events
The contract keeps many independent slots in persistent storage (see the table). Each setter writes its slot and emits a typed contractevent (useful for indexers and dashboards). For BlobSet, CodeSet, and PointerSet, the event payload matches the function input (bytes, label string, optional address), not just a length or flag.
| Area | Write | Read | Event |
|---|---|---|---|
| Unsigned 32-bit | set(value: u32) | get() -> u32 | ValueSet |
| Signed 32-bit | set_signed(v: i32) | get_signed() -> i32 | SignedSet |
| Short text | set_tag(label: String) | get_tag() -> String | TagSet |
| Unsigned 64-bit | set_counter(n: u64) | get_counter() -> u64 | CounterSet |
| Boolean | set_flag(on: bool) | get_flag() -> bool | FlagSet |
| Signed 64-bit | set_i64(v: i64) | get_i64() -> i64 | I64Set |
| Bytes (≤64) | set_blob(data: Bytes) | get_blob() -> Bytes | BlobSet |
| Unsigned 128-bit | set_u128(v: u128) | get_u128() -> u128 | WideU128Set |
| Symbol (short) | set_symbol(label: String) | get_symbol() -> Symbol | CodeSet (label) |
| Optional address | set_pointer(who: Option<Address>) | get_pointer() -> Option<Address> | PointerSet |
| Signed 128-bit | set_i128(v: i128) | get_i128() -> i128 | WideI128Set |
| Vec of u32 (≤16) | set_vec_u32(items: Vec<u32>) | get_vec_u32() -> Vec<u32> | VecU32Set |
| String → u32 map (≤8) | set_scores(scores: Map<String, u32>) | get_scores() -> Map<String, u32> | ScoresSet |
| Plain address | set_plain_addr(who: Address) | get_plain_addr() -> Address | PlainAddrSet |
| Nested struct | set_nested(outer: OuterBits) | get_nested() -> OuterBits | NestedSet |
| User enum | set_widget(w: DemoWidget) | get_widget() -> DemoWidget | WidgetSet |
When you change the contract, run make deploy, put the printed CONTRACT_ID into NEXT_PUBLIC_CONTRACT_ID, and run make contract-bindings so the repo's interface JSON and the Interface page stay aligned with the wasm you deploy.
If your deployed WASM is older and only exposed get / set, the app detects missing get_signed (etc.) on the client built from chain spec and disables the extended forms until you redeploy and update the env id. If the contract has i32 / String / u64 but not get_flag, the home page enables those three writes and shows a notice until you deploy wasm that includes the wide types (bool, i64, Bytes, u128). If get_flag exists but get_symbol does not, you get the eight-slot tier only; redeploy again for Symbol, optional Address, and i128. If get_symbol exists but get_vec_u32 does not, you get the eleven-slot tier; redeploy once more for Vec, Map, plain Address, nested OuterBits, and DemoWidget.
Home page: what each block does
Intro line
The sentence under the title summarizes the flow: simulate the getters (get*) over RPC, then submit writes through Freighter. Each successful write emits a matching typed contractevent (see the contract section above).
Contract
Shows the configured contract id from NEXT_PUBLIC_CONTRACT_ID. When set, it is usually a link to Stellar Expert (testnet contract page) so you can inspect transactions and events in a block explorer. If the variable is missing, the UI shows (not configured).
Stored values
After the app runs read simulations against your contract, it prints the latest u32, i32, tag, and u64 from chain. Only set() changes the first column; the other three columns change only when you submit their matching buttons. Defaults on a fresh contract are 0, 0, empty string, and 0.
Refresh read
Runs the same snapshot logic again (without Freighter). Use it after a write to confirm state, or any time you want to pull the latest values from testnet.
Writes (testnet)
Four forms map one-to-one to the contract setters. You must connect your wallet (header, top right) first: writes are signed transactions paid by your testnet account, so it needs a small XLM balance for fees.
Fill demo values fills every write input from a rotating set of 10 named presets (the first matches contracts/basic-storage/src/test.rs). Each click applies the next preset in order (the button shows which is next); your browser remembers the position in localStorage so the sequence continues across reloads. It does not send transactions—you still press each set… button and approve in your wallet.
The note about txBadSeq means Stellar rejected a transaction because the source account sequence number did not match the ledger—often from double-submitting (two overlapping Freighter flows) or another tab spending from the same account. The app blocks overlapping submits while one write is in flight to reduce this.
Transaction log
A local, append-only trace of what the UI did: timestamps, read summaries, Freighter prompts, successes, errors, and hints. Copy log copies all lines as plain text. Clear log wipes the in-memory list (it is not stored on the server).
[10:23:27] reads → u32=42, i32=-17, tag="hello-events", u64=99
A line like this is the output of a successful read snapshot: all four getters were simulated and those are the values stored on the contract at that moment. Seeing it twice back-to-back can happen after two refreshes or in development when effects run more than once; the content should match your chain state.
Wallet connected: G… means Freighter returned a public key; writes will use that account as the transaction source (payer/signer per SDK rules).
Result: null (or the UI text about no return value) on a write is normal for these setters: they do not return a meaningful value to the client the way get returns a number. Trust the following reads → line to confirm the write landed.
Demo page
/demo is a placeholder for a screen recording (drop frontend/public/demo/recording.mp4). It does not change chain state.
WalletConnect mobile (verified)
Screenshots, testnet tx links, and LOBSTR / Freighter QR proof live on the Contract tests → mobile wallet (/tests/mobilewallet, or any card link under /tests/unit, /tests/integration, …), next to Vitest wallet rows. Repo copy: docs/WalletConnect-Mobile-Success-Log.md.
Quick troubleshooting
- Extended buttons disabled — Redeploy the contract from this repo and set
NEXT_PUBLIC_CONTRACT_IDto the new id. - Submits fail immediately — Fund the connected account on testnet via Friendbot; “connected” only means WalletConnect or the extension returned an address.
- LOBSTR: Account not found — LOBSTR connect can work on mainnet-funded keys; this app submits to testnet. Turn on testnet in LOBSTR and Friendbot-fund your
G…on testnet (separate from mainnet XLM). - Hosted build shows not configured — Set the same env var in Vercel (or your host) and redeploy; local
.env.localdoes not affect the server build.