Docs

Group chat

This guide adds a private group chat (TAP-27) to an application that already uses TapeAPI. A group is up to 32 TapeOut containers; one of them, the owner, keeps the member list. Messages are end-to-end encrypted and signed; whatever carries them (a relay or ChannelBus) sees ciphertext only.

A working example to start from: examples/group-chat/ (node examples/group-chat/index.mjs, over the public relay).

Before you start

Every member, the owner included, needs:

  • A circuit and its container. The container is the circuit's ERC-6551 account. It is the address a group knows a member by. It is not the wallet that holds the circuit.
  • A published channel identity. An X25519 key for invites and an Ed25519 key for signatures, generated by the application, authorised by the holder's wallet and published in the container's site: scripts/channel-keys.mjs (see Private channels) or api.tx.publishChannelKeys({ container, record }). Keep the identity file with its secret keys; the published record holds only public keys.
  • A transport. The free public relay 12.1013.tape (Public API), your own relay, or ChannelBus.

Check a member with await api.chain.channelKeys(container): it returns the published keys only while the current holder of the circuit authorised them.

Two rooms: the part that is easy to get wrong

A group uses two kinds of room, and a new member needs both:

RoomIdWhat goes thereWho reads it
Group roomgroup.roomepoch messages (the key and the encrypted member list) and group messagesevery member
Inbox roomchannel.inboxRoom(container, chainId), one per memberthe sealed invite that tells a new member the group existsthat member

createGroup and addMembers return only the epoch message (epochWire), which goes to the group room. A new member does not know the group room yet: it learns it from the invite, which the owner must create with group.inviteFor(member) and post to that member's inbox room. Post only the epoch message and the new member waits for ever; the relay is working, it just has nothing in the room the member reads.

deliverGroupUpdate does both in one call. Use it.

Owner

import { createTapeAPI, rpcUrlsFor, group as G, deliverGroupUpdate } from '@tapeapi/sdk'

const api = createTapeAPI({ rpcUrls: rpcUrlsFor(56), quorum: 2 })
const relay = { api, svc: await api.resolve('12.1013.tape') }
const verifyMember = api.groupVerifier()                 // checks every member against its channel record

// Other members as the chain publishes them: container addresses, never wallets
const members = await Promise.all([bobContainer, carolContainer].map((c) => api.chain.channelKeys(c)))

const created = await G.createGroup({
  self: { container: myContainer, chainId: 56 }, identity: myIdentity, members, verifyMember,
  relays: [{ url: 'https://relay.tapeapi.fun/tapeapi/v1', container: relay.svc.container }],
})
const group = created.group
const sent = await deliverGroupUpdate({ group, update: created, relay })
// sent.deliveries: one entry per post, invites first, then the epoch message:
//   { what: 'invite', room, container, chainId, via: 'relay', ok: true, i: 0, epoch: '<room epoch>' }
//   { what: 'epoch',  room: group.room, via: 'relay', ok: true, i: 0, epoch: '<room epoch>' }

Membership changes return the same kind of update, and added says who is new:

const up = await group.addMembers([await api.chain.channelKeys(daveContainer)], { verifyMember })
up.added                                                 // [{ container, chainId, x25519, ed25519 }]: invited by default
await deliverGroupUpdate({ group, update: up, relay })

await deliverGroupUpdate({ group, update: await group.removeMembers([carolContainer]), relay })   // no invites
await deliverGroupUpdate({ group, update: await group.rotate(), relay })                          // at least every 30 days

What deliverGroupUpdate does and returns:

  • Order. Invites to each member's inbox room first, then the epoch message to the group room (TAP-27 §3.5).
  • Who is invited. invite: 'new' (the default: update.added), 'all' (every member but the owner), 'none', or a list of containers that must be in the current roster. Invites always use the roster's container and chainId, the ones the owner signed.
  • Several transports. relay and bus each take one or a list; every post goes to each of them. Each invite is sealed once, so a member that reads two transports sees one invite twice, not two invites.
  • Failures. Every post is tried. If any failed, the call then throws TapeAPIError('GROUP_DELIVERY'), whose message names the first failed room and whose data holds every delivery; pass throwOnError: false to get { ok: false, deliveries } instead. It never swallows an error.
  • Reposts. Without update it reposts the current epoch message (group.epochWire). TAP-27 §3.5 recommends a repost every 10 minutes on a relay whose rooms live 15 minutes, and every 30 minutes on ChannelBus read through public nodes:
setInterval(() => deliverGroupUpdate({ group, relay }).catch(report), 10 * 60_000)

Member

import { createTapeAPI, rpcUrlsFor, channel, group as G, checkGroupInvites } from '@tapeapi/sdk'

const api = createTapeAPI({ rpcUrls: rpcUrlsFor(56), quorum: 2 })
const relay = { api, svc: await api.resolve('12.1013.tape') }
const self = { container: myContainer, chainId: 56 }    // the CONTAINER, on the chain it lives on
const cursors = new Map()                                // or your own store: see "Saving state"

const found = await checkGroupInvites({ self, identity: myIdentity, relay, cursors, waitMs: 20_000, checkSelf: true })
for (const { invite } of found.invites) {
  const ownerKeys = await api.chain.channelKeys(invite.owner.container)   // from the chain, never from the invite
  const g = G.joinGroup({ self, identity: myIdentity, invite, ownerKeys })
  let greeted = false
  const link = channel.relayTransport({ api, svc: relay.svc, inbound: g.room, outbound: g.room })
  link.start(async (wire) => {
    const t = channel.decodeWire(wire)
    if (t.groupEpoch) {
      await g.acceptEpoch(t.groupEpoch, { verifyMember: api.groupVerifier() })
      if (!greeted && g.epoch !== null) { greeted = true; await link.send(g.seal('hello')) }   // send only after the first epoch is accepted
    }
    if (t.groupMessage) { const m = g.open(t.groupMessage, { text: true }); if (!m.own) show(m.from, m.data) }
  }, { onError: report })
}
  • checkGroupInvites reads channel.inboxRoom(self.container, self.chainId) on each relay, keeps one cursor per relay and room with the relay's room epoch (the first read is after: -1, epoch: null), and returns { room, invites, skipped, skippedBy, failed }. Frames that are not a group invite for this container are skipped and counted; a TAP-26 channel invite in the same inbox is counted as channelInvite, for your channel code.
  • checkSelf: true reads the container's channel record first and refuses unless it publishes this identity's X25519 key on this chainId: a wallet address, a wrong chainId or a stale identity file is reported at once instead of as an empty inbox. Pass holder (the wallet) and a mix-up of the two is refused without any chain read.
  • A relay that cannot be read is listed in failed (and ok is false); if none can be read, the call throws.
  • The invite only says where to look. The member accepts nothing but epoch messages the owner signed, and after the first one it uses the roster's relays and bus (g.roster.relays, g.roster.bus), which the owner signed.
  • A new member also finds older epoch messages in the group room. They do not open with its key and acceptEpoch refuses them ("not a member of it"); that is expected. Catch and continue.

Relay or ChannelBus

RelayChannelBus
Latency and costabout one round trip; the public relay is freeblock time; every post is a transaction paid in gas
What staysrooms in memory, forgotten 15 minutes after the last accessevents on chain, public for ever
Limitsinvites and epoch messages: 8 per source per room per 10 minutes on the reference relayup to 16,448 bytes per post
Ownerrelay: { api, svc, payer? }bus: { address: MAINNET.channelBus, sendTx }: your wallet sends, one transaction per room
MembercheckGroupInvites({ relay }), channel.relayTransportbusPrivacy.busPrivacyReader (reads the whole contract by default, see Read privacy) or channel.busReader on channel.inboxRoom(...) and group.room

Name both in createGroup({ relays, bus }) and deliver over both when the group matters: a member can read either.

Saving state and restarting

Nothing needs saving to stay safe; these keep a restart smooth:

  • Owner: save group.snapshot() (no secrets: the roster and the epoch). After a restart, G.resumeGroup({ self, identity, snapshot, verifyMember }) starts the next epoch at once (the old key is gone) and returns it as an update: deliverGroupUpdate({ group: resumed.group, update: resumed, relay }).
  • Member: save g.snapshot() after sealing. After a restart, pass minEpoch: snapshot.epoch and lastSeq: snapshot.lastSeq to joinGroup: a relay replaying an older epoch is refused, and a clock that stepped back cannot make new messages look like replays.
  • Cursors: cursors takes any { get(key), set(key, value) }, sync or async; back it with a file or a database. Keys are relay:<relay container>:<room>, values { after, epoch }. Keep the epoch with the index: an index without its room epoch is exactly the mistake below.
  • Identity: the identity file with the secret keys. Lose it and the member must publish a new identity, and the owner must add it again.

Troubleshooting checklist

SymptomCauseFix
The owner created the group, members never get an invite, the relay returns 0 framesOnly the epoch message was posted, to the group room; the invites were never posted to the inbox roomsdeliverGroupUpdate({ group, update, relay }), and check that deliveries has one invite per new member
The same, with invites postedWrong room: the inbox room was computed from the holder's wallet, not the container, or with another chainIdCompare the room of the owner's invite delivery with the room that checkGroupInvites returns. Use the container address and the chainId it lives on; checkSelf: true names the mistake
The invite at index 0 is never seenThe read reused the after cursor of another room (the group room) and sent no epoch, so the relay started past index 0Keep a cursor per room with its room epoch; the first read is after: -1, epoch: null. checkGroupInvites does this and ignores a stored cursor without an epoch
Invites or the epoch message vanish after a whileA relay keeps a room in memory only, and forgets it 15 minutes after the last accessRepost the epoch message every 10 minutes (deliverGroupUpdate({ group, relay })); re-invite members who have not joined (invite: 'all'); a member's stale cursor is reset by the new room epoch
Posting fails with too many invites / epoch messages from this source in this roomThe relay limits 0x03 / 0x04 frames per source per room (8 per 10 minutes on the reference relay)Wait error.retryAfterS and deliver again; do not repost in a tight loop. Never swallow the error: deliverGroupUpdate throws GROUP_DELIVERY with rateLimited: true
A mobile wallet never returns to the app when signing the channel keysThe app is served on a LAN HTTP address (http://192.168.x.x), which the wallet will not call backServe it through an HTTPS tunnel, and set WalletConnect's metadata.url to that exact HTTPS origin
A new member logs "not a member of it" for some epoch messagesOlder epochs in the group room were not made for itExpected: catch and continue; the epoch that added it opens

Limits

  • At most 32 members per group.
  • The owner is a single point: only the owner adds, removes and rotates, and there is no owner transfer. A group that needs a new owner is a new group.
  • Metadata is visible: a relay sees room ids, frame sizes, timing and the IP addresses that post and poll; anyone reading the group room sees the member count and message sizes and times. On ChannelBus all of that is public for ever. Content and the member list stay encrypted.
  • A removed member keeps everything it could read before its removal.
  • Pre-alpha and not audited by a third party.

Edit this page on GitHub