The reputation publisher is the off-chain service that keeps the PSR
reputation-verifier usable. It accepts holder-submitted V2 leaf commitments, builds the
deterministic Poseidon Merkle tree, and publishes the latest root to Soroban with
update_merkle_root.
The browser cannot complete Submit On-Chain until the proof's Merkle root is present in
the verifier contract. This service is the missing liveness piece.
The V2 reputation circuit proves inclusion of a leaf:
Poseidon(stealth_pk, schema_id, issuer_pk_x, trait_data_hash, nonce)
Two of those values, stealth_pk and trait_data_hash, are private holder-side data.
A passive indexer that only listens to public attestation announcements cannot derive the
leaf. That is intentional: otherwise reputation leaves could be enumerated.
So the publisher's input is not "all public attestations." Its input is the set of leaf commitments that holders or wallet clients choose to submit:
holder wallet -> computes leaf locally -> submits leaf commitment -> publisher -> root
The publisher never receives the stealth private key, decoded trait data, or proof witness.
This workspace ships a file-backed MVP:
- Holder/client writes leaf JSON into
publisher/data/inbox/. - The publisher loop reads the inbox and normalizes each 32-byte leaf commitment.
- It deduplicates by id and leaf.
- It rebuilds a depth-20 Poseidon(2) Merkle tree from the ordered leaf set.
- It compares the local root with
reputation-verifier.get_latest_root. - If the root differs, it writes a root manifest under
publisher/data/roots/and sendsupdate_merkle_root(admin, root, dataset_hash). - Accepted inbox files are archived and durable state is written under
publisher/data/state/.
The current testnet contract allows only the verifier admin to publish roots, so the MVP
uses PUBLISHER_SECRET with the admin/deployer key. This is acceptable for testnet and
local demos only.
In production, this should become a dedicated root-publisher service:
- Replace the file inbox with an authenticated HTTPS endpoint or queue.
- Add a dedicated
root_publisherrole in the contract instead of using the admin key. - Store commitments and cursors in Postgres or another durable database.
- Backfill from a durable event/source log on restart.
- Publish before the current root expiry window elapses, even if no new leaves arrived.
- Expose an API for wallets to fetch the current root and Merkle path for their leaf.
- Monitor ingestion lag, publish failures, root age, and signer balance.
- Run the signer with minimal privileges and key isolation.
The production service can still listen to public attestation announcements, but those events are only context for validation and observability. They are not enough to build the private V2 leaf.
cd publisher
npm ciCreate an environment file outside git:
nano ~/.opaque-reputation-publisher.env
chmod 600 ~/.opaque-reputation-publisher.envExample:
PUBLISHER_SECRET=S...current_testnet_admin_secret...
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
PUBLISHER_INTERVAL_MS=15000
PUBLISHER_HTTP_HOST=127.0.0.1
PUBLISHER_HTTP_PORT=8790
PUBLISHER_CORS_ORIGIN=http://localhost:5173
PUBLISHER_DATA_DIR=/var/lib/opaque-reputation-publisherVariables:
| Variable | Default | Meaning |
|---|---|---|
PUBLISHER_SECRET |
required | Signer allowed to call update_merkle_root. On current testnet this is the verifier admin. |
DEPLOYER_SECRET |
fallback | Accepted only as a local/testnet convenience when PUBLISHER_SECRET is unset. |
REPUTATION_VERIFIER_ID |
manifest value | Override verifier contract id. |
STELLAR_RPC_URL |
manifest RPC | Soroban RPC endpoint. |
PUBLISHER_INTERVAL_MS |
15000 |
Loop interval for continuous publishing. |
PUBLISHER_HTTP_HOST |
127.0.0.1 |
HTTP API bind host. |
PUBLISHER_HTTP_PORT |
8790 |
HTTP API port. |
PUBLISHER_CORS_ORIGIN |
* |
Browser origin allowed to submit leaves/fetch paths. |
PUBLISHER_DATA_DIR |
publisher/data |
Durable inbox/state/root manifest directory. |
This is the mode the frontend uses for Option 2. POST /v1/reputation/leaves
queues a holder-computed leaf, immediately reconciles/publishes the root, and returns the
leaf's inclusion path when available.
cd publisher
set -a
source ~/.opaque-reputation-publisher.env
set +a
npm run serveEndpoints:
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/reputation/leaves |
Accept a holder-submitted V2 leaf commitment and run a publish tick. |
GET |
/v1/reputation/root/:leaf |
Return current root, leaf index, and Merkle path for a leaf. |
GET |
/health |
Health check with verifier id. |
Submit body:
{
"id": "attestation-uid-or-client-generated-id",
"leaf": "0x...",
"schemaId": "0x...",
"attestationUid": "0x...",
"txHash": "...",
"ledger": 3123456
}Root response:
{
"verifierId": "C...",
"leaf": "0x...",
"leafIndex": 0,
"leafCount": 1,
"root": "0x...",
"datasetHash": "0x...",
"pathElements": ["0x..."],
"pathIndices": [0]
}For local/demo use, write a leaf into the inbox:
cd publisher
npm run submit:leaf -- \
--id demo-leaf-1 \
--leaf 0x0000000000000000000000000000000000000000000000000000000000000001The real wallet should submit the actual V2 leaf commitment it computed from the holder's private attestation material. The publisher only checks shape and dedupe; it cannot prove the leaf is semantically valid until a user later proves inclusion with the Groth16 proof.
cd publisher
set -a
source ~/.opaque-reputation-publisher.env
set +a
npm run publisher:onceHealthy output:
leaves=1 (+1) root=0x1234abcd... PUBLISHED <txHash>
If the on-chain root already matches:
leaves=1 (+0) root=0x1234abcd... in-sync
The loop mode is useful when another process writes inbox files. The wallet flow usually
uses npm run serve instead.
cd publisher
set -a
source ~/.opaque-reputation-publisher.env
set +a
npm run publisher[Unit]
Description=Opaque reputation root publisher
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=opaque
WorkingDirectory=/srv/opaque/stellar/publisher
EnvironmentFile=/home/opaque/.opaque-reputation-publisher.env
ExecStart=/usr/bin/npm run publisher
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetpublisher/data/inbox/*.json pending holder-submitted leaves
publisher/data/archive/*.json accepted inbox files
publisher/data/state/<verifier>.json durable leaf set and last published root
publisher/data/roots/<verifier>/*.json published root manifests
Inbox item shape:
{
"id": "attestation-uid-or-client-generated-id",
"leaf": "0x...",
"schemaId": "0x...",
"attestationUid": "0x...",
"txHash": "...",
"ledger": 3123456
}Only id and leaf are required.
- Do not run the daemon with the admin key in production.
- Add a dedicated root-publisher role before mainnet usage.
- Treat the signer as a hot key and isolate it from web request handling.
- Rate-limit and authenticate holder leaf submissions.
- Keep the append-only commitment log so roots can be reconstructed and audited.
- A malicious publisher can censor leaves or publish stale roots, but it cannot forge a holder's Groth16 proof or bypass nullifier replay protection in the verifier.
| Symptom | Likely cause | Fix |
|---|---|---|
set PUBLISHER_SECRET |
Missing signer secret. | Export PUBLISHER_SECRET in the environment file. |
| Frontend says no root | No leaves have been accepted or publish transaction failed. | Add a leaf and run npm run publisher:once; check signer funding. |
| Frontend says root mismatch | The proof was generated against a leaf/root not in the published tree. | Submit the exact leaf commitment used by the proof, publish, then regenerate/fetch the path. |
Unauthorized from Soroban |
Signer is not verifier admin on current contract. | Use the current admin for testnet, or deploy a contract with a root-publisher role. |
| Repeated publishing | On-chain root read is failing or another publisher is racing. | Check RPC health and ensure only one active publisher per verifier. |