@iamoberlin/chorus 1.3.9 → 2.1.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.
@@ -0,0 +1,666 @@
1
+ /**
2
+ * CHORUS Prayer Chain — Solana Client
3
+ *
4
+ * TypeScript client for interacting with the on-chain prayer program.
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.
10
+ */
11
+
12
+ import { Program, AnchorProvider, web3, Wallet } from "@coral-xyz/anchor";
13
+ import BN from "bn.js";
14
+ import { Connection, PublicKey, Keypair, SystemProgram } from "@solana/web3.js";
15
+ import { createHash } from "crypto";
16
+ import * as fs from "fs";
17
+ import * as path from "path";
18
+ import { fileURLToPath } from "url";
19
+ import {
20
+ deriveEncryptionKeypair,
21
+ encryptForRecipient,
22
+ decryptFromSender,
23
+ getEncryptionKeyForChain,
24
+ } from "./crypto.js";
25
+
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = path.dirname(__filename);
28
+
29
+ // Program ID (deployed to devnet)
30
+ export const PROGRAM_ID = new PublicKey("DZuj1ZcX4H6THBSgW4GhKA7SbZNXtPDE5xPkW2jN53PQ");
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
+
42
+ // Prayer types matching the on-chain enum
43
+ export enum PrayerType {
44
+ Knowledge = 0,
45
+ Compute = 1,
46
+ Review = 2,
47
+ Signal = 3,
48
+ Collaboration = 4,
49
+ }
50
+
51
+ // Prayer status matching the on-chain enum
52
+ export enum PrayerStatus {
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)
59
+ }
60
+
61
+ export interface PrayerChainState {
62
+ authority: PublicKey;
63
+ totalPrayers: number;
64
+ totalAnswered: number;
65
+ totalAgents: number;
66
+ }
67
+
68
+ export interface AgentAccount {
69
+ wallet: PublicKey;
70
+ name: string;
71
+ skills: string;
72
+ encryptionKey: number[]; // X25519 public key for E2E encryption
73
+ prayersPosted: number;
74
+ prayersAnswered: number;
75
+ prayersConfirmed: number;
76
+ reputation: number;
77
+ registeredAt: number;
78
+ }
79
+
80
+ export interface PrayerAccount {
81
+ id: number;
82
+ requester: PublicKey;
83
+ prayerType: PrayerType;
84
+ contentHash: number[]; // SHA-256 of plaintext content
85
+ rewardLamports: number;
86
+ status: PrayerStatus;
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
91
+ createdAt: number;
92
+ expiresAt: number;
93
+ fulfilledAt: number;
94
+ }
95
+
96
+ export interface ClaimAccount {
97
+ prayerId: number;
98
+ claimer: PublicKey;
99
+ contentDelivered: boolean;
100
+ claimedAt: number;
101
+ }
102
+
103
+ // Load IDL from the build output
104
+ function loadIDL(): any {
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
+ }
113
+ }
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
+ );
117
+ }
118
+
119
+ // ── PDA Derivations ─────────────────────────────────────────
120
+
121
+ export function getPrayerChainPDA(): [PublicKey, number] {
122
+ return PublicKey.findProgramAddressSync(
123
+ [Buffer.from("prayer-chain")],
124
+ PROGRAM_ID
125
+ );
126
+ }
127
+
128
+ export function getAgentPDA(wallet: PublicKey): [PublicKey, number] {
129
+ return PublicKey.findProgramAddressSync(
130
+ [Buffer.from("agent"), wallet.toBuffer()],
131
+ PROGRAM_ID
132
+ );
133
+ }
134
+
135
+ export function getPrayerPDA(prayerId: number): [PublicKey, number] {
136
+ const idBuf = Buffer.alloc(8);
137
+ idBuf.writeBigUInt64LE(BigInt(prayerId));
138
+ return PublicKey.findProgramAddressSync(
139
+ [Buffer.from("prayer"), idBuf],
140
+ PROGRAM_ID
141
+ );
142
+ }
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
+
153
+ /**
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.
160
+ */
161
+ export class ChorusPrayerClient {
162
+ program: Program;
163
+ provider: AnchorProvider;
164
+ wallet: PublicKey;
165
+ private keypair: Keypair;
166
+ private encryptionKeypair: { publicKey: Uint8Array; secretKey: Uint8Array };
167
+
168
+ constructor(connection: Connection, keypair: Keypair) {
169
+ const wallet = new Wallet(keypair);
170
+ this.provider = new AnchorProvider(connection, wallet, {
171
+ commitment: "confirmed",
172
+ });
173
+ const idl = loadIDL();
174
+ this.program = new Program(idl, this.provider);
175
+ this.wallet = keypair.publicKey;
176
+ this.keypair = keypair;
177
+ this.encryptionKeypair = deriveEncryptionKeypair(keypair);
178
+ }
179
+
180
+ static fromKeypairFile(rpcUrl: string, keypairPath: string): ChorusPrayerClient {
181
+ const connection = new Connection(rpcUrl, "confirmed");
182
+ const raw = JSON.parse(fs.readFileSync(keypairPath, "utf-8"));
183
+ const keypair = Keypair.fromSecretKey(Uint8Array.from(raw));
184
+ return new ChorusPrayerClient(connection, keypair);
185
+ }
186
+
187
+ static fromDefaultKeypair(rpcUrl: string): ChorusPrayerClient {
188
+ const home = process.env.HOME || process.env.USERPROFILE || "";
189
+ const keypairPath = path.join(home, ".config", "solana", "id.json");
190
+ return ChorusPrayerClient.fromKeypairFile(rpcUrl, keypairPath);
191
+ }
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
+
218
+ // ── Read Methods ──────────────────────────────────────────
219
+
220
+ async getPrayerChain(): Promise<PrayerChainState | null> {
221
+ const [pda] = getPrayerChainPDA();
222
+ try {
223
+ const account = await (this.program.account as any).prayerChain.fetch(pda);
224
+ return {
225
+ authority: account.authority,
226
+ totalPrayers: account.totalPrayers.toNumber(),
227
+ totalAnswered: account.totalAnswered.toNumber(),
228
+ totalAgents: account.totalAgents.toNumber(),
229
+ };
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ async getAgent(wallet: PublicKey): Promise<AgentAccount | null> {
236
+ const [pda] = getAgentPDA(wallet);
237
+ try {
238
+ const account = await (this.program.account as any).agent.fetch(pda);
239
+ return {
240
+ wallet: account.wallet,
241
+ name: account.name,
242
+ skills: account.skills,
243
+ encryptionKey: account.encryptionKey,
244
+ prayersPosted: account.prayersPosted.toNumber(),
245
+ prayersAnswered: account.prayersAnswered.toNumber(),
246
+ prayersConfirmed: account.prayersConfirmed.toNumber(),
247
+ reputation: account.reputation.toNumber(),
248
+ registeredAt: account.registeredAt.toNumber(),
249
+ };
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ async getPrayer(prayerId: number): Promise<PrayerAccount | null> {
256
+ const [pda] = getPrayerPDA(prayerId);
257
+ try {
258
+ const account = await (this.program.account as any).prayer.fetch(pda);
259
+ return {
260
+ id: account.id.toNumber(),
261
+ requester: account.requester,
262
+ prayerType: Object.keys(account.prayerType)[0] as unknown as PrayerType,
263
+ contentHash: account.contentHash,
264
+ rewardLamports: account.rewardLamports.toNumber(),
265
+ status: Object.keys(account.status)[0] as unknown as PrayerStatus,
266
+ maxClaimers: account.maxClaimers,
267
+ numClaimers: account.numClaimers,
268
+ answerer: account.answerer,
269
+ answerHash: account.answerHash,
270
+ createdAt: account.createdAt.toNumber(),
271
+ expiresAt: account.expiresAt.toNumber(),
272
+ fulfilledAt: account.fulfilledAt.toNumber(),
273
+ };
274
+ } catch {
275
+ return null;
276
+ }
277
+ }
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
+
347
+ async listOpenPrayers(limit = 20): Promise<PrayerAccount[]> {
348
+ const chain = await this.getPrayerChain();
349
+ if (!chain) return [];
350
+
351
+ const prayers: PrayerAccount[] = [];
352
+ const total = chain.totalPrayers;
353
+
354
+ for (let i = total - 1; i >= 0 && prayers.length < limit; i--) {
355
+ const prayer = await this.getPrayer(i);
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
+ }
363
+ }
364
+ }
365
+
366
+ return prayers;
367
+ }
368
+
369
+ // ── Write Methods ─────────────────────────────────────────
370
+
371
+ async initialize(): Promise<string> {
372
+ const [prayerChainPda] = getPrayerChainPDA();
373
+
374
+ const tx = await this.program.methods
375
+ .initialize()
376
+ .accounts({
377
+ prayerChain: prayerChainPda,
378
+ authority: this.wallet,
379
+ systemProgram: SystemProgram.programId,
380
+ })
381
+ .rpc();
382
+
383
+ return tx;
384
+ }
385
+
386
+ async registerAgent(name: string, skills: string): Promise<string> {
387
+ const [prayerChainPda] = getPrayerChainPDA();
388
+ const [agentPda] = getAgentPDA(this.wallet);
389
+
390
+ const encryptionKey = this.getEncryptionPublicKey();
391
+
392
+ const tx = await this.program.methods
393
+ .registerAgent(name, skills, encryptionKey)
394
+ .accounts({
395
+ prayerChain: prayerChainPda,
396
+ agent: agentPda,
397
+ wallet: this.wallet,
398
+ systemProgram: SystemProgram.programId,
399
+ })
400
+ .rpc();
401
+
402
+ return tx;
403
+ }
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
+ */
412
+ async postPrayer(
413
+ prayerType: PrayerType,
414
+ content: string,
415
+ rewardLamports = 0,
416
+ ttlSeconds = 86400,
417
+ maxClaimers = 1,
418
+ ): Promise<{ tx: string; prayerId: number }> {
419
+ const chain = await this.getPrayerChain();
420
+ if (!chain) throw new Error("PrayerChain not initialized");
421
+
422
+ if (maxClaimers < 1 || maxClaimers > MAX_CLAIMERS) {
423
+ throw new Error(`max_claimers must be 1-${MAX_CLAIMERS}`);
424
+ }
425
+
426
+ const prayerId = chain.totalPrayers;
427
+ const [prayerChainPda] = getPrayerChainPDA();
428
+ const [agentPda] = getAgentPDA(this.wallet);
429
+ const [prayerPda] = getPrayerPDA(prayerId);
430
+
431
+ const typeName = typeof prayerType === "string" ? (prayerType as string).toLowerCase() : PrayerType[prayerType as number].toLowerCase();
432
+ const typeArg = { [typeName]: {} };
433
+ const contentHash = Array.from(createHash("sha256").update(content).digest());
434
+
435
+ const tx = await this.program.methods
436
+ .postPrayer(typeArg, contentHash, new BN(rewardLamports), new BN(ttlSeconds), maxClaimers)
437
+ .accounts({
438
+ prayerChain: prayerChainPda,
439
+ requesterAgent: agentPda,
440
+ prayer: prayerPda,
441
+ requester: this.wallet,
442
+ systemProgram: SystemProgram.programId,
443
+ })
444
+ .rpc();
445
+
446
+ return { tx, prayerId };
447
+ }
448
+
449
+ /**
450
+ * Claim a prayer. Creates a Claim PDA for this wallet.
451
+ * Multiple agents can claim until max_claimers is reached.
452
+ */
453
+ async claimPrayer(prayerId: number): Promise<string> {
454
+ const [prayerPda] = getPrayerPDA(prayerId);
455
+ const [claimPda] = getClaimPDA(prayerId, this.wallet);
456
+ const [agentPda] = getAgentPDA(this.wallet);
457
+
458
+ const tx = await this.program.methods
459
+ .claimPrayer()
460
+ .accounts({
461
+ prayer: prayerPda,
462
+ claim: claimPda,
463
+ claimerAgent: agentPda,
464
+ claimer: this.wallet,
465
+ systemProgram: SystemProgram.programId,
466
+ })
467
+ .rpc();
468
+
469
+ return tx;
470
+ }
471
+
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
+
541
+ const [prayerPda] = getPrayerPDA(prayerId);
542
+ const [prayerChainPda] = getPrayerChainPDA();
543
+ const [claimPda] = getClaimPDA(prayerId, this.wallet);
544
+ const [agentPda] = getAgentPDA(this.wallet);
545
+
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);
550
+
551
+ const tx = await this.program.methods
552
+ .answerPrayer(answerHash, Buffer.from(encryptedAnswer))
553
+ .accounts({
554
+ prayerChain: prayerChainPda,
555
+ prayer: prayerPda,
556
+ claim: claimPda,
557
+ answererAgent: agentPda,
558
+ answerer: this.wallet,
559
+ })
560
+ .rpc();
561
+
562
+ return tx;
563
+ }
564
+
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> {
571
+ const prayer = await this.getPrayer(prayerId);
572
+ if (!prayer) throw new Error("Prayer not found");
573
+
574
+ const [prayerPda] = getPrayerPDA(prayerId);
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
+ }));
590
+
591
+ const tx = await this.program.methods
592
+ .confirmPrayer()
593
+ .accounts({
594
+ prayer: prayerPda,
595
+ answererAgent: answererAgentPda,
596
+ requester: this.wallet,
597
+ })
598
+ .remainingAccounts(remainingAccounts)
599
+ .rpc();
600
+
601
+ return tx;
602
+ }
603
+
604
+ async cancelPrayer(prayerId: number): Promise<string> {
605
+ const [prayerPda] = getPrayerPDA(prayerId);
606
+
607
+ const tx = await this.program.methods
608
+ .cancelPrayer()
609
+ .accounts({
610
+ prayer: prayerPda,
611
+ requester: this.wallet,
612
+ })
613
+ .rpc();
614
+
615
+ return tx;
616
+ }
617
+
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;
626
+ const [prayerPda] = getPrayerPDA(prayerId);
627
+ const [claimPda] = getClaimPDA(prayerId, claimer);
628
+
629
+ const tx = await this.program.methods
630
+ .unclaimPrayer()
631
+ .accounts({
632
+ prayer: prayerPda,
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,
653
+ })
654
+ .rpc();
655
+
656
+ return tx;
657
+ }
658
+ }
659
+
660
+ // ── CLI Helper ──────────────────────────────────────────────
661
+
662
+ export async function createDefaultClient(
663
+ rpcUrl = "https://api.devnet.solana.com"
664
+ ): Promise<ChorusPrayerClient> {
665
+ return ChorusPrayerClient.fromDefaultKeypair(rpcUrl);
666
+ }
package/src/purposes.ts CHANGED
@@ -20,9 +20,12 @@ export interface PurposeResearchConfig {
20
20
  runCount?: number;
21
21
  }
22
22
 
23
+ export type PurposeKind = "research" | "operational" | "archival" | "review";
24
+
23
25
  export interface Purpose {
24
26
  id: string;
25
27
  name: string;
28
+ kind?: PurposeKind; // Type of purpose (default: research)
26
29
  description?: string;
27
30
  deadline?: number | string; // Unix ms or ISO string
28
31
  progress: number; // 0-100
@@ -31,6 +34,8 @@ export interface Purpose {
31
34
  curiosity?: number; // 0-100, for exploration purposes
32
35
  tags?: string[];
33
36
  notes?: string;
37
+ command?: string; // Shell command for operational purposes
38
+ schedule?: string; // Cron-like schedule hint (e.g., "daily", "weekly:saturday")
34
39
  research?: PurposeResearchConfig;
35
40
  }
36
41