A regulated stablecoin platform on BSV: a self-hosted overlay that polices token registration, issuance, transfer, redemption, and regulatory controls, paired with a React frontend for the issuer console (admin) and a holder wallet (neobank-style) UI.
Every state change is a real on-chain BSV transaction. The overlay indexes and enforces admissible transactions; MessageBox handles peer-to-peer handoff so recipients can claim what was sent.
Built on: @bsv/sdk v2.1.6, @bsv/templates v1.9.0, @bsv/overlay-topics v1.5.0, @bsv/overlay v2.2.0, BRC-100 identity protocol.
- Overlay Service (
overlay/): OverlayExpress instance on localhost:8080 running thetm_mandalatopic manager andls_mandalalookup service. MongoDB stores tokens, key-linkage records, asset admin state and history; SQLite caches engine transactions/outputs. Exposes custom admin read endpoints (asset state, admin history, aggregated supply summary, and an overlay-wide activity feed with linkage-proven counterparties). A Go port lives inoverlay-go/(see that package’s README). - App Frontend (
app/): React/Vite SPA, role-gated by wallet identity.- Issuer console (
/issuer/:section) — sidebar sections: Overview (KPIs, admin history, register-asset strip), Treasury (issuer's own balance, send/receive), Operations (issue/redeem + regulatory controls), Activity (overlay-wide transaction feed), Banking (simulated bank transfers + reserve reconciliation). - Holder wallet — accounts overview, per-asset account with send/receive/ history, contacts, QR receive.
- Issuer console (
- Token client (
lib/):@bsv/mandala— send, receive, and administer assets with overlay-first commit and journaled recovery. - Token Flows: Register asset (genesis outpoint = assetId) → Issue →
Transfer (per-output
revealSpecificKeyLinkageproves counterparties to the overlay) → Receive (MessageBox internalize) → Redeem (burn) → Freeze/Reissue (regulatory recovery, supply-conserving).
cd overlay
npm install
npm run gen-keyThis prints:
SERVER_PRIVATE_KEY: <private_key>
IDENTITY_PUBLIC_KEY: <public_key>
Save the output. Copy .env.example → .env and paste the SERVER_PRIVATE_KEY:
cp .env.example .env
# Edit .env, replace SERVER_PRIVATE_KEY with the output aboveStart the overlay and MongoDB:
docker compose up --buildVerify it's running:
curl http://localhost:8080/api/v1/infoYou should see the overlay info response with tm_mandala configured.
In a new terminal:
cd app
npm install
cp .env.example .envEdit app/.env and set:
VITE_OVERLAY_IDENTITY_KEY: theIDENTITY_PUBLIC_KEYfrom step 1VITE_OVERLAY_URL:http://localhost:8080VITE_MESSAGEBOX_URL:https://messagebox.babbage.systems(or your own MessageBox instance)
Run the dev server:
npm run devOpen http://localhost:5173 in your browser.
The app requires a BRC-100 (MetaNet) wallet running locally. When the
wallet's identity key matches VITE_OVERLAY_IDENTITY_KEY, the app renders the
issuer console; any other wallet gets the holder wallet UI.
Navigate between sections in the left sidebar; the selected asset lives in the
URL (?asset=) so a reload restores exactly where you were.
KPI tiles (in circulation, net issued, reserve ratio, restrictions) computed from the overlay's aggregated supply summary, the ordered admin-action history (virtualized, infinite scroll), and the Register a new asset strip in the top bar (label, ticker, decimals → one genesis tx; its outpoint becomes the assetId).
The issuer's own holdings of the selected asset: balance card and Send / Receive tabs (the same flows a holder uses, locked to the asset).
- Issue tokens — mint new units into circulation (optionally referencing a bank deposit ref from the Banking page).
- Redeem tokens — burn units out of circulation.
- Regulatory controls — pause/unpause transfers, block/allow identities, set access mode (denylist/allowlist), freeze/unfreeze outputs, and reissue from a frozen output (supply-conserving recovery: the frozen coin is evicted and replacement units are minted to the rightful owner).
The overlay-wide transaction feed — every transaction admitted by the overlay for the asset, with sender and recipient identities proven by the key linkage revealed at submission. Each row is a semantic summary (issued / transfer A→B / self / redeemed, with net units moved) plus a linkage-proof badge. This is the operator’s oversight surface: not just the issuer’s own transfers, but every party to every movement of the asset. Cursor-paginated and virtualized — it stays snappy at thousands of transactions.
A bank feed for reserve reconciliation: add incoming/outgoing transfers per asset (persisted locally, deletable), and a reconciliation view comparing the bank balance to net on-chain supply, flagging drift with guidance to issue or redeem.
- Receive shows your identity key as a QR code, plus pending inbound transfers from the message box.
- Accept internalizes the tokens into your wallet basket; balance updates immediately.
- Send — search recipients by name/@handle/email, pick a recent contact, paste an identity key (one field handles all three), or use the Return to issuer shortcut.
- Amount keypad → review → send. The overlay validates conservation, key linkage, sanctions, pause state and access mode before admitting.
- The recipient is notified via MessageBox.
Per-asset transaction table (type, counterparty, amount, txid) with CSV export. Counterparty identity keys resolve to names/avatars where the identity network knows them.
Custom read endpoints registered by overlay/src/index.ts (all CORS-open for
local development):
| Endpoint | Purpose |
|---|---|
GET /admin/asset-state/:assetId |
Derived AssetAdminState (paused, access mode, blocked/allowed identities, frozen outpoints). |
GET /admin/admin-history/:assetId |
Full ordered admin-action history (used for CSV export). |
GET /admin/admin-history-page/:assetId?limit=&offset= |
Paged, newest-first admin history (drives the audit log UI). |
GET /admin/admin-summary/:assetId |
Aggregated totalIssued / totalRedeemed / actionCount (drives KPI + reconciliation math without shipping the full history). |
GET /admin/activity?assetId=&limit=&before= |
Cursor-paginated overlay-wide transaction feed with linkage-proven counterparties (see overlay/src/activity.ts). |
Mongo indexes backing the hot paths (linkage.createdAt,
adminHistory.(assetId, admitSeq)) are created at overlay boot.
- Overlay running on localhost:8080;
curl http://localhost:8080/api/v1/inforeturns valid response. - App running on localhost:5173 with
VITE_OVERLAY_IDENTITY_KEYset to overlay's identity pubkey. - Issuer wallet connected (identity key matches the overlay's).
- Issuer console renders (only when wallet identity === overlay identity).
- Overview → Register "Gold" (ticker GLD) → asset appears in the top-bar switcher; assetId is
<genesis_txid>.0. - Operations → Issue 100 → Overview "In circulation" shows 100; Treasury balance shows 100.
- Activity page shows the issuance (
Issued +100, minted → issuer identity).
- Connect a second wallet (different identity) — it gets the holder UI, no issuer console.
- Issuer: Treasury → Send 40 to the second wallet's identity.
- Second wallet: Receive shows 40 pending → Accept → balance 40.
- Issuer Treasury balance 60 (change).
- Activity page shows the transfer as
Transfer 40from issuer to recipient (change output not shown — it's a technical detail). - Second wallet sends 10 back → issuer accepts → balances 30 / 70.
- Operations → Redeem 20 → Overview "In circulation" drops to 50; Activity shows
Redeemed −20.
- Operations → Pause → second wallet's send is rejected by the overlay (frontend guard bypassable via the Dev toggle to prove server-side enforcement).
- Freeze one of the second wallet's outpoints → that coin cannot be spent.
- Reissue from the frozen outpoint to the same identity → replacement units arrive; audit log records
freezeOutputthenreissue.
- Banking → add incoming transfer 50 → reconciliation shows drift (bank 50 vs supply) with guidance.
- Issue 50 from Operations → reconciliation shows "Reconciled — 100%".
cd app && npm run test # component, lib and flow tests (Vitest)
cd overlay && npm run test # activity feed classifier + pagination testsBoth suites must pass. Typecheck with npx tsc --noEmit in either package.
NODE_NAME=mandala
SERVER_PRIVATE_KEY=<output_from_npm_run_gen-key>
HOSTING_URL=http://localhost:8080
MONGO_URL=mongodb://mongodb:27017/mandala
NETWORK=main # or test
SQLITE_FILE=/data/overlay.sqlite
# Optional — makes the overlay a full network participant (broadcast + SPV):
# ARCADE_URL=<arcade host>
# ARCADE_API_KEY=<key>
# CHAINTRACKS_URL=<defaults to $ARCADE_URL/chaintracks>Without ARCADE_URL the overlay validates scripts only and the wallet is the
sole broadcaster (local development mode).
VITE_OVERLAY_URL=http://localhost:8080
VITE_OVERLAY_IDENTITY_KEY=<IDENTITY_PUBLIC_KEY_from_overlay_gen-key>
VITE_MESSAGEBOX_URL=https://messagebox.babbage.systems- @bsv/sdk
^2.1.6— core blockchain and transaction utilities. - @bsv/templates
^1.9.0—MandalaToken/MandalaAdminscript templates. App and overlay MUST run the same version (assetId byte-order encoding must agree). - @bsv/overlay
^2.2.0— overlay engine + Knex/Mongo storage. - @bsv/overlay-express
^2.4.1— Express host for the overlay HTTP API. - @bsv/overlay-topics
^1.5.0—tm_mandalatopic manager +ls_mandalalookup service +MandalaStorageManager. - @bsv/identity-react
^1.1.14— identity resolution hooks. - @bsv/message-box-client
^2.2.0— peer-to-peer transfer handoff. - React 19 / Vite 6 / Tailwind CSS 4 — frontend stack.
- @tanstack/react-query + react-virtual — cached/paged data fetching and virtualized lists.
- Ensure Docker and Docker Compose are installed and running.
- Check
docker compose logs mongodbfor MongoDB errors. - Verify
.envhas the correctSERVER_PRIVATE_KEY(no leading/trailing whitespace). - Clear volumes:
docker compose down -v && docker compose up --build.
- Verify
VITE_OVERLAY_URL=http://localhost:8080. - Check overlay is running:
curl http://localhost:8080/api/v1/info. - Clear browser cache and restart dev server.
- Ensure wallet identity key matches overlay's
IDENTITY_PUBLIC_KEY(fromnpm run gen-key). - Check
app/.envhas correctVITE_OVERLAY_IDENTITY_KEY.
- A stale Vite pre-bundle cache can serve an old
@bsv/templatesafter a bump, causing an assetId encoding mismatch → conservation failure. Fix:rm -rf app/node_modules/.viteand restart the dev server.
- Confirm both wallets are connected and running.
- Check overlay logs:
docker compose logs overlay. - Refresh the Receive tab.
The issuer console appears only when the connected wallet's identity key equals the overlay's identity key. Everyone else gets the holder wallet. Only the token authority can mint, redeem, pause, freeze or reissue.
Every FT output submitted to the overlay carries a revealSpecificKeyLinkage
proof binding it to a controlling identity key. The overlay verifies the proof
before admission and retains the linkage record permanently — this is what
powers sanctions screening, access-mode enforcement, and the issuer's
Activity page (sender/recipient per transaction, derived by walking each
input back to its linkage-proven source output).
Per asset, the overlay only admits transactions where
outputs == inputs + authorizedIssuance. Minting balances only because the
issuer's admin-authorized +amount is present; redemption is negative
issuance; a reissue mints replacement units while evicting the frozen coin
from the overlay's balance view, keeping net supply constant.
Pending transfers are delivered as MessageBox entries. The recipient's wallet
calls internalizeAction to accept, which inserts the output into its basket
and updates the balance.
Mandala is part of the BSV Mandala Token stack. Refer to docs/ for detailed
architecture (docs/PROJECT-STATE.md) and operator documentation
(docs/STABLECOIN-ADMIN.md).