Social
Chat

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

PieceValue
ProtocolNostr
RoomsNIP-29 relay-based groups
Group idThe token sale address (ct_…), verbatim
Room messagesKind 9 (group chat message) and kind 11 (thread root)
Room metadataKind 39000, addressable by its d tag, fields in tags
RosterKinds 39001 admins, 39002 members, 39003 roles
Membership eventsKinds 9000 add, 9001 remove, 9021 join request, 9022 leave request
Private-room authNIP-42 client auth, kind 22242
Direct messagesNIP-04 encrypted events, kind 4
Default relaywss://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

RoutePurpose
/chatInbox with All, Communities and DMs tabs, each with a count
/chat/dm/:addressA direct message thread
/chat/:saleAddressA token's community room
/chat/:saleAddress/infoRoom info: metadata, members, mute
/chat/settings/relaysRelay 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 challenge

GET /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:

  1. AEX-9 Transfer events 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.
  2. A balance change, a CommunityManagement state change (threshold, moderator set, muted set or privacy flag), or a Nostr link change each trigger a recheck.
  3. Eligibility is recomputed for the affected (sale_address, member_address) pairs as balance >= min_token_threshold AND NOT muted, raw against raw with no decimal shifting.
  4. On a real flip, the bot publishes the NIP-29 kind 9000 (add) or 9001 (remove) event to the relay, moving relay_state through pending_add to added.

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:

FieldMeaning
sale_addressThe NIP-29 group id
token_addressThe AEX-9 token contract
symbolThe token symbol
is_privateWhether reading requires AUTH and membership
min_token_thresholdMinimum balance to be eligible, in raw base units
is_communityWhether this is a community room
rolemember or admin
relay_statepending_add, added, pending_remove or removed
member_pubkeyThe resolved Nostr pubkey, or null until the holder links one
readableTrue only when relay_state is added and a pubkey is set

The composer derives its gate from those fields:

ConditionGateWhat the banner offers
member_pubkey is nulllink-requiredOpen the AE-to-Nostr link dialog
relay_state is pending_addprovisioningWait; a spinner
readable is truenonePost
The room is absent from the listnot-eligibleBuy 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.

ConstantValue
Event queue debounce50 ms
Periodic fetch interval10 s
Initial fetch delay500 ms
Relay connection timeout5 s
Online-status timeout5 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