solana-messenger-sdk 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE.md +753 -0
- package/dist/accounts.d.ts +91 -0
- package/dist/accounts.d.ts.map +1 -0
- package/dist/accounts.js +195 -0
- package/dist/accounts.js.map +1 -0
- package/dist/events.d.ts +6 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +60 -0
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/messenger.d.ts +130 -12
- package/dist/messenger.d.ts.map +1 -1
- package/dist/messenger.js +542 -49
- package/dist/messenger.js.map +1 -1
- package/package.json +2 -1
package/GUIDE.md
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
# Integration Guide — Building on Solana Messenger
|
|
2
|
+
|
|
3
|
+
This guide walks you through every operation the protocol supports, with code examples and explanations of when and why you'd use each one.
|
|
4
|
+
|
|
5
|
+
**Program:** `msg1SxLsvf1ZL374noHwUWcVYjPsNSNwKb3xphg6Lxf` (mainnet & devnet)
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Setup](#setup)
|
|
10
|
+
2. [Identity & Registration](#identity--registration)
|
|
11
|
+
3. [Direct Messages](#direct-messages)
|
|
12
|
+
4. [Servers](#servers)
|
|
13
|
+
5. [Channels](#channels)
|
|
14
|
+
6. [Discovery](#discovery)
|
|
15
|
+
7. [Key Management](#key-management)
|
|
16
|
+
8. [Account Deserialization](#account-deserialization)
|
|
17
|
+
9. [Common Patterns](#common-patterns)
|
|
18
|
+
10. [Architecture Reference](#architecture-reference)
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { SolanaMessenger, ChannelType } from "solana-messenger-sdk";
|
|
26
|
+
import { readFileSync } from "fs";
|
|
27
|
+
|
|
28
|
+
// Option 1: Self-custody (you have a keypair file)
|
|
29
|
+
const keypair = new Uint8Array(JSON.parse(readFileSync("~/.config/solana/id.json", "utf-8")));
|
|
30
|
+
const messenger = new SolanaMessenger({
|
|
31
|
+
apiKey: "YOUR_HELIUS_API_KEY",
|
|
32
|
+
network: "mainnet", // or "devnet"
|
|
33
|
+
keypair,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Option 2: Custodial wallet (Privy, Turnkey, etc.)
|
|
37
|
+
const messenger = new SolanaMessenger({
|
|
38
|
+
apiKey: "YOUR_HELIUS_API_KEY",
|
|
39
|
+
walletAddress: "YourCustodialWalletAddress",
|
|
40
|
+
signer: async (unsignedTx, recentBlockhash, feePayer) => {
|
|
41
|
+
return await yourCustodialProvider.signTransaction(unsignedTx);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Always call init() first — generates encryption key, registers on-chain
|
|
46
|
+
const { encryptionAddress, status } = await messenger.init();
|
|
47
|
+
// status: "registered" | "already_registered" | "updated"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Why two keys?** Your wallet key (A) signs transactions and can be custodial. Your encryption key (B) is generated locally and never leaves your machine. This means Privy/Turnkey can sign transactions for you but can never read your messages.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Identity & Registration
|
|
55
|
+
|
|
56
|
+
Every user needs an on-chain encryption key registry before they can send or receive messages.
|
|
57
|
+
|
|
58
|
+
### Register (automatic via init)
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
await messenger.init(); // handles registration automatically
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`init()` does three things:
|
|
65
|
+
1. Generates a local encryption keypair (stored at `~/.solana-messenger/keys/<address>.json`)
|
|
66
|
+
2. Registers the public key on-chain at PDA `["messenger", walletAddress]`
|
|
67
|
+
3. Loads platform config (fee vault, protocol fee)
|
|
68
|
+
|
|
69
|
+
### Set spam fee
|
|
70
|
+
|
|
71
|
+
**When:** You want senders to pay you a fee to send DMs (spam deterrent).
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
await messenger.setMinFee(10000); // 10,000 lamports (~$0.001) per DM to you
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Senders automatically pay this fee when calling `send()`. It goes directly to your wallet.
|
|
78
|
+
|
|
79
|
+
### Look up someone's encryption key
|
|
80
|
+
|
|
81
|
+
**When:** You want to verify someone is registered, or you're building custom encryption.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const encKey = await messenger.lookupEncryptionKey("SomeWalletAddress");
|
|
85
|
+
if (!encKey) console.log("User not registered — can't message them");
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Deregister
|
|
89
|
+
|
|
90
|
+
**When:** You're done with the protocol and want to reclaim rent (~0.001 SOL).
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
await messenger.deregister();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
⚠️ After deregistering, nobody can send you messages until you re-register.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Direct Messages
|
|
101
|
+
|
|
102
|
+
Wallet-to-wallet encrypted messages. Permanent, protocol-level, nobody can revoke them.
|
|
103
|
+
|
|
104
|
+
### Send a DM
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const signatures = await messenger.send("RecipientAddress", "Hey, what's up?");
|
|
108
|
+
// Returns array of tx signatures (multiple if message was chunked)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The SDK:
|
|
112
|
+
1. Looks up recipient's encryption key from the registry
|
|
113
|
+
2. Performs X25519 Diffie-Hellman key exchange
|
|
114
|
+
3. Encrypts with NaCl box (XSalsa20-Poly1305)
|
|
115
|
+
4. Auto-chunks messages > 661 bytes
|
|
116
|
+
5. Pays protocol fee + recipient min_fee automatically
|
|
117
|
+
|
|
118
|
+
### Read DMs
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const messages = await messenger.read({ limit: 20, since: 1710000000 });
|
|
122
|
+
for (const msg of messages) {
|
|
123
|
+
console.log(`[${new Date(msg.timestamp * 1000).toISOString()}] ${msg.sender}: ${msg.text}`);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Listen for DMs in real-time
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
const unsubscribe = await messenger.listen((msg) => {
|
|
131
|
+
console.log(`New DM from ${msg.sender}: ${msg.text}`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Later: stop listening
|
|
135
|
+
unsubscribe();
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Uses WebSocket subscription — ~400ms latency.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Servers
|
|
143
|
+
|
|
144
|
+
Servers are workspaces (like Discord servers or Slack workspaces). They contain channels and members with roles.
|
|
145
|
+
|
|
146
|
+
### Create a server
|
|
147
|
+
|
|
148
|
+
**When:** You want to create a workspace for a team, community, or organization.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
const { serverId, serverPda, signature } = await messenger.createServer("My Team");
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
What happens:
|
|
155
|
+
1. Generates a random server ID (keypair pubkey)
|
|
156
|
+
2. Creates Server PDA at `["server", serverId]`
|
|
157
|
+
3. Creates your ServerMember PDA at `["server_member", serverId, yourWallet]` with role=Owner
|
|
158
|
+
4. Generates the first server encryption key (version 0) and wraps it for you
|
|
159
|
+
|
|
160
|
+
You're now the **Owner** — full control over the server.
|
|
161
|
+
|
|
162
|
+
### Invite a member
|
|
163
|
+
|
|
164
|
+
**When:** You want to add someone to your server. You must be Admin or Owner.
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// You need to know the current server keys to wrap them for the new member
|
|
168
|
+
const myMember = await messenger.getServerMember(serverId);
|
|
169
|
+
// Unwrap your server keys first
|
|
170
|
+
const serverKeys = myMember.keyWraps.map(kw => {
|
|
171
|
+
const key = messenger.unwrapKey(kw.wrappedKey, kw.nonce, adminEncryptionPubkey);
|
|
172
|
+
if (!key) throw new Error("Failed to unwrap key");
|
|
173
|
+
return key;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await messenger.inviteToServer(serverId, "NewMemberWallet", serverKeys);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
What happens:
|
|
180
|
+
1. Verifies you're Admin/Owner (checks your ServerMember PDA)
|
|
181
|
+
2. Creates a ServerMember for the new member
|
|
182
|
+
3. Wraps ALL historical server key versions for them (so they can read full history)
|
|
183
|
+
4. New member gets role=Member
|
|
184
|
+
|
|
185
|
+
### Remove a member
|
|
186
|
+
|
|
187
|
+
**When:** Someone needs to be kicked. Admin/Owner only. **Rotates the server key** so the removed member can't read future messages.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// Get current member list
|
|
191
|
+
const members = await messenger.getServerMembers(serverId);
|
|
192
|
+
const remaining = members
|
|
193
|
+
.filter(m => m.account.member !== "RemovedMemberWallet")
|
|
194
|
+
.map(m => m.account.member);
|
|
195
|
+
|
|
196
|
+
await messenger.removeServerMember(serverId, "RemovedMemberWallet", remaining);
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
What happens:
|
|
200
|
+
1. Closes the removed member's ServerMember PDA (reclaims rent)
|
|
201
|
+
2. Generates a new server key (version N+1)
|
|
202
|
+
3. Wraps the new key for all remaining members
|
|
203
|
+
4. Removed member still has old keys cached locally (can read old messages) but can't decrypt anything new
|
|
204
|
+
|
|
205
|
+
### Leave a server
|
|
206
|
+
|
|
207
|
+
**When:** You want to leave voluntarily. Also rotates the key (forward secrecy).
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
const members = await messenger.getServerMembers(serverId);
|
|
211
|
+
const remaining = members
|
|
212
|
+
.filter(m => m.account.member !== myAddress)
|
|
213
|
+
.map(m => m.account.member);
|
|
214
|
+
|
|
215
|
+
await messenger.leaveServer(serverId, remaining);
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
⚠️ The server **owner** cannot leave — transfer ownership first (not yet in SDK, use low-level builders).
|
|
219
|
+
|
|
220
|
+
### Update server name
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
await messenger.updateServer(serverId, "New Server Name");
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Close a server
|
|
227
|
+
|
|
228
|
+
**When:** You're done with the server entirely. Owner only. Reclaims all rent.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
const members = await messenger.getServerMembers(serverId);
|
|
232
|
+
const memberAddresses = members.map(m => m.account.member);
|
|
233
|
+
await messenger.closeServer(serverId, memberAddresses);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Channels
|
|
239
|
+
|
|
240
|
+
Channels are where messages are sent. Two types:
|
|
241
|
+
|
|
242
|
+
| Type | Key Source | Members | Use Case |
|
|
243
|
+
|------|-----------|---------|----------|
|
|
244
|
+
| **Public** | Server key | All server members | #general, #announcements |
|
|
245
|
+
| **Private** | Own channel key | Invite-only | DM groups, private discussions |
|
|
246
|
+
|
|
247
|
+
Channels can be **server channels** (belong to a server) or **standalone** (group DMs without a server).
|
|
248
|
+
|
|
249
|
+
### Create a public channel (in a server)
|
|
250
|
+
|
|
251
|
+
**When:** You want a channel visible to all server members. Messages encrypted with the server key.
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
const { channelId, channelPda } = await messenger.createChannel({
|
|
255
|
+
serverId: serverId,
|
|
256
|
+
channelType: ChannelType.Public,
|
|
257
|
+
members: [], // public channels don't need a member list
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Create a private channel (in a server)
|
|
262
|
+
|
|
263
|
+
**When:** You want an invite-only channel within a server. Has its own encryption key.
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const { channelId, channelPda } = await messenger.createChannel({
|
|
267
|
+
serverId: serverId,
|
|
268
|
+
channelType: ChannelType.Private,
|
|
269
|
+
members: [myAddress, member1, member2],
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Create a standalone group DM (no server)
|
|
274
|
+
|
|
275
|
+
**When:** You want a group chat without the overhead of a full server.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const { channelId, channelPda } = await messenger.createChannel({
|
|
279
|
+
members: [myAddress, friend1, friend2], // you must be in the list
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Send a message to a channel
|
|
284
|
+
|
|
285
|
+
**When:** Sending to any channel (public or private).
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// For PUBLIC channels: use the server key
|
|
289
|
+
const myServerMember = await messenger.getServerMember(serverId);
|
|
290
|
+
const serverKey = messenger.unwrapKey(
|
|
291
|
+
myServerMember.keyWraps[keyVersion].wrappedKey,
|
|
292
|
+
myServerMember.keyWraps[keyVersion].nonce,
|
|
293
|
+
wrapperEncryptionPubkey,
|
|
294
|
+
);
|
|
295
|
+
const senderServerMemberPda = await deriveServerMemberPda(serverId, myAddress, programId);
|
|
296
|
+
|
|
297
|
+
await messenger.sendChannelMessage({
|
|
298
|
+
channelPda,
|
|
299
|
+
message: "Hello everyone!",
|
|
300
|
+
channelKey: serverKey,
|
|
301
|
+
keyVersion: myServerMember.keyWraps.length - 1, // latest version
|
|
302
|
+
senderServerMemberPda, // REQUIRED for public channels
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// For PRIVATE channels: use the channel key
|
|
306
|
+
const myChannelMember = await messenger.getChannelMember(channelId);
|
|
307
|
+
const channelKey = messenger.unwrapKey(
|
|
308
|
+
myChannelMember.keyWraps[0].wrappedKey,
|
|
309
|
+
myChannelMember.keyWraps[0].nonce,
|
|
310
|
+
wrapperEncryptionPubkey,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
await messenger.sendChannelMessage({
|
|
314
|
+
channelPda,
|
|
315
|
+
message: "Private discussion",
|
|
316
|
+
channelKey: channelKey,
|
|
317
|
+
keyVersion: 0,
|
|
318
|
+
// no senderServerMemberPda needed for private channels
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Key difference:** Public channels require `senderServerMemberPda` — the program verifies you're a server member before accepting the message.
|
|
323
|
+
|
|
324
|
+
### Read channel messages
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
const messages = await messenger.readChannelMessages(channelId, channelKey, {
|
|
328
|
+
limit: 50,
|
|
329
|
+
since: 1710000000, // unix timestamp
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
for (const msg of messages) {
|
|
333
|
+
console.log(`[${msg.sender}] ${msg.text} (key v${msg.keyVersion})`);
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Listen for channel messages in real-time
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
const unsubscribe = await messenger.listenChannel(channelPda, channelKey, (msg) => {
|
|
341
|
+
console.log(`${msg.sender}: ${msg.text}`);
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Add members to a private channel
|
|
346
|
+
|
|
347
|
+
**When:** Inviting new people to an existing private channel. Creator only.
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
// New members get ALL historical key versions (Slack-style full history)
|
|
351
|
+
await messenger.addChannelMembers(channelPda, channelId, ["NewMember"], [allChannelKeys]);
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Remove members from a private channel
|
|
355
|
+
|
|
356
|
+
**When:** Kicking someone. Rotates the channel key. Creator only.
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
const { newChannelPda } = await messenger.removeChannelMembers(
|
|
360
|
+
channelId,
|
|
361
|
+
currentVersion, // current channel version (from channel account)
|
|
362
|
+
["RemovedMember"], // who to remove
|
|
363
|
+
["Remaining1", "Remaining2"], // who stays
|
|
364
|
+
);
|
|
365
|
+
// ⚠️ Channel PDA changes! Old PDA is closed, new one at version+1
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Close a channel
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
await messenger.closeChannel(channelPda, channelId, memberAddresses);
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Discovery
|
|
377
|
+
|
|
378
|
+
**The key promise:** With just your wallet key + encryption key, you can discover everything you're part of — no backend needed.
|
|
379
|
+
|
|
380
|
+
### Find all my servers
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
const servers = await messenger.getMyServers();
|
|
384
|
+
for (const { server, account } of servers) {
|
|
385
|
+
const roleName = ["Member", "Admin", "Owner"][account.role];
|
|
386
|
+
console.log(`${server.name} — ${roleName} (joined ${new Date(account.joinedAt * 1000).toISOString()})`);
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Find all my private channels
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
const channels = await messenger.getMyChannels();
|
|
394
|
+
for (const { channel, account } of channels) {
|
|
395
|
+
if (channel) {
|
|
396
|
+
const type = channel.channelType === 0 ? "Private" : "Public";
|
|
397
|
+
console.log(`Channel ${channel.channelId} (${type}, ${channel.members.length} members)`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Find all channels in a server
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
const channels = await messenger.getServerChannels(serverId);
|
|
406
|
+
for (const { account } of channels) {
|
|
407
|
+
const type = account.channelType === 0 ? "Private" : "Public";
|
|
408
|
+
console.log(`${account.channelId} — ${type}, v${account.version}`);
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Find all members of a server
|
|
413
|
+
|
|
414
|
+
```typescript
|
|
415
|
+
const members = await messenger.getServerMembers(serverId);
|
|
416
|
+
for (const { account } of members) {
|
|
417
|
+
const roleName = ["Member", "Admin", "Owner"][account.role];
|
|
418
|
+
console.log(`${account.member} — ${roleName}`);
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Get a specific server/channel/member
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
const server = await messenger.getServer(serverId);
|
|
426
|
+
const channel = await messenger.getChannel(channelId, version);
|
|
427
|
+
const myServerMember = await messenger.getServerMember(serverId);
|
|
428
|
+
const myChannelMember = await messenger.getChannelMember(channelId);
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## Key Management
|
|
434
|
+
|
|
435
|
+
The encryption model uses **shared symmetric keys** wrapped per-member.
|
|
436
|
+
|
|
437
|
+
### How it works
|
|
438
|
+
|
|
439
|
+
1. **Server key**: One symmetric key per server version. Stored wrapped in each ServerMember's `keyWraps`.
|
|
440
|
+
2. **Channel key**: One symmetric key per channel version (private channels only). Stored in ChannelMember's `keyWraps`.
|
|
441
|
+
3. **Key rotation**: When a member is removed (or leaves), a NEW key is generated and wrapped for all remaining members. The old key still exists in their records (for reading old messages).
|
|
442
|
+
4. **`keyVersion`**: When sending a message, you specify which key version you used. Readers use this to find the right key wrap to decrypt.
|
|
443
|
+
|
|
444
|
+
### Unwrap a key
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
// Get your ServerMember or ChannelMember account
|
|
448
|
+
const myMember = await messenger.getServerMember(serverId);
|
|
449
|
+
|
|
450
|
+
// Unwrap the latest key
|
|
451
|
+
const latestWrap = myMember.keyWraps[myMember.keyWraps.length - 1];
|
|
452
|
+
const key = messenger.unwrapKey(
|
|
453
|
+
latestWrap.wrappedKey,
|
|
454
|
+
latestWrap.nonce,
|
|
455
|
+
wrapperEncryptionPubkey, // encryption pubkey of whoever wrapped it (admin/creator)
|
|
456
|
+
);
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Who wrapped the key?
|
|
460
|
+
|
|
461
|
+
The wrapper is whoever called `inviteToServer`, `createServer`, `removeServerMember`, or `leaveServer`. You need their **encryption public key** (from the EncryptionRegistry) to unwrap.
|
|
462
|
+
|
|
463
|
+
For key wraps created by different people at different times, you may need to try multiple encryption pubkeys. In practice, most servers have one admin who does all invites.
|
|
464
|
+
|
|
465
|
+
### Key version timeline example
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
v0: Server created with key K0 (wrapped for owner)
|
|
469
|
+
→ invite member B (K0 wrapped for B)
|
|
470
|
+
→ invite member C (K0 wrapped for C)
|
|
471
|
+
v1: Member C removed → new key K1 (wrapped for owner + B)
|
|
472
|
+
→ C can still read v0 messages, not v1+
|
|
473
|
+
v2: Member B leaves → new key K2 (wrapped for owner only)
|
|
474
|
+
→ B can still read v0-v1 messages, not v2+
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## Account Deserialization
|
|
480
|
+
|
|
481
|
+
For custom integrations, you can deserialize raw account data directly:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import {
|
|
485
|
+
deserializeServer,
|
|
486
|
+
deserializeServerMember,
|
|
487
|
+
deserializeChannel,
|
|
488
|
+
deserializeChannelMember,
|
|
489
|
+
deserializeEncryptionRegistry,
|
|
490
|
+
deserializePlatformConfig,
|
|
491
|
+
ACCOUNT_DISCRIMINATORS,
|
|
492
|
+
} from "solana-messenger-sdk";
|
|
493
|
+
|
|
494
|
+
// From raw RPC data
|
|
495
|
+
const accountInfo = await rpc.getAccountInfo(address(pda), { encoding: "base64" }).send();
|
|
496
|
+
const data = Buffer.from(accountInfo.value.data[0], "base64");
|
|
497
|
+
const server = deserializeServer(new Uint8Array(data));
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
### Account layouts
|
|
501
|
+
|
|
502
|
+
| Account | PDA Seeds | Key Fields |
|
|
503
|
+
|---------|-----------|------------|
|
|
504
|
+
| `Server` | `["server", serverId]` | serverId, owner, name |
|
|
505
|
+
| `ServerMember` | `["server_member", serverId, member]` | serverId, member, role, keyWraps |
|
|
506
|
+
| `Channel` | `["channel", channelId, versionBytes]` | channelId, serverId?, creator, version, channelType, members |
|
|
507
|
+
| `ChannelMember` | `["channel_member", channelId, member]` | channelId, member, keyWraps |
|
|
508
|
+
| `EncryptionRegistry` | `["messenger", owner]` | owner, encryptionKey, minFee |
|
|
509
|
+
| `PlatformConfig` | `["config"]` | authority, feeVault, protocolFee |
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## WebSocket Strategy
|
|
514
|
+
|
|
515
|
+
The SDK offers multiple real-time listening strategies depending on your use case.
|
|
516
|
+
|
|
517
|
+
### Single-channel listening
|
|
518
|
+
|
|
519
|
+
Subscribe to one channel's PDA — ideal when you only care about one conversation.
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
const unsubscribe = await messenger.listenChannel(channelPda, channelKey, (msg) => {
|
|
523
|
+
console.log(`${msg.sender}: ${msg.text}`);
|
|
524
|
+
});
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Pros: Simple, auto-decrypts messages for you.
|
|
528
|
+
Cons: One WebSocket per channel. Doesn't scale to many channels.
|
|
529
|
+
|
|
530
|
+
### Server-wide listening
|
|
531
|
+
|
|
532
|
+
Subscribe to all public channels in a server at once. Each channel gets its own WebSocket subscription (typically 10-50 per server — very manageable).
|
|
533
|
+
|
|
534
|
+
**Why not program-wide?** Subscribing to the program address means receiving EVERY message from EVERY server globally. At scale (10,000+ servers), that's a firehose of data you don't need and a waste of RPC credits.
|
|
535
|
+
|
|
536
|
+
```typescript
|
|
537
|
+
// Unwrap all your server key versions
|
|
538
|
+
const myMember = await messenger.getServerMember(serverId);
|
|
539
|
+
const serverKeys = new Map<number, Uint8Array>();
|
|
540
|
+
for (const kw of myMember.keyWraps) {
|
|
541
|
+
const key = messenger.unwrapKey(kw.wrappedKey, kw.nonce, wrapperEncPubkey);
|
|
542
|
+
if (key) serverKeys.set(kw.version, key);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Listen to all public channels in this server
|
|
546
|
+
const unsubscribe = await messenger.listenServer(serverId, serverKeys, (msg) => {
|
|
547
|
+
console.log(`[${msg.channelId}] ${msg.sender}: ${msg.text}`);
|
|
548
|
+
});
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### DM listening
|
|
552
|
+
|
|
553
|
+
Subscribe to your wallet address — only YOUR DMs, not everyone's.
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
const unsubDM = await messenger.listen((msg) => {
|
|
557
|
+
console.log(`DM from ${msg.sender}: ${msg.text}`);
|
|
558
|
+
});
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### Pattern: Slack-like real-time
|
|
562
|
+
|
|
563
|
+
The recommended approach for a full messaging app:
|
|
564
|
+
|
|
565
|
+
1. **DMs**: one `listen()` call (subscribes to your wallet)
|
|
566
|
+
2. **Per-server**: one `listenServer()` call per server you're in (subscribes to that server's channel PDAs)
|
|
567
|
+
3. **Private channels**: one `listenChannel()` per private channel (they have separate keys)
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
// 1. DMs
|
|
571
|
+
const unsubDM = await messenger.listen((msg) => renderDM(msg));
|
|
572
|
+
|
|
573
|
+
// 2. For each server, listen to public channels
|
|
574
|
+
const serverUnsubs: (() => void)[] = [];
|
|
575
|
+
const servers = await messenger.getMyServers();
|
|
576
|
+
for (const { server, account } of servers) {
|
|
577
|
+
const serverKeys = new Map<number, Uint8Array>();
|
|
578
|
+
for (const kw of account.keyWraps) {
|
|
579
|
+
const key = messenger.unwrapKey(kw.wrappedKey, kw.nonce, wrapperPubkey);
|
|
580
|
+
if (key) serverKeys.set(kw.version, key);
|
|
581
|
+
}
|
|
582
|
+
const unsub = await messenger.listenServer(server.serverId, serverKeys, (msg) => {
|
|
583
|
+
renderChannelMessage(msg);
|
|
584
|
+
});
|
|
585
|
+
serverUnsubs.push(unsub);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 3. Private channels (separate keys)
|
|
589
|
+
const privateUnsubs: (() => void)[] = [];
|
|
590
|
+
const myChannels = await messenger.getMyChannels();
|
|
591
|
+
for (const { account, channel } of myChannels) {
|
|
592
|
+
if (!channel || channel.channelType !== 0) continue; // skip public
|
|
593
|
+
const latestWrap = account.keyWraps[account.keyWraps.length - 1];
|
|
594
|
+
const key = messenger.unwrapKey(latestWrap.wrappedKey, latestWrap.nonce, wrapperPubkey);
|
|
595
|
+
if (!key) continue;
|
|
596
|
+
const channelPda = await deriveChannelPda(channel.channelId, channel.version, programId);
|
|
597
|
+
const unsub = await messenger.listenChannel(channelPda, key, (msg) => {
|
|
598
|
+
renderChannelMessage(msg);
|
|
599
|
+
});
|
|
600
|
+
privateUnsubs.push(unsub);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Clean up
|
|
604
|
+
unsubDM();
|
|
605
|
+
serverUnsubs.forEach(fn => fn());
|
|
606
|
+
privateUnsubs.forEach(fn => fn());
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**Subscription count for a typical workspace:**
|
|
610
|
+
- 1 WebSocket for DMs
|
|
611
|
+
- 10-50 WebSockets per server (one per public channel)
|
|
612
|
+
- 1-5 WebSockets for private channels
|
|
613
|
+
|
|
614
|
+
Totals ~20-60 subscriptions — well within Helius limits.
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
## Common Patterns
|
|
619
|
+
|
|
620
|
+
### Pattern: "Show me everything" (first login / new device)
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
await messenger.init();
|
|
624
|
+
|
|
625
|
+
// 1. Find all servers
|
|
626
|
+
const servers = await messenger.getMyServers();
|
|
627
|
+
|
|
628
|
+
// 2. For each server, find channels
|
|
629
|
+
for (const { server, account } of servers) {
|
|
630
|
+
const channels = await messenger.getServerChannels(server.serverId);
|
|
631
|
+
console.log(`Server: ${server.name} (${channels.length} channels)`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// 3. Find standalone group DMs
|
|
635
|
+
const myChannels = await messenger.getMyChannels();
|
|
636
|
+
const groupDMs = myChannels.filter(c => c.channel && !c.channel.serverId);
|
|
637
|
+
|
|
638
|
+
// 4. DM history
|
|
639
|
+
const dms = await messenger.read({ limit: 50 });
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Pattern: Full server setup
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
// 1. Create server
|
|
646
|
+
const { serverId } = await messenger.createServer("Acme Corp");
|
|
647
|
+
|
|
648
|
+
// 2. Create channels
|
|
649
|
+
await messenger.createChannel({ serverId, channelType: ChannelType.Public, members: [] }); // #general
|
|
650
|
+
await messenger.createChannel({ serverId, channelType: ChannelType.Private, members: [myAddr, cofounderAddr] }); // #founders
|
|
651
|
+
|
|
652
|
+
// 3. Invite team
|
|
653
|
+
const myMember = await messenger.getServerMember(serverId);
|
|
654
|
+
const serverKey = messenger.unwrapKey(myMember.keyWraps[0].wrappedKey, myMember.keyWraps[0].nonce, myEncPubkey);
|
|
655
|
+
await messenger.inviteToServer(serverId, "TeamMember1", [serverKey]);
|
|
656
|
+
await messenger.inviteToServer(serverId, "TeamMember2", [serverKey]);
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### Pattern: Offboarding a member
|
|
660
|
+
|
|
661
|
+
```typescript
|
|
662
|
+
// 1. Remove from server (rotates server key → public channels secured)
|
|
663
|
+
const members = await messenger.getServerMembers(serverId);
|
|
664
|
+
const remaining = members.filter(m => m.account.member !== removedAddr).map(m => m.account.member);
|
|
665
|
+
await messenger.removeServerMember(serverId, removedAddr, remaining);
|
|
666
|
+
|
|
667
|
+
// 2. Remove from private channels (must do separately!)
|
|
668
|
+
const channels = await messenger.getServerChannels(serverId);
|
|
669
|
+
for (const { account: ch } of channels) {
|
|
670
|
+
if (ch.channelType === 0 && ch.members.includes(removedAddr)) {
|
|
671
|
+
const chRemaining = ch.members.filter(m => m !== removedAddr);
|
|
672
|
+
await messenger.removeChannelMembers(ch.channelId, ch.version, [removedAddr], chRemaining);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
⚠️ **Important:** Removing from a server does NOT automatically remove from private channels. You must do both.
|
|
678
|
+
|
|
679
|
+
### Pattern: Real-time Slack-like app
|
|
680
|
+
|
|
681
|
+
See the full example in the [WebSocket Strategy](#websocket-strategy) section above — it covers DMs, per-server listening, and private channel listening with proper key management.
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Architecture Reference
|
|
686
|
+
|
|
687
|
+
### Encryption
|
|
688
|
+
|
|
689
|
+
| Message Type | Algorithm | Key Source |
|
|
690
|
+
|---|---|---|
|
|
691
|
+
| DM | NaCl box (X25519-XSalsa20-Poly1305) | Diffie-Hellman shared secret |
|
|
692
|
+
| Public channel | NaCl secretbox (XSalsa20-Poly1305) | Server key (from ServerMember.keyWraps) |
|
|
693
|
+
| Private channel | NaCl secretbox (XSalsa20-Poly1305) | Channel key (from ChannelMember.keyWraps) |
|
|
694
|
+
|
|
695
|
+
### Roles
|
|
696
|
+
|
|
697
|
+
| Value | Role | Can do |
|
|
698
|
+
|---|---|---|
|
|
699
|
+
| 0 | Member | Send messages |
|
|
700
|
+
| 1 | Admin | Invite/remove members |
|
|
701
|
+
| 2 | Owner | Everything + cannot be removed |
|
|
702
|
+
|
|
703
|
+
### Events (on-chain)
|
|
704
|
+
|
|
705
|
+
Messages are emitted as events (not stored in accounts):
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
// DM event
|
|
709
|
+
{ sender, recipient, ciphertext, nonce, timestamp }
|
|
710
|
+
|
|
711
|
+
// Channel message event
|
|
712
|
+
{ sender, channelId, version, keyVersion, ciphertext, nonce, timestamp }
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### Security model
|
|
716
|
+
|
|
717
|
+
- **Forward secrecy**: Key rotation on removal AND voluntary leave
|
|
718
|
+
- **Full history**: New members get all historical keys
|
|
719
|
+
- **Public channel auth**: Sender's ServerMember PDA verified on-chain
|
|
720
|
+
- **Server channel auth**: Creator's ServerMember PDA required for channel creation
|
|
721
|
+
- **Admin verification**: PDA derivation check prevents spoofing
|
|
722
|
+
- **On-chain immutability**: Messages are permanent events — protocol cannot enforce deletion
|
|
723
|
+
- **Custody separation**: Signing key (A) ≠ encryption key (B) — custodial wallets can't read messages
|
|
724
|
+
|
|
725
|
+
### Costs
|
|
726
|
+
|
|
727
|
+
| Action | Cost |
|
|
728
|
+
|--------|------|
|
|
729
|
+
| Send DM | ~5000 lamports + protocol fee + recipient min_fee |
|
|
730
|
+
| Send channel message | ~5000 lamports + protocol fee |
|
|
731
|
+
| Register | ~0.001 SOL rent (reclaimable) |
|
|
732
|
+
| Create server | ~0.001 SOL rent |
|
|
733
|
+
| Create channel | ~0.06 SOL rent (reclaimable) |
|
|
734
|
+
| ServerMember | ~0.002 SOL rent (reclaimable) |
|
|
735
|
+
| ChannelMember | ~0.002 SOL rent (reclaimable) |
|
|
736
|
+
|
|
737
|
+
All rent is reclaimable when accounts are closed.
|
|
738
|
+
|
|
739
|
+
### Sponsored transactions (payer option)
|
|
740
|
+
|
|
741
|
+
Every transaction-sending method accepts an optional `payer` address via `TxOptions` (or inline `payer` field for params-style methods). When provided, the payer account pays the transaction fee instead of the signer.
|
|
742
|
+
|
|
743
|
+
```typescript
|
|
744
|
+
// Server methods use options object
|
|
745
|
+
await messenger.createServer("My Server", { payer: "SponsorAddress" });
|
|
746
|
+
await messenger.send("Recipient", "Hello", undefined, { payer: "SponsorAddress" });
|
|
747
|
+
|
|
748
|
+
// Params-style methods use inline payer field
|
|
749
|
+
await messenger.sendChannelMessage({ channelPda, message: "Hi", channelKey, keyVersion: 0, payer: "SponsorAddress" });
|
|
750
|
+
await messenger.createChannel({ members: [myAddr, friend], payer: "SponsorAddress" });
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
The payer must sign the transaction separately (e.g., via a relayer service or multi-sig flow). This enables gasless/sponsored experiences where users don't need SOL.
|