A Rust/Axum service that acts as the off-chain companion to the MergeMint Soroban smart contract. It does three jobs:
- Read model — polls the Soroban RPC event stream, decodes contract events, and mirrors bounty/contributor state into a local SQLite database so the frontend can list and filter bounties without simulating a contract call per query.
- Transaction builder — exposes
POST /api/tx/*endpoints that build unsigned Soroban transaction XDR for every contract mutation (create/claim/complete/dispute/etc.), ready for a wallet like Freighter to sign client-side. - Compensating security controls — enforces off-chain checks the contract intentionally does not (reward-token allowlisting, creator-cannot-claim), since the contract has no on-chain escrow and trusts the caller for some inputs.
It never holds a private key. Signing always happens in the user's wallet; the backend only assembles and submits XDR.
Horizon (Stellar's classic API) does not index Soroban contract events, so there is no equivalent of "subscribe to my contract's logs" via a simple REST API the frontend could poll directly with cheap, ordered pagination guarantees. The Soroban RPC getEvents endpoint does expose events, but:
- it only retains a rolling window of recent ledgers,
- querying it on every page load would be slow and hammer the RPC endpoint,
- computing "all open bounties" would require indexing IDs some other way.
So this backend runs a small indexer that tails getEvents, and a read API that serves pre-materialized rows out of SQLite. See docs/horizon-polling.md in the contracts repo for the design rationale this mirrors.
┌─────────────────────────┐
│ Soroban RPC endpoint │
│ (soroban-testnet.stellar │
│ .org) │
└───────────┬─────────────┘
getEvents / │ simulateTransaction /
getLatestLedger sendTransaction
│
┌───────────────────┴────────────────────┐
│ SorobanClient │
│ (src/soroban.rs) │
└─────┬───────────────────────────┬───────┘
│ │
┌──────────────▼───────────┐ ┌───────────▼──────────────┐
│ Indexer │ │ Axum HTTP API │
│ (src/indexer.rs) │ │ (src/routes/*.rs) │
│ polls every N seconds, │ │ /api/bounties (read) │
│ decodes events, re-reads│ │ /api/contributors (read) │
│ affected entities, and │ │ /api/tx/* (build │
│ upserts into SQLite │ │ + submit) │
└──────────────┬───────────┘ └───────────┬──────────────┘
│ │
└───────────┬───────────────┘
▼
┌───────────────────┐
│ SQLite (WAL) │
│ src/db.rs │
│ bounties │
│ assignees │
│ contributors │
│ indexer_state │
└────────────────────┘
Frontend Backend Soroban RPC Freighter Wallet
│ POST /api/tx/claim-bounty │ │ │
├────────────────────────────► │ │
│ │ build_call_xdr("claim_bounty") │
│ ├─────────────────────────────► │
│ │ simulate + prepare │ │
│ │◄───────────────────────────── │
│ { xdr } │ │ │
│◄────────────────────────────┤ │ │
│ hand XDR to wallet for signature │ │
├─────────────────────────────────────────────────────────────────────────────────►
│ signTransaction │
│ signed XDR │
│◄─────────────────────────────────────────────────────────────────────────────────
│ POST /api/tx/submit { signedXdr, affected } │ │
├────────────────────────────► │ │
│ │ sendTransaction (raw JSON-RPC forward) │
│ ├─────────────────────────────► │
│ │ wait_transaction (poll until final) │
│ │◄───────────────────────────── │
│ │ refresh_bounty()/refresh_contributor() (re-read │
│ │ affected rows immediately, don't wait for the │
│ │ next indexer tick) + spawn a full poll_once() │
│ { hash, status, returnValue}│ │
│◄────────────────────────────┤ │ │
The backend never signs or holds funds — it only ever sees a transaction after the wallet has signed it, at /api/tx/submit.
| Module | Responsibility |
|---|---|
src/main.rs |
Wires up config, DB, Soroban client, starts the indexer task, mounts the Axum router, serves /api/health. |
src/config.rs |
Loads and validates required environment variables (Config::load, panics on missing required vars). |
src/db.rs |
SQLite schema + all queries. Owns a single Mutex<Connection> (WAL mode) — no ORM, hand-written SQL with rusqlite. |
src/dto.rs |
Maps internal db::BountyRow/ContributorRow (flat, JSON-string-encoded columns) into public, camelCase, nested BountyDto/ContributorDto for the API. |
src/error.rs |
AppError — a single error type with a StatusCode + message, convertible from rusqlite, soroban_client, and reqwest errors, and implementing IntoResponse as {"error": "..."}. |
src/soroban.rs |
Thin wrapper around soroban-client: read-only simulation (read_call), unsigned tx building (build_call_xdr), event polling (get_events), and raw signed-XDR submission (submit_and_wait). |
src/scval.rs |
Bidirectional conversion between native Rust types and Soroban's ScVal XDR representation — the encode/decode boundary for every contract argument and return value. |
src/indexer.rs |
Background polling loop: reads new contract events since the last indexed ledger, figures out which bounty IDs they touched, and re-reads (get_bounty/get_contributor via simulation) rather than reconstructing state from event payloads. |
src/routes/bounties.rs |
GET /api/bounties, GET /api/bounties/:id, GET /api/bounties/creator/:address — pure reads from SQLite. |
src/routes/contributors.rs |
GET /api/contributors/:address — pure read from SQLite. |
src/routes/tx.rs |
POST /api/tx/* — one endpoint per contract method that returns unsigned XDR, plus POST /api/tx/submit which accepts wallet-signed XDR, submits it, and eagerly refreshes the read model. |
MergeMint's Soroban events (see src/events.rs in the contracts repo) are intentionally thin — mostly just a bounty ID — rather than full before/after snapshots. Reconstructing state purely from event payloads would mean duplicating the contract's business logic in Rust and keeping it in sync forever. Instead, indexer.rs treats every event purely as "something about this bounty ID changed," and re-simulates get_bounty / get_contributor reads against the live contract to pull the authoritative current state:
// src/indexer.rs
const SINGLE_VALUE_EVENTS: &[&str] = &[
"bounty_claimed", "bounty_completed", "bounty_cancelled",
"bounty_expired", "bounty_disputed",
];
const TUPLE_EVENTS: &[&str] = &[
"bounty_created", "reward_paid", "approval_recorded", "dispute_resolved",
];
fn extract_bounty_id_hex(event_name: &str, data: &xdr::ScVal) -> Option<String> {
if SINGLE_VALUE_EVENTS.contains(&event_name) {
return scval::decode_bytes_hex(data).ok();
}
if TUPLE_EVENTS.contains(&event_name) {
let items = scval::decode_vec(data).ok()?;
return items.first().and_then(|v| scval::decode_bytes_hex(v).ok());
}
None
}poll_once tracks the last-seen ledger in the indexer_state table (so a restart resumes rather than re-scanning from genesis), collects the distinct set of touched bounty IDs in a poll window, and refreshes each one exactly once regardless of how many events referenced it:
// src/indexer.rs
pub async fn poll_once(soroban: &SorobanClient, db: &Db) -> Result<(), AppError> {
let stored = db.get_last_ledger()?;
let start_ledger = match stored {
Some(l) => l + 1,
None => soroban.get_latest_ledger().await?.saturating_sub(100).max(1),
};
let response = soroban.get_events(start_ledger).await?;
let mut touched = HashSet::new();
for event in &response.events {
// ... decode topic[0] as the event name, extract bounty id, insert into `touched`
}
for bounty_id in &touched {
refresh_bounty(soroban, db, bounty_id).await?;
}
db.set_last_ledger(highest_ledger)?;
Ok(())
}refresh_bounty also cascades to every assignee's contributor row, since assignees are the only addresses the contract tracks reputation/earnings for.
The contract is deliberately minimal and has no on-chain escrow — it trusts the backend/caller for a couple of invariants. This service enforces them before ever building a transaction:
- Reward-token allowlisting (
ALLOWLISTED_REWARD_TOKENS) —create_bountyrejects anyreward_tokennot in the configured allowlist, because the contract itself never validates that the token address is a real, well-behaved SAC/token contract. - Creator-cannot-claim —
claim_bountylooks up the bounty's storedcreatorand rejects the request ifcontributor == creator, because the contract does not enforce this invariant on-chain.
See docs/security.md in the contracts repo for the full threat model these compensate for.
All responses are JSON. Errors are { "error": "message" } with a matching HTTP status.
| Method & path | Description |
|---|---|
GET /api/health |
Liveness check; also echoes the configured contract ID. |
GET /api/bounties?status=open |
List bounties, optionally filtered by status (open, in_progress, completed, cancelled, disputed). |
GET /api/bounties/:id |
Fetch one bounty (hex-encoded 32-byte ID) with its assignees. |
GET /api/bounties/creator/:address |
Bounties created by a given G/C address. |
GET /api/contributors/:address |
Reputation, total earned, contribution count, active claims, metadata for an address. |
| Path | Body | Contract method invoked |
|---|---|---|
/api/tx/create-bounty |
{ creator, title, description, rewardAmount, rewardToken, minReputation?, deadline?, tags? } |
create_bounty |
/api/tx/claim-bounty |
{ contributor, bountyId } |
claim_bounty |
/api/tx/complete-bounty |
{ verifier, bountyId } |
complete_bounty |
/api/tx/approve-completion |
{ verifier, bountyId } |
approve_completion (multi-sig verifier flow) |
/api/tx/raise-dispute |
{ caller, bountyId } |
raise_dispute |
/api/tx/resolve-dispute |
{ arbitrator, bountyId, resolution: "complete"|"cancel" } |
resolve_dispute |
/api/tx/cancel-bounty |
{ caller, bountyId } |
cancel_bounty |
/api/tx/expire-bounty |
{ caller, bountyId } |
expire_bounty |
/api/tx/update-metadata |
{ contributor, metadata } |
update_contributor_metadata |
POST /api/tx/submit
{
"signedXdr": "<base64 signed envelope from wallet>",
"affected": { "bountyId": "…hex…", "contributorAddress": "G…" }
}Submits the signed XDR over raw JSON-RPC (sendTransaction, then polls wait_transaction until final), eagerly refreshes the affected rows so the UI can show fresh state immediately, and spawns a full poll_once() in the background to catch any other side effects (e.g. an approval that also paid out a reward). Responds with:
{ "hash": "…", "status": "SUCCESS", "returnValue": "…" }returnValue is normalized: a BytesN<32> (e.g. a new bounty ID) is hex-encoded, i128 amounts are decimal strings, u32s are numbers, and anything else falls back to a debug string.
soroban-client's built-in send_transaction takes its own Transaction struct, which would require reconstructing the transaction from the signed XDR via Transaction::from_xdr_envelope — but that constructor unconditionally drops the Soroban footprint/resource-fee data (soroban_data: None) that every contract-invoking transaction depends on. Since the backend already has the final signed XDR string verbatim from the wallet, submit_and_wait forwards it directly as a raw sendTransaction JSON-RPC call instead of round-tripping through that lossy struct.
CREATE TABLE bounties (
id TEXT PRIMARY KEY, -- hex-encoded BytesN<32>
creator TEXT NOT NULL,
title TEXT, description TEXT, -- from get_bounty_meta (Optional; None once dropped by the contract)
reward_amount TEXT NOT NULL, -- i128 stored as decimal string (avoids f64 precision loss)
reward_token TEXT NOT NULL,
max_assignees INTEGER NOT NULL,
status TEXT NOT NULL, -- open | in_progress | completed | cancelled | disputed
min_reputation INTEGER NOT NULL,
deadline INTEGER, -- ledger sequence, nullable
required_verifiers TEXT, -- JSON array of addresses, or NULL (single-verifier mode)
approval_threshold INTEGER NOT NULL,
tags TEXT NOT NULL, -- JSON array, max 5 entries
updated_at INTEGER NOT NULL -- ms since epoch, set on every upsert
);
CREATE TABLE assignees (
bounty_id TEXT NOT NULL,
address TEXT NOT NULL,
share_bp INTEGER NOT NULL, -- basis points of the reward (10000 = 100%)
PRIMARY KEY (bounty_id, address)
);
CREATE TABLE contributors (
address TEXT PRIMARY KEY,
reputation INTEGER NOT NULL,
total_earned TEXT NOT NULL, -- i128 as decimal string
contribution_count INTEGER NOT NULL,
active_claims INTEGER NOT NULL,
metadata TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE indexer_state (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
last_ledger INTEGER NOT NULL
);Amounts (reward_amount, total_earned) are stored and transmitted as decimal strings, never as JSON numbers — Soroban's i128 can exceed Number.MAX_SAFE_INTEGER, so the frontend parses them with BigInt.
Copy .env.example to .env and fill in:
| Variable | Required | Description |
|---|---|---|
PORT |
no (default 4000) |
HTTP port the Axum server binds. |
RPC_URL |
yes | Soroban RPC endpoint (not Horizon — Horizon doesn't index contract events). |
NETWORK_PASSPHRASE |
yes | Must match the network the contract is deployed to. |
CONTRACT_ID |
yes | Deployed MergeMint contract address (C...). |
READER_ACCOUNT |
yes | Any funded G... address, used only to supply a sequence number for read-only simulations. Never signed or spent from. |
DB_PATH |
no (default ./data/mergemint.sqlite) |
SQLite file location. |
ALLOWLISTED_REWARD_TOKENS |
yes (empty = nothing allowed) | Comma-separated contract addresses permitted as reward_token in create_bounty. |
POLL_INTERVAL_SECS |
no (default 5) |
Indexer polling cadence. |
# 1. Deploy or point at an existing MergeMint contract (see ../mergemint-contracts)
cp .env.example .env
# edit .env: RPC_URL, CONTRACT_ID, READER_ACCOUNT, ALLOWLISTED_REWARD_TOKENS
# 2. Run
cargo run
# Server listens on http://0.0.0.0:4000 (or $PORT)
# Health check:
curl http://localhost:4000/api/healthOn first run, Db::open creates data/ and the SQLite file, applies the schema (idempotent CREATE TABLE IF NOT EXISTS), and the indexer starts polling from latest_ledger - 100 since there's no prior indexer_state row.
- axum 0.8 — HTTP routing/extractors, built on
tokio+tower-http(CORS). - rusqlite 0.40 (bundled SQLite) — no async DB driver needed; the whole read model comfortably fits behind a single mutex given expected write volume.
- soroban-client 0.5 — Soroban RPC client (simulate/prepare/submit transactions, XDR types).
- reqwest — used only for the raw
sendTransactionJSON-RPC forward insubmit_and_wait. - thiserror / tracing — error ergonomics and structured logging.