Managing a Room
The RoomAdminClient creates rooms, mutates membership and publishes epoch headers. Everything key-related derives from a single 32-byte admin seed — whoever holds the seed is the room admin, cryptographically.
The seed model
The seed is the room's master secret: the BGW admin secret, every member's user secret and every epoch key derive from it. There are two ways to get one:
- Explicit seed (
CreateManual/Loadwithseed) — you generate 32 random bytes and store them. Natural for servers and CLIs. - Wallet-signature derivation (
Create/createRoom/LoadwithsignMessage) — the SDK builds a deterministic message bound to the admin wallet, theroomIdand an optionaloriginstring, asks the wallet to sign it, and derives the seed from the signature (HKDF). The same wallet signing the same message always re-derives the same seed, so nothing needs to be stored. Natural for browser wallets.
Keep the seed (or the ability to re-sign) safe. Losing it means losing admin control of the room; leaking it means leaking every key the room ever had.
Creating a room
With a wallet signature
const { receipt, client: adminRoom } = await client.createRoom({
signMessage: (message) => wallet.signMessage(message),
// roomId?: Bytes — random 32 bytes if omitted
// origin?: string — defaults to "packet"; changing it changes the derived seed
});
With an explicit seed
import { RoomAdminClient } from "xpkt-sdk";
const { receipt, client: adminRoom } = await RoomAdminClient.CreateManual({
client,
params: {
roomId: crypto.getRandomValues(new Uint8Array(32)),
seed: storedSeed, // 32 bytes — store it
},
// bgwParams?: BgwParamsClient — per-room override; defaults to the configured process-wide params
});
Creation pins the BGW params identity (paramsId + paramsRoot) on the room account. Every later client interacting with the room must load the same artifact — a mismatch is rejected (see BGW params).
Recovering the admin (zero state)
The admin client is fully recoverable from scratch — no local state besides the seed (or the wallet) is needed. Load fetches the room, re-derives the admin secret, reconstructs the recipient state from the on-chain header chain and verifies it against the room's state-root mirror:
// from a stored seed
const adminRoom = await client.roomAdmin({ roomId, seed: storedSeed });
// or from the wallet signature (same wallet + roomId + origin as at creation)
const adminRoom = await client.roomAdmin({
roomId, // or address: roomPda
signMessage: (message) => wallet.signMessage(message),
});
Load throws if the resolved BGW params root doesn't match the room's pinned paramsRoot, or if the reconstructed recipient state doesn't verify — both fail closed.
Adding members
const result = await adminRoom.addMember({ member: alicePublicKey });
What happens:
- Slot assignment — a brand-new member gets the room's next slot; a previously removed member is re-activated and keeps their original slot.
- Member secret encryption — the member's BGW user secret is extracted and encrypted to their registered packet encryption key; if the wallet has no registered key, it falls back to the wallet-derived ed25519→x25519 key. Browser-wallet members must register a key before being added — see Member keys.
- Two transactions — the membership mutation, then the covering epoch header (an
Activatealways breaks the key chain, hiding history from the new member).
RoomMemberMutationResult
| Field | Type | Description |
|---|---|---|
member | PublicKey | The member wallet |
slot | number | Assigned (or kept) 1-based slot |
signatures | string[] | Mutation tx signature(s) followed by header tx signature(s) |
header | RoomPublishedHeader | The covering header (see below) |
RoomPublishedHeader
| Field | Type | Description |
|---|---|---|
epoch | BN | Epoch this header created |
kind | "reuse" | "delta" | "inlineCheckpoint" | "externalCheckpoint" | Descriptor kind |
chainBreak | boolean | true when a new key-chain segment started (always for adds) |
chainStartEpoch | BN | First epoch of the segment this header belongs to |
memberVersion | BN | Member version covered by this header |
recipientStateRoot | Uint8Array | Verified recipient state root after this header |
signatures | string[] | Page-staging signatures (external checkpoints only), then the publish signature(s) |
Removing members
const result = await adminRoom.removeMember({ member: bobPublicKey });
Same two-transaction shape. The covering header does not break the chain — the one-way key chain already prevents the removed member from deriving newer keys. After removal:
- the member's sends are rejected on-chain,
- their
recoverEpochKey()returns{ status: "not-recipient" }for new epochs, - everything they could already read stays readable to them (they had the keys),
- if re-added later, the messages from their absence remain locked to them.
Rotating keys without a membership change
const header = await adminRoom.publishReuseHeader();
Publishes a new epoch with a fresh key for the unchanged recipient set (kind "reuse"; the segment continues, so existing members chain-derive older keys for free). Use it for periodic rotation or to bound how long one epoch key encrypts traffic.
Recovery: mutation landed, header didn't
Membership mutations are serialized on-chain: the program rejects a new add/remove until the previous mutation's header has been published (MutationRequiresHeader). addMember/removeMember publish the header for you, but if the process dies (or the header tx fails) between the two transactions, the room is left with a pending mutation. The thrown error tells you exactly how to recover:
import { RecipientDeltaOp } from "xpkt-sdk";
// op: RecipientDeltaOp.Activate (1) for a pending add, RecipientDeltaOp.Remove (2) for a pending remove
await adminRoom.publishMutationHeader({
mutation: { op: RecipientDeltaOp.Activate, slot: pendingSlot },
});
After that, normal mutations work again. See Troubleshooting for diagnosis.
RoomAdminClient reference
| Member | Type | Description |
|---|---|---|
address | PublicKey | Room PDA |
roomId | Bytes | 32-byte room id |
Room | Room | Cached room account (throws before first refresh()/load) |
recipientState | RecipientState | Local mirror of the latest activated recipient state |
bgwParams | BgwParamsClient | Params bound to this admin client |
refresh() | Promise<this> | Re-fetch the room account |
addMember({ member, options? }) | Promise<RoomMemberMutationResult> | Add or re-activate |
removeMember({ member, options? }) | Promise<RoomMemberMutationResult> | Remove |
publishReuseHeader({ options? }?) | Promise<RoomPublishedHeader> | Key rotation |
publishMutationHeader({ mutation, options? }) | Promise<RoomPublishedHeader> | Cover a pending mutation |