The first UI-driven interface for CAP-0077 (Quorum Freeze), allowing RWA issuers to quarantine compromised assets on the Stellar network.
The freeze_entry host function defined in CAP-0077 is not yet available on Stellar testnet.
Stellar-Guard's governance layer — multisig voting, quorum enforcement, admin management, proposal TTL — is fully functional and deployed on testnet today. When a quorum of 3 admins votes, the contract emits a FREEZE event on-chain (visible in Stellar Explorer).
The final step — calling freeze_entry to actually restrict the target account's trustline — will be a one-line replacement in vote_freeze once the host function is stabilised:
// Current (event-based, testnet-compatible):
env.events().publish((symbol_short!("FREEZE"), asset_code, issuer, target), vote_count);
// Future (one-line swap when freeze_entry is available):
env.freeze_entry(issuer, target, asset_code);Everything else in the stack — dashboard, Horizon monitor, Freighter signing, mobile biometric approval, Soroban RPC vote queries — works end-to-end on testnet right now.
┌─────────────────────────────────────────────────────────────┐
│ Stellar-Guard │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Frontend │ │ Backend │ │ Smart Contract│ │
│ │ Next.js 14 │◄──│ Express/TS │◄──│ Soroban/Rust │ │
│ │ Freighter │ │ Horizon │ │ FreezeGov │ │
│ │ Dashboard │ │ + Soroban │ │ Quorum=3 │ │
│ └──────────────┘ │ RPC │ └────────────────┘ │
│ └──────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Mobile App (Flutter) — Emergency Alert │ │
│ │ Biometric approval · FCM push · stellar_flutter_sdk │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
| Component | Stack | Purpose |
|---|---|---|
frontend/ |
Next.js 14, Tailwind, Freighter | Command Center dashboard — live ledger explorer + freeze voting UI |
backend/ |
Node.js, Express, TypeScript, SQLite | Horizon monitor + REST API; vote counts read from Soroban RPC |
contracts/freeze-governance/ |
Rust, Soroban SDK 20.5 | Multisig freeze governance — CAP-0077 quorum trigger |
mobile/ |
Flutter, FCM, local_auth, stellar_flutter_sdk | Emergency Alert app — biometric freeze approval with real XDR signing |
- Connect Freighter wallet (set to Testnet) in the dashboard
- Enter asset code, issuer, and target account in the Freeze Panel
- Click Vote to Freeze — Freighter prompts for signature
- Repeat from 2 more admin accounts (or use the mobile app)
- On the 3rd vote, the contract emits a
FREEZEevent — visible at:https://stellar.expert/explorer/testnet/contract/<CONTRACT_ID>
- Store your admin public key in Flutter secure storage:
await storage.write(key: 'admin_public_key', value: 'G...');
- Open the app — pending proposals appear automatically
- Tap Approve with Biometrics — fingerprint/face ID gates the signing
- The app builds an unsigned XDR from the backend and sends it to WalletConnect for signing — the secret key never touches the app
- Signed transaction is submitted to the network
Requires Docker and Docker Compose v2.
# 1. Copy and fill in your contract ID (all other vars have testnet defaults)
cp backend/.env.example .env
# Edit .env — set FREEZE_CONTRACT_ID at minimum
# 2. Start backend (:4000) and frontend (:3000)
docker compose up --build
# Open http://localhost:3000To stop: docker compose down
- Node.js 20+
- Rust +
wasm32-unknown-unknowntarget - Flutter 3.19+
- Stellar CLI
- Freighter wallet browser extension
cd contracts/freeze-governance
# Fund 3 testnet accounts: https://laboratory.stellar.org/#account-creator
# Add them to Stellar CLI identities:
stellar keys generate admin1 --network testnet
stellar keys generate admin2 --network testnet
stellar keys generate admin3 --network testnet
# Build and deploy
cargo build --release --target wasm32-unknown-unknown
stellar contract optimize --wasm target/wasm32-unknown-unknown/release/stellar_guard_contract.wasm
CONTRACT_ID=$(stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/stellar_guard_contract.optimized.wasm \
--source admin1 \
--network testnet)
echo "CONTRACT_ID=$CONTRACT_ID"
# Initialize with 3 admins
ADMIN1=$(stellar keys address admin1)
ADMIN2=$(stellar keys address admin2)
ADMIN3=$(stellar keys address admin3)
stellar contract invoke \
--id "$CONTRACT_ID" --source admin1 --network testnet \
-- init --admins "[\"$ADMIN1\",\"$ADMIN2\",\"$ADMIN3\"]"cd backend
cp .env.example .env
# Fill in: FREEZE_CONTRACT_ID, ALLOWED_ORIGIN (your frontend URL)
npm install
npm run devcd frontend
cp .env.example .env
# Set NEXT_PUBLIC_API_URL to your backend URL
npm install
npm run dev
# Open http://localhost:3000cd mobile
flutter pub get
flutter run| Method | Endpoint | Description |
|---|---|---|
GET |
/api/transfers |
Recent payment stream (last 200) |
GET |
/api/transfers/asset/:code |
Filter by asset code |
POST |
/api/freeze/build |
Build unsigned freeze XDR |
POST |
/api/freeze/submit |
Submit signed XDR; vote count read from chain |
GET |
/api/freeze/proposals |
Pending proposals with on-chain vote counts |
GET |
/health |
Health check |
{
"assetCode": "RWAUSD",
"issuer": "G...",
"target": "G...",
"adminKey": "G..."
}The FreezeGovernance contract implements CAP-0077 multisig governance:
| Function | Description |
|---|---|
init(admins) |
Initialize with authorized admin addresses (one-time) |
vote_freeze(caller, asset_code, issuer, target) |
Cast admin vote; emits FREEZE event at quorum=3 |
revoke_vote(caller, asset_code, target) |
Retract a previously cast vote |
get_votes(asset_code, target) |
Query current on-chain vote count |
add_admin(caller, new_admin) |
Add admin (requires existing admin auth) |
remove_admin(caller, admin_to_remove) |
Remove admin (cannot remove last admin) |
Storage: Admin list in instance storage (persistent). Proposals in temporary storage with 7-day TTL (~120,960 ledgers).
Vote count authority: The backend reads vote counts from the contract via Soroban RPC (simulateTransaction → get_votes). SQLite is an audit log only.
| Workflow | Trigger | Action |
|---|---|---|
contract-ci.yml |
Push to contracts/ |
Rust fmt, clippy, WASM build |
backend-ci.yml |
Push to backend/ |
TypeScript check + Jest tests |
frontend-deploy.yml |
Push to main |
Next.js build + lint + Vercel deploy |
Add these in Settings → Secrets and variables → Actions:
| Secret | How to obtain |
|---|---|
VERCEL_TOKEN |
Vercel dashboard → Account Settings → Tokens |
VERCEL_ORG_ID |
Vercel dashboard → Team/Personal Settings → General → Team ID |
VERCEL_PROJECT_ID |
Vercel project → Settings → General → Project ID |
NEXT_PUBLIC_API_URL |
Your deployed backend URL, e.g. https://stellar-guard-api.onrender.com |
Frontend: https://stellar-guard.vercel.app (update after first deploy)
| Error | Handling |
|---|---|
ContractNotFound |
Backend returns 503; frontend shows banner |
UnauthorizedFreezeAttempt |
Backend returns 403; frontend shows banner |
NetworkTimeout |
Backend returns 504; frontend retries silently |
| Soroban RPC unavailable | Falls back to SQLite audit log count |
- Issue a custom RWA token via Stellar Laboratory
- Fund 3 admin accounts on testnet
- Deploy contract with the steps above
- Connect Freighter (set to Testnet) in the dashboard
- Submit freeze votes from 3 admin accounts to trigger quorum
- Observe the
FREEZEevent in Stellar Expert
⚠️ Wait for CAP-0077 (freeze_entry) to be available on mainnet before deploying. The governance layer is production-ready; the final trustline-restriction step is not yet live at the protocol level.
| Variable | Testnet | Mainnet |
|---|---|---|
HORIZON_URL |
https://horizon-testnet.stellar.org |
https://horizon.stellar.org |
SOROBAN_RPC_URL |
https://soroban-testnet.stellar.org |
https://soroban-rpc.stellar.org |
NETWORK_PASSPHRASE |
Test SDF Network ; September 2015 |
Public Global Stellar Network ; September 2015 |
FREEZE_CONTRACT_ID |
testnet contract ID | new mainnet contract ID (re-deploy required) |
Use real XLM. Each admin account needs a minimum balance to cover transaction fees and contract storage rent.
# Generate keys (or import existing ones)
stellar keys generate admin1 --network mainnet
stellar keys generate admin2 --network mainnet
stellar keys generate admin3 --network mainnetcd contracts/freeze-governance
cargo build --release --target wasm32-unknown-unknown
stellar contract optimize \
--wasm target/wasm32-unknown-unknown/release/stellar_guard_contract.wasm
CONTRACT_ID=$(stellar contract deploy \
--wasm target/wasm32-unknown-unknown/release/stellar_guard_contract.optimized.wasm \
--source admin1 \
--network mainnet)
echo "CONTRACT_ID=$CONTRACT_ID"ADMIN1=$(stellar keys address admin1)
ADMIN2=$(stellar keys address admin2)
ADMIN3=$(stellar keys address admin3)
stellar contract invoke \
--id "$CONTRACT_ID" --source admin1 --network mainnet \
-- init --admins "[\"$ADMIN1\",\"$ADMIN2\",\"$ADMIN3\"]"HORIZON_URL=https://horizon.stellar.org
SOROBAN_RPC_URL=https://soroban-rpc.stellar.org
NETWORK_PASSPHRASE=Public Global Stellar Network ; September 2015
FREEZE_CONTRACT_ID=<your-mainnet-contract-id>In contracts/freeze-governance/src/lib.rs, replace the event emission with the one-line host function call:
// Remove this (current testnet-compatible implementation):
env.events().publish((symbol_short!("FREEZE"), asset_code, issuer, target), vote_count);
// Add this (mainnet — requires CAP-0077 freeze_entry to be available):
env.freeze_entry(issuer, target, asset_code);Then rebuild, re-deploy, and re-initialize the contract.
MIT