Chat
Chat is off chain. It runs over Nostr relays, using NIP-29 relay-based groups for community rooms and NIP-04 encrypted events for direct messages. Messages live on relays, not in a contract, so they carry none of the permanence guarantees that posts do.
Live at https://superhero.com/chat.
What it is built on
| Piece | Value |
|---|---|
| Protocol | Nostr |
| Rooms | NIP-29 relay-based groups |
| Group id | The token sale address (ct_…), verbatim |
| Room messages | Kind 9 (group chat message) and kind 11 (thread root) |
| Room metadata | Kind 39000, addressable by its d tag, fields in tags |
| Roster | Kinds 39001 admins, 39002 members, 39003 roles |
| Membership events | Kinds 9000 add, 9001 remove, 9021 join request, 9022 leave request |
| Private-room auth | NIP-42 client auth, kind 22242 |
| Direct messages | NIP-04 encrypted events, kind 4 |
| Default relay | wss://relay.superhero.chat |
The relay, not a contract, is the authority on who is in a room. For token rooms that authority is exercised by a bot the API controls, which adds and removes members as balances change.
Routes
| Route | Purpose |
|---|---|
/chat | Inbox with All, Communities and DMs tabs, each with a count |
/chat/dm/:address | A direct message thread |
/chat/:saleAddress | A token's community room |
/chat/:saleAddress/info | Room info: metadata, members, mute |
/chat/settings/relays | Relay configuration |
Identity: a second key from the same seed
Your chat identity is a Nostr keypair derived from the same wallet seed as your æternity account, at the BIP-32 path:
m/44'/1237'/0'/0/<accountIndex>Coin type 1237 is the registered Nostr type, and the account index matches the active æternity account's index, so your npub is identical on web and mobile without any sync.
The derivation runs the same user-verification step the transaction signer uses (passphrase or
passkey), unseals the mnemonic transiently, derives only the Nostr key, and returns only that
key. It never returns the mnemonic, the seed, or the æternity spending key. Above it sits a NIP-07-shaped
identity provider (getPublicKey, signEvent, nip04Encrypt, nip04Decrypt) that keeps the
secret key in a closure. Once the session locks, the provider refuses to sign. Read-only flows
(public room history and metadata) work with no identity at all.
Linking your Nostr key to your address
The room bot needs to know which æternity address owns your npub. That link is its own signed
flow, on the same claim-sign-submit pattern as every other address link, and it ends in an
on-chain AddressLink.link call the backend pays for:
POST /api/address-links/nostr/claim { address, value: "npub1…" }
-> { message: "link:ak_…:nostr:npub1…:0", nonce, value }
POST /api/address-links/nostr/submit { address, value, nonce, signature, nostr_event }
-> { txHash }
POST /api/address-links/nostr/unclaim -> { message: "unlink:ak_…:nostr:1", nonce }
POST /api/address-links/nostr/unclaim/submit -> { txHash }Two proofs are required. The signature is the æternity wallet's signature over the exact
message string (128 lowercase hex characters, no prefix), proving the address consents. The
nostr_event is a JSON-serialised Nostr event proving control of the key: kind 22242, empty
tags, the claim message verbatim as content, Schnorr-signed. The backend rejects the submit
if the event's kind is wrong, its content does not match the expected link message, its
pubkey does not decode to the claimed npub, its signature fails, or its created_at is more
than 300 seconds old or more than 60 seconds in the future. Build the event immediately before
submitting.
The nonce is the contract's own per-address counter, read with
AddressLink.get_nonce(address), and it advances on every link or unlink. If another device
links something between your claim and your submit, the submit fails with a nonce mismatch and
you restart from claim.
The npub lands in the account's links.nostr once the indexer sees the transaction, and
surfaces as the profile's nostr field. See SuperheroID.
Token-gated rooms
Every community token can have a room. Eligibility is a token balance, evaluated by the API, and enforced by the relay through NIP-29 membership.
GET /api/rooms/config -> relay_url, admin_pubkey
GET /api/rooms?address=ak_… -> rooms this address is ELIGIBLE for
GET /api/rooms/{saleAddress}/members -> the roster (include_pending optional)
POST /api/rooms/{saleAddress}/recheck -> force a re-evaluation for one address
GET /api/rooms/{saleAddress}/mute -> per-room mute state
POST /api/rooms/{saleAddress}/mute -> set it, with a signed challengeGET /api/rooms/config returns the groups relay URL and the relay-admin bot's hex public key,
used for NIP-42 AUTH on private rooms.
How membership tracks your balance
The API runs a reactive pipeline off the middleware's live transaction stream:
- AEX-9
Transferevents on community token contracts are applied to a balance ledger of raw base units, clamped at zero. Every transfer is indexed, including plain wallet-to-wallet transfers, DEX swaps and airdrops as well as bonding-curve buys and sells, so a balance cannot go stale because the tokens moved outside the curve. - A balance change, a
CommunityManagementstate change (threshold, moderator set, muted set or privacy flag), or a Nostr link change each trigger a recheck. - Eligibility is recomputed for the affected
(sale_address, member_address)pairs asbalance >= min_token_threshold AND NOT muted, raw against raw with no decimal shifting. - On a real flip, the bot publishes the NIP-29 kind 9000 (add) or 9001 (remove) event to the
relay, moving
relay_statethroughpending_addtoadded.
POST /api/rooms/{saleAddress}/recheck forces one pass through the same path for a single
address; it is a repair tool, not the normal mechanism. A reconciliation service and a startup
backfill cover drift. When no relay is configured, the API still indexes balances and serves the
read endpoints but publishes nothing.
A room row carries:
| Field | Meaning |
|---|---|
sale_address | The NIP-29 group id |
token_address | The AEX-9 token contract |
symbol | The token symbol |
is_private | Whether reading requires AUTH and membership |
min_token_threshold | Minimum balance to be eligible, in raw base units |
is_community | Whether this is a community room |
role | member or admin |
relay_state | pending_add, added, pending_remove or removed |
member_pubkey | The resolved Nostr pubkey, or null until the holder links one |
readable | True only when relay_state is added and a pubkey is set |
The composer derives its gate from those fields:
| Condition | Gate | What the banner offers |
|---|---|---|
member_pubkey is null | link-required | Open the AE-to-Nostr link dialog |
relay_state is pending_add | provisioning | Wait; a spinner |
readable is true | none | Post |
| The room is absent from the list | not-eligible | Buy the token |
Provisioning is not instant. The client polls while a room is pending_add (every 5 seconds
for the first minute, then every 15 seconds, backing off to roughly 10 minutes) and fires one
recheck so a desync heals on its own.
Muting a room
Mute is a signed write, because it changes server-side notification state:
Superhero Rooms
Mute <saleAddress> for <address>
body: <sha256 of the canonical body>
nonce: <nonce>The canonical body binds the exact delta so a captured nonce and signature cannot be replayed with a swapped payload:
canonicalRoomMuteHash(true, undefined) = sha256("muted=1;mute_all=-")
canonicalRoomMuteHash(false, false) = sha256("muted=0;mute_all=0")mute_all is tri-state on the wire: true, false, or omitted, meaning "leave the
account-wide switch alone". The absent case serializes as - so it cannot collide with
either boolean. The message string must match the backend byte for byte. On an expired or
consumed nonce the client retries once with a fresh challenge.
Relays
NOSTR_RELAY_URLS is a comma-separated list of wss:// origins. It defaults to
wss://relay.superhero.chat, so chat works out of the box; a runtime value overrides it. Each
entry passes a scheme guard (wss:// always, ws:// only on localhost, 127.0.0.1 or
[::1]) and is de-duplicated by origin. A malformed or insecure entry is dropped. With no
usable relay, the chat entry points render an unavailable notice.
/chat/settings/relays lists the configured relays with per-relay read and write toggles, a
reachability test that opens a short-lived socket with a 5-second timeout, and add and remove
controls. Anything you add is validated against the same scheme guard and origin allowlist.
Trailing slashes are removed so the same relay cannot be stored twice.
Storage and timing
Messages, contacts, profiles, relay settings, unread counts and read receipts are kept in the browser's IndexedDB through the chat KV layer, with an encrypted store for sensitive values. Nothing is on chain and nothing is on a Superhero server.
| Constant | Value |
|---|---|
| Event queue debounce | 50 ms |
| Periodic fetch interval | 10 s |
| Initial fetch delay | 500 ms |
| Relay connection timeout | 5 s |
| Online-status timeout | 5 min |
What chat is not
- Not permanent. A relay can drop history, go away, or refuse to serve you. Nothing is anchored on chain.
- Not censorship-resistant in the way posts are. The relay controls membership and can refuse events. Posts go to a contract; chat goes to a server someone runs.
- Not end-to-end encrypted in group rooms. DMs use NIP-04; room messages are kind 9 events readable by anyone the relay serves.
- Not a substitute for the on-chain record. If it matters, post it.
Related
- SuperheroID: the profile's
nostrfield and how it is linked. - Moderation: the
CommunityManagementstate the eligibility pipeline reads, and what a mute does to a room. - Notifications:
room-membershipandroom-messagesare two of the notification types. - Tokens overview: a room exists because a token does.
- NIP-29: relay-based groups (opens in a new tab): the specification behind community rooms.