- Rust 96.2%
- Shell 2.1%
- Solidity 1.7%
| contracts | ||
| decentralized-micro-services-gateway | ||
| declaw | ||
| demsg-api | ||
| demsg-client | ||
| demsgd | ||
| dequic | ||
| examples | ||
| scripts | ||
| test_alloy_address | ||
| .gitignore | ||
| .gitmodules | ||
| ARCHITECTURE.md | ||
| Cargo.toml | ||
| README.md | ||
| REVIEW.md | ||
DeMSG
DeMSG (Decentralized Micro-Services Gateway) lets anyone sell access to an online service for on-chain payments on Litheum, and lets platforms ("consumer apps") host many sellers' services without touching the chain or wallet cryptography.
Sellers register services in the ServiceMarketPlace contract; buyers pay
for units of a service there. One demsgd serves the whole contract: it
indexes every service, purchase, usage record, and withdrawal; lets buyers
sign in with their wallet and create API keys for the services they buy; and
lets consumer apps verify those keys and follow everything as an ordered
event stream.
See ARCHITECTURE.md for diagrams of the components, the
seller and buyer flows, and the activity lifecycle.
Contents
- Concepts
- Building a consumer app
- The contract
- Running demsgd
- API reference
- Events
- Confirmations and rollbacks
- Persistence and backups
- Repository layout and tests
- Known limitations
Concepts
| Term | Meaning |
|---|---|
| Seller | Any wallet that registers services in the contract. Earnings from all its services accrue in one balance it withdraws. |
| Service | A seller's offering at a network endpoint. Identified by serviceId = keccak256(abi.encode(ip, port)), so no two services share an ip:port. Has a name, a price per unit, a unit name, an optional 33-byte pubkey, an app tag, and a biller. Its ip and port never change. |
| App tag | The consumer app a service is sold through (e.g. declaw), chosen by the seller; empty for standalone sellers. |
| Biller | An address the seller allows to record buyers' usage of the service on-chain with deductBatch, usually the consumer app's wallet; the zero address if none. Usage records are a log: no funds move. |
| Buyer | The address that called buy(serviceId, pubkey). |
| Purchase | One SessionPurchased(serviceId, seller, buyer, pubkey, units) event; units = msg.value / pricePerUnit. |
| Deduction | One UsageDeducted(serviceId, seller, buyer, units) event: the biller recorded units of the buyer's purchased units as used. |
| Holding | What a buyer has of one service: confirmed units bought, units deducted, remaining = bought - deducted, and the pubkeys passed to buy. |
| Consumer app | A platform for one type of service (e.g. an LLM platform). It runs at one known address. Sellers register services tagged with the app and report them to it; buyers send every request to the app, which looks up the API key's service with DeMSG, forwards the request to the service's ip:port, and keeps buyers' balances (what a unit is worth and how much is used is the app's decision). |
| Standalone seller | A seller that runs its service itself (e.g. dequic, a chat relay): registered with an empty app tag, serving buyers directly at its ip:port and verifying keys with DeMSG. |
| API key | A random secret (demsg_…) a buyer creates for one service after signing in with their wallet. Clients send it to the service in cleartext over TLS, like any API key. DeMSG stores only its SHA-256 hash. A key may be bound to one of the pubkeys the buyer bought the service with, for services that identify users by pubkey. |
| Activity | One contract log (service registered/updated, purchase, deduction, withdrawal) with a status: pending, confirmed, dropped, or revoked. |
| Event | One entry in DeMSG's append-only, numbered log: an activity changing status, or an API key created or revoked. |
| Confirmation depth | Blocks required on top of an activity's block before it is confirmed (default 1). |
demsgd serves two listeners:
- Public (
public_bind): service listing, holdings, wallet sign-in, and API key management. Reached by dapps through your TLS reverse proxy. - Internal (
internal_bind): key verification, events, health. No authentication: only consumer app backends may reach it (bind it to127.0.0.1or firewall it). All consumer apps are trusted and see all activity.
Every path lives under /demsg/, so DeMSG can share a domain and TLS
certificate with other services.
Building a consumer app
A consumer app is a platform for one type of service, at one known address written into its dapp. Its parts:
Dapp (written for that service type):
- Sellers: sign in to the app's backend with the wallet; a form that
calls
register(pubkey, pricePerUnit, unitName, name, ip, port, app, biller)from the seller's wallet, whereip:portis the seller's own endpoint,appis the app's tag, andbilleris the app's wallet if the seller lets it record usage on-chain (else the zero address); then report the service id to the backend.update(...)changes everything butip/port;withdraw(amount)pays out earnings. The seller's services are listed withGET /demsg/services?seller=0x…&app=<tag>. - Buyers: list the services the app offers (from the backend), call
buy(serviceId, pubkey)with payment, then sign in to DeMSG and create an API key for the service (see Sign-in and API keys). Send requests to the app with the key.
Backend (at the app's address):
- Sign sellers in and accept their services: the same wallet sign-in
DeMSG uses (
demsg_api::signinin Rust). When a seller reports a service id, check withGET /demsg/services/{id}that itsselleris the signed-in address and itsappis the app's tag. - Follow the event stream (
/demsg/events/stream?app=<tag>), storing theseqof the last event applied in the same database transaction as the event's effect. On restart, resume from it: every event is delivered once and in order, so each is applied exactly once. - Keep balances per (service, buyer): on
purchase.confirmed, credit the buyer by the app's own rule per unit; onpurchase.revoked, remove exactly that credit (look it up by the activityid). Track usage. - Route requests: call
POST /demsg/verifywith the key (noservice_id). Ifvalid, check the key's service is one the app accepted and still carries its tag, and thatkey.buyerhas balance; forward the request toservice.ip:service.portand bill the usage. Cache valid answers briefly and drop them onkey.revoked. - Record usage on-chain (for services whose
billeris the app's wallet): periodically send onedeductBatchwith the whole units used since the last batch. Count them as recorded only ondeduction.confirmed; send them again if that does not arrive (the transaction was dropped, ordeduction.revoked).
A standalone seller does steps 2–4 for its one service, without routing:
follow /demsg/events/stream?service=<id>, verify keys with
service_id set to its own, and serve the buyer itself.
JavaScript example: a standalone service (Node 18+)
// npm install ws
import WebSocket from 'ws';
import { createHash } from 'node:crypto';
import { keccak256, AbiCoder } from 'ethers';
const DEMSG = 'http://127.0.0.1:8072'; // internal API
const SERVICE_ID = keccak256(AbiCoder.defaultAbiCoder().encode(['string', 'uint16'], ['203.0.113.7', 8091]));
const CREDITS_PER_UNIT = 1000n;
// The app's own state; persist it (with `lastSeq`) in one transaction per event.
const credits = new Map(); // buyer -> bigint
const applied = new Map(); // purchase activity id -> granted credits
let lastSeq = 0;
function apply(event) {
const a = event.activity;
if (event.type === 'purchase.confirmed' && !applied.has(a.id)) {
const granted = BigInt(a.units) * CREDITS_PER_UNIT;
applied.set(a.id, granted);
credits.set(a.buyer, (credits.get(a.buyer) ?? 0n) + granted);
} else if (event.type === 'purchase.revoked' && applied.has(a.id)) {
const granted = applied.get(a.id);
applied.delete(a.id);
const left = (credits.get(a.buyer) ?? 0n) - granted;
credits.set(a.buyer, left < 0n ? 0n : left);
} else if (event.type === 'key.revoked') {
cache.delete(event.key.key_hash);
}
lastSeq = event.seq;
}
function follow() {
const ws = new WebSocket(`${DEMSG.replace('http', 'ws')}/demsg/events/stream?since_seq=${lastSeq}&service=${SERVICE_ID}`);
ws.on('message', (data) => {
const msg = JSON.parse(data);
if (msg.type !== 'replay.end') apply(msg);
});
ws.on('close', () => setTimeout(follow, 1000));
}
follow();
const cache = new Map(); // key hash (as DeMSG's key_hash) -> { buyer, until }
async function verify(key) {
const hash = '0x' + createHash('sha256').update(key.trim()).digest('hex');
const hit = cache.get(hash);
if (hit && hit.until > Date.now()) return hit.buyer;
const resp = await fetch(`${DEMSG}/demsg/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, service_id: SERVICE_ID }),
});
const v = await resp.json();
if (!v.valid) return null;
cache.set(hash, { buyer: v.key.buyer, until: Date.now() + 30_000 });
return v.key.buyer;
}
// In a request handler:
// const buyer = await verify(req.headers.authorization?.replace(/^Bearer /, ''));
// if (!buyer) return res.status(401).end();
// if ((credits.get(buyer) ?? 0n) === 0n) return res.status(402).end();
// ...serve, then deduct usage from credits.get(buyer).
Rust
demsg-client provides DemsgClient (verify with cache, services,
holdings, events, a reconnecting subscribe, spawn_cache_invalidator),
signin (EIP-4361 challenge messages and personal_sign recovery), and
ledger::Ledger, which implements steps 2 and 3 in a local redb file:
balances per (service, buyer) for one service or one app tag, time or
quantity metering, idempotent usage, and tracking of usage sent to
deductBatch until it is confirmed. declaw (a platform) and dequic (a
standalone seller) are complete examples.
The contract
contracts/ServiceMarketPlace.sol; deploy with
scripts/deploy-servicemarketplace.sh (writes the address to the
deployments directory for LITHEUM_NETWORK). Register a dequic relay with
scripts/register-dequic.sh --name <name> --price <IONS per minute> (ip and
port from the dequic config, or --ip/--port); it signs with lcli 1,
whose wallet becomes the seller.
| Function | Who | Effect |
|---|---|---|
register(bytes pubkey, uint256 pricePerUnit, string unitName, string name, string ip, uint16 port, string app, address biller) returns (bytes32) |
seller | New service; reverts if ip:port is taken, the price is 0, ip is empty, or pubkey is neither empty nor 33 bytes |
update(bytes32 serviceId, bytes pubkey, uint256 pricePerUnit, string unitName, string name, string app, address biller) |
the service's seller | Changes everything but ip/port |
buy(bytes32 serviceId, bytes pubkey) payable |
buyer | value must be a positive multiple of the price; pubkey must be 33 bytes; credits the seller's earnings |
deductBatch(bytes32[] serviceIds, address[] buyers, uint256[] units) |
each service's biller | Records usage: adds each entry, capped at the buyer's remaining units, to deducted. Reverts entirely if any service is unregistered or has another biller. No funds move |
withdraw(uint256 amount) |
seller | Pays out earnings |
services(bytes32), serviceIdOf(string, uint16), bought(bytes32, address), deducted(bytes32, address), earnings(address), serviceCount(), getServicesPage(start, limit) |
anyone | Reads |
Events: ServiceRegistered/ServiceUpdated(bytes32 indexed serviceId, address indexed seller, Service service) (the whole record),
SessionPurchased(bytes32 indexed serviceId, address indexed seller, address indexed buyer, bytes pubkey, uint256 units), UsageDeducted(bytes32 indexed serviceId, address indexed seller, address indexed buyer, uint256 units)
(not emitted for 0 units), ServiceWithdrew(address indexed seller, uint256 amount).
The id hashes the exact ip string: 203.0.113.7 and a DNS name for the
same host are different services.
Running demsgd
cargo build --release -p demsgd
./target/release/demsgd --config ~/.litheum/demsg/config.toml
The first run creates the config interactively (--non-interactive fails
instead). demsgd holds no wallet. Flags: --config, --non-interactive,
--bootstrap.
Config
public_bind = "0.0.0.0:8070"
internal_bind = "127.0.0.1:8072"
public_url = "https://example.com" # as buyers reach it; shown in the sign-in message
db_path = "$HOME/.litheum/demsg/demsg.redb"
peers_storage = "$HOME/.litheum/demsg/peers.bin"
demsg_contract_address = "0x…" # ServiceMarketPlace
confirmation_depth = 1 # optional, default 1
start_block_id = 0 # optional: first block scanned on a new database
[[bootstrap_peers]]
ip = "127.0.0.1"
port = 8001
address = "0101010101010101010101010101010101010101"
roles_bitmask = 7
Set start_block_id to the block the contract was deployed in to skip older
blocks.
The bundled consumer apps and their default ports:
| Process | Ports |
|---|---|
| demsgd | public 8070, internal 127.0.0.1:8072 |
| dequic | HTTP 8081 (/ws, /service-info, /balance/{address}; register this port with scripts/register-dequic.sh), QUIC 4443, WebTransport 4444 |
| declaw | 8091: the platform's one address (sellers register their own endpoints' ports) |
TLS
demsgd speaks plain HTTP. Put a TLS reverse proxy in front of the public
listener (never the internal one), e.g. with Caddy:
example.com {
handle /demsg/* {
reverse_proxy 127.0.0.1:8070
}
}
Set public_url to the URL buyers use, so wallets show the right site in
the sign-in message.
API reference
All bodies are JSON. Addresses, pubkeys, ids, and hashes are 0x-prefixed
lowercase hex; addresses are accepted in any case. Times are unix seconds;
uint256 amounts and prices are decimal strings. Errors are
{"error": "<message>"} with a 4xx/5xx status. Paths carry no version:
changes only add fields and endpoints, so ignore unknown fields.
Public listener
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /demsg/info |
– | Contract, chain id, confirmation depth, public URL |
| GET | /demsg/services?seller=&app=&after=&limit= |
– | Registered services |
| GET | /demsg/services/{service_id} |
– | One service (404 if not registered) |
| GET | /demsg/holdings/{service_id}/{buyer} |
– | A buyer's holding for a service |
| POST | /demsg/auth/challenge |
– | Get a one-time sign-in message |
| POST | /demsg/auth/login |
– | Exchange the signed message for a management session |
| POST | /demsg/keys |
session | Create an API key for a service |
| GET | /demsg/keys |
session | List the buyer's keys |
| DELETE | /demsg/keys/{key_id} |
session | Revoke a key |
Internal listener
The read-only public endpoints, plus:
| Method | Path | Purpose |
|---|---|---|
| POST | /demsg/verify |
Look up an API key presented to a service |
| GET | /demsg/events?since_seq=&limit=&service=&seller=&buyer=&app= |
Page through events |
| GET (WebSocket) | /demsg/events/stream?since_seq=&service=&seller=&buyer=&app= |
Replay, then live events |
| GET | /demsg/health |
Sync status |
Services and holdings
GET /demsg/services/{service_id} returns the service's current record (its
latest confirmed registration or update):
{
"service_id": "0x6c1f…",
"seller": "0x5a0b…",
"pubkey": "0x",
"price_per_unit": "1000000000000000",
"unit_name": "kilotokens",
"name": "Alice's LLM",
"ip": "203.0.113.7",
"port": 11434,
"app": "declaw",
"biller": "0x9e4d…",
"registered_block_id": 18300,
"updated_block_id": 18452
}
GET /demsg/services returns {"services": [...], "next_after"}, ordered by
service_id, up to limit (default 100, max 1000), optionally only one
seller's and/or only those with one app tag.
Pass next_after as after for the next page; it is absent on the last
page.
GET /demsg/holdings/{service_id}/{buyer}:
{ "service_id": "0x6c1f…", "buyer": "0x1c2b…", "bought": 12, "deducted": 5, "remaining": 7, "pubkeys": ["0x02ab…"] }
bought counts confirmed purchases and deducted confirmed deductions.
Sign-in and API keys
-
POST /demsg/auth/challengewith{"address": "0x…"}returns{"nonce", "message", "expires_at"}. The message is in the Sign-In with Ethereum (EIP-4361) format and expires after 5 minutes:example.com wants you to sign in with your Ethereum account: 0x1c2BD067b0f155d5034e9fb623A97e1F3670a89F Manage your DeMSG API keys. URI: https://example.com Version: 1 Chain ID: 1174 Nonce: 3f9c… Issued At: 2026-10-22T23:10:00Z Expiration Time: 2026-10-22T23:15:00Z -
The wallet signs the message with
personal_sign(EIP-191). Both the Litheum wallet and MetaMask support this. -
POST /demsg/auth/loginwith{"nonce", "signature"}returns{"session", "buyer", "expires_at"}. Each challenge can be used once, successful or not. Sessions last 15 minutes. -
With
Authorization: Bearer <session>:-
POST /demsg/keyswith{"service_id": "0x…", "name": "opencode", "bound_pubkey": "0x02…" (optional), "replace": true (optional)}returns201with the key — the only time it is shown — and its info:{ "key": "demsg_9b1e…", "key_id": "key_4f2a…", "service_id": "0x6c1f…", "buyer": "0x1c2b…", "name": "opencode", "bound_pubkey": null, "key_hash": "0x77d0…", "created_at": 1793142600, "revoked_at": null }The service must be registered.
bound_pubkeymust be a pubkey from one of the buyer's confirmed purchases of that service.replacerevokes the buyer's other active keys for that service with the same name. A buyer can have at most 100 active keys; names are 1–64 characters. -
GET /demsg/keysreturns{"keys": [<key info>, …]}(never the keys). -
DELETE /demsg/keys/{key_id}revokes the key and returns its info.
-
Verify
POST /demsg/verify with {"key": "demsg_…", "service_id": "0x…"}
(service_id optional):
{
"valid": true,
"reason": null,
"key": { "key_id": "key_4f2a…", "service_id": "0x6c1f…", "buyer": "0x1c2b…", "name": "opencode", "bound_pubkey": null, "key_hash": "0x77d0…", "created_at": 1793142600, "revoked_at": null },
"service": { "service_id": "0x6c1f…", "seller": "0x5a0b…", "…": "…" },
}
A key is valid when it exists, is not revoked, and, if service_id is
given, is for that service. Otherwise reason is unknown_key, revoked,
or wrong_service. A standalone service passes its own id; a platform omits
it and routes by key.service_id and service. Validity does not depend on
units: whether the buyer may use the service is the app's decision, from its
own balances (or from GET /demsg/holdings). key is present unless the
key is unknown; service only if valid.
Health
GET /demsg/health returns
{"chain_tip", "cursor", "pending_activities", "last_seq"}: the latest block
the chain sync knows, the latest block applied (a block is applied only once
confirmation_depth blocks sit on top of it), and the latest event number.
pending_activities is always 0. chain_tip - cursor includes blocks still
waiting for confirmation.
Events
Every event has a gap-free seq (from 1), a time, a type, and app:
the app tag of its service when the event was recorded (absent for
withdrawals and untagged services).
| Type | Also carries |
|---|---|
purchase.confirmed, purchase.revoked |
activity |
deduction.confirmed, deduction.revoked |
activity |
service.confirmed, service.revoked |
activity |
withdrawal.confirmed, withdrawal.revoked |
activity |
key.created, key.revoked |
key |
activity:
{
"id": "0x…",
"status": "confirmed",
"block_id": 18452,
"block_hash": "0x…",
"block_time": 1793142600,
"tx_hash": "0x…",
"tx_index": 3,
"log_index": 0,
"kind": "purchase",
"service_id": "0x6c1f…",
"seller": "0x5a0b…",
"buyer": "0x1c2b…",
"pubkey": "0x02ab…",
"units": 3
}
Service activities (kind: "service") carry action (registered or
updated), service_id, seller, pubkey, price_per_unit, unit_name,
name, ip, port, app, biller. Deductions (kind: "deduction")
carry service_id, seller, buyer, units. Withdrawals
(kind: "withdrawal") carry seller and amount.
GET /demsg/events?since_seq=N&limit=L&service=&seller=&buyer=&app=
returns {"events": [...], "last_seq"}: events after N (default 0)
matching every given filter, oldest first, up to L (default 100, max
1000). service matches purchase, deduction, service, and key events of that
service; seller matches purchase, deduction, service, and withdrawal events
of that seller; buyer matches purchase, deduction, and key events of that
buyer; app matches events whose app is that tag.
The WebSocket /demsg/events/stream (same filters) sends each event as a
JSON text message: first every matching event after since_seq, then
{"type": "replay.end", "last_seq": M}, then new events as they happen.
Every event is sent once and in order. To resume, reconnect with since_seq
set to the last seq you processed.
Confirmations and rollbacks
demsgd scans blocks of the longest chain in order and compares the hashes
it has scanned against the chain on every pass, so restarts, missed blocks,
and reorganizations all converge:
- An activity is confirmed once its block has
confirmation_depthblocks on top (0confirms immediately). Activities in one block are applied in transaction and log order. - If a block is rolled back before its activities are confirmed, they become
dropped. - If a block is rolled back after confirmation, its activities become
revokedand their effect is undone: a purchase's units leave the holding and a deduction's units return to it; a service registration or update is removed, restoring the previous record (or removing the service). Apps must undo their own effects on*.revoked. - If a rolled-back block returns to the longest chain, its activities are
pending again under the same
idand confirmed once more — never twice. - Blocks missed while
demsgdwas down are scanned on startup. If a transaction receipt is not available yet, the block is retried rather than skipped.
Persistence and backups
All state is in one redb file (db_path): activities, services, holdings,
keys, sessions, and events. Writes are transactional, so a crash never
leaves a half-applied block. Back it up by copying the file while demsgd is
stopped. The file carries a schema version; demsgd refuses to open a file
from a different version (start with a new db_path; it rebuilds from the
chain, but API keys are not on-chain and are lost).
Repository layout and tests
| Crate | Purpose |
|---|---|
decentralized-micro-services-gateway |
Library behind demsgd: chain scanning, store, sign-in, HTTP API |
demsgd |
The demsgd binary |
demsg-api |
JSON types of the API and service_id(ip, port) |
demsg-client |
Rust client for the internal API and the balance ledger |
dequic |
QUIC / WebTransport / WebSocket chat relay, a standalone seller |
declaw |
LLM platform: routes OpenAI-compatible requests to sellers' endpoints, meters tokens, records usage on-chain; a consumer app |
CARGO_TARGET_DIR=target.noindex cargo test --workspace
The contract tests (decentralized-micro-services-gateway/tests/contract.rs)
compile the contract with solc and run it in revm. The gateway crate's
test-util feature exposes FakeChain and TestServer (both listeners over
a temporary database and a fake chain), which the client, dequic, and declaw
tests use.
Known limitations
- API keys are bearer credentials: whoever holds a key can use it with its service until it is revoked. Clients must send keys over TLS.
- Services cannot be unregistered; a seller pauses one with a very high price.
- The event log is never trimmed.
- A platform forwards requests to the seller's public
ip:port, so a buyer who knows a service's key can also call that endpoint directly, unmetered. Sellers must allow only the platform to reach their endpoint (firewall it, or check a shared secret). - The app tag is not verified on-chain: any seller can tag a service with any app, which is why platforms accept only services their sellers reported to them.
- The chat dapp lists standalone services sold in
minutes; the declaw dapp lists the services declaw offers.
Complete dev evn deployment
export LITHEUM_NETWORK=local
- Contract
cd DeMSG
scripts/deploy-servicemarketplace.sh
lcli 1 eth-call --to "$(cat ../LitheumCore/deployments/local/servicemarketplace)"
--data "$(lcli 1 function-selector 'serviceCount()' | sed -n 's/^Selector: //p')" That should print a zero word, not bare 0x. - demsgd Copy examples/demsg.toml to ~/.litheum/demsg/config.toml. Set demsg_contract_address to the new address and start_block_id to the current block (lcli 0 block tip). Delete ~/.litheum/demsg/demsg.redb if it exists. Then: cargo run -p demsgd -- --config ~/.litheum/demsg/config.toml curl http://127.0.0.1:8072/demsg/health
- dequic cargo run -p dequic scripts/register-dequic.sh --name dequic --price 0.001 --ip 127.0.0.1 --port 8081 service_ip and service_port in ~/.litheum/dequic/config.toml must be 127.0.0.1 and 8081. Check with curl http://127.0.0.1:8081/service-info.
- declaw Copy examples/declaw.toml to ~/.litheum/declaw/config.toml. For a local-only test, delete the [biller] section so it does not need a wallet or chain sync. cargo run -p declaw curl http://127.0.0.1:8091/info Sellers register their own ollama ip:port from the declaw dapp. declaw does not register itself.
- Dapps cd ../Dapps scripts/deploy-js-libs.sh scripts/set-demsg-url.sh http://127.0.0.1:8070 scripts/set-declaw-url.sh http://127.0.0.1:8091 scripts/deploy-chat-js.sh && scripts/deploy-chat-html.sh scripts/deploy-declaw-js.sh && scripts/deploy-declaw-html.sh