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) orapi.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:
| Room | Id | What goes there | Who reads it |
|---|---|---|---|
| Group room | group.room | epoch messages (the key and the encrypted member list) and group messages | every member |
| Inbox room | channel.inboxRoom(container, chainId), one per member | the sealed invite that tells a new member the group exists | that 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 daysWhat 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.
relayandbuseach 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 whosedataholds every delivery; passthrowOnError: falseto get{ ok: false, deliveries }instead. It never swallows an error. - Reposts. Without
updateit 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 })
}checkGroupInvitesreadschannel.inboxRoom(self.container, self.chainId)on each relay, keeps one cursor per relay and room with the relay's room epoch (the first read isafter: -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 aschannelInvite, for your channel code.checkSelf: truereads 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. Passholder(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(andokis 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
acceptEpochrefuses them ("not a member of it"); that is expected. Catch and continue.
Relay or ChannelBus
| Relay | ChannelBus | |
|---|---|---|
| Latency and cost | about one round trip; the public relay is free | block time; every post is a transaction paid in gas |
| What stays | rooms in memory, forgotten 15 minutes after the last access | events on chain, public for ever |
| Limits | invites and epoch messages: 8 per source per room per 10 minutes on the reference relay | up to 16,448 bytes per post |
| Owner | relay: { api, svc, payer? } | bus: { address: MAINNET.channelBus, sendTx }: your wallet sends, one transaction per room |
| Member | checkGroupInvites({ relay }), channel.relayTransport | channel.busTransport / 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, passminEpoch: snapshot.epochandlastSeq: snapshot.lastSeqtojoinGroup: a relay replaying an older epoch is refused, and a clock that stepped back cannot make new messages look like replays. - Cursors:
cursorstakes any{ get(key), set(key, value) }, sync or async; back it with a file or a database. Keys arerelay:<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
| Symptom | Cause | Fix |
|---|---|---|
| The owner created the group, members never get an invite, the relay returns 0 frames | Only the epoch message was posted, to the group room; the invites were never posted to the inbox rooms | deliverGroupUpdate({ group, update, relay }), and check that deliveries has one invite per new member |
| The same, with invites posted | Wrong room: the inbox room was computed from the holder's wallet, not the container, or with another chainId | Compare 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 seen | The read reused the after cursor of another room (the group room) and sent no epoch, so the relay started past index 0 | Keep 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 while | A relay keeps a room in memory only, and forgets it 15 minutes after the last access | Repost 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 room | The 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 keys | The app is served on a LAN HTTP address (http://192.168.x.x), which the wallet will not call back | Serve 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 messages | Older epochs in the group room were not made for it | Expected: 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.