@iamoberlin/chorus 2.0.0 → 2.2.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/{target/idl → idl}/chorus_prayers.json +387 -42
- package/index.ts +4 -1
- package/package.json +6 -5
- package/src/choirs.ts +12 -8
- package/src/prayers/cli.ts +231 -84
- package/src/prayers/crypto.ts +132 -0
- package/src/prayers/solana.ts +329 -52
- package/src/scheduler.ts +84 -36
package/src/prayers/solana.ts
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* CHORUS Prayer Chain — Solana Client
|
|
3
3
|
*
|
|
4
4
|
* TypeScript client for interacting with the on-chain prayer program.
|
|
5
|
-
*
|
|
5
|
+
* All prayers are private by default — content is encrypted end-to-end
|
|
6
|
+
* using X25519 DH key exchange derived from Solana wallet keypairs.
|
|
7
|
+
*
|
|
8
|
+
* Supports multi-claimer collaboration: prayers can accept 1-10 claimers
|
|
9
|
+
* who work together. Bounty splits equally among all claimers on confirm.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { Program, AnchorProvider, web3, Wallet } from "@coral-xyz/anchor";
|
|
@@ -12,6 +16,12 @@ import { createHash } from "crypto";
|
|
|
12
16
|
import * as fs from "fs";
|
|
13
17
|
import * as path from "path";
|
|
14
18
|
import { fileURLToPath } from "url";
|
|
19
|
+
import {
|
|
20
|
+
deriveEncryptionKeypair,
|
|
21
|
+
encryptForRecipient,
|
|
22
|
+
decryptFromSender,
|
|
23
|
+
getEncryptionKeyForChain,
|
|
24
|
+
} from "./crypto.js";
|
|
15
25
|
|
|
16
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
17
27
|
const __dirname = path.dirname(__filename);
|
|
@@ -19,6 +29,16 @@ const __dirname = path.dirname(__filename);
|
|
|
19
29
|
// Program ID (deployed to devnet)
|
|
20
30
|
export const PROGRAM_ID = new PublicKey("DZuj1ZcX4H6THBSgW4GhKA7SbZNXtPDE5xPkW2jN53PQ");
|
|
21
31
|
|
|
32
|
+
// Max plaintext size that fits in a Solana transaction after encryption overhead
|
|
33
|
+
// Encrypted blob = plaintext + 40 bytes (24 nonce + 16 Poly1305 tag)
|
|
34
|
+
// deliver_content (3 accounts): ~942 char max
|
|
35
|
+
// answer_prayer (5 accounts): ~842 char max
|
|
36
|
+
export const MAX_CONTENT_LENGTH = 900; // Conservative limit for deliver_content
|
|
37
|
+
export const MAX_ANSWER_LENGTH = 800; // Conservative limit for answer_prayer
|
|
38
|
+
|
|
39
|
+
// Max collaborators per prayer (matches on-chain MAX_CLAIMERS_LIMIT)
|
|
40
|
+
export const MAX_CLAIMERS = 10;
|
|
41
|
+
|
|
22
42
|
// Prayer types matching the on-chain enum
|
|
23
43
|
export enum PrayerType {
|
|
24
44
|
Knowledge = 0,
|
|
@@ -30,12 +50,12 @@ export enum PrayerType {
|
|
|
30
50
|
|
|
31
51
|
// Prayer status matching the on-chain enum
|
|
32
52
|
export enum PrayerStatus {
|
|
33
|
-
Open = 0,
|
|
34
|
-
|
|
35
|
-
Fulfilled = 2,
|
|
36
|
-
Confirmed = 3,
|
|
37
|
-
Expired = 4,
|
|
38
|
-
Cancelled = 5,
|
|
53
|
+
Open = 0, // Accepting claims (until max_claimers reached)
|
|
54
|
+
Active = 1, // All slots filled, work in progress
|
|
55
|
+
Fulfilled = 2, // Answer submitted, awaiting confirmation
|
|
56
|
+
Confirmed = 3, // Requester approved, bounty distributed
|
|
57
|
+
Expired = 4, // TTL elapsed
|
|
58
|
+
Cancelled = 5, // Requester cancelled (only when 0 claims)
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
export interface PrayerChainState {
|
|
@@ -49,6 +69,7 @@ export interface AgentAccount {
|
|
|
49
69
|
wallet: PublicKey;
|
|
50
70
|
name: string;
|
|
51
71
|
skills: string;
|
|
72
|
+
encryptionKey: number[]; // X25519 public key for E2E encryption
|
|
52
73
|
prayersPosted: number;
|
|
53
74
|
prayersAnswered: number;
|
|
54
75
|
prayersConfirmed: number;
|
|
@@ -60,27 +81,43 @@ export interface PrayerAccount {
|
|
|
60
81
|
id: number;
|
|
61
82
|
requester: PublicKey;
|
|
62
83
|
prayerType: PrayerType;
|
|
63
|
-
contentHash: number[]; // SHA-256 of content
|
|
84
|
+
contentHash: number[]; // SHA-256 of plaintext content
|
|
64
85
|
rewardLamports: number;
|
|
65
86
|
status: PrayerStatus;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
maxClaimers: number; // How many agents can collaborate (1 = solo)
|
|
88
|
+
numClaimers: number; // Current number of claims
|
|
89
|
+
answerer: PublicKey; // Who submitted the answer (must be a claimer)
|
|
90
|
+
answerHash: number[]; // SHA-256 of plaintext answer
|
|
69
91
|
createdAt: number;
|
|
70
92
|
expiresAt: number;
|
|
71
93
|
fulfilledAt: number;
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
export interface ClaimAccount {
|
|
97
|
+
prayerId: number;
|
|
98
|
+
claimer: PublicKey;
|
|
99
|
+
contentDelivered: boolean;
|
|
100
|
+
claimedAt: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
// Load IDL from the build output
|
|
75
104
|
function loadIDL(): any {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
const candidates = [
|
|
106
|
+
path.join(__dirname, "../../idl/chorus_prayers.json"),
|
|
107
|
+
path.join(__dirname, "../../target/idl/chorus_prayers.json"),
|
|
108
|
+
];
|
|
109
|
+
for (const idlPath of candidates) {
|
|
110
|
+
if (fs.existsSync(idlPath)) {
|
|
111
|
+
return JSON.parse(fs.readFileSync(idlPath, "utf-8"));
|
|
112
|
+
}
|
|
79
113
|
}
|
|
80
|
-
|
|
114
|
+
throw new Error(
|
|
115
|
+
`IDL not found. Looked in:\n${candidates.map(p => ` - ${p}`).join("\n")}\nRun 'anchor build' or check that idl/chorus_prayers.json exists.`
|
|
116
|
+
);
|
|
81
117
|
}
|
|
82
118
|
|
|
83
|
-
// PDA
|
|
119
|
+
// ── PDA Derivations ─────────────────────────────────────────
|
|
120
|
+
|
|
84
121
|
export function getPrayerChainPDA(): [PublicKey, number] {
|
|
85
122
|
return PublicKey.findProgramAddressSync(
|
|
86
123
|
[Buffer.from("prayer-chain")],
|
|
@@ -104,13 +141,29 @@ export function getPrayerPDA(prayerId: number): [PublicKey, number] {
|
|
|
104
141
|
);
|
|
105
142
|
}
|
|
106
143
|
|
|
144
|
+
export function getClaimPDA(prayerId: number, claimer: PublicKey): [PublicKey, number] {
|
|
145
|
+
const idBuf = Buffer.alloc(8);
|
|
146
|
+
idBuf.writeBigUInt64LE(BigInt(prayerId));
|
|
147
|
+
return PublicKey.findProgramAddressSync(
|
|
148
|
+
[Buffer.from("claim"), idBuf, claimer.toBuffer()],
|
|
149
|
+
PROGRAM_ID
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
107
153
|
/**
|
|
108
|
-
* CHORUS Prayer Chain Client
|
|
154
|
+
* CHORUS Prayer Chain Client — Private by Default
|
|
155
|
+
*
|
|
156
|
+
* All prayer content is encrypted end-to-end. Only the asker and claimer
|
|
157
|
+
* can read prayer content and answers.
|
|
158
|
+
*
|
|
159
|
+
* Supports multi-claimer collaboration: prayers can accept 1-10 agents.
|
|
109
160
|
*/
|
|
110
161
|
export class ChorusPrayerClient {
|
|
111
162
|
program: Program;
|
|
112
163
|
provider: AnchorProvider;
|
|
113
164
|
wallet: PublicKey;
|
|
165
|
+
private keypair: Keypair;
|
|
166
|
+
private encryptionKeypair: { publicKey: Uint8Array; secretKey: Uint8Array };
|
|
114
167
|
|
|
115
168
|
constructor(connection: Connection, keypair: Keypair) {
|
|
116
169
|
const wallet = new Wallet(keypair);
|
|
@@ -120,30 +173,48 @@ export class ChorusPrayerClient {
|
|
|
120
173
|
const idl = loadIDL();
|
|
121
174
|
this.program = new Program(idl, this.provider);
|
|
122
175
|
this.wallet = keypair.publicKey;
|
|
176
|
+
this.keypair = keypair;
|
|
177
|
+
this.encryptionKeypair = deriveEncryptionKeypair(keypair);
|
|
123
178
|
}
|
|
124
179
|
|
|
125
|
-
|
|
126
|
-
* Create from a keypair file path
|
|
127
|
-
*/
|
|
128
|
-
static fromKeypairFile(
|
|
129
|
-
rpcUrl: string,
|
|
130
|
-
keypairPath: string
|
|
131
|
-
): ChorusPrayerClient {
|
|
180
|
+
static fromKeypairFile(rpcUrl: string, keypairPath: string): ChorusPrayerClient {
|
|
132
181
|
const connection = new Connection(rpcUrl, "confirmed");
|
|
133
182
|
const raw = JSON.parse(fs.readFileSync(keypairPath, "utf-8"));
|
|
134
183
|
const keypair = Keypair.fromSecretKey(Uint8Array.from(raw));
|
|
135
184
|
return new ChorusPrayerClient(connection, keypair);
|
|
136
185
|
}
|
|
137
186
|
|
|
138
|
-
/**
|
|
139
|
-
* Create from default Solana CLI keypair
|
|
140
|
-
*/
|
|
141
187
|
static fromDefaultKeypair(rpcUrl: string): ChorusPrayerClient {
|
|
142
188
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
143
189
|
const keypairPath = path.join(home, ".config", "solana", "id.json");
|
|
144
190
|
return ChorusPrayerClient.fromKeypairFile(rpcUrl, keypairPath);
|
|
145
191
|
}
|
|
146
192
|
|
|
193
|
+
/** Get this agent's X25519 encryption public key (for on-chain storage) */
|
|
194
|
+
getEncryptionPublicKey(): number[] {
|
|
195
|
+
return Array.from(this.encryptionKeypair.publicKey);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Encrypt content for a recipient given their on-chain encryption key.
|
|
200
|
+
* Returns the encrypted blob as a number array (for Anchor serialization).
|
|
201
|
+
*/
|
|
202
|
+
encrypt(plaintext: string, recipientEncryptionKey: number[]): number[] {
|
|
203
|
+
const recipientKey = Uint8Array.from(recipientEncryptionKey);
|
|
204
|
+
const encrypted = encryptForRecipient(plaintext, recipientKey, this.encryptionKeypair.secretKey);
|
|
205
|
+
return Array.from(encrypted);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Decrypt content from a sender given their on-chain encryption key.
|
|
210
|
+
* Returns the plaintext string, or null if decryption fails.
|
|
211
|
+
*/
|
|
212
|
+
decrypt(encryptedBlob: number[], senderEncryptionKey: number[]): string | null {
|
|
213
|
+
const blob = Uint8Array.from(encryptedBlob);
|
|
214
|
+
const senderKey = Uint8Array.from(senderEncryptionKey);
|
|
215
|
+
return decryptFromSender(blob, senderKey, this.encryptionKeypair.secretKey);
|
|
216
|
+
}
|
|
217
|
+
|
|
147
218
|
// ── Read Methods ──────────────────────────────────────────
|
|
148
219
|
|
|
149
220
|
async getPrayerChain(): Promise<PrayerChainState | null> {
|
|
@@ -169,6 +240,7 @@ export class ChorusPrayerClient {
|
|
|
169
240
|
wallet: account.wallet,
|
|
170
241
|
name: account.name,
|
|
171
242
|
skills: account.skills,
|
|
243
|
+
encryptionKey: account.encryptionKey,
|
|
172
244
|
prayersPosted: account.prayersPosted.toNumber(),
|
|
173
245
|
prayersAnswered: account.prayersAnswered.toNumber(),
|
|
174
246
|
prayersConfirmed: account.prayersConfirmed.toNumber(),
|
|
@@ -191,8 +263,9 @@ export class ChorusPrayerClient {
|
|
|
191
263
|
contentHash: account.contentHash,
|
|
192
264
|
rewardLamports: account.rewardLamports.toNumber(),
|
|
193
265
|
status: Object.keys(account.status)[0] as unknown as PrayerStatus,
|
|
194
|
-
|
|
195
|
-
|
|
266
|
+
maxClaimers: account.maxClaimers,
|
|
267
|
+
numClaimers: account.numClaimers,
|
|
268
|
+
answerer: account.answerer,
|
|
196
269
|
answerHash: account.answerHash,
|
|
197
270
|
createdAt: account.createdAt.toNumber(),
|
|
198
271
|
expiresAt: account.expiresAt.toNumber(),
|
|
@@ -203,6 +276,74 @@ export class ChorusPrayerClient {
|
|
|
203
276
|
}
|
|
204
277
|
}
|
|
205
278
|
|
|
279
|
+
async getClaim(prayerId: number, claimer: PublicKey): Promise<ClaimAccount | null> {
|
|
280
|
+
const [pda] = getClaimPDA(prayerId, claimer);
|
|
281
|
+
try {
|
|
282
|
+
const account = await (this.program.account as any).claim.fetch(pda);
|
|
283
|
+
return {
|
|
284
|
+
prayerId: account.prayerId.toNumber(),
|
|
285
|
+
claimer: account.claimer,
|
|
286
|
+
contentDelivered: account.contentDelivered,
|
|
287
|
+
claimedAt: account.claimedAt.toNumber(),
|
|
288
|
+
};
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Find all Claim PDAs for a prayer by scanning known claimers.
|
|
296
|
+
* Since we can't enumerate PDAs directly, this tries all registered agents.
|
|
297
|
+
* For efficiency, pass known claimer wallets if available.
|
|
298
|
+
*/
|
|
299
|
+
async getClaimsForPrayer(prayerId: number, knownClaimers?: PublicKey[]): Promise<ClaimAccount[]> {
|
|
300
|
+
const claims: ClaimAccount[] = [];
|
|
301
|
+
|
|
302
|
+
if (knownClaimers) {
|
|
303
|
+
for (const claimer of knownClaimers) {
|
|
304
|
+
const claim = await this.getClaim(prayerId, claimer);
|
|
305
|
+
if (claim) claims.push(claim);
|
|
306
|
+
}
|
|
307
|
+
return claims;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Use getProgramAccounts to find all Claim accounts for this prayer
|
|
311
|
+
const idBuf = Buffer.alloc(8);
|
|
312
|
+
idBuf.writeBigUInt64LE(BigInt(prayerId));
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const accounts = await this.provider.connection.getProgramAccounts(
|
|
316
|
+
this.program.programId,
|
|
317
|
+
{
|
|
318
|
+
filters: [
|
|
319
|
+
// Anchor discriminator for Claim account
|
|
320
|
+
{ memcmp: { offset: 0, bytes: Buffer.from([155, 70, 22, 176, 123, 215, 246, 102]).toString("base64"), encoding: "base64" } },
|
|
321
|
+
// prayer_id at offset 8
|
|
322
|
+
{ memcmp: { offset: 8, bytes: idBuf.toString("base64"), encoding: "base64" } },
|
|
323
|
+
],
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
for (const { account } of accounts) {
|
|
328
|
+
try {
|
|
329
|
+
const decoded = this.program.coder.accounts.decode("claim", account.data);
|
|
330
|
+
claims.push({
|
|
331
|
+
prayerId: decoded.prayerId.toNumber(),
|
|
332
|
+
claimer: decoded.claimer,
|
|
333
|
+
contentDelivered: decoded.contentDelivered,
|
|
334
|
+
claimedAt: decoded.claimedAt.toNumber(),
|
|
335
|
+
});
|
|
336
|
+
} catch {
|
|
337
|
+
// Skip malformed accounts
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// getProgramAccounts may not be available on all RPC endpoints
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return claims;
|
|
345
|
+
}
|
|
346
|
+
|
|
206
347
|
async listOpenPrayers(limit = 20): Promise<PrayerAccount[]> {
|
|
207
348
|
const chain = await this.getPrayerChain();
|
|
208
349
|
if (!chain) return [];
|
|
@@ -210,11 +351,15 @@ export class ChorusPrayerClient {
|
|
|
210
351
|
const prayers: PrayerAccount[] = [];
|
|
211
352
|
const total = chain.totalPrayers;
|
|
212
353
|
|
|
213
|
-
// Scan backwards from newest
|
|
214
354
|
for (let i = total - 1; i >= 0 && prayers.length < limit; i--) {
|
|
215
355
|
const prayer = await this.getPrayer(i);
|
|
216
|
-
if (prayer
|
|
217
|
-
|
|
356
|
+
if (prayer) {
|
|
357
|
+
const statusStr = typeof prayer.status === "object"
|
|
358
|
+
? Object.keys(prayer.status)[0]?.toLowerCase()
|
|
359
|
+
: String(prayer.status).toLowerCase();
|
|
360
|
+
if (statusStr === "open") {
|
|
361
|
+
prayers.push(prayer);
|
|
362
|
+
}
|
|
218
363
|
}
|
|
219
364
|
}
|
|
220
365
|
|
|
@@ -242,8 +387,10 @@ export class ChorusPrayerClient {
|
|
|
242
387
|
const [prayerChainPda] = getPrayerChainPDA();
|
|
243
388
|
const [agentPda] = getAgentPDA(this.wallet);
|
|
244
389
|
|
|
390
|
+
const encryptionKey = this.getEncryptionPublicKey();
|
|
391
|
+
|
|
245
392
|
const tx = await this.program.methods
|
|
246
|
-
.registerAgent(name, skills)
|
|
393
|
+
.registerAgent(name, skills, encryptionKey)
|
|
247
394
|
.accounts({
|
|
248
395
|
prayerChain: prayerChainPda,
|
|
249
396
|
agent: agentPda,
|
|
@@ -255,27 +402,38 @@ export class ChorusPrayerClient {
|
|
|
255
402
|
return tx;
|
|
256
403
|
}
|
|
257
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Post a prayer. Content is stored locally and as a hash on-chain.
|
|
407
|
+
* No plaintext ever touches the blockchain.
|
|
408
|
+
*
|
|
409
|
+
* @param maxClaimers How many agents can collaborate (1 = solo, up to 10)
|
|
410
|
+
* After someone claims, call deliverContent() to send them the encrypted text.
|
|
411
|
+
*/
|
|
258
412
|
async postPrayer(
|
|
259
413
|
prayerType: PrayerType,
|
|
260
414
|
content: string,
|
|
261
415
|
rewardLamports = 0,
|
|
262
|
-
ttlSeconds = 86400
|
|
416
|
+
ttlSeconds = 86400,
|
|
417
|
+
maxClaimers = 1,
|
|
263
418
|
): Promise<{ tx: string; prayerId: number }> {
|
|
264
419
|
const chain = await this.getPrayerChain();
|
|
265
420
|
if (!chain) throw new Error("PrayerChain not initialized");
|
|
266
421
|
|
|
422
|
+
if (maxClaimers < 1 || maxClaimers > MAX_CLAIMERS) {
|
|
423
|
+
throw new Error(`max_claimers must be 1-${MAX_CLAIMERS}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
267
426
|
const prayerId = chain.totalPrayers;
|
|
268
427
|
const [prayerChainPda] = getPrayerChainPDA();
|
|
269
428
|
const [agentPda] = getAgentPDA(this.wallet);
|
|
270
429
|
const [prayerPda] = getPrayerPDA(prayerId);
|
|
271
430
|
|
|
272
|
-
|
|
273
|
-
const typeName = typeof prayerType === "string" ? prayerType.toLowerCase() : PrayerType[prayerType].toLowerCase();
|
|
431
|
+
const typeName = typeof prayerType === "string" ? (prayerType as string).toLowerCase() : PrayerType[prayerType as number].toLowerCase();
|
|
274
432
|
const typeArg = { [typeName]: {} };
|
|
275
433
|
const contentHash = Array.from(createHash("sha256").update(content).digest());
|
|
276
434
|
|
|
277
435
|
const tx = await this.program.methods
|
|
278
|
-
.postPrayer(typeArg,
|
|
436
|
+
.postPrayer(typeArg, contentHash, new BN(rewardLamports), new BN(ttlSeconds), maxClaimers)
|
|
279
437
|
.accounts({
|
|
280
438
|
prayerChain: prayerChainPda,
|
|
281
439
|
requesterAgent: agentPda,
|
|
@@ -288,41 +446,114 @@ export class ChorusPrayerClient {
|
|
|
288
446
|
return { tx, prayerId };
|
|
289
447
|
}
|
|
290
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Claim a prayer. Creates a Claim PDA for this wallet.
|
|
451
|
+
* Multiple agents can claim until max_claimers is reached.
|
|
452
|
+
*/
|
|
291
453
|
async claimPrayer(prayerId: number): Promise<string> {
|
|
292
454
|
const [prayerPda] = getPrayerPDA(prayerId);
|
|
455
|
+
const [claimPda] = getClaimPDA(prayerId, this.wallet);
|
|
293
456
|
const [agentPda] = getAgentPDA(this.wallet);
|
|
294
457
|
|
|
295
458
|
const tx = await this.program.methods
|
|
296
459
|
.claimPrayer()
|
|
297
460
|
.accounts({
|
|
298
461
|
prayer: prayerPda,
|
|
462
|
+
claim: claimPda,
|
|
299
463
|
claimerAgent: agentPda,
|
|
300
464
|
claimer: this.wallet,
|
|
465
|
+
systemProgram: SystemProgram.programId,
|
|
301
466
|
})
|
|
302
467
|
.rpc();
|
|
303
468
|
|
|
304
469
|
return tx;
|
|
305
470
|
}
|
|
306
471
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
472
|
+
/**
|
|
473
|
+
* Deliver encrypted prayer content to a specific claimer.
|
|
474
|
+
* Call this after someone claims your prayer.
|
|
475
|
+
* Each claimer gets their own DH-encrypted copy.
|
|
476
|
+
*
|
|
477
|
+
* @param claimerWallet The wallet of the claimer to deliver to
|
|
478
|
+
*/
|
|
479
|
+
async deliverContent(prayerId: number, plaintext: string, claimerWallet: PublicKey): Promise<string> {
|
|
480
|
+
if (plaintext.length > MAX_CONTENT_LENGTH) {
|
|
481
|
+
throw new Error(`Content too long (${plaintext.length} chars, max ${MAX_CONTENT_LENGTH}). Shorten or split across multiple prayers.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Look up claimer's encryption key
|
|
485
|
+
const claimerAgent = await this.getAgent(claimerWallet);
|
|
486
|
+
if (!claimerAgent) throw new Error("Claimer agent not found");
|
|
487
|
+
|
|
488
|
+
// Encrypt content for the claimer
|
|
489
|
+
const encryptedContent = this.encrypt(plaintext, claimerAgent.encryptionKey);
|
|
490
|
+
|
|
491
|
+
const [prayerPda] = getPrayerPDA(prayerId);
|
|
492
|
+
const [claimPda] = getClaimPDA(prayerId, claimerWallet);
|
|
493
|
+
|
|
494
|
+
const tx = await this.program.methods
|
|
495
|
+
.deliverContent(Buffer.from(encryptedContent))
|
|
496
|
+
.accounts({
|
|
497
|
+
prayer: prayerPda,
|
|
498
|
+
claim: claimPda,
|
|
499
|
+
requester: this.wallet,
|
|
500
|
+
})
|
|
501
|
+
.rpc();
|
|
502
|
+
|
|
503
|
+
return tx;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Deliver content to ALL current claimers of a prayer.
|
|
508
|
+
* Convenience method for multi-claimer prayers.
|
|
509
|
+
*/
|
|
510
|
+
async deliverContentToAll(prayerId: number, plaintext: string): Promise<string[]> {
|
|
511
|
+
const claims = await this.getClaimsForPrayer(prayerId);
|
|
512
|
+
if (claims.length === 0) throw new Error("No claimers to deliver to");
|
|
513
|
+
|
|
514
|
+
const txs: string[] = [];
|
|
515
|
+
for (const claim of claims) {
|
|
516
|
+
if (!claim.contentDelivered) {
|
|
517
|
+
const tx = await this.deliverContent(prayerId, plaintext, claim.claimer);
|
|
518
|
+
txs.push(tx);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return txs;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Answer a claimed prayer with encrypted answer.
|
|
526
|
+
* Encrypts the answer for the requester using their on-chain encryption key.
|
|
527
|
+
* The answerer must have a Claim PDA (be a claimer).
|
|
528
|
+
*/
|
|
529
|
+
async answerPrayer(prayerId: number, answer: string): Promise<string> {
|
|
530
|
+
if (answer.length > MAX_ANSWER_LENGTH) {
|
|
531
|
+
throw new Error(`Answer too long (${answer.length} chars, max ${MAX_ANSWER_LENGTH}). Shorten or split.`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const prayer = await this.getPrayer(prayerId);
|
|
535
|
+
if (!prayer) throw new Error("Prayer not found");
|
|
536
|
+
|
|
537
|
+
// Look up requester's encryption key
|
|
538
|
+
const requesterAgent = await this.getAgent(prayer.requester);
|
|
539
|
+
if (!requesterAgent) throw new Error("Requester agent not found");
|
|
540
|
+
|
|
312
541
|
const [prayerPda] = getPrayerPDA(prayerId);
|
|
313
542
|
const [prayerChainPda] = getPrayerChainPDA();
|
|
543
|
+
const [claimPda] = getClaimPDA(prayerId, this.wallet);
|
|
314
544
|
const [agentPda] = getAgentPDA(this.wallet);
|
|
315
545
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
546
|
+
const answerHash = Array.from(createHash("sha256").update(answer).digest());
|
|
547
|
+
|
|
548
|
+
// Encrypt answer for the requester
|
|
549
|
+
const encryptedAnswer = this.encrypt(answer, requesterAgent.encryptionKey);
|
|
320
550
|
|
|
321
551
|
const tx = await this.program.methods
|
|
322
|
-
.answerPrayer(
|
|
552
|
+
.answerPrayer(answerHash, Buffer.from(encryptedAnswer))
|
|
323
553
|
.accounts({
|
|
324
554
|
prayerChain: prayerChainPda,
|
|
325
555
|
prayer: prayerPda,
|
|
556
|
+
claim: claimPda,
|
|
326
557
|
answererAgent: agentPda,
|
|
327
558
|
answerer: this.wallet,
|
|
328
559
|
})
|
|
@@ -331,21 +562,40 @@ export class ChorusPrayerClient {
|
|
|
331
562
|
return tx;
|
|
332
563
|
}
|
|
333
564
|
|
|
334
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Confirm a prayer and distribute bounty.
|
|
567
|
+
* Bounty splits equally among ALL claimers.
|
|
568
|
+
* Pass claimer wallets as remaining accounts for bounty distribution.
|
|
569
|
+
*/
|
|
570
|
+
async confirmPrayer(prayerId: number, claimerWallets?: PublicKey[]): Promise<string> {
|
|
335
571
|
const prayer = await this.getPrayer(prayerId);
|
|
336
572
|
if (!prayer) throw new Error("Prayer not found");
|
|
337
573
|
|
|
338
574
|
const [prayerPda] = getPrayerPDA(prayerId);
|
|
339
|
-
const [answererAgentPda] = getAgentPDA(prayer.
|
|
575
|
+
const [answererAgentPda] = getAgentPDA(prayer.answerer);
|
|
576
|
+
|
|
577
|
+
// If claimer wallets not provided, look them up
|
|
578
|
+
let wallets = claimerWallets;
|
|
579
|
+
if (!wallets) {
|
|
580
|
+
const claims = await this.getClaimsForPrayer(prayerId);
|
|
581
|
+
wallets = claims.map(c => c.claimer);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Build remaining accounts: claimer wallets (writable) for bounty distribution
|
|
585
|
+
const remainingAccounts = wallets.map(w => ({
|
|
586
|
+
pubkey: w,
|
|
587
|
+
isSigner: false,
|
|
588
|
+
isWritable: true,
|
|
589
|
+
}));
|
|
340
590
|
|
|
341
591
|
const tx = await this.program.methods
|
|
342
592
|
.confirmPrayer()
|
|
343
593
|
.accounts({
|
|
344
594
|
prayer: prayerPda,
|
|
345
595
|
answererAgent: answererAgentPda,
|
|
346
|
-
answererWallet: prayer.claimer,
|
|
347
596
|
requester: this.wallet,
|
|
348
597
|
})
|
|
598
|
+
.remainingAccounts(remainingAccounts)
|
|
349
599
|
.rpc();
|
|
350
600
|
|
|
351
601
|
return tx;
|
|
@@ -365,14 +615,41 @@ export class ChorusPrayerClient {
|
|
|
365
615
|
return tx;
|
|
366
616
|
}
|
|
367
617
|
|
|
368
|
-
|
|
618
|
+
/**
|
|
619
|
+
* Remove a claim. Claimer can unclaim voluntarily, or anyone can
|
|
620
|
+
* unclaim after the 1-hour timeout.
|
|
621
|
+
*
|
|
622
|
+
* @param claimerWallet The wallet of the claim to remove (defaults to self)
|
|
623
|
+
*/
|
|
624
|
+
async unclaimPrayer(prayerId: number, claimerWallet?: PublicKey): Promise<string> {
|
|
625
|
+
const claimer = claimerWallet || this.wallet;
|
|
369
626
|
const [prayerPda] = getPrayerPDA(prayerId);
|
|
627
|
+
const [claimPda] = getClaimPDA(prayerId, claimer);
|
|
370
628
|
|
|
371
629
|
const tx = await this.program.methods
|
|
372
630
|
.unclaimPrayer()
|
|
373
631
|
.accounts({
|
|
374
632
|
prayer: prayerPda,
|
|
375
|
-
|
|
633
|
+
claim: claimPda,
|
|
634
|
+
claimerWallet: claimer,
|
|
635
|
+
caller: this.wallet,
|
|
636
|
+
})
|
|
637
|
+
.rpc();
|
|
638
|
+
|
|
639
|
+
return tx;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Close a resolved prayer and return rent to requester.
|
|
644
|
+
*/
|
|
645
|
+
async closePrayer(prayerId: number): Promise<string> {
|
|
646
|
+
const [prayerPda] = getPrayerPDA(prayerId);
|
|
647
|
+
|
|
648
|
+
const tx = await this.program.methods
|
|
649
|
+
.closePrayer()
|
|
650
|
+
.accounts({
|
|
651
|
+
prayer: prayerPda,
|
|
652
|
+
requester: this.wallet,
|
|
376
653
|
})
|
|
377
654
|
.rpc();
|
|
378
655
|
|