Develop the relayer
The reference relayer at relayer/ is meant to be forked. It deliberately ships with in-memory storage and a fixed auth pipeline so you can substitute production-grade pieces.
Mental model
You're forking a reference — not modifying a library. Don't worry about preserving an upstream API. Do preserve:
- The wire protocol the SDK speaks (
docs/sui-stack-messaging/Relayer.md). Breaking this means forking the SDK too. - The auth invariants: every write is a wallet signature over a canonical message, verified against on-chain group membership. If you change auth, the SDK must verify the same way.
Everything else — routes you add, storage you swap in, observability, deployment — is yours.
Run + test loop
cd relayer
cargo run # dev server :3000 (see spin-up-relayer)
cargo test # all tests, no network needed
cargo test --test auth_integration_test
cargo test --test membership_sync_test
cargo test --test walrus_sync_test
cargo test -- --ignored # tests that hit Walrus testnet
cargo fmt
cargo clippy --all-targets -- -D warnings
Toolchain: stable, with clippy and rustfmt (rust-toolchain.toml).
Source layout
src/
├── main.rs binary entry; wires Router + services
├── lib.rs library entry
├── auth/
│ ├── middleware.rs 7-step verification pipeline
│ ├── membership.rs on-chain membership lookups
│ └── schemes.rs Ed25519 / Secp256k1 / Secp256r1 signature dispatch
├── handlers/messages/handlers.rs CRUD endpoints
├── storage/
│ ├── adapter.rs StorageAdapter trait
│ └── memory.rs in-memory impl (default)
└── services/
├── membership_sync.rs gRPC checkpoint listener
├── walrus_sync.rs batched Walrus archival
└── event_parser.rs
Common extensions
0. Checkpoint backfill / resume (usually first)
The reference relayer does not backfill. MembershipSyncService (src/services/membership_sync.rs) starts with last_cursor: None and subscribes to the live gRPC checkpoint stream — events emitted while the relayer was down are dropped, and on restart membership state begins from "now." A fresh relayer therefore has an empty MembershipStore and will reject every authenticated write until the on-chain events it cares about fire again. Fix this before any deployment that survives restarts.
The canonical pattern is to walk historical checkpoints via the same gRPC service the live tail uses, not via paginated RPC event queries. Shape:
- Persist a cursor pair
(checkpoint_sequence, last_processed_tx_digest)after each processed checkpoint. The tx digest is needed because checkpoints contain many transactions, and on resume you must skip the ones already processed in the boundary checkpoint. - On startup, read the persisted cursor and the current chain tip. Walk forward one checkpoint at a time using gRPC
LedgerService.GetCheckpoint(sequence_number). For the first (boundary) checkpoint, skip transactions until you passlast_processed_tx_digest; for every subsequent checkpoint, process every transaction normally. Persist the cursor after each one so a crash mid-backfill resumes correctly. - Then subscribe to
SubscriptionService.SubscribeCheckpointsfor the live tail and continue updating the cursor. - On reconnect to the live tail, repeat step 2 from the persisted cursor up to the new tip before re-joining the stream. This handles transient disconnects without dropping events.
The same pattern applies to WalrusSyncService if you care about archiving messages that arrived while the relayer was down — though there last_cursor is per-message in the storage adapter, so the gap is naturally smaller.
1. Custom storage backend (PostgreSQL / Redis / S3)
Implement the trait in src/storage/adapter.rs. The in-memory impl in src/storage/memory.rs is your reference.
// new file: src/storage/postgres.rs
use crate::storage::adapter::StorageAdapter;
pub struct PostgresStorage { /* pool */ }
#[async_trait::async_trait]
impl StorageAdapter for PostgresStorage {
// implement create/get/update/delete + sync-status methods
}
Then dispatch on STORAGE_TYPE env var in main.rs:
let storage: Arc<dyn StorageAdapter> = match env::var("STORAGE_TYPE").as_deref() {
Ok("memory") | _ => Arc::new(InMemoryStorage::new()),
Ok("postgres") => Arc::new(PostgresStorage::new(/* … */).await?),
};
The same pattern applies to MEMBERSHIP_STORE_TYPE (src/auth/membership.rs).
A note on the abstraction: the reference uses Arc<dyn StorageAdapter> for env-var-driven runtime swappability. A fork can pick the shape that fits its posture:
- Trait object (status quo) — keeps multiple backends behind one type at the cost of a v-table dispatch per call.
- Generics (
Relayer<S: StorageAdapter>) — compile-time monomorphization; threads a generic param through anything that holds storage. (Note:async_traitalready pays a boxed-future cost, so the dispatch win overdynis smaller than for sync traits — pick generics for inlining and type-specific specialization, not micro-optimization.) - Concrete type — drop the trait entirely; depend on
PostgresStorage(or whatever) directly. Idiomatic when you have one backend forever and no test-double need that an in-memory shim can't cover. Trades swap-out for simplicity.
Don't carry the trait just because the reference does — pick the shape that matches your fork's posture.
2. Operator policy (rate limiting, tenant scoping, abuse mitigation)
Things the wire contract leaves to the operator:
- Rate limiting —
tower::limit::RateLimitLayeron the router inmain.rs, scoped per-IP, per-signer, or per-group as fits your hosting model. - Tenant scoping — a multi-tenant relayer can constrain itself to a configured set of group IDs. Reject writes for group IDs outside that set before they hit the auth pipeline.
- Abuse mitigation — operator-side block of signer addresses or IPs as an emergency lever; the durable fix is removing the offending member from the group on-chain.
- Pre-auth filtering — payload size limits, malformed-header rejection, etc., to drop bad requests before signature verification.
Insert as axum middleware layers on the Router in main.rs, or as from_fn_with_state middleware before handler dispatch when shared config is needed.
Note: app-level access control (who can post, join gating, role permissions) is enforced on-chain by sui_groups and the messaging permission types — it belongs in your own Move package, not here. See extend-smart-contracts.
3. New endpoint
Add a handler under src/handlers/, register on the Router in main.rs. If the endpoint mutates state, run it through the same auth middleware so signatures are verified consistently.
A common motivating example: expose a Group Discovery endpoint backed by the relayer's groups index so clients can fetch a user's group memberships via a single REST call instead of running their own GraphQL queries (which is what chat-app/ does today). MembershipSyncService already maintains the data; you only need a read handler over MembershipStore and a matching client caller.
Whichever endpoint you add, the SDK won't call it on its own. Pair this with configure-custom-relayer-transport to add the matching client method — either extend HTTPRelayerTransport (SDK-side fork) or implement a custom RelayerTransport that wraps the canonical methods plus your new one (keeps the SDK upgrade path clean).
4. Sponsor-key / gas strategy
The reference relayer does not sponsor transactions today — clients sign their own. Adding sponsorship to the relayer is a non-trivial design choice (sponsor key custody, abuse limits, dry-run policy, refund flows) and there is no in-repo blueprint for it.
If you need sponsored-tx UX for onboarding or smoother flows, Mysten Enoki is an option. See https://enoki.mystenlabs.com/
5. New signature scheme
src/auth/schemes.rs enumerates supported schemes. Add a variant + dispatch arm. Touch points: scheme parsing, verification, error mapping.
zkLogin support is a known gap — the relayer's current schemes are Ed25519, Secp256k1, Secp256r1 only. See GitHub issue #63 for the discussion.
6. Observability
What's actually shipped today is minimal: tracing + tracing-subscriber are in Cargo.toml, main.rs:39 calls tracing_subscriber::fmt::init(), and there are a handful of tracing::info! / tracing::debug! log lines (in main.rs, config.rs, auth/membership.rs). Log level is controlled by RUST_LOG (default messaging_relayer=info).
What's not there: no #[instrument] attributes on handlers or services, no explicit spans, no metrics, no OpenTelemetry wiring. So a real observability story for a fork involves:
- Annotate handlers and service methods with
#[tracing::instrument(skip(...))]to get per-request and per-service spans automatically. - Swap
tracing_subscriber::fmt::init()for a registry that combinesEnvFilter+fmt+ atracing-opentelemetrylayer (with an OTLP exporter viaopentelemetry-otlp) if you want distributed traces. - For metrics, add
metrics+metrics-exporter-prometheus(or similar) and instrument hot paths in handlers/services.
This is greenfield work — pick the stack your deployment uses and follow the standard Rust patterns. There's no in-repo precedent to mirror.
Load-bearing surfaces — change only if you accept the cost
These three things are wire contracts shared with the SDK and the indexer. The default advice is "don't change them," but if you do, here's what falls out:
HTTP request/response shapes in
handlers/messages/handlers.rsare the contractHTTPRelayerTransportin the SDK speaks. If you change them, you must either:- Fork the SDK's
HTTPRelayerTransport(ts-sdks/packages/sui-stack-messaging/src/relayer/http-transport.ts) to match — you're now on a SDK-side fork too, and need to track upstream changes to canonical wire fields (versioning, sender verification metadata, etc.) by hand. - Or implement a custom
RelayerTransport(the public TS interface ints-sdks/packages/sui-stack-messaging/src/relayer/transport.ts) and pass it viarelayer: { transport: myTransport }instead ofrelayer: { relayerUrl }. This keeps the SDK upstream-clean but you own the bridging code on the client.
- Fork the SDK's
Canonical-message format used for signature verification must match the SDK's
buildCanonicalMessage/verifyMessageSender(ts-sdks/packages/sui-stack-messaging/src/verification.ts) byte-for-byte. If you change this, you change a security-critical surface and you must update the SDK in lockstep — otherwise signatures the SDK produces won't verify on the relayer (silent auth failures) or vice versa (forgeable writes). If you fork the SDK, this is also what every other group member uses to independently verify a message's sender; diverging means messages from your relayer's clients won't be verifiable by canonical-SDK clients in the same group.Walrus archive format / patch naming in
relayer/src/services/walrus_sync.rsis consumed by two readers, not one:walrus-discovery-indexer/(which inspects certified blobs and exposes them via REST).- The SDK's
RecoveryTransport(ts-sdks/packages/sui-stack-messaging/src/recovery/), which uses the indexer's output to reconstruct message history.
Changing the format means coordinated updates across all three sides. Existing archives written under the old format also stop being readable unless you handle migration explicitly. Treat this as a versioned contract — bump a format version and support both during transition rather than flipping the schema.
If you find yourself wanting to change one of these to fix an underlying problem, the usual better answer is to add a new surface alongside (a new endpoint, a new permission type, a new archive variant) rather than mutating an existing one — keeps you on the upstream upgrade path.
Deployment notes
Dockerfilebuilds a release binary;docker-compose.ymlis dev-only (single container, no network).- For Nautilus-attested deployments, see the architecture note in the root
README.md("Architecture Evolution") anddocs/sui-stack-messaging/Security.md. RUST_LOGcontrols log level; default ismessaging_relayer=info.
Pre-deploy safety checklist (for a fork going to mainnet)
.envsecrets are out of git. Confirmgit statusshows no.envstaged; sponsor keys and admin keys never get committed even briefly.- Network alignment.
GROUPS_PACKAGE_IDmatches the network theSUI_RPC_URLpoints to. Pointing a mainnet relayer at a testnetGROUPS_PACKAGE_ID(or vice versa) silently rejects every write — the membership store stays empty because the on-chain events it watches never fire. - Wire protocol unchanged, OR all consumers updated. If you modified
handlers/messages/handlers.rstypes,models/, orservices/walrus_sync.rs, every SDK client and the indexer that connect to your relayer have to be updated to the same wire format before your relayer goes live — otherwise existing canonical-SDK clients in the same groups stop being able to verify messages from your relayer's clients. This is the load-bearing constraint from the "Load-bearing surfaces" section above. - Sponsor-key custody. If your fork sponsors transactions, the sponsor key controls real value and is a single point of failure. Use hardware-backed signing or a multisig, not a plaintext key file. Rotate periodically.
- Capacity for the new network. Mainnet checkpoint stream + Walrus testnet/mainnet are noisier than localnet — expect higher RAM and outbound bandwidth, and plan for the membership store growing without bound until you implement persistence (see "Checkpoint backfill / resume" above).
- Don't run a forked relayer pointed at the canonical mainnet
sui_stack_messagingpackage as a casual experiment. Any messages your forked relayer accepts and archives become persistent state real users may try to read. Use testnet for forks-in-progress.
Cross-links
- Run-local-only flow:
spin-up-relayer. - Full-stack with chat-app:
spin-up-e2e-stack. - Relayer protocol:
docs/sui-stack-messaging/Relayer.md. - Trust model (read before re-implementing auth):
docs/sui-stack-messaging/Security.md. - Relayer deep-dive:
relayer/README.md.