onchain-novel-cli 0.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,3071 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // ../packages/shared/dist/chain/abi.js
13
+ import { parseAbi } from "viem";
14
+ var novelCoreAbi, roundManagerAbi, votingEngineAbi, prizePoolAbi, bountyBoardAbi, rulesEngineAbi, userRegistryAbi;
15
+ var init_abi = __esm({
16
+ "../packages/shared/dist/chain/abi.js"() {
17
+ "use strict";
18
+ novelCoreAbi = parseAbi([
19
+ // --- Events ---
20
+ "event NovelCreated(uint64 indexed novelId, address indexed creator)",
21
+ "event NovelForked(uint64 indexed novelId, uint64 indexed sourceChapterId, address indexed creator)",
22
+ "event ChapterSubmitted(uint64 indexed novelId, uint64 indexed chapterId, address indexed author, uint64 parentId, uint32 depth)",
23
+ "event RewardClaimed(uint64 indexed novelId, address indexed recipient, uint256 amount)",
24
+ "event NovelMetadataUpdated(uint64 indexed novelId, string title, string description, string coverUri)",
25
+ // --- View functions ---
26
+ "function getNovel(uint64 novelId) external view returns ((uint64 id, address creator, (uint64 minChapterLength, uint64 maxChapterLength, uint256 submissionFee, uint32 worldLineCount, uint256 voteStake, uint256 nominationFee, uint64 nominateDuration, uint64 commitDuration, uint64 revealDuration, uint64 minRoundGap, uint16 prizeReleaseRate, uint16 voterRewardRate, uint8 contentLocation, string contentBaseUrl, uint256 ruleFee, uint64 ruleVoteDuration, uint32 ruleQuorum) config, uint32 currentRound, uint8 roundPhase, uint64 phaseStartTime, uint64 lastSettleTime, bool active))",
27
+ "function getNovelMetadata(uint64 novelId) external view returns ((string title, string description, string coverUri))",
28
+ "function getChapter(uint64 chapterId) external view returns ((uint64 id, uint64 novelId, uint64 parentId, address author, bytes32 contentHash, uint64 declaredLength, uint32 depth, uint64 timestamp, uint64[] children))",
29
+ "function getWorldLineAncestors(uint64 novelId) external view returns (uint64[])",
30
+ "function getChapterChildren(uint64 chapterId) external view returns (uint64[])",
31
+ "function verifyChapterPath(uint64 novelId, uint64[] path) external view",
32
+ "function isCurrentWorldLineAncestor(uint64 novelId, uint64 chapterId) external view returns (bool)",
33
+ "function verifyWorldLineAuthor(uint64 novelId, address expectedAuthor, uint64[] path) external view",
34
+ "function collectPathAuthors(uint64 novelId, uint64[] startNodes, uint64[] stopAnchors, bool requireAnchorHit) external view returns (address[])",
35
+ "function novelCount() external view returns (uint64)",
36
+ "function chapterCount() external view returns (uint64)",
37
+ // --- Address-book getters (used by client-side resolveContracts) ---
38
+ "function votingEngine() external view returns (address)",
39
+ "function prizePool() external view returns (address)",
40
+ "function rulesEngine() external view returns (address)",
41
+ "function roundManager() external view returns (address)",
42
+ "function userRegistry() external view returns (address)",
43
+ // --- Write functions ---
44
+ "function submitChapter(uint64 novelId, uint64 parentId, (bytes32 contentHash, uint64 declaredLength, bytes content) submission) external payable",
45
+ "function createNovel((uint64 minChapterLength, uint64 maxChapterLength, uint256 submissionFee, uint32 worldLineCount, uint256 voteStake, uint256 nominationFee, uint64 nominateDuration, uint64 commitDuration, uint64 revealDuration, uint64 minRoundGap, uint16 prizeReleaseRate, uint16 voterRewardRate, uint8 contentLocation, string contentBaseUrl, uint256 ruleFee, uint64 ruleVoteDuration, uint32 ruleQuorum) config, (string title, string description, string coverUri) metadata, (bytes32 contentHash, uint64 declaredLength, bytes content) rootChapter) external payable returns (uint64 novelId)",
46
+ "function forkNovel(uint64 sourceChapterId, (uint64 minChapterLength, uint64 maxChapterLength, uint256 submissionFee, uint32 worldLineCount, uint256 voteStake, uint256 nominationFee, uint64 nominateDuration, uint64 commitDuration, uint64 revealDuration, uint64 minRoundGap, uint16 prizeReleaseRate, uint16 voterRewardRate, uint8 contentLocation, string contentBaseUrl, uint256 ruleFee, uint64 ruleVoteDuration, uint32 ruleQuorum) config, (string title, string description, string coverUri) metadata, (bytes32 contentHash, uint64 declaredLength, bytes content) rootChapter) external payable returns (uint64 novelId)",
47
+ "function claimReward(uint64 novelId) external",
48
+ "function updateNovelMetadata(uint64 novelId, (string title, string description, string coverUri) metadata) external"
49
+ ]);
50
+ roundManagerAbi = parseAbi([
51
+ // --- Events ---
52
+ "event KeeperUpdated(address indexed oldAddr, address indexed newAddr)",
53
+ "event RoundStarted(uint64 indexed novelId, uint32 round, uint64[] candidates)",
54
+ "event NominationClosed(uint64 indexed novelId, uint32 round)",
55
+ "event CommitClosed(uint64 indexed novelId, uint32 round)",
56
+ "event RoundSettled(uint64 indexed novelId, uint32 round, uint64[] worldLines)",
57
+ "event CandidateNominated(uint64 indexed novelId, uint32 round, uint64 chapterId, address nominator)",
58
+ "event VoteCommitted(uint64 indexed novelId, uint32 round, address indexed voter)",
59
+ "event VoteRevealed(uint64 indexed novelId, uint32 round, address indexed voter, uint64 candidateId)",
60
+ "event RewardClaimed(uint64 indexed novelId, address indexed recipient, uint256 amount)",
61
+ "event NovelCompleted(uint64 indexed novelId)",
62
+ "event KeeperRewarded(uint64 indexed novelId, address indexed keeper, uint256 amount)",
63
+ // --- View ---
64
+ "function getRoundData(uint64 novelId, uint32 round) external view returns ((uint64[] candidates, uint64 nominateEndTime, uint64 commitEndTime, uint64 revealEndTime, bool settled))",
65
+ "function keeper() external view returns (address)",
66
+ // --- Admin ---
67
+ "function setKeeper(address newKeeper) external",
68
+ // --- Write ---
69
+ "function startRound(uint64 novelId, uint64[] leaves) external",
70
+ "function closeNomination(uint64 novelId) external",
71
+ "function closeCommit(uint64 novelId) external",
72
+ "function settleRound(uint64 novelId) external",
73
+ "function nominateCandidate(uint64 novelId, uint64 chapterId, uint64[] path) external payable",
74
+ "function commitVote(uint64 novelId, bytes32 commitHash) external payable",
75
+ "function revealVote(uint64 novelId, address voter, uint64 candidateId, bytes32 salt) external",
76
+ "function claimVotingReward(uint64 novelId, uint32 round) external",
77
+ "function completeNovel(uint64 novelId) external"
78
+ ]);
79
+ votingEngineAbi = parseAbi([
80
+ "event VotingInitialized(uint64 indexed novelId, uint32 indexed round, uint256 candidateCount)",
81
+ "event VoteCommitted(uint64 indexed novelId, uint32 indexed round, address indexed voter)",
82
+ "event VoteRevealed(uint64 indexed novelId, uint32 indexed round, address indexed voter, uint64 candidateId)",
83
+ "event VotesTallied(uint64 indexed novelId, uint32 indexed round, uint64[] rankedCandidateIds)",
84
+ "event VoterRewardsSettled(uint64 indexed novelId, uint32 indexed round, uint256 totalRewardPool)",
85
+ "event VotingRewardClaimed(uint64 indexed novelId, uint32 indexed round, address indexed voter, uint256 amount)"
86
+ ]);
87
+ prizePoolAbi = parseAbi([
88
+ "event PoolDeposited(uint64 indexed novelId, uint256 amount, string reason)",
89
+ "event RoundRewardsDistributed(uint64 indexed novelId, uint32 round, uint256 creatorRoyalty, uint256 authorRewards, uint256 voterRewards)",
90
+ "event TipReceived(uint64 indexed novelId, address indexed tipper, uint256 amount)",
91
+ "event ChapterTipped(uint64 indexed novelId, uint64 indexed chapterId, address indexed tipper, uint256 amount)",
92
+ "event RewardClaimed(uint64 indexed novelId, address indexed recipient, uint256 amount)",
93
+ "event KeeperRewardPaid(uint64 indexed novelId, address indexed keeper, uint256 amount)",
94
+ "function getPoolBalance(uint64 novelId) external view returns (uint256)",
95
+ "function getPendingReward(uint64 novelId, address user) external view returns (uint256)",
96
+ "function tipNovel(uint64 novelId) external payable",
97
+ "function tipChapter(uint64 chapterId) external payable",
98
+ // --- Address-book getter (used by resolveContracts; bountyBoard is the
99
+ // one address NovelCore doesn't know about) ---
100
+ "function bountyBoard() external view returns (address)"
101
+ ]);
102
+ bountyBoardAbi = parseAbi([
103
+ "event BountyCreated(uint64 indexed bountyId, uint64 indexed chapterId, address indexed tipper, uint256 lockedAmount, uint64 createTime, uint64 deadline)",
104
+ "event BountyDesignated(uint64 indexed bountyId, uint64 indexed chapterId)",
105
+ "event BountyClaimed(uint64 indexed bountyId, address indexed author, uint256 amount)",
106
+ "event BountyRefunded(uint64 indexed bountyId, address indexed tipper, uint256 amount)",
107
+ "function createBounty(uint64 chapterId, uint64 deadline) external payable returns (uint64 bountyId)",
108
+ "function designateBounty(uint64 bountyId, uint64 chapterId) external",
109
+ "function claimBounty(uint64 bountyId) external",
110
+ "function refundBounty(uint64 bountyId) external"
111
+ ]);
112
+ rulesEngineAbi = parseAbi([
113
+ "event RuleSet(uint64 indexed novelId, string name)",
114
+ "event RuleDeleted(uint64 indexed novelId, string name)",
115
+ "event RuleProposed(uint64 indexed proposalId, uint64 indexed novelId, address indexed proposer, uint8 proposalType, string ruleName)",
116
+ "event RuleProposalVoted(uint64 indexed proposalId, address indexed voter, uint32 newVoteCount)",
117
+ "event RuleProposalExecuted(uint64 indexed proposalId, uint64 indexed novelId)",
118
+ "function getRule(uint64 novelId, string name) external view returns (string)",
119
+ "function getRuleNames(uint64 novelId) external view returns (string[])",
120
+ "function getRuleProposal(uint64 proposalId) external view returns ((uint64 id, uint64 novelId, uint64 createdAt, address proposer, uint8 proposalType, uint32 voteCount, bool executed, string ruleName, string ruleContent))",
121
+ "function setCreatorRules(uint64 novelId, string[] names, string[] contents) external",
122
+ "function proposeRule(uint64 novelId, uint8 proposalType, string ruleName, string ruleContent, uint64[] path) external payable returns (uint64 proposalId)",
123
+ "function voteOnRuleProposal(uint64 proposalId, uint64[] path) external"
124
+ ]);
125
+ userRegistryAbi = parseAbi([
126
+ "event NicknameSet(address indexed user, bytes32 nickname)",
127
+ "function nicknames(address user) external view returns (bytes32)",
128
+ "function setNickname(bytes32 nickname) external"
129
+ ]);
130
+ }
131
+ });
132
+
133
+ // ../packages/shared/dist/chain/resolveContracts.js
134
+ import { createPublicClient, http } from "viem";
135
+ async function resolveContracts(opts) {
136
+ const client2 = opts.client ?? (() => {
137
+ if (!opts.rpcUrl)
138
+ throw new Error("resolveContracts: must supply either client or rpcUrl");
139
+ return createPublicClient({ transport: http(opts.rpcUrl) });
140
+ })();
141
+ const [votingEngine, prizePool, rulesEngine, roundManager, userRegistry] = await Promise.all([
142
+ client2.readContract({ address: opts.novelCore, abi: novelCoreAbi, functionName: "votingEngine" }),
143
+ client2.readContract({ address: opts.novelCore, abi: novelCoreAbi, functionName: "prizePool" }),
144
+ client2.readContract({ address: opts.novelCore, abi: novelCoreAbi, functionName: "rulesEngine" }),
145
+ client2.readContract({ address: opts.novelCore, abi: novelCoreAbi, functionName: "roundManager" }),
146
+ client2.readContract({ address: opts.novelCore, abi: novelCoreAbi, functionName: "userRegistry" })
147
+ ]);
148
+ const bountyBoard = await client2.readContract({
149
+ address: prizePool,
150
+ abi: prizePoolAbi,
151
+ functionName: "bountyBoard"
152
+ });
153
+ return {
154
+ novelCore: opts.novelCore,
155
+ votingEngine,
156
+ prizePool,
157
+ rulesEngine,
158
+ roundManager,
159
+ userRegistry,
160
+ bountyBoard
161
+ };
162
+ }
163
+ var init_resolveContracts = __esm({
164
+ "../packages/shared/dist/chain/resolveContracts.js"() {
165
+ "use strict";
166
+ init_abi();
167
+ }
168
+ });
169
+
170
+ // ../packages/shared/dist/chain/contracts.js
171
+ import { encodePacked, keccak256, toHex } from "viem";
172
+ function computeCommitHash(voter, candidateId, salt) {
173
+ return keccak256(encodePacked(["address", "uint64", "bytes32"], [voter, candidateId, salt]));
174
+ }
175
+ function toBytes32Salt(salt) {
176
+ const bytes = new TextEncoder().encode(salt);
177
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
178
+ return "0x" + hex.padEnd(64, "0");
179
+ }
180
+ async function buildPathToAnchor(client2, novelCore, novelId, from, anchors) {
181
+ void novelId;
182
+ const path = [];
183
+ let cur = from;
184
+ while (cur !== 0n) {
185
+ path.push(cur);
186
+ if (anchors.includes(cur))
187
+ return path;
188
+ const ch = await client2.readContract({
189
+ address: novelCore,
190
+ abi: novelCoreAbi,
191
+ functionName: "getChapter",
192
+ args: [cur]
193
+ });
194
+ if (ch.depth <= 1)
195
+ break;
196
+ cur = ch.parentId;
197
+ }
198
+ return null;
199
+ }
200
+ async function buildWorldLineProof(client2, novelCore, novelId, chapterId) {
201
+ const ancestors = await client2.readContract({
202
+ address: novelCore,
203
+ abi: novelCoreAbi,
204
+ functionName: "getWorldLineAncestors",
205
+ args: [novelId]
206
+ });
207
+ for (const ancestor of ancestors) {
208
+ const path = [];
209
+ let cur = ancestor;
210
+ while (cur !== 0n) {
211
+ path.push(cur);
212
+ if (cur === chapterId)
213
+ return path;
214
+ const ch = await client2.readContract({
215
+ address: novelCore,
216
+ abi: novelCoreAbi,
217
+ functionName: "getChapter",
218
+ args: [cur]
219
+ });
220
+ if (ch.depth <= 1)
221
+ break;
222
+ cur = ch.parentId;
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+ function buildContentSubmission(content) {
228
+ const encoded = new TextEncoder().encode(content);
229
+ const contentBytes = toHex(encoded);
230
+ const contentHash = keccak256(contentBytes);
231
+ return {
232
+ contentHash,
233
+ declaredLength: BigInt(encoded.length),
234
+ content: contentBytes
235
+ };
236
+ }
237
+ async function updateNovelMetadata(client2, params) {
238
+ return client2.writeContract({
239
+ address: params.novelCore,
240
+ abi: novelCoreAbi,
241
+ functionName: "updateNovelMetadata",
242
+ args: [params.novelId, params.metadata],
243
+ chain: client2.chain,
244
+ account: client2.account
245
+ });
246
+ }
247
+ async function createNovel(client2, params) {
248
+ return client2.writeContract({
249
+ address: params.novelCore,
250
+ abi: novelCoreAbi,
251
+ functionName: "createNovel",
252
+ args: [params.config, params.metadata, params.rootChapter],
253
+ value: params.value ?? 0n,
254
+ chain: client2.chain,
255
+ account: client2.account
256
+ });
257
+ }
258
+ async function submitChapter(client2, params) {
259
+ return client2.writeContract({
260
+ address: params.novelCore,
261
+ abi: novelCoreAbi,
262
+ functionName: "submitChapter",
263
+ args: [params.novelId, params.parentId, params.submission],
264
+ value: params.value ?? 0n,
265
+ chain: client2.chain,
266
+ account: client2.account
267
+ });
268
+ }
269
+ async function forkNovel(client2, params) {
270
+ return client2.writeContract({
271
+ address: params.novelCore,
272
+ abi: novelCoreAbi,
273
+ functionName: "forkNovel",
274
+ args: [params.sourceChapterId, params.config, params.metadata, params.rootChapter],
275
+ value: params.value ?? 0n,
276
+ chain: client2.chain,
277
+ account: client2.account
278
+ });
279
+ }
280
+ async function claimReward(client2, novelId, novelCore) {
281
+ return client2.writeContract({
282
+ address: novelCore,
283
+ abi: novelCoreAbi,
284
+ functionName: "claimReward",
285
+ args: [novelId],
286
+ chain: client2.chain,
287
+ account: client2.account
288
+ });
289
+ }
290
+ async function commitVote(client2, params) {
291
+ return client2.writeContract({
292
+ address: params.roundManager,
293
+ abi: roundManagerAbi,
294
+ functionName: "commitVote",
295
+ args: [params.novelId, params.commitHash],
296
+ value: params.value ?? 0n,
297
+ chain: client2.chain,
298
+ account: client2.account
299
+ });
300
+ }
301
+ async function revealVote(client2, params) {
302
+ return client2.writeContract({
303
+ address: params.roundManager,
304
+ abi: roundManagerAbi,
305
+ functionName: "revealVote",
306
+ args: [params.novelId, params.voter, params.candidateId, params.salt],
307
+ chain: client2.chain,
308
+ account: client2.account
309
+ });
310
+ }
311
+ async function startRound(client2, params) {
312
+ return client2.writeContract({
313
+ address: params.roundManager,
314
+ abi: roundManagerAbi,
315
+ functionName: "startRound",
316
+ args: [params.novelId, params.leaves],
317
+ chain: client2.chain,
318
+ account: client2.account
319
+ });
320
+ }
321
+ async function closeNomination(client2, novelId, roundManager) {
322
+ return client2.writeContract({
323
+ address: roundManager,
324
+ abi: roundManagerAbi,
325
+ functionName: "closeNomination",
326
+ args: [novelId],
327
+ chain: client2.chain,
328
+ account: client2.account
329
+ });
330
+ }
331
+ async function closeCommit(client2, novelId, roundManager) {
332
+ return client2.writeContract({
333
+ address: roundManager,
334
+ abi: roundManagerAbi,
335
+ functionName: "closeCommit",
336
+ args: [novelId],
337
+ chain: client2.chain,
338
+ account: client2.account
339
+ });
340
+ }
341
+ async function settleRound(client2, params) {
342
+ return client2.writeContract({
343
+ address: params.roundManager,
344
+ abi: roundManagerAbi,
345
+ functionName: "settleRound",
346
+ args: [params.novelId],
347
+ chain: client2.chain,
348
+ account: client2.account
349
+ });
350
+ }
351
+ async function nominateCandidate(client2, params) {
352
+ return client2.writeContract({
353
+ address: params.roundManager,
354
+ abi: roundManagerAbi,
355
+ functionName: "nominateCandidate",
356
+ args: [params.novelId, params.chapterId, params.path],
357
+ value: params.value ?? 0n,
358
+ chain: client2.chain,
359
+ account: client2.account
360
+ });
361
+ }
362
+ async function claimVotingReward(client2, novelId, round, roundManager) {
363
+ return client2.writeContract({
364
+ address: roundManager,
365
+ abi: roundManagerAbi,
366
+ functionName: "claimVotingReward",
367
+ args: [novelId, round],
368
+ chain: client2.chain,
369
+ account: client2.account
370
+ });
371
+ }
372
+ async function completeNovel(client2, params) {
373
+ return client2.writeContract({
374
+ address: params.roundManager,
375
+ abi: roundManagerAbi,
376
+ functionName: "completeNovel",
377
+ args: [params.novelId],
378
+ chain: client2.chain,
379
+ account: client2.account
380
+ });
381
+ }
382
+ async function tipNovel(client2, params) {
383
+ return client2.writeContract({
384
+ address: params.prizePool,
385
+ abi: prizePoolAbi,
386
+ functionName: "tipNovel",
387
+ args: [params.id],
388
+ value: params.value,
389
+ chain: client2.chain,
390
+ account: client2.account
391
+ });
392
+ }
393
+ async function tipChapter(client2, params) {
394
+ return client2.writeContract({
395
+ address: params.prizePool,
396
+ abi: prizePoolAbi,
397
+ functionName: "tipChapter",
398
+ args: [params.id],
399
+ value: params.value,
400
+ chain: client2.chain,
401
+ account: client2.account
402
+ });
403
+ }
404
+ async function setNickname(client2, nickname, userRegistry) {
405
+ return client2.writeContract({
406
+ address: userRegistry,
407
+ abi: userRegistryAbi,
408
+ functionName: "setNickname",
409
+ args: [nickname],
410
+ chain: client2.chain,
411
+ account: client2.account
412
+ });
413
+ }
414
+ async function createBounty(client2, params) {
415
+ return client2.writeContract({
416
+ address: params.bountyBoard,
417
+ abi: bountyBoardAbi,
418
+ functionName: "createBounty",
419
+ args: [params.chapterId, params.deadline],
420
+ value: params.value,
421
+ chain: client2.chain,
422
+ account: client2.account
423
+ });
424
+ }
425
+ async function designateBounty(client2, params) {
426
+ return client2.writeContract({
427
+ address: params.bountyBoard,
428
+ abi: bountyBoardAbi,
429
+ functionName: "designateBounty",
430
+ args: [params.bountyId, params.chapterId],
431
+ chain: client2.chain,
432
+ account: client2.account
433
+ });
434
+ }
435
+ async function claimBounty(client2, bountyId, bountyBoard) {
436
+ return client2.writeContract({
437
+ address: bountyBoard,
438
+ abi: bountyBoardAbi,
439
+ functionName: "claimBounty",
440
+ args: [bountyId],
441
+ chain: client2.chain,
442
+ account: client2.account
443
+ });
444
+ }
445
+ async function refundBounty(client2, bountyId, bountyBoard) {
446
+ return client2.writeContract({
447
+ address: bountyBoard,
448
+ abi: bountyBoardAbi,
449
+ functionName: "refundBounty",
450
+ args: [bountyId],
451
+ chain: client2.chain,
452
+ account: client2.account
453
+ });
454
+ }
455
+ async function setCreatorRules(client2, params) {
456
+ return client2.writeContract({
457
+ address: params.rulesEngine,
458
+ abi: rulesEngineAbi,
459
+ functionName: "setCreatorRules",
460
+ args: [params.novelId, params.names, params.contents],
461
+ chain: client2.chain,
462
+ account: client2.account
463
+ });
464
+ }
465
+ async function proposeRule(client2, params) {
466
+ return client2.writeContract({
467
+ address: params.rulesEngine,
468
+ abi: rulesEngineAbi,
469
+ functionName: "proposeRule",
470
+ args: [params.novelId, params.proposalType, params.ruleName, params.ruleContent, params.path],
471
+ value: params.value ?? 0n,
472
+ chain: client2.chain,
473
+ account: client2.account
474
+ });
475
+ }
476
+ async function voteOnRuleProposal(client2, params) {
477
+ return client2.writeContract({
478
+ address: params.rulesEngine,
479
+ abi: rulesEngineAbi,
480
+ functionName: "voteOnRuleProposal",
481
+ args: [params.proposalId, params.path],
482
+ chain: client2.chain,
483
+ account: client2.account
484
+ });
485
+ }
486
+ async function getNovel(client2, novelId, novelCore) {
487
+ return client2.readContract({
488
+ address: novelCore,
489
+ abi: novelCoreAbi,
490
+ functionName: "getNovel",
491
+ args: [novelId]
492
+ });
493
+ }
494
+ async function getChapter(client2, chapterId, novelCore) {
495
+ return client2.readContract({
496
+ address: novelCore,
497
+ abi: novelCoreAbi,
498
+ functionName: "getChapter",
499
+ args: [chapterId]
500
+ });
501
+ }
502
+ async function getWorldLineAncestors(client2, novelId, novelCore) {
503
+ return client2.readContract({
504
+ address: novelCore,
505
+ abi: novelCoreAbi,
506
+ functionName: "getWorldLineAncestors",
507
+ args: [novelId]
508
+ });
509
+ }
510
+ async function getRoundData(client2, novelId, round, roundManager) {
511
+ return client2.readContract({
512
+ address: roundManager,
513
+ abi: roundManagerAbi,
514
+ functionName: "getRoundData",
515
+ args: [novelId, round]
516
+ });
517
+ }
518
+ async function getNovelMetadata(client2, novelId, novelCore) {
519
+ return client2.readContract({
520
+ address: novelCore,
521
+ abi: novelCoreAbi,
522
+ functionName: "getNovelMetadata",
523
+ args: [novelId]
524
+ });
525
+ }
526
+ async function getPoolBalance(client2, novelId, prizePool) {
527
+ return client2.readContract({
528
+ address: prizePool,
529
+ abi: prizePoolAbi,
530
+ functionName: "getPoolBalance",
531
+ args: [novelId]
532
+ });
533
+ }
534
+ async function getRuleNames(client2, novelId, rulesEngine) {
535
+ return client2.readContract({
536
+ address: rulesEngine,
537
+ abi: rulesEngineAbi,
538
+ functionName: "getRuleNames",
539
+ args: [novelId]
540
+ });
541
+ }
542
+ async function getRule(client2, novelId, name, rulesEngine) {
543
+ return client2.readContract({
544
+ address: rulesEngine,
545
+ abi: rulesEngineAbi,
546
+ functionName: "getRule",
547
+ args: [novelId, name]
548
+ });
549
+ }
550
+ async function getRuleProposal(client2, proposalId, rulesEngine) {
551
+ return client2.readContract({
552
+ address: rulesEngine,
553
+ abi: rulesEngineAbi,
554
+ functionName: "getRuleProposal",
555
+ args: [proposalId]
556
+ });
557
+ }
558
+ async function getNickname(client2, user, userRegistry) {
559
+ return client2.readContract({
560
+ address: userRegistry,
561
+ abi: userRegistryAbi,
562
+ functionName: "nicknames",
563
+ args: [user]
564
+ });
565
+ }
566
+ var init_contracts = __esm({
567
+ "../packages/shared/dist/chain/contracts.js"() {
568
+ "use strict";
569
+ init_abi();
570
+ }
571
+ });
572
+
573
+ // ../packages/shared/dist/chain/index.js
574
+ var init_chain = __esm({
575
+ "../packages/shared/dist/chain/index.js"() {
576
+ "use strict";
577
+ init_abi();
578
+ init_contracts();
579
+ init_resolveContracts();
580
+ }
581
+ });
582
+
583
+ // src/shared/index.ts
584
+ var shared_exports = {};
585
+ __export(shared_exports, {
586
+ bountyBoardAbi: () => bountyBoardAbi,
587
+ buildContentSubmission: () => buildContentSubmission,
588
+ buildPathToAnchor: () => buildPathToAnchor,
589
+ buildWorldLineProof: () => buildWorldLineProof,
590
+ claimBounty: () => claimBounty,
591
+ claimReward: () => claimReward,
592
+ claimVotingReward: () => claimVotingReward,
593
+ closeCommit: () => closeCommit,
594
+ closeNomination: () => closeNomination,
595
+ commitVote: () => commitVote,
596
+ completeNovel: () => completeNovel,
597
+ computeCommitHash: () => computeCommitHash,
598
+ createBounty: () => createBounty,
599
+ createNovel: () => createNovel,
600
+ designateBounty: () => designateBounty,
601
+ forkNovel: () => forkNovel,
602
+ getChapter: () => getChapter,
603
+ getNickname: () => getNickname,
604
+ getNovel: () => getNovel,
605
+ getNovelMetadata: () => getNovelMetadata,
606
+ getPoolBalance: () => getPoolBalance,
607
+ getRoundData: () => getRoundData,
608
+ getRule: () => getRule,
609
+ getRuleNames: () => getRuleNames,
610
+ getRuleProposal: () => getRuleProposal,
611
+ getWorldLineAncestors: () => getWorldLineAncestors,
612
+ nominateCandidate: () => nominateCandidate,
613
+ novelCoreAbi: () => novelCoreAbi,
614
+ prizePoolAbi: () => prizePoolAbi,
615
+ proposeRule: () => proposeRule,
616
+ refundBounty: () => refundBounty,
617
+ resolveContracts: () => resolveContracts,
618
+ revealVote: () => revealVote,
619
+ roundManagerAbi: () => roundManagerAbi,
620
+ rulesEngineAbi: () => rulesEngineAbi,
621
+ setCreatorRules: () => setCreatorRules,
622
+ setNickname: () => setNickname,
623
+ settleRound: () => settleRound,
624
+ startRound: () => startRound,
625
+ submitChapter: () => submitChapter,
626
+ tipChapter: () => tipChapter,
627
+ tipNovel: () => tipNovel,
628
+ toBytes32Salt: () => toBytes32Salt,
629
+ updateNovelMetadata: () => updateNovelMetadata,
630
+ userRegistryAbi: () => userRegistryAbi,
631
+ voteOnRuleProposal: () => voteOnRuleProposal,
632
+ votingEngineAbi: () => votingEngineAbi
633
+ });
634
+ var init_shared = __esm({
635
+ "src/shared/index.ts"() {
636
+ "use strict";
637
+ init_chain();
638
+ }
639
+ });
640
+
641
+ // src/bin/onchain-novel-cli.ts
642
+ import { program } from "commander";
643
+
644
+ // ../packages/shared/dist/config.js
645
+ init_resolveContracts();
646
+ import { existsSync, readFileSync } from "fs";
647
+ import { dirname, join, resolve } from "path";
648
+ import YAML from "yaml";
649
+ import { z } from "zod";
650
+ import { createPublicClient as createPublicClient2, http as http2 } from "viem";
651
+ var addressLike = z.union([z.string(), z.number()]).transform((v) => String(v));
652
+ var hexAddress = addressLike.pipe(z.string().regex(/^0x[0-9a-fA-F]{40}$/, "expected 0x-prefixed 20-byte hex address").transform((s) => s));
653
+ var optionalHexAddress = z.union([z.string(), z.number()]).optional().transform((v) => {
654
+ if (v === void 0)
655
+ return void 0;
656
+ const s = String(v);
657
+ return s.length > 0 ? s : void 0;
658
+ });
659
+ var NativeCurrencySchema = z.object({
660
+ name: z.string().default("Ether"),
661
+ symbol: z.string().default("ETH"),
662
+ decimals: z.number().int().positive().default(18)
663
+ }).default({});
664
+ var ChainSchema = z.object({
665
+ rpcUrl: z.string().url(),
666
+ // Optional: when omitted, callers resolve via `eth_chainId` against rpcUrl
667
+ // (see bootstrapConfig). Keep it as an explicit override for offline tooling
668
+ // or for sanity-checking against an expected network.
669
+ chainId: z.number().int().positive().optional(),
670
+ nativeCurrency: NativeCurrencySchema
671
+ });
672
+ var ContractsSchema = z.object({
673
+ novelCore: optionalHexAddress
674
+ });
675
+ var IndexerSchema = z.object({
676
+ startBlock: z.number().int().nonnegative().default(0),
677
+ pollIntervalMs: z.number().int().positive().default(5e3),
678
+ confirmationBlocks: z.number().int().nonnegative().default(12),
679
+ batchSize: z.number().int().positive().default(100)
680
+ });
681
+ var KeeperSchema = z.object({
682
+ pollIntervalMs: z.number().int().positive().default(1e4)
683
+ });
684
+ var BackendSchema = z.object({
685
+ host: z.string().default("127.0.0.1"),
686
+ port: z.number().int().positive().default(3001),
687
+ // CLI-only configs (e.g. onchain-novel-cli setup) omit this section; backend
688
+ // runtime still needs a real URL, but the placeholder here keeps schema
689
+ // validation happy and the backend itself errors later with a clearer message
690
+ // if it tries to connect to a non-existent database.
691
+ databaseUrl: z.string().min(1).default("postgresql://127.0.0.1:5432/onchain_novel"),
692
+ indexer: IndexerSchema.default({}),
693
+ keeper: KeeperSchema.default({})
694
+ });
695
+ var FrontendSchema = z.object({
696
+ port: z.number().int().positive().default(3e3),
697
+ backendUrl: z.string().url().default("http://127.0.0.1:3001"),
698
+ // Extra Next.js dev-server origins allowed for HMR etc. Empty = same-origin only.
699
+ allowedDevOrigins: z.array(z.string()).default([])
700
+ });
701
+ var CliSchema = z.object({
702
+ apiUrl: z.string().url().default("http://127.0.0.1:3001"),
703
+ // Base URL of the frontend. Full chapter path is constructed as
704
+ // `{frontUrl}/novels/{novelId}/chapter/{chapterId}` at runtime.
705
+ frontUrl: z.string().url().default("http://34.135.19.173:8546"),
706
+ // Base URL of the chain explorer. Full tx path is constructed as
707
+ // `{chainExplorer}/tx/{txHash}` at runtime.
708
+ chainExplorer: z.string().url().default("https://mainnet-explorer.gravity.xyz")
709
+ });
710
+ var AppConfigSchema = z.object({
711
+ chain: ChainSchema,
712
+ contracts: ContractsSchema.default({}),
713
+ backend: BackendSchema.default({}),
714
+ frontend: FrontendSchema.default({}),
715
+ cli: CliSchema.default({})
716
+ });
717
+ function findRepoRoot(startDir) {
718
+ let dir = resolve(startDir);
719
+ while (true) {
720
+ if (existsSync(join(dir, "config.yaml")) || existsSync(join(dir, "foundry.toml"))) {
721
+ return dir;
722
+ }
723
+ const parent = dirname(dir);
724
+ if (parent === dir) {
725
+ throw new Error(`Could not find repo root from ${startDir} \u2014 no config.yaml or foundry.toml anchor found.`);
726
+ }
727
+ dir = parent;
728
+ }
729
+ }
730
+ function readYaml(path) {
731
+ const raw = readFileSync(path, "utf-8");
732
+ const parsed = YAML.parse(raw);
733
+ if (parsed === null || parsed === void 0)
734
+ return {};
735
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
736
+ throw new Error(`Expected YAML object at top level of ${path}`);
737
+ }
738
+ return parsed;
739
+ }
740
+ function loadConfig(opts = {}) {
741
+ const searchFrom = opts.searchFrom ?? process.cwd();
742
+ const explicitPath = opts.configPath ?? process.env.ONCHAIN_NOVEL_CONFIG;
743
+ let mainPath;
744
+ let root;
745
+ if (explicitPath) {
746
+ mainPath = resolve(explicitPath);
747
+ root = dirname(mainPath);
748
+ } else {
749
+ root = findRepoRoot(searchFrom);
750
+ mainPath = join(root, "config.yaml");
751
+ }
752
+ if (!existsSync(mainPath)) {
753
+ throw new Error(`config.yaml not found at ${mainPath}`);
754
+ }
755
+ let merged = readYaml(mainPath);
756
+ const result = AppConfigSchema.safeParse(merged);
757
+ if (!result.success) {
758
+ const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
759
+ throw new Error(`Invalid config (${mainPath}):
760
+ ${issues}`);
761
+ }
762
+ return result.data;
763
+ }
764
+ function getPrivateKey() {
765
+ const pk = process.env.PRIVATE_KEY?.trim();
766
+ if (!pk)
767
+ return null;
768
+ return pk.startsWith("0x") ? pk : `0x${pk}`;
769
+ }
770
+ async function bootstrapConfig(opts = {}) {
771
+ const config = loadConfig(opts);
772
+ if (!config.contracts.novelCore) {
773
+ throw new Error("Missing contracts.novelCore in config.yaml. Run scripts/deploy.sh, then patch-config.ts will fill it in.");
774
+ }
775
+ const client2 = createPublicClient2({ transport: http2(config.chain.rpcUrl) });
776
+ const [chainId, contracts] = await Promise.all([
777
+ config.chain.chainId !== void 0 ? Promise.resolve(config.chain.chainId) : client2.getChainId(),
778
+ resolveContracts({ novelCore: config.contracts.novelCore, client: client2 })
779
+ ]);
780
+ return { config, chainId, contracts };
781
+ }
782
+
783
+ // src/utils/config.ts
784
+ var _cache = null;
785
+ async function ensureBootstrapped() {
786
+ if (_cache) return;
787
+ try {
788
+ _cache = await bootstrapConfig();
789
+ } catch (err) {
790
+ console.error(String(err));
791
+ process.exit(1);
792
+ }
793
+ }
794
+ function requireConfig() {
795
+ if (!_cache) {
796
+ console.error(
797
+ "CLI bootstrap was not called for this command. This is a bug \u2014 please file an issue."
798
+ );
799
+ process.exit(1);
800
+ }
801
+ const { config, chainId, contracts } = _cache;
802
+ return {
803
+ rpcUrl: config.chain.rpcUrl,
804
+ chainId,
805
+ nativeCurrency: config.chain.nativeCurrency,
806
+ apiUrl: config.cli.apiUrl,
807
+ frontUrl: config.cli.frontUrl,
808
+ chainExplorer: config.cli.chainExplorer,
809
+ contracts
810
+ };
811
+ }
812
+ function getPrivateKey2() {
813
+ return getPrivateKey();
814
+ }
815
+
816
+ // src/utils/format.ts
817
+ import chalk from "chalk";
818
+ import { formatUnits } from "viem";
819
+ function kv(label, value) {
820
+ console.log(` ${chalk.gray(label + ":")} ${value}`);
821
+ }
822
+ function header(text) {
823
+ console.log(chalk.bold.cyan(`
824
+ ${text}`));
825
+ console.log(chalk.gray("\u2500".repeat(60)));
826
+ }
827
+ function success(msg) {
828
+ console.log(chalk.green(`\u2713 ${msg}`));
829
+ }
830
+ function error(msg) {
831
+ console.error(chalk.red(`\u2717 ${msg}`));
832
+ }
833
+ function txHash(hash) {
834
+ console.log(chalk.green(`\u2713 Transaction sent: ${hash}`));
835
+ }
836
+ function token(wei, decimals, symbol) {
837
+ const val = typeof wei === "string" ? BigInt(wei) : wei;
838
+ return `${formatUnits(val, decimals)} ${symbol}`;
839
+ }
840
+ function table(rows, columns) {
841
+ if (rows.length === 0) {
842
+ console.log(chalk.gray(" (no results)"));
843
+ return;
844
+ }
845
+ const cols = columns ?? Object.keys(rows[0]);
846
+ const widths = cols.map(
847
+ (col) => Math.max(col.length, ...rows.map((r) => String(r[col] ?? "").length))
848
+ );
849
+ const headerLine = cols.map((col, i) => col.padEnd(widths[i])).join(" ");
850
+ console.log(chalk.bold(" " + headerLine));
851
+ console.log(chalk.gray(" " + widths.map((w) => "\u2500".repeat(w)).join(" ")));
852
+ for (const row of rows) {
853
+ const line = cols.map((col, i) => String(row[col] ?? "").padEnd(widths[i])).join(" ");
854
+ console.log(" " + line);
855
+ }
856
+ }
857
+ function parseDuration(duration) {
858
+ const match = duration.match(/^(\d+)(s|m|h|d)$/);
859
+ if (!match) {
860
+ throw new Error(`Invalid duration format: ${duration}. Use <number>(s|m|h|d), e.g., 7d, 24h`);
861
+ }
862
+ const value = parseInt(match[1]);
863
+ const unit = match[2];
864
+ switch (unit) {
865
+ case "s":
866
+ return value;
867
+ case "m":
868
+ return value * 60;
869
+ case "h":
870
+ return value * 3600;
871
+ case "d":
872
+ return value * 86400;
873
+ default:
874
+ throw new Error(`Unknown unit: ${unit}`);
875
+ }
876
+ }
877
+ function roundPhaseName(phase) {
878
+ switch (phase) {
879
+ case 0:
880
+ return "Idle";
881
+ case 1:
882
+ return "Nominating";
883
+ case 2:
884
+ return "Committing";
885
+ case 3:
886
+ return "Revealing";
887
+ default:
888
+ return `Unknown(${phase})`;
889
+ }
890
+ }
891
+
892
+ // src/commands/admin.ts
893
+ async function adminFetch(method, path, label) {
894
+ const cfg = requireConfig();
895
+ const url = `${cfg.apiUrl}${path}`;
896
+ header(label);
897
+ try {
898
+ const res = await fetch(url, { method });
899
+ const body = await res.json().catch(() => ({}));
900
+ if (!res.ok) {
901
+ error(`Backend returned ${res.status}: ${body.error ?? "unknown error"}`);
902
+ process.exit(1);
903
+ }
904
+ const id = body.deleted ?? body.restored;
905
+ success(`${label} \u2014 Novel #${id}. On-chain data is unchanged.`);
906
+ } catch (err) {
907
+ error(`Could not reach backend at ${cfg.apiUrl}: ${String(err)}`);
908
+ process.exit(1);
909
+ }
910
+ }
911
+ function registerAdminCommands(program2) {
912
+ const admin = program2.command("admin").description("Admin commands (localhost-only backend calls)");
913
+ admin.command("delete-novel <novel-id>").description("Hide a novel and all its chapters from the frontend.").action(async (novelId) => {
914
+ await adminFetch("DELETE", `/api/admin/novels/${novelId}`, `Delete Novel #${novelId}`);
915
+ });
916
+ admin.command("restore-novel <novel-id>").description("Restore a previously hidden novel and its chapters.").action(async (novelId) => {
917
+ await adminFetch("POST", `/api/admin/novels/${novelId}/restore`, `Restore Novel #${novelId}`);
918
+ });
919
+ }
920
+
921
+ // src/commands/bounty.ts
922
+ init_shared();
923
+ import { parseEther } from "viem";
924
+
925
+ // ../packages/shared/dist/api/client.js
926
+ function createApiClient(options) {
927
+ const base = options.baseUrl.replace(/\/+$/, "");
928
+ const root = base.endsWith("/api") ? base : `${base}/api`;
929
+ async function get(path) {
930
+ const res = await fetch(`${root}${path}`, {
931
+ headers: { "Content-Type": "application/json" }
932
+ });
933
+ if (!res.ok) {
934
+ const body = await res.text().catch(() => "");
935
+ throw new Error(`API ${res.status}: ${body}`);
936
+ }
937
+ return res.json();
938
+ }
939
+ async function post(path, body) {
940
+ const res = await fetch(`${root}${path}`, {
941
+ method: "POST",
942
+ headers: { "Content-Type": "application/json" },
943
+ body: JSON.stringify(body)
944
+ });
945
+ let parsed = null;
946
+ try {
947
+ parsed = await res.json();
948
+ } catch {
949
+ parsed = null;
950
+ }
951
+ return { status: res.status, body: parsed };
952
+ }
953
+ return {
954
+ get,
955
+ post,
956
+ fetchNovels(params) {
957
+ const sp = new URLSearchParams();
958
+ if (params.page)
959
+ sp.set("page", String(params.page));
960
+ if (params.limit)
961
+ sp.set("limit", String(params.limit));
962
+ if (params.sort)
963
+ sp.set("sort", params.sort);
964
+ if (params.filter)
965
+ sp.set("filter", params.filter);
966
+ if (params.search)
967
+ sp.set("search", params.search);
968
+ return get(`/novels?${sp.toString()}`);
969
+ },
970
+ fetchNovel(id) {
971
+ return get(`/novels/${id}`);
972
+ },
973
+ fetchNovelTree(id, maxDepth) {
974
+ const q = maxDepth ? `?maxDepth=${maxDepth}` : "";
975
+ return get(`/novels/${id}/tree${q}`);
976
+ },
977
+ fetchWorldlines(id) {
978
+ return get(`/novels/${id}/worldlines`);
979
+ },
980
+ fetchNovelLines(id, mode = "longest", limit) {
981
+ const sp = new URLSearchParams({ mode });
982
+ if (limit)
983
+ sp.set("limit", String(limit));
984
+ return get(`/novels/${id}/lines?${sp.toString()}`);
985
+ },
986
+ fetchRound(novelId, round) {
987
+ return get(`/novels/${novelId}/rounds/${round}`);
988
+ },
989
+ fetchChapter(id) {
990
+ return get(`/chapters/${id}`);
991
+ },
992
+ fetchChapterContext(id) {
993
+ return get(`/chapters/${id}/context`);
994
+ },
995
+ fetchChapterChildren(id) {
996
+ return get(`/chapters/${id}/children`);
997
+ },
998
+ fetchChapterBounties(id) {
999
+ return get(`/chapters/${id}/bounties`);
1000
+ },
1001
+ fetchChapterTips(id) {
1002
+ return get(`/chapters/${id}/tips`);
1003
+ },
1004
+ fetchComments(chapterId, page = 1, limit = 20) {
1005
+ return get(`/chapters/${chapterId}/comments?page=${page}&limit=${limit}`);
1006
+ },
1007
+ fetchNickname(address) {
1008
+ return get(`/users/${address}/nickname`);
1009
+ },
1010
+ fetchNicknamesBatch(addresses) {
1011
+ if (addresses.length === 0) {
1012
+ return Promise.resolve({ nicknames: {} });
1013
+ }
1014
+ return get(`/users/nicknames/batch?addresses=${addresses.join(",")}`);
1015
+ },
1016
+ fetchUserChapters(address) {
1017
+ return get(`/users/${address}/chapters`);
1018
+ },
1019
+ fetchUserVotes(address, page = 1) {
1020
+ return get(`/users/${address}/votes?page=${page}`);
1021
+ },
1022
+ fetchUserRewards(address) {
1023
+ return get(`/users/${address}/rewards`);
1024
+ },
1025
+ async postComment(chapterId, body) {
1026
+ const r = await post(`/chapters/${chapterId}/comments`, body);
1027
+ if (r.status === 201 && r.body)
1028
+ return { ok: true, comment: r.body };
1029
+ return {
1030
+ ok: false,
1031
+ status: r.status,
1032
+ error: r.body ? JSON.stringify(r.body) : ""
1033
+ };
1034
+ },
1035
+ async submitVotePlaintext(body) {
1036
+ const r = await post(`/votes/submit`, body);
1037
+ return { status: r.status, ok: r.status === 201 };
1038
+ }
1039
+ };
1040
+ }
1041
+
1042
+ // src/utils/api.ts
1043
+ var _client = null;
1044
+ function client() {
1045
+ if (!_client) {
1046
+ _client = createApiClient({ baseUrl: requireConfig().apiUrl });
1047
+ }
1048
+ return _client;
1049
+ }
1050
+ function apiGet(path) {
1051
+ const stripped = path.replace(/^\/api/, "");
1052
+ return client().get(stripped);
1053
+ }
1054
+ function apiPost(path, body) {
1055
+ const stripped = path.replace(/^\/api/, "");
1056
+ return client().post(stripped, body);
1057
+ }
1058
+ async function fetchNovelConfig(novelId) {
1059
+ const novel = await client().fetchNovel(novelId);
1060
+ const config = novel.config;
1061
+ if (!config) {
1062
+ throw new Error(
1063
+ `Novel #${novelId} has no config in backend response. Pass the fee explicitly via --value.`
1064
+ );
1065
+ }
1066
+ return { novel, config };
1067
+ }
1068
+
1069
+ // src/utils/client.ts
1070
+ import {
1071
+ createPublicClient as createPublicClient3,
1072
+ createWalletClient,
1073
+ defineChain,
1074
+ http as http3
1075
+ } from "viem";
1076
+ import { privateKeyToAccount } from "viem/accounts";
1077
+ function getChain() {
1078
+ const config = requireConfig();
1079
+ return defineChain({
1080
+ id: config.chainId,
1081
+ name: `Chain ${config.chainId}`,
1082
+ nativeCurrency: config.nativeCurrency,
1083
+ rpcUrls: { default: { http: [config.rpcUrl] } }
1084
+ });
1085
+ }
1086
+ function getPublicClient() {
1087
+ const config = requireConfig();
1088
+ return createPublicClient3({
1089
+ chain: getChain(),
1090
+ transport: http3(config.rpcUrl)
1091
+ });
1092
+ }
1093
+ function getWalletClient() {
1094
+ const config = requireConfig();
1095
+ const pk = getPrivateKey2();
1096
+ if (!pk) {
1097
+ console.error(
1098
+ "PRIVATE_KEY env var not set. The CLI never persists secrets; export it in your shell:\n export PRIVATE_KEY=0x...\nOr inject it with a secret manager (direnv, 1Password CLI, etc)."
1099
+ );
1100
+ process.exit(1);
1101
+ }
1102
+ const account = privateKeyToAccount(pk);
1103
+ return createWalletClient({
1104
+ account,
1105
+ chain: getChain(),
1106
+ transport: http3(config.rpcUrl)
1107
+ });
1108
+ }
1109
+ function getContracts() {
1110
+ const config = requireConfig();
1111
+ return config.contracts;
1112
+ }
1113
+ async function waitForTx(hash) {
1114
+ const client2 = getPublicClient();
1115
+ const receipt = await client2.waitForTransactionReceipt({ hash });
1116
+ if (receipt.status !== "success") {
1117
+ throw new Error(`Transaction ${hash} reverted`);
1118
+ }
1119
+ return receipt;
1120
+ }
1121
+
1122
+ // src/commands/bounty.ts
1123
+ function registerBountyCommands(program2) {
1124
+ const bounty = program2.command("bounty").description("Bounty commands");
1125
+ bounty.command("create <chapter-id>").description("Create a bounty to incentivize writing continuations").requiredOption("--value <eth>", "bounty amount in ETH").requiredOption("--deadline <duration>", "deadline from now (e.g., 7d, 24h, 30m)").action(async (chapterId, opts) => {
1126
+ try {
1127
+ const client2 = getWalletClient();
1128
+ const contracts = getContracts();
1129
+ const durationSeconds = parseDuration(opts.deadline);
1130
+ const deadlineTimestamp = BigInt(Math.floor(Date.now() / 1e3) + durationSeconds);
1131
+ const hash = await createBounty(client2, {
1132
+ chapterId: BigInt(chapterId),
1133
+ deadline: deadlineTimestamp,
1134
+ value: parseEther(opts.value),
1135
+ bountyBoard: contracts.bountyBoard
1136
+ });
1137
+ txHash(hash);
1138
+ await waitForTx(hash);
1139
+ success(
1140
+ `Bounty created for chapter ID.${chapterId} (${opts.value} ETH, deadline: ${opts.deadline})`
1141
+ );
1142
+ } catch (err) {
1143
+ error(String(err));
1144
+ process.exit(1);
1145
+ }
1146
+ });
1147
+ bounty.command("designate <bounty-id> <chapter-id>").description("Designate a preferred continuation for your bounty (before deadline)").action(async (bountyId, chapterId) => {
1148
+ try {
1149
+ const client2 = getWalletClient();
1150
+ const contracts = getContracts();
1151
+ const hash = await designateBounty(client2, {
1152
+ bountyId: BigInt(bountyId),
1153
+ chapterId: BigInt(chapterId),
1154
+ bountyBoard: contracts.bountyBoard
1155
+ });
1156
+ txHash(hash);
1157
+ await waitForTx(hash);
1158
+ success(`Bounty #${bountyId} designated chapter ID.${chapterId}`);
1159
+ } catch (err) {
1160
+ error(String(err));
1161
+ process.exit(1);
1162
+ }
1163
+ });
1164
+ bounty.command("list").description("List active bounties (earning opportunities)").option("--novel-id <id>", "Filter by novel ID").action(async (opts) => {
1165
+ try {
1166
+ const { nativeCurrency } = requireConfig();
1167
+ const url = opts.novelId ? `/api/bounties/active?novelId=${opts.novelId}` : "/api/bounties/active";
1168
+ const data = await apiGet(url);
1169
+ if (data.bounties.length === 0) {
1170
+ console.log("No active bounties found.");
1171
+ return;
1172
+ }
1173
+ header("Active Bounties");
1174
+ for (const b of data.bounties) {
1175
+ kv(`Bounty #${b.id}`, `Chapter ID.${b.chapter_id} (${b.novel_title})`);
1176
+ kv(" Locked", token(BigInt(String(b.locked_amount ?? "0")), nativeCurrency.decimals, nativeCurrency.symbol));
1177
+ if (b.create_time) kv(" Created", new Date(Number(b.create_time) * 1e3).toISOString());
1178
+ kv(" Deadline", new Date(Number(b.deadline) * 1e3).toISOString());
1179
+ if (Number(b.designated_chapter_id) > 0) {
1180
+ kv(" Designated", `Chapter ID.${b.designated_chapter_id}`);
1181
+ }
1182
+ console.log();
1183
+ }
1184
+ } catch (err) {
1185
+ error(String(err));
1186
+ process.exit(1);
1187
+ }
1188
+ });
1189
+ bounty.command("claim <bounty-id>").description("Claim bounty reward (for qualifying authors after deadline)").action(async (bountyId) => {
1190
+ try {
1191
+ const client2 = getWalletClient();
1192
+ const contracts = getContracts();
1193
+ const hash = await claimBounty(client2, BigInt(bountyId), contracts.bountyBoard);
1194
+ txHash(hash);
1195
+ await waitForTx(hash);
1196
+ success("Bounty claimed");
1197
+ } catch (err) {
1198
+ error(String(err));
1199
+ process.exit(1);
1200
+ }
1201
+ });
1202
+ bounty.command("refund <bounty-id>").description("Refund bounty (if no continuations were submitted before deadline)").action(async (bountyId) => {
1203
+ try {
1204
+ const client2 = getWalletClient();
1205
+ const contracts = getContracts();
1206
+ const hash = await refundBounty(client2, BigInt(bountyId), contracts.bountyBoard);
1207
+ txHash(hash);
1208
+ await waitForTx(hash);
1209
+ success("Bounty refunded");
1210
+ } catch (err) {
1211
+ error(String(err));
1212
+ process.exit(1);
1213
+ }
1214
+ });
1215
+ bounty.command("info <bounty-id>").description("Show bounty details").action(async (bountyId) => {
1216
+ try {
1217
+ const { nativeCurrency } = requireConfig();
1218
+ const data = await apiGet(`/api/bounties/${bountyId}`);
1219
+ header(`Bounty #${bountyId}`);
1220
+ kv("Chapter", `ID.${data.chapter_id} (${data.novel_title})`);
1221
+ kv("Tipper", data.tipper);
1222
+ kv("Locked Amount", token(BigInt(String(data.locked_amount ?? "0")), nativeCurrency.decimals, nativeCurrency.symbol));
1223
+ kv("Deadline", data.deadline);
1224
+ kv("Claimed", data.claimed ? "Yes" : "No");
1225
+ console.log();
1226
+ } catch (err) {
1227
+ error(String(err));
1228
+ process.exit(1);
1229
+ }
1230
+ });
1231
+ }
1232
+
1233
+ // src/commands/chapter.ts
1234
+ init_shared();
1235
+ import { existsSync as existsSync2, mkdirSync, writeFileSync } from "fs";
1236
+ import { join as join2 } from "path";
1237
+ import chalk3 from "chalk";
1238
+ import { decodeEventLog, parseAbiItem, parseEther as parseEther2 } from "viem";
1239
+
1240
+ // src/utils/content.ts
1241
+ import { readFileSync as readFileSync2, statSync } from "fs";
1242
+ import chalk2 from "chalk";
1243
+ function resolveContent(opts) {
1244
+ if (opts.content && opts.file) {
1245
+ throw new Error("Pass only one of --content or --file, not both.");
1246
+ }
1247
+ if (!opts.content && !opts.file) {
1248
+ throw new Error("Missing chapter content. Pass --content <text> or --file <path>.");
1249
+ }
1250
+ if (opts.file) {
1251
+ const st = statSync(opts.file);
1252
+ if (!st.isFile()) throw new Error(`--file ${opts.file} is not a regular file.`);
1253
+ return readFileSync2(opts.file, "utf-8");
1254
+ }
1255
+ return opts.content;
1256
+ }
1257
+ function warnIfOutOfRange(content, cfg) {
1258
+ if (!cfg) return;
1259
+ const bytes = Buffer.byteLength(content, "utf-8");
1260
+ const min = cfg.minChapterLength ? Number(cfg.minChapterLength) : 0;
1261
+ const max = cfg.maxChapterLength ? Number(cfg.maxChapterLength) : 0;
1262
+ if (min > 0 && bytes < min) {
1263
+ console.log(
1264
+ chalk2.yellow(
1265
+ ` \u26A0 content is ${bytes} bytes; novel requires \u2265 ${min}. Submission will revert.`
1266
+ )
1267
+ );
1268
+ }
1269
+ if (max > 0 && bytes > max) {
1270
+ console.log(
1271
+ chalk2.yellow(
1272
+ ` \u26A0 content is ${bytes} bytes; novel allows \u2264 ${max}. Submission will revert.`
1273
+ )
1274
+ );
1275
+ }
1276
+ }
1277
+
1278
+ // src/commands/chapter.ts
1279
+ function renderNotesSkeleton(id, depth, parentId) {
1280
+ const isRoot = String(parentId) === "0";
1281
+ const header2 = `# ID.${id} \u2014 ch${depth}, ${isRoot ? "root (no parent)" : `continues from ID.${parentId}`}`;
1282
+ const preamble = `<!-- This file is your STRUCTURED analysis of chapter ${id}. Filling it IS the act
1283
+ of understanding \u2014 skimming the raw content without writing notes means you have
1284
+ not analyzed this chapter.
1285
+
1286
+ Remove each <!-- TODO --> marker once you finish that section. A gate before
1287
+ drafting (Step 5) checks that every ancestor's notes have zero TODO markers. -->`;
1288
+ const happened = `## \u672C\u7AE0\u4E3B\u8981\u53D1\u751F\u4E86\u4EC0\u4E48
1289
+ <!-- TODO: 3-6 \u53E5\u5177\u4F53\u60C5\u8282\u3002
1290
+ \u2713 \u597D\uFF1A"A \u4E3A\u6551 B \u62D4\u67AA\u5C04\u4E2D\u4E86 C \u7684\u80A9\u8180\uFF0CC \u9000\u5374\u4F46\u8BA4\u51FA\u4E86 A \u6234\u7684\u5341\u5B57\u540A\u5760\u3002"
1291
+ \u2717 \u5DEE\uFF1A"\u5C55\u5F00\u4E86\u4E00\u573A\u7D27\u5F20\u7684\u5BF9\u51B3\u3002" -->`;
1292
+ const delta = isRoot ? `## \u521D\u59CB\u4E16\u754C\u8BBE\u5B9A
1293
+ <!-- TODO: \u65F6\u95F4 / \u5730\u70B9 / \u4E3B\u8981\u4EBA\u7269 / \u6743\u529B\u683C\u5C40 / \u6280\u672F\u6C34\u5E73 / \u6587\u5316\u7279\u5F81\u3002
1294
+ \u8FD9\u662F\u540E\u7EED\u6240\u6709\u7EED\u5199\u7684\u8D77\u70B9\uFF0C\u8981\u5177\u4F53\u3002 -->` : `## \u76F8\u5BF9 parent \u63A8\u8FDB\u4E86\u4EC0\u4E48
1295
+ <!-- TODO: \u72B6\u6001\u589E\u91CF\u3002\u4EFB\u9009\u9002\u7528\uFF1A
1296
+ - \u8C01\u7684\u4F4D\u7F6E / \u5904\u5883\u53D8\u4E86
1297
+ - \u8C01\u548C\u8C01\u7684\u5173\u7CFB\u53D8\u4E86
1298
+ - \u8C01\u77E5\u9053\u4E86\u4EC0\u4E48\u4E4B\u524D\u4E0D\u77E5\u9053\u7684\u4E8B
1299
+ - \u54EA\u4E2A\u60AC\u5FF5\u88AB\u89E3\u5F00 / \u52A0\u6DF1
1300
+ - \u4E16\u754C\u683C\u5C40\u6709\u4EC0\u4E48\u53D8\u5316 -->`;
1301
+ const newElements = `## \u65B0\u5F15\u5165\u7684\u5143\u7D20
1302
+ <!-- TODO: \u672C\u7AE0\u624D\u51FA\u73B0\u7684\uFF1A
1303
+ - \u65B0\u4EBA\u7269\uFF08\u540D\u5B57\u3001\u8EAB\u4EFD\u3001\u5173\u7CFB\uFF09
1304
+ - \u65B0\u5730\u70B9 / \u65B0\u7EC4\u7EC7 / \u65B0\u8BBE\u5B9A
1305
+ - \u65B0\u4F0F\u7B14
1306
+ \u82E5\u5B8C\u5168\u65E0\u65B0\u5143\u7D20\uFF0C\u76F4\u63A5\u5199"\u65E0"\u3002 -->`;
1307
+ const hooks = `## \u57CB\u4E0B / \u6536\u5272\u7684\u94A9\u5B50
1308
+ <!-- TODO:
1309
+ - \u57CB\u4E0B\uFF1A\u672C\u7AE0\u7559\u4E0B\u7684\u65B0\u60AC\u5FF5\u3002\u5177\u4F53\u4E9B\uFF0C\u4E0D\u8981"\u4E00\u4E2A\u795E\u79D8\u7684\u9884\u611F"\u3002
1310
+ - \u6536\u5272${isRoot ? "\uFF08root \u7AE0\u8282\u901A\u5E38\u65E0\u53EF\u6536\u5272\uFF0C\u7559\u7A7A\u6216\u5199'\u65E0'\uFF09" : ""}\uFF1A\u672C\u7AE0\u89E3\u7B54\u4E86 / \u63A8\u8FDB\u4E86\u54EA\u4E2A ancestor \u7684\u60AC\u5FF5\uFF1F\u6CE8\u660E ancestor chapterId\u3002 -->`;
1311
+ const voice = `## \u8BED\u6C14\u548C\u98CE\u683C\u7279\u5F81
1312
+ <!-- TODO: 1-2 \u53E5\uFF0C\u591F\u4E0B\u4E00\u4F4D\u4F5C\u8005\u5339\u914D voice \u5C31\u884C\u3002
1313
+ \u4F8B\uFF1A"\u7B2C\u4E09\u4EBA\u79F0\u9650\u77E5\u89C6\u89D2\uFF0C\u51B7\u5CFB\u514B\u5236\u7684\u77ED\u53E5\uFF0C\u5927\u91CF\u611F\u5B98\u7EC6\u8282\uFF0C\u5BF9\u8BDD\u4E0D\u7528\u5F15\u53F7\u3002" -->`;
1314
+ return [header2, preamble, happened, delta, newElements, hooks, voice].join("\n\n") + "\n";
1315
+ }
1316
+ function registerChapterCommands(program2) {
1317
+ const chapter = program2.command("chapter").description("Chapter commands");
1318
+ chapter.command("submit <novel-id> <parent-id>").description("Submit a new chapter").option("--content <text>", "chapter content (mutually exclusive with --file)").option("--file <path>", "read chapter content from a UTF-8 text file").option("--value <eth>", "submission fee in ETH (auto-detected from novel config if not set)").action(async (novelId, parentId, opts) => {
1319
+ try {
1320
+ const client2 = getWalletClient();
1321
+ const contracts = getContracts();
1322
+ const content = resolveContent(opts);
1323
+ let value;
1324
+ let novelConfig;
1325
+ if (opts.value) {
1326
+ value = parseEther2(opts.value);
1327
+ } else {
1328
+ const { config } = await fetchNovelConfig(novelId);
1329
+ novelConfig = config;
1330
+ value = BigInt(config.submissionFee ?? "0");
1331
+ }
1332
+ warnIfOutOfRange(content, novelConfig);
1333
+ const submission = buildContentSubmission(content);
1334
+ const hash = await submitChapter(client2, {
1335
+ novelId: BigInt(novelId),
1336
+ parentId: BigInt(parentId),
1337
+ submission,
1338
+ value,
1339
+ novelCore: contracts.novelCore
1340
+ });
1341
+ txHash(hash);
1342
+ const receipt = await waitForTx(hash);
1343
+ const chapterSubmittedTopic = parseAbiItem(
1344
+ "event ChapterSubmitted(uint64 indexed novelId, uint64 indexed chapterId, address indexed author, uint64 parentId, uint32 depth)"
1345
+ );
1346
+ let newChapterId;
1347
+ for (const log of receipt.logs) {
1348
+ try {
1349
+ const decoded = decodeEventLog({ abi: [chapterSubmittedTopic], data: log.data, topics: log.topics });
1350
+ if (decoded.eventName === "ChapterSubmitted") {
1351
+ newChapterId = decoded.args.chapterId;
1352
+ break;
1353
+ }
1354
+ } catch {
1355
+ }
1356
+ }
1357
+ if (newChapterId !== void 0) {
1358
+ kv("Chapter ID", newChapterId.toString());
1359
+ }
1360
+ success("Chapter submitted");
1361
+ const cfg = requireConfig();
1362
+ const frontendUrl = newChapterId !== void 0 ? `${cfg.frontUrl}/novels/${novelId}/chapter/${newChapterId}` : `${cfg.frontUrl}/novels/${novelId}`;
1363
+ const explorerUrl = `${cfg.chainExplorer}/tx/${hash}`;
1364
+ console.log(chalk3.blue(` Frontend: ${frontendUrl}`));
1365
+ console.log(chalk3.blue(` Explorer: ${explorerUrl}`));
1366
+ } catch (err) {
1367
+ error(String(err));
1368
+ process.exit(1);
1369
+ }
1370
+ });
1371
+ chapter.command("read <chapter-id>").description("Read a chapter's details and content").action(async (chapterId) => {
1372
+ try {
1373
+ const data = await apiGet(`/api/chapters/${chapterId}`);
1374
+ header(`Chapter ID.${chapterId}`);
1375
+ kv("Novel", `${data.novel_title} (#${data.novel_id})`);
1376
+ kv("Author", data.author);
1377
+ kv("Parent", `ID.${data.parent_id}`);
1378
+ kv("Depth", data.depth);
1379
+ kv("World Line", data.is_world_line ? "Yes" : "No");
1380
+ kv("Length", data.declared_length);
1381
+ kv("Timestamp", data.timestamp);
1382
+ kv("Content Hash", data.content_hash);
1383
+ if (data.content_text) {
1384
+ console.log(chalk3.gray("\n\u2500\u2500\u2500 Content \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1385
+ console.log(String(data.content_text));
1386
+ console.log(chalk3.gray("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
1387
+ } else if (data.content_fetched === false) {
1388
+ console.log(chalk3.yellow("\n Content not yet indexed.\n"));
1389
+ }
1390
+ } catch (err) {
1391
+ error(String(err));
1392
+ process.exit(1);
1393
+ }
1394
+ });
1395
+ chapter.command("tree <novel-id>").description("Show the chapter tree of a novel").action(async (novelId) => {
1396
+ try {
1397
+ let printTree2 = function(id, prefix, isLast) {
1398
+ const ch = byId.get(id);
1399
+ const connector = isLast ? "\\-- " : "|-- ";
1400
+ const wl = ch.is_world_line ? chalk3.green(" *") : "";
1401
+ const authorShort = String(ch.author ?? "").slice(0, 8);
1402
+ console.log(`${prefix}${connector}ID.${ch.id} (d=${ch.depth}) by ${authorShort}..${wl}`);
1403
+ const kids = children.get(id) ?? [];
1404
+ for (let i = 0; i < kids.length; i++) {
1405
+ const childPrefix = prefix + (isLast ? " " : "| ");
1406
+ printTree2(kids[i], childPrefix, i === kids.length - 1);
1407
+ }
1408
+ };
1409
+ var printTree = printTree2;
1410
+ const data = await apiGet(
1411
+ `/api/novels/${novelId}/tree`
1412
+ );
1413
+ header(`Chapter Tree \u2014 Novel #${novelId}`);
1414
+ if (data.chapters.length === 0) {
1415
+ console.log(chalk3.gray(" (no chapters)"));
1416
+ return;
1417
+ }
1418
+ const byId = /* @__PURE__ */ new Map();
1419
+ const children = /* @__PURE__ */ new Map();
1420
+ for (const ch of data.chapters) {
1421
+ const id = String(ch.id);
1422
+ byId.set(id, ch);
1423
+ const parentId = String(ch.parent_id);
1424
+ if (!children.has(parentId)) children.set(parentId, []);
1425
+ children.get(parentId).push(id);
1426
+ }
1427
+ const roots = data.chapters.filter(
1428
+ (ch) => String(ch.parent_id) === "0" || !byId.has(String(ch.parent_id))
1429
+ );
1430
+ for (let i = 0; i < roots.length; i++) {
1431
+ printTree2(String(roots[i].id), " ", i === roots.length - 1);
1432
+ }
1433
+ console.log(chalk3.gray("\n * = world line\n"));
1434
+ } catch (err) {
1435
+ error(String(err));
1436
+ process.exit(1);
1437
+ }
1438
+ });
1439
+ chapter.command("children <chapter-id>").description("Show direct children of a chapter").action(async (chapterId) => {
1440
+ try {
1441
+ const data = await apiGet(
1442
+ `/api/chapters/${chapterId}/children`
1443
+ );
1444
+ header(`Children of Chapter ID.${chapterId}`);
1445
+ table(
1446
+ data.children.map((ch) => ({
1447
+ ID: ch.id,
1448
+ Author: String(ch.author ?? "").slice(0, 12) + "...",
1449
+ Depth: ch.depth,
1450
+ "World Line": ch.is_world_line ? "Yes" : "No",
1451
+ Length: ch.declared_length
1452
+ }))
1453
+ );
1454
+ console.log();
1455
+ } catch (err) {
1456
+ error(String(err));
1457
+ process.exit(1);
1458
+ }
1459
+ });
1460
+ chapter.command("context <chapter-id>").description(
1461
+ "Fetch the full ancestor chain (root \u2192 target). Essential for evaluating a candidate chapter before voting or continuing the story \u2014 gives you the complete narrative so far."
1462
+ ).option("--max-depth <n>", "max ancestors to walk back", "100").option("--summary", "print only metadata; skip full content").option(
1463
+ "--cache <dir>",
1464
+ "also write each ancestor's content to <dir>/<id>.md and a TODO notes skeleton to <dir>/<id>-ch<depth>-<parentId>.md. Existing files are NOT overwritten."
1465
+ ).action(async (chapterId, opts) => {
1466
+ try {
1467
+ const maxDepth = Math.min(parseInt(opts.maxDepth) || 100, 200);
1468
+ const data = await apiGet(
1469
+ `/api/chapters/${chapterId}/context?maxDepth=${maxDepth}`
1470
+ );
1471
+ header(`Context for Chapter ID.${chapterId}`);
1472
+ kv("Ancestors", data.ancestors.length);
1473
+ if (opts.cache) {
1474
+ mkdirSync(opts.cache, { recursive: true });
1475
+ let wrote = 0;
1476
+ let skipped = 0;
1477
+ let skeletonsAdded = 0;
1478
+ for (const a of data.ancestors) {
1479
+ const id = String(a.id);
1480
+ const depth = String(a.depth);
1481
+ const parentId = String(a.parent_id ?? "0");
1482
+ const contentPath = join2(opts.cache, `${id}.md`);
1483
+ if (!existsSync2(contentPath)) {
1484
+ if (a.content_text) {
1485
+ writeFileSync(contentPath, String(a.content_text));
1486
+ wrote++;
1487
+ }
1488
+ } else {
1489
+ skipped++;
1490
+ }
1491
+ const notesPath = join2(opts.cache, `${id}-ch${depth}-${parentId}.md`);
1492
+ if (!existsSync2(notesPath)) {
1493
+ writeFileSync(notesPath, renderNotesSkeleton(id, depth, parentId));
1494
+ skeletonsAdded++;
1495
+ }
1496
+ }
1497
+ kv("Cached content", `${wrote} new, ${skipped} existing (preserved)`);
1498
+ kv("Notes skeletons", `${skeletonsAdded} new (TODO \u2014 fill before drafting)`);
1499
+ kv("Cache dir", opts.cache);
1500
+ }
1501
+ if (opts.summary) {
1502
+ table(
1503
+ data.ancestors.map((a) => ({
1504
+ Chapter: `ID.${a.id}`,
1505
+ Depth: a.depth,
1506
+ Author: String(a.author ?? "").slice(0, 12) + "...",
1507
+ WorldLine: a.is_world_line ? "yes" : "no",
1508
+ Fetched: a.content_fetched ? "yes" : "no"
1509
+ }))
1510
+ );
1511
+ console.log();
1512
+ return;
1513
+ }
1514
+ for (const a of data.ancestors) {
1515
+ console.log(
1516
+ chalk3.gray(`
1517
+ \u2500\u2500\u2500 Chapter ID.${a.id} (depth ${a.depth}, by ${String(a.author ?? "").slice(0, 10)}...) \u2500\u2500\u2500`)
1518
+ );
1519
+ if (a.content_text) {
1520
+ console.log(String(a.content_text));
1521
+ } else if (a.content_fetched === false) {
1522
+ console.log(chalk3.yellow(" (content not yet indexed)"));
1523
+ } else {
1524
+ console.log(chalk3.gray(" (no content)"));
1525
+ }
1526
+ }
1527
+ console.log();
1528
+ } catch (err) {
1529
+ error(String(err));
1530
+ process.exit(1);
1531
+ }
1532
+ });
1533
+ chapter.command("comments <chapter-id>").description("List comments on a chapter").option("--page <n>", "page number", "1").option("--limit <n>", "results per page", "20").action(async (chapterId, opts) => {
1534
+ try {
1535
+ const data = await apiGet(
1536
+ `/api/chapters/${chapterId}/comments?page=${opts.page}&limit=${opts.limit}`
1537
+ );
1538
+ header(`Comments on Chapter ID.${chapterId}`);
1539
+ if (data.comments.length === 0) {
1540
+ console.log(chalk3.gray(" (no comments)\n"));
1541
+ return;
1542
+ }
1543
+ for (const c of data.comments) {
1544
+ console.log(
1545
+ `${chalk3.cyan(String(c.author ?? "").slice(0, 12) + "...")} ` + chalk3.gray(`#${c.id} ${c.created_at}`)
1546
+ );
1547
+ console.log(` ${c.content}
1548
+ `);
1549
+ }
1550
+ } catch (err) {
1551
+ error(String(err));
1552
+ process.exit(1);
1553
+ }
1554
+ });
1555
+ chapter.command("comment <chapter-id> <content>").description("Post an off-chain comment (EIP-191 signed, no on-chain tx)").action(async (chapterId, content) => {
1556
+ try {
1557
+ const client2 = getWalletClient();
1558
+ const address = client2.account.address;
1559
+ const ts = Math.floor(Date.now() / 1e3);
1560
+ const message = `Comment on chapter ${chapterId} at ${ts}: ${content}`;
1561
+ const signature = await client2.signMessage({ account: client2.account, message });
1562
+ const result = await apiPost(`/api/chapters/${chapterId}/comments`, {
1563
+ address,
1564
+ content,
1565
+ timestamp: ts,
1566
+ signature
1567
+ });
1568
+ if (result.status === 201 && result.body?.id) {
1569
+ success(`Comment posted (id=${result.body.id})`);
1570
+ } else {
1571
+ error(`Backend rejected comment (status ${result.status})`);
1572
+ process.exit(1);
1573
+ }
1574
+ } catch (err) {
1575
+ error(String(err));
1576
+ process.exit(1);
1577
+ }
1578
+ });
1579
+ }
1580
+
1581
+ // src/commands/config.ts
1582
+ function registerConfigCommand(program2) {
1583
+ program2.command("config").description(
1584
+ "Show current configuration. Edit config.yaml (repo root) directly to change values. Secrets (PRIVATE_KEY, KEEPER_PRIVATE_KEY, VOTE_ENCRYPTION_KEY) stay in env vars."
1585
+ ).action(async () => {
1586
+ try {
1587
+ const { config, chainId, contracts } = await bootstrapConfig();
1588
+ header("Configuration");
1589
+ kv("chain.rpcUrl", config.chain.rpcUrl);
1590
+ kv("chain.chainId", chainId);
1591
+ kv("cli.apiUrl", config.cli.apiUrl);
1592
+ kv("PRIVATE_KEY env", process.env.PRIVATE_KEY ? "(set)" : "(not set)");
1593
+ kv("contracts.novelCore", contracts.novelCore);
1594
+ kv("contracts.roundManager", contracts.roundManager);
1595
+ kv("contracts.votingEngine", contracts.votingEngine);
1596
+ kv("contracts.prizePool", contracts.prizePool);
1597
+ kv("contracts.bountyBoard", contracts.bountyBoard);
1598
+ kv("contracts.rulesEngine", contracts.rulesEngine);
1599
+ kv("contracts.userRegistry", contracts.userRegistry);
1600
+ console.log();
1601
+ } catch (err) {
1602
+ error(String(err));
1603
+ process.exit(1);
1604
+ }
1605
+ });
1606
+ }
1607
+
1608
+ // src/commands/faucet.ts
1609
+ import { isAddress } from "viem";
1610
+ import { privateKeyToAccount as privateKeyToAccount2 } from "viem/accounts";
1611
+ function resolveSelfAddress() {
1612
+ const pk = getPrivateKey2();
1613
+ if (!pk) {
1614
+ error(
1615
+ "No --address given and PRIVATE_KEY env not set. Either pass --address 0x... or export PRIVATE_KEY."
1616
+ );
1617
+ process.exit(1);
1618
+ }
1619
+ return privateKeyToAccount2(pk).address;
1620
+ }
1621
+ function formatHms(ms) {
1622
+ const total = Math.max(0, Math.floor(ms / 1e3));
1623
+ const h = Math.floor(total / 3600);
1624
+ const m = Math.floor(total % 3600 / 60);
1625
+ const s = total % 60;
1626
+ return `${h}h${m}m${s}s`;
1627
+ }
1628
+ function registerFaucetCommands(program2) {
1629
+ const faucet = program2.command("faucet").description("Testnet faucet \u2014 claim native tokens to fund chain commands");
1630
+ faucet.command("claim").description(
1631
+ "Claim 10 native tokens from the backend faucet. One claim per address per local day. Defaults to the PRIVATE_KEY wallet."
1632
+ ).option("--address <addr>", "address to fund (defaults to PRIVATE_KEY wallet)").action(async (opts) => {
1633
+ try {
1634
+ let target;
1635
+ if (opts.address) {
1636
+ if (!isAddress(opts.address)) {
1637
+ error(`Not a valid address: ${opts.address}`);
1638
+ process.exit(1);
1639
+ }
1640
+ target = opts.address;
1641
+ } else {
1642
+ target = resolveSelfAddress();
1643
+ }
1644
+ header("Faucet \u2014 claim");
1645
+ kv("Address", target);
1646
+ const { status, body } = await apiPost("/api/faucet/claim", {
1647
+ address: target
1648
+ });
1649
+ if (body?.txHash) {
1650
+ txHash(body.txHash);
1651
+ success(`Sent ${body.amount} ${body.symbol} to ${body.to}`);
1652
+ return;
1653
+ }
1654
+ const msg = body?.error ?? `HTTP ${status}`;
1655
+ error(
1656
+ status === 429 && body?.nextResetMs ? `${msg} (resets in ${formatHms(body.nextResetMs)})` : msg
1657
+ );
1658
+ process.exit(1);
1659
+ } catch (err) {
1660
+ error(String(err));
1661
+ process.exit(1);
1662
+ }
1663
+ });
1664
+ }
1665
+
1666
+ // src/commands/guide.ts
1667
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
1668
+ import { dirname as dirname2, join as join3 } from "path";
1669
+ import { fileURLToPath } from "url";
1670
+ function getSkillPath() {
1671
+ const here = dirname2(fileURLToPath(import.meta.url));
1672
+ return existsSync3(join3(here, "guides")) ? join3(here, "guides", "SKILL.md") : join3(here, "..", "guides", "SKILL.md");
1673
+ }
1674
+ function registerGuideCommands(program2) {
1675
+ program2.command("guide").description("Print the agent workflow guide (covers reader / voter / author / creator)").action(() => {
1676
+ try {
1677
+ console.log(readFileSync3(getSkillPath(), "utf-8"));
1678
+ } catch (err) {
1679
+ error(`Could not load guide. ${String(err)}`);
1680
+ process.exit(1);
1681
+ }
1682
+ });
1683
+ }
1684
+
1685
+ // src/commands/novel.ts
1686
+ init_shared();
1687
+ import { parseEther as parseEther3 } from "viem";
1688
+ function buildNovelConfig(opts) {
1689
+ return {
1690
+ minChapterLength: BigInt(opts.minLength ?? "1000"),
1691
+ maxChapterLength: BigInt(opts.maxLength ?? "50000"),
1692
+ submissionFee: parseEther3(opts.submissionFee ?? "0.001"),
1693
+ worldLineCount: parseInt(opts.worldLines ?? "3"),
1694
+ voteStake: parseEther3(opts.voteStake ?? "0.001"),
1695
+ nominationFee: parseEther3(opts.nominationFee ?? "0.1"),
1696
+ nominateDuration: BigInt(opts.nominateDuration ?? "3600"),
1697
+ commitDuration: BigInt(opts.commitDuration ?? "3600"),
1698
+ revealDuration: BigInt(opts.revealDuration ?? "3600"),
1699
+ minRoundGap: BigInt(opts.minRoundGap ?? "60"),
1700
+ prizeReleaseRate: parseInt(opts.prizeReleaseRate ?? "2000"),
1701
+ voterRewardRate: parseInt(opts.voterRewardRate ?? "500"),
1702
+ contentLocation: parseInt(opts.contentLocation ?? "0"),
1703
+ contentBaseUrl: opts.contentBaseUrl ?? "",
1704
+ ruleFee: parseEther3(opts.ruleFee ?? "0.01"),
1705
+ ruleVoteDuration: BigInt(opts.ruleVoteDuration ?? "86400"),
1706
+ ruleQuorum: parseInt(opts.ruleQuorum ?? "3")
1707
+ };
1708
+ }
1709
+ function buildMetadata(opts) {
1710
+ return {
1711
+ title: opts.title ?? "Untitled Novel",
1712
+ description: opts.description ?? "",
1713
+ coverUri: opts.coverUri ?? ""
1714
+ };
1715
+ }
1716
+ function registerNovelCommands(program2) {
1717
+ const novel = program2.command("novel").description("Novel management commands");
1718
+ novel.command("create").description("Create a new novel with a root chapter").option("--title <text>", "novel title", "Untitled Novel").option("--description <text>", "novel description", "").option("--cover-uri <uri>", "cover image URI", "").option("--min-length <n>", "min chapter length", "100").option("--max-length <n>", "max chapter length", "50000").option("--submission-fee <eth>", "submission fee in ETH", "0.001").option("--world-lines <n>", "world line count", "3").option("--vote-stake <eth>", "vote stake in ETH (must be <= submission fee)", "0.001").option("--nomination-fee <eth>", "nomination fee in ETH", "0.01").option("--nominate-duration <s>", "nominate duration in seconds", "3600").option("--commit-duration <s>", "commit duration in seconds", "3600").option("--reveal-duration <s>", "reveal duration in seconds", "3600").option("--min-round-gap <s>", "min round gap in seconds", "60").option("--prize-release-rate <bps>", "prize release rate in basis points", "2000").option("--voter-reward-rate <bps>", "voter reward rate in basis points", "1500").option("--content-location <n>", "0=Onchain, 1=External, 2=HTTP", "0").option("--content-base-url <url>", "content base URL (for External/HTTP)", "").option("--rule-fee <eth>", "rule proposal fee in ETH", "0.01").option("--rule-vote-duration <s>", "rule vote duration in seconds", "86400").option("--rule-quorum <n>", "rule quorum", "3").option("--content <text>", "root chapter content (mutually exclusive with --file)").option("--file <path>", "read root chapter content from a UTF-8 text file").option("--value <eth>", "genesis fund in ETH", "0").action(async (opts) => {
1719
+ try {
1720
+ const client2 = getWalletClient();
1721
+ const contracts = getContracts();
1722
+ const config = buildNovelConfig(opts);
1723
+ const metadata = buildMetadata(opts);
1724
+ const content = resolveContent(opts);
1725
+ warnIfOutOfRange(content, {
1726
+ minChapterLength: config.minChapterLength.toString(),
1727
+ maxChapterLength: config.maxChapterLength.toString()
1728
+ });
1729
+ const submission = buildContentSubmission(content);
1730
+ const submissionFee = config.submissionFee;
1731
+ const extraValue = parseEther3(opts.value ?? "0");
1732
+ const totalValue = submissionFee + extraValue;
1733
+ const hash = await createNovel(client2, {
1734
+ config,
1735
+ metadata,
1736
+ rootChapter: submission,
1737
+ value: totalValue,
1738
+ novelCore: contracts.novelCore
1739
+ });
1740
+ txHash(hash);
1741
+ await waitForTx(hash);
1742
+ success("Novel created successfully");
1743
+ } catch (err) {
1744
+ error(String(err));
1745
+ process.exit(1);
1746
+ }
1747
+ });
1748
+ novel.command("info <id>").description("Show novel details").action(async (id) => {
1749
+ try {
1750
+ const { nativeCurrency } = requireConfig();
1751
+ const data = await apiGet(`/api/novels/${id}`);
1752
+ header(`Novel #${id}`);
1753
+ kv("Title", data.title);
1754
+ kv("Creator", data.creator);
1755
+ kv("Description", data.description || "(none)");
1756
+ kv("Active", data.active);
1757
+ kv("Current Round", data.current_round);
1758
+ kv("Round Phase", roundPhaseName(Number(data.round_phase)));
1759
+ kv("Pool Balance", token(BigInt(String(data.pool_balance ?? "0")), nativeCurrency.decimals, nativeCurrency.symbol));
1760
+ kv("Total Tipped", token(BigInt(String(data.total_tipped ?? "0")), nativeCurrency.decimals, nativeCurrency.symbol));
1761
+ kv("Chapters", data.chapter_count);
1762
+ kv("Authors", data.author_count);
1763
+ kv("Views", data.view_count);
1764
+ kv("Created", data.created_at);
1765
+ console.log();
1766
+ } catch (err) {
1767
+ error(String(err));
1768
+ process.exit(1);
1769
+ }
1770
+ });
1771
+ novel.command("list").description("List novels").option("--sort <field>", "sort by: hot, pool, tipped, active, latest", "latest").option("--limit <n>", "results per page", "10").option("--page <n>", "page number", "1").option("--filter <status>", "filter by: active, completed").option(
1772
+ "--search <query>",
1773
+ "search by title/description substring, novel id, or creator address (0x...)"
1774
+ ).action(async (opts) => {
1775
+ try {
1776
+ const { nativeCurrency } = requireConfig();
1777
+ const params = new URLSearchParams();
1778
+ params.set("sort", opts.sort);
1779
+ params.set("limit", opts.limit);
1780
+ params.set("page", opts.page);
1781
+ if (opts.filter) params.set("filter", opts.filter);
1782
+ if (opts.search) params.set("search", opts.search);
1783
+ const data = await apiGet(`/api/novels?${params.toString()}`);
1784
+ header("Novels");
1785
+ table(
1786
+ data.novels.map((n) => ({
1787
+ ID: n.id,
1788
+ Title: String(n.title ?? "").slice(0, 30),
1789
+ Creator: String(n.creator ?? "").slice(0, 10) + "...",
1790
+ Active: n.active ? "Yes" : "No",
1791
+ Round: n.current_round,
1792
+ Chapters: n.chapter_count,
1793
+ Pool: token(BigInt(String(n.pool_balance ?? "0")), nativeCurrency.decimals, nativeCurrency.symbol)
1794
+ }))
1795
+ );
1796
+ const p = data.pagination;
1797
+ console.log(`
1798
+ Page ${p.page}/${p.totalPages} (${p.total} total)
1799
+ `);
1800
+ } catch (err) {
1801
+ error(String(err));
1802
+ process.exit(1);
1803
+ }
1804
+ });
1805
+ novel.command("fork <chapter-id>").description("Fork a novel from a specific chapter").option("--title <text>", "novel title", "Untitled Fork").option("--description <text>", "novel description", "").option("--cover-uri <uri>", "cover image URI", "").option("--min-length <n>", "min chapter length", "100").option("--max-length <n>", "max chapter length", "50000").option("--submission-fee <eth>", "submission fee in ETH", "0.001").option("--world-lines <n>", "world line count", "3").option("--vote-stake <eth>", "vote stake in ETH (must be <= submission fee)", "0.001").option("--nomination-fee <eth>", "nomination fee in ETH", "0.01").option("--nominate-duration <s>", "nominate duration in seconds", "3600").option("--commit-duration <s>", "commit duration in seconds", "3600").option("--reveal-duration <s>", "reveal duration in seconds", "3600").option("--min-round-gap <s>", "min round gap in seconds", "60").option("--prize-release-rate <bps>", "prize release rate in basis points", "2000").option("--voter-reward-rate <bps>", "voter reward rate in basis points", "1500").option("--content-location <n>", "0=Onchain, 1=External, 2=HTTP", "0").option("--content-base-url <url>", "content base URL", "").option("--rule-fee <eth>", "rule proposal fee in ETH", "0.01").option("--rule-vote-duration <s>", "rule vote duration in seconds", "86400").option("--rule-quorum <n>", "rule quorum", "3").option("--content <text>", "fork root chapter content (mutually exclusive with --file)").option("--file <path>", "read fork root chapter content from a UTF-8 text file").option("--value <eth>", "genesis fund in ETH", "0").action(async (chapterId, opts) => {
1806
+ try {
1807
+ const client2 = getWalletClient();
1808
+ const contracts = getContracts();
1809
+ const config = buildNovelConfig(opts);
1810
+ const metadata = buildMetadata(opts);
1811
+ const content = resolveContent(opts);
1812
+ warnIfOutOfRange(content, {
1813
+ minChapterLength: config.minChapterLength.toString(),
1814
+ maxChapterLength: config.maxChapterLength.toString()
1815
+ });
1816
+ const submission = buildContentSubmission(content);
1817
+ const extraValue = parseEther3(opts.value ?? "0");
1818
+ const totalValue = config.submissionFee + extraValue;
1819
+ const hash = await forkNovel(client2, {
1820
+ sourceChapterId: BigInt(chapterId),
1821
+ config,
1822
+ metadata,
1823
+ rootChapter: submission,
1824
+ value: totalValue,
1825
+ novelCore: contracts.novelCore
1826
+ });
1827
+ txHash(hash);
1828
+ await waitForTx(hash);
1829
+ success("Novel forked successfully");
1830
+ } catch (err) {
1831
+ error(String(err));
1832
+ process.exit(1);
1833
+ }
1834
+ });
1835
+ novel.command("update-metadata <id>").description("Update novel title / description / coverUri (creator only)").option("--title <text>", "new title").option("--description <text>", "new description").option("--cover-uri <uri>", "new cover image URI").action(async (id, opts) => {
1836
+ try {
1837
+ if (!opts.title && !opts.description && opts.coverUri === void 0) {
1838
+ error("Pass at least one of --title, --description, --cover-uri.");
1839
+ return process.exit(1);
1840
+ }
1841
+ const client2 = getWalletClient();
1842
+ const contracts = getContracts();
1843
+ const current = await apiGet(`/api/novels/${id}`);
1844
+ const metadata = {
1845
+ title: opts.title ?? String(current.title ?? ""),
1846
+ description: opts.description ?? String(current.description ?? ""),
1847
+ coverUri: opts.coverUri ?? String(current.cover_uri ?? "")
1848
+ };
1849
+ const hash = await updateNovelMetadata(client2, {
1850
+ novelId: BigInt(id),
1851
+ metadata,
1852
+ novelCore: contracts.novelCore
1853
+ });
1854
+ txHash(hash);
1855
+ await waitForTx(hash);
1856
+ success("Novel metadata updated");
1857
+ } catch (err) {
1858
+ error(String(err));
1859
+ process.exit(1);
1860
+ }
1861
+ });
1862
+ novel.command("complete <id>").description(
1863
+ "Complete a novel (creator / keeper / owner, or anyone after inactivity timeout). Final-path author derivation happens fully on-chain via NovelCore.collectPathAuthors."
1864
+ ).action(async (id) => {
1865
+ try {
1866
+ const client2 = getWalletClient();
1867
+ const contracts = getContracts();
1868
+ const hash = await completeNovel(client2, {
1869
+ novelId: BigInt(id),
1870
+ roundManager: contracts.roundManager
1871
+ });
1872
+ txHash(hash);
1873
+ await waitForTx(hash);
1874
+ success("Novel completed");
1875
+ } catch (err) {
1876
+ error(String(err));
1877
+ process.exit(1);
1878
+ }
1879
+ });
1880
+ }
1881
+
1882
+ // src/commands/rule.ts
1883
+ init_shared();
1884
+ import chalk4 from "chalk";
1885
+ import { parseEther as parseEther4 } from "viem";
1886
+ function registerRuleCommands(program2) {
1887
+ const rule = program2.command("rule").description("World-building rules commands");
1888
+ rule.command("list <novel-id>").description("List all rules for a novel").action(async (novelId) => {
1889
+ try {
1890
+ const client2 = getPublicClient();
1891
+ const rulesEngine = getContracts().rulesEngine;
1892
+ const names = await getRuleNames(client2, BigInt(novelId), rulesEngine);
1893
+ header(`Rules \u2014 Novel #${novelId}`);
1894
+ if (names.length === 0) {
1895
+ console.log(chalk4.gray(" (no rules set)"));
1896
+ console.log();
1897
+ return;
1898
+ }
1899
+ for (const name of names) {
1900
+ const content = await getRule(client2, BigInt(novelId), name, rulesEngine);
1901
+ console.log(chalk4.bold(` ${name}:`));
1902
+ console.log(` ${content}`);
1903
+ console.log();
1904
+ }
1905
+ } catch (err) {
1906
+ error(String(err));
1907
+ process.exit(1);
1908
+ }
1909
+ });
1910
+ rule.command("set <novel-id> <name> <content>").description("Set a creator rule (only before first round, creator only)").action(async (novelId, name, content) => {
1911
+ try {
1912
+ const client2 = getWalletClient();
1913
+ const rulesEngine = getContracts().rulesEngine;
1914
+ const hash = await setCreatorRules(client2, {
1915
+ novelId: BigInt(novelId),
1916
+ names: [name],
1917
+ contents: [content],
1918
+ rulesEngine
1919
+ });
1920
+ txHash(hash);
1921
+ await waitForTx(hash);
1922
+ success(`Rule "${name}" set`);
1923
+ } catch (err) {
1924
+ error(String(err));
1925
+ process.exit(1);
1926
+ }
1927
+ });
1928
+ rule.command("propose <novel-id> <action> <name> <chapter-id> [content]").description(
1929
+ "Propose adding or deleting a rule (action: add|delete). <chapter-id> is one of your authored chapters that's currently on a world line \u2014 the path proof is computed automatically."
1930
+ ).option("--value <eth>", "rule proposal fee in ETH").action(async (novelId, action, name, chapterId, content, opts) => {
1931
+ try {
1932
+ if (action !== "add" && action !== "delete") {
1933
+ error("Action must be 'add' or 'delete'");
1934
+ return process.exit(1);
1935
+ }
1936
+ if (action === "add" && !content) {
1937
+ error("Content is required for 'add' proposals");
1938
+ return process.exit(1);
1939
+ }
1940
+ const wallet = getWalletClient();
1941
+ const pub = getPublicClient();
1942
+ const contracts = getContracts();
1943
+ const rulesEngine = contracts.rulesEngine;
1944
+ let value;
1945
+ if (opts.value) {
1946
+ value = parseEther4(opts.value);
1947
+ } else {
1948
+ const { config } = await fetchNovelConfig(novelId);
1949
+ value = BigInt(config.ruleFee ?? "0");
1950
+ }
1951
+ const path = await buildWorldLineProof(
1952
+ pub,
1953
+ contracts.novelCore,
1954
+ BigInt(novelId),
1955
+ BigInt(chapterId)
1956
+ );
1957
+ if (!path) {
1958
+ error(`Chapter ID.${chapterId} is not currently on any world line of novel #${novelId}.`);
1959
+ return process.exit(1);
1960
+ }
1961
+ const proposalType = action === "add" ? 0 : 1;
1962
+ const hash = await proposeRule(wallet, {
1963
+ novelId: BigInt(novelId),
1964
+ proposalType,
1965
+ ruleName: name,
1966
+ ruleContent: content ?? "",
1967
+ path,
1968
+ value,
1969
+ rulesEngine
1970
+ });
1971
+ txHash(hash);
1972
+ await waitForTx(hash);
1973
+ success(`Rule proposal created: ${action} "${name}"`);
1974
+ } catch (err) {
1975
+ error(String(err));
1976
+ process.exit(1);
1977
+ }
1978
+ });
1979
+ rule.command("vote <proposal-id> <chapter-id>").description(
1980
+ "Vote on a rule proposal. <chapter-id> is one of your authored chapters that's currently on a world line \u2014 the path proof is computed automatically."
1981
+ ).action(async (proposalId, chapterId) => {
1982
+ try {
1983
+ const wallet = getWalletClient();
1984
+ const pub = getPublicClient();
1985
+ const contracts = getContracts();
1986
+ const rulesEngine = contracts.rulesEngine;
1987
+ const proposal = await getRuleProposal(pub, BigInt(proposalId), rulesEngine);
1988
+ const path = await buildWorldLineProof(
1989
+ pub,
1990
+ contracts.novelCore,
1991
+ proposal.novelId,
1992
+ BigInt(chapterId)
1993
+ );
1994
+ if (!path) {
1995
+ error(
1996
+ `Chapter ID.${chapterId} is not currently on any world line of novel #${proposal.novelId}.`
1997
+ );
1998
+ return process.exit(1);
1999
+ }
2000
+ const hash = await voteOnRuleProposal(wallet, {
2001
+ proposalId: BigInt(proposalId),
2002
+ path,
2003
+ rulesEngine
2004
+ });
2005
+ txHash(hash);
2006
+ await waitForTx(hash);
2007
+ success("Voted on rule proposal");
2008
+ } catch (err) {
2009
+ error(String(err));
2010
+ process.exit(1);
2011
+ }
2012
+ });
2013
+ rule.command("proposal <proposal-id>").description("Show details of a rule proposal").action(async (proposalId) => {
2014
+ try {
2015
+ const client2 = getPublicClient();
2016
+ const rulesEngine = getContracts().rulesEngine;
2017
+ const proposal = await getRuleProposal(client2, BigInt(proposalId), rulesEngine);
2018
+ header(`Rule Proposal #${proposalId}`);
2019
+ kv("Novel", proposal.novelId.toString());
2020
+ kv("Proposer", proposal.proposer);
2021
+ kv("Type", proposal.proposalType === 0 ? "Add" : "Delete");
2022
+ kv("Rule Name", proposal.ruleName);
2023
+ if (proposal.ruleContent) {
2024
+ kv("Rule Content", proposal.ruleContent);
2025
+ }
2026
+ kv("Vote Count", proposal.voteCount.toString());
2027
+ kv("Executed", proposal.executed ? "Yes" : "No");
2028
+ kv("Created At", new Date(Number(proposal.createdAt) * 1e3).toISOString());
2029
+ console.log();
2030
+ } catch (err) {
2031
+ error(String(err));
2032
+ process.exit(1);
2033
+ }
2034
+ });
2035
+ }
2036
+
2037
+ // src/commands/setup.ts
2038
+ init_shared();
2039
+ import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
2040
+ import { dirname as dirname3, join as join4 } from "path";
2041
+ import { stdin, stdout } from "process";
2042
+ import { createInterface } from "readline/promises";
2043
+ import { fileURLToPath as fileURLToPath2 } from "url";
2044
+ import {
2045
+ createPublicClient as createPublicClient4,
2046
+ createWalletClient as createWalletClient2,
2047
+ defineChain as defineChain2,
2048
+ http as http4,
2049
+ pad,
2050
+ stringToHex
2051
+ } from "viem";
2052
+ import { generatePrivateKey, privateKeyToAccount as privateKeyToAccount3 } from "viem/accounts";
2053
+ import { parse as yamlParse, stringify as yamlStringify } from "yaml";
2054
+ import { z as z2 } from "zod";
2055
+ async function prompt(rl, question, defaultValue) {
2056
+ const answer = await rl.question(`${question} [${defaultValue}]: `);
2057
+ return answer.trim() || defaultValue;
2058
+ }
2059
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
2060
+ var SetupConfigSchema = z2.object({
2061
+ chain: z2.object({
2062
+ rpcUrl: z2.string().url(),
2063
+ nativeCurrency: z2.object({
2064
+ name: z2.string(),
2065
+ symbol: z2.string(),
2066
+ decimals: z2.number().int().positive()
2067
+ })
2068
+ }).passthrough(),
2069
+ contracts: z2.object({
2070
+ novelCore: z2.string().regex(ADDRESS_RE).transform((s) => s)
2071
+ }).passthrough(),
2072
+ cli: z2.object({
2073
+ apiUrl: z2.string().url()
2074
+ }).passthrough()
2075
+ }).passthrough();
2076
+ function resolveGuidesDir(here) {
2077
+ const candidates = [join4(here, "guides"), join4(here, "..", "guides")];
2078
+ const dir = candidates.find((candidate) => existsSync4(candidate));
2079
+ if (!dir) throw new Error("Could not locate bundled guides directory for setup");
2080
+ return dir;
2081
+ }
2082
+ function resolveConfigExamplePath(here) {
2083
+ const candidates = [
2084
+ join4(here, "config.yaml.example"),
2085
+ join4(here, "..", "..", "config.yaml.example"),
2086
+ join4(here, "..", "..", "..", "config.yaml.example")
2087
+ ];
2088
+ const path = candidates.find((candidate) => existsSync4(candidate));
2089
+ if (!path) throw new Error("Could not locate bundled config.yaml.example for setup");
2090
+ return path;
2091
+ }
2092
+ function loadSetupDefaults(here) {
2093
+ const path = resolveConfigExamplePath(here);
2094
+ return SetupConfigSchema.parse(yamlParse(readFileSync4(path, "utf-8")));
2095
+ }
2096
+ function renderConfig(config) {
2097
+ return `# Generated by \`onchain-novel-cli setup\`.
2098
+ # Non-secret settings only. Secrets (PRIVATE_KEY, KEEPER_PRIVATE_KEY,
2099
+ # VOTE_ENCRYPTION_KEY) belong in env vars, never here.
2100
+ #
2101
+ # Defaults are sourced from \`config.yaml.example\` bundled with this CLI.
2102
+ # Edit any of the values below to match your deployment.
2103
+
2104
+ ${yamlStringify(config)}`;
2105
+ }
2106
+ function nicknameToBytes32(nickname) {
2107
+ return pad(stringToHex(nickname), { size: 32, dir: "right" });
2108
+ }
2109
+ function appendGitignore(line) {
2110
+ const path = join4(process.cwd(), ".gitignore");
2111
+ let body = existsSync4(path) ? readFileSync4(path, "utf-8") : "";
2112
+ if (body.split("\n").some((l) => l.trim() === line)) return;
2113
+ if (body.length > 0 && !body.endsWith("\n")) body += "\n";
2114
+ body += line + "\n";
2115
+ writeFileSync2(path, body);
2116
+ }
2117
+ async function createIdentity(nickname) {
2118
+ header(`Identity \u2014 ${nickname}`);
2119
+ const privateKey = generatePrivateKey();
2120
+ const account = privateKeyToAccount3(privateKey);
2121
+ const address = account.address;
2122
+ const identityPath = join4(process.cwd(), "identity.yaml");
2123
+ const banner = "# WARNING: contains a private key. File mode is 0600. Do not commit.\n# After memorising the key, prefer `export PRIVATE_KEY=...` then `rm identity.yaml`.\n";
2124
+ writeFileSync2(identityPath, banner + yamlStringify({ nickname, address, privateKey }), {
2125
+ mode: 384
2126
+ });
2127
+ chmodSync(identityPath, 384);
2128
+ appendGitignore("identity.yaml");
2129
+ success("Wrote identity.yaml (mode 0600)");
2130
+ kv("Address", address);
2131
+ try {
2132
+ await ensureBootstrapped();
2133
+ } catch (err) {
2134
+ error(`Bootstrap failed: ${String(err)}`);
2135
+ console.log(
2136
+ ` Once the chain is reachable, run (with PRIVATE_KEY exported from identity.yaml):
2137
+ onchain-novel-cli faucet claim
2138
+ onchain-novel-cli user set-nickname ${nickname}
2139
+ `
2140
+ );
2141
+ return;
2142
+ }
2143
+ const cfg = requireConfig();
2144
+ const chain = defineChain2({
2145
+ id: cfg.chainId,
2146
+ name: `Chain ${cfg.chainId}`,
2147
+ nativeCurrency: cfg.nativeCurrency,
2148
+ rpcUrls: { default: { http: [cfg.rpcUrl] } }
2149
+ });
2150
+ const pub = createPublicClient4({ chain, transport: http4(cfg.rpcUrl) });
2151
+ const wallet = createWalletClient2({ account, chain, transport: http4(cfg.rpcUrl) });
2152
+ let faucetTx;
2153
+ try {
2154
+ const { status, body } = await apiPost("/api/faucet/claim", { address });
2155
+ if (!body?.txHash) {
2156
+ error(`Faucet claim failed: ${body?.error ?? `HTTP ${status}`}`);
2157
+ console.log(" Retry: onchain-novel-cli faucet claim\n");
2158
+ return;
2159
+ }
2160
+ faucetTx = body.txHash;
2161
+ txHash(faucetTx);
2162
+ success(`Faucet sent ${body.amount} ${body.symbol}`);
2163
+ } catch (err) {
2164
+ error(`Faucet API call failed: ${String(err)}`);
2165
+ console.log(" Retry: onchain-novel-cli faucet claim\n");
2166
+ return;
2167
+ }
2168
+ try {
2169
+ await pub.waitForTransactionReceipt({ hash: faucetTx });
2170
+ } catch (err) {
2171
+ error(`Faucet tx broadcast but not yet confirmed: ${String(err)}`);
2172
+ console.log(
2173
+ ` Once the tx lands, run: onchain-novel-cli user set-nickname ${nickname}
2174
+ `
2175
+ );
2176
+ return;
2177
+ }
2178
+ try {
2179
+ const hash = await setNickname(
2180
+ wallet,
2181
+ nicknameToBytes32(nickname),
2182
+ cfg.contracts.userRegistry
2183
+ );
2184
+ txHash(hash);
2185
+ await pub.waitForTransactionReceipt({ hash });
2186
+ success(`Nickname registered on-chain: ${nickname}`);
2187
+ } catch (err) {
2188
+ error(`setNickname failed: ${String(err)}`);
2189
+ console.log(` Retry: onchain-novel-cli user set-nickname ${nickname}
2190
+ `);
2191
+ }
2192
+ }
2193
+ function registerSetupCommand(program2) {
2194
+ program2.command("setup").description(
2195
+ "Generate config.yaml plus a workflow skill for onchain-novel-cli. Writes the skill to .agent/skills/onchain-novel/SKILL.md AND .claude/commands/onchain-novel.md (so any agent can find it), and drops onchain-novel-index.md at the project root as a discovery hint."
2196
+ ).option(
2197
+ "--full",
2198
+ "Interactive full setup: prompt for Ethereum RPC URL, Backend API URL, NovelCore proxy address, Frontend base URL, and Chain Explorer base URL. Without this flag, defaults from config.yaml.example are used without prompting."
2199
+ ).action(async (options) => {
2200
+ try {
2201
+ header("Onchain Novel CLI Setup");
2202
+ const here = dirname3(fileURLToPath2(import.meta.url));
2203
+ const guidesDir = resolveGuidesDir(here);
2204
+ const defaults = loadSetupDefaults(here);
2205
+ const skillContent = readFileSync4(join4(guidesDir, "SKILL.md"), "utf-8");
2206
+ const indexContent = readFileSync4(join4(guidesDir, "onchain-novel-index.md"), "utf-8");
2207
+ const targets = [
2208
+ {
2209
+ dir: join4(process.cwd(), ".agent", "skills", "onchain-novel"),
2210
+ file: "SKILL.md",
2211
+ content: skillContent,
2212
+ label: ".agent/skills/onchain-novel/SKILL.md"
2213
+ },
2214
+ {
2215
+ dir: join4(process.cwd(), ".claude", "commands"),
2216
+ file: "onchain-novel.md",
2217
+ content: skillContent,
2218
+ label: ".claude/commands/onchain-novel.md"
2219
+ },
2220
+ {
2221
+ dir: process.cwd(),
2222
+ file: "onchain-novel-index.md",
2223
+ content: indexContent,
2224
+ label: "onchain-novel-index.md"
2225
+ }
2226
+ ];
2227
+ for (const t of targets) {
2228
+ if (!existsSync4(t.dir)) mkdirSync2(t.dir, { recursive: true });
2229
+ writeFileSync2(join4(t.dir, t.file), t.content);
2230
+ success(`Generated ${t.label}`);
2231
+ }
2232
+ const configPath = join4(process.cwd(), "config.yaml");
2233
+ if (existsSync4(configPath)) {
2234
+ console.log(
2235
+ `
2236
+ config.yaml already exists at ${configPath} -- keeping it. Delete it first if you want to regenerate.`
2237
+ );
2238
+ return;
2239
+ }
2240
+ const setupConfig = structuredClone(defaults);
2241
+ const DEFAULT_RPC = defaults.chain.rpcUrl;
2242
+ const DEFAULT_API = defaults.cli.apiUrl;
2243
+ const DEFAULT_NOVEL_CORE = defaults.contracts.novelCore;
2244
+ const DEFAULT_FRONT_URL = defaults.cli.frontUrl;
2245
+ const DEFAULT_EXPLORER = defaults.cli.chainExplorer;
2246
+ let identityNickname = null;
2247
+ if (stdin.isTTY) {
2248
+ const rl = createInterface({ input: stdin, output: stdout });
2249
+ try {
2250
+ if (options.full) {
2251
+ console.log(
2252
+ "\nGenerating config.yaml (full mode). Press Enter to accept defaults where shown.\nDefaults are loaded from the bundled config.yaml.example.\n"
2253
+ );
2254
+ setupConfig.chain.rpcUrl = await prompt(rl, "BlockChain RPC URL", DEFAULT_RPC);
2255
+ setupConfig.cli.apiUrl = await prompt(rl, "Backend API URL", DEFAULT_API);
2256
+ while (true) {
2257
+ const answer = await prompt(rl, "NovelCore proxy address (0x...)", DEFAULT_NOVEL_CORE);
2258
+ if (ADDRESS_RE.test(answer)) {
2259
+ setupConfig.contracts.novelCore = answer;
2260
+ break;
2261
+ }
2262
+ console.log(" not a 0x-prefixed 20-byte hex address -- try again");
2263
+ }
2264
+ setupConfig.cli.frontUrl = await prompt(
2265
+ rl,
2266
+ "Frontend base URL (chapter paths appended at runtime)",
2267
+ DEFAULT_FRONT_URL
2268
+ );
2269
+ setupConfig.cli.chainExplorer = await prompt(
2270
+ rl,
2271
+ "Chain explorer base URL (/tx/{hash} appended at runtime)",
2272
+ DEFAULT_EXPLORER
2273
+ );
2274
+ }
2275
+ const identityPath = join4(process.cwd(), "identity.yaml");
2276
+ if (existsSync4(identityPath)) {
2277
+ console.log(
2278
+ `
2279
+ identity.yaml already exists -- skipping identity creation.
2280
+ Delete it first if you want a fresh wallet.`
2281
+ );
2282
+ } else {
2283
+ const create = (await prompt(rl, "Create a new wallet for quick start [Y] / Use existing PRIVATE_KEY env [n]", "Y")).toLowerCase();
2284
+ if (create === "y" || create === "yes") {
2285
+ console.log(
2286
+ " Nickname is registered ON-CHAIN, ONE-TIME, and IMMUTABLE.\n Limit: \u2264 32 UTF-8 bytes."
2287
+ );
2288
+ while (true) {
2289
+ const nick = (await rl.question("Pick a nickname: ")).trim();
2290
+ if (nick.length === 0) {
2291
+ console.log(" nickname cannot be empty -- try again");
2292
+ continue;
2293
+ }
2294
+ const bytes = Buffer.byteLength(nick, "utf-8");
2295
+ if (bytes > 32) {
2296
+ console.log(` too long (${bytes} bytes) -- max 32 UTF-8 bytes`);
2297
+ continue;
2298
+ }
2299
+ identityNickname = nick;
2300
+ break;
2301
+ }
2302
+ }
2303
+ }
2304
+ } finally {
2305
+ rl.close();
2306
+ }
2307
+ } else {
2308
+ console.log(
2309
+ `
2310
+ Non-interactive stdin -- writing config.yaml with bundled defaults (rpcUrl=${DEFAULT_RPC}, apiUrl=${DEFAULT_API}, novelCore=${DEFAULT_NOVEL_CORE}).
2311
+ Edit config.yaml before running any chain command if you need a different deployment.`
2312
+ );
2313
+ }
2314
+ writeFileSync2(configPath, renderConfig(setupConfig));
2315
+ success(`Wrote ${configPath}`);
2316
+ if (identityNickname) {
2317
+ await createIdentity(identityNickname);
2318
+ }
2319
+ console.log(
2320
+ "\nNext steps:\n - Edit config.yaml if anything's wrong.\n" + (identityNickname ? " - Read identity.yaml for your wallet. To harden, run\n export PRIVATE_KEY=0x... # paste the privateKey from identity.yaml\n rm identity.yaml # avoid leaking the key on disk\n" : " - export PRIVATE_KEY=0x... before running write commands.\n")
2321
+ );
2322
+ } catch (err) {
2323
+ error(String(err));
2324
+ process.exit(1);
2325
+ }
2326
+ });
2327
+ }
2328
+
2329
+ // src/commands/tip.ts
2330
+ init_shared();
2331
+ import { parseEther as parseEther5 } from "viem";
2332
+ function registerTipCommands(program2) {
2333
+ const tip = program2.command("tip").description("Tip novels or chapters");
2334
+ tip.command("novel <novel-id>").description("Tip a novel").requiredOption("--value <eth>", "tip amount in ETH").action(async (novelId, opts) => {
2335
+ try {
2336
+ const client2 = getWalletClient();
2337
+ const contracts = getContracts();
2338
+ const hash = await tipNovel(client2, {
2339
+ id: BigInt(novelId),
2340
+ value: parseEther5(opts.value),
2341
+ prizePool: contracts.prizePool
2342
+ });
2343
+ txHash(hash);
2344
+ await waitForTx(hash);
2345
+ success(`Tipped novel #${novelId} with ${opts.value} ETH`);
2346
+ } catch (err) {
2347
+ error(String(err));
2348
+ process.exit(1);
2349
+ }
2350
+ });
2351
+ tip.command("chapter <chapter-id>").description("Tip a chapter (50% to author, 50% to prize pool)").requiredOption("--value <eth>", "tip amount in ETH").action(async (chapterId, opts) => {
2352
+ try {
2353
+ const client2 = getWalletClient();
2354
+ const contracts = getContracts();
2355
+ const hash = await tipChapter(client2, {
2356
+ id: BigInt(chapterId),
2357
+ value: parseEther5(opts.value),
2358
+ prizePool: contracts.prizePool
2359
+ });
2360
+ txHash(hash);
2361
+ await waitForTx(hash);
2362
+ success(`Tipped chapter ID.${chapterId} with ${opts.value} ETH`);
2363
+ } catch (err) {
2364
+ error(String(err));
2365
+ process.exit(1);
2366
+ }
2367
+ });
2368
+ tip.command("claim <novel-id>").description("Claim accumulated author/creator rewards").action(async (novelId) => {
2369
+ try {
2370
+ const client2 = getWalletClient();
2371
+ const contracts = getContracts();
2372
+ const hash = await claimReward(client2, BigInt(novelId), contracts.novelCore);
2373
+ txHash(hash);
2374
+ await waitForTx(hash);
2375
+ success("Rewards claimed");
2376
+ } catch (err) {
2377
+ error(String(err));
2378
+ process.exit(1);
2379
+ }
2380
+ });
2381
+ }
2382
+
2383
+ // src/commands/user.ts
2384
+ init_shared();
2385
+ import { hexToString, pad as pad2, stringToHex as stringToHex2 } from "viem";
2386
+ import { privateKeyToAccount as privateKeyToAccount4 } from "viem/accounts";
2387
+ function nicknameToBytes322(nickname) {
2388
+ const bytes = Buffer.byteLength(nickname, "utf-8");
2389
+ if (bytes === 0) throw new Error("Nickname cannot be empty.");
2390
+ if (bytes > 32) throw new Error(`Nickname must be \u2264 32 UTF-8 bytes (got ${bytes}).`);
2391
+ return pad2(stringToHex2(nickname), { size: 32, dir: "right" });
2392
+ }
2393
+ function resolveAddress(argAddr) {
2394
+ if (argAddr) return argAddr.toLowerCase();
2395
+ const pk = getPrivateKey2();
2396
+ if (!pk) {
2397
+ error(
2398
+ "No address given and PRIVATE_KEY env not set. Either pass an address or export PRIVATE_KEY."
2399
+ );
2400
+ process.exit(1);
2401
+ }
2402
+ return privateKeyToAccount4(pk).address.toLowerCase();
2403
+ }
2404
+ function registerUserCommands(program2) {
2405
+ const user = program2.command("user").description("Query user (address) activity");
2406
+ user.command("set-nickname <nickname>").description(
2407
+ "Register a one-time, immutable on-chain nickname (\u226432 UTF-8 bytes). Cannot be changed once set."
2408
+ ).action(async (nickname) => {
2409
+ try {
2410
+ const contracts = getContracts();
2411
+ const bytes32 = nicknameToBytes322(nickname);
2412
+ const client2 = getWalletClient();
2413
+ const hash = await setNickname(client2, bytes32, contracts.userRegistry);
2414
+ txHash(hash);
2415
+ await waitForTx(hash);
2416
+ success(`Nickname set: ${nickname}`);
2417
+ } catch (err) {
2418
+ error(String(err));
2419
+ process.exit(1);
2420
+ }
2421
+ });
2422
+ user.command("nickname [address]").description("Show the on-chain nickname for an address (defaults to PRIVATE_KEY wallet).").action(async (addrArg) => {
2423
+ try {
2424
+ const contracts = getContracts();
2425
+ const addr = resolveAddress(addrArg);
2426
+ const pub = getPublicClient();
2427
+ const raw = await getNickname(pub, addr, contracts.userRegistry);
2428
+ const decoded = raw && raw !== `0x${"00".repeat(32)}` ? hexToString(raw, { size: 32 }) : "";
2429
+ header(`Nickname \u2014 ${addr}`);
2430
+ kv("Nickname", decoded || "(unset)");
2431
+ console.log();
2432
+ } catch (err) {
2433
+ error(String(err));
2434
+ process.exit(1);
2435
+ }
2436
+ });
2437
+ user.command("votes [address]").description("List voting history. Defaults to the wallet derived from PRIVATE_KEY.").option("--page <n>", "page number", "1").option("--limit <n>", "page size (max 100)", "20").action(async (addrArg, opts) => {
2438
+ try {
2439
+ const addr = resolveAddress(addrArg);
2440
+ const page = parseInt(opts.page) || 1;
2441
+ const limit = Math.min(parseInt(opts.limit) || 20, 100);
2442
+ const data = await apiGet(
2443
+ `/api/users/${addr}/votes?page=${page}&limit=${limit}`
2444
+ );
2445
+ header(`Votes \u2014 ${addr}`);
2446
+ kv("Total", data.total);
2447
+ if (data.votes.length === 0) {
2448
+ console.log(" (no votes)\n");
2449
+ return;
2450
+ }
2451
+ table(
2452
+ data.votes.map((v) => ({
2453
+ Novel: `#${v.novel_id}`,
2454
+ Round: v.round,
2455
+ Revealed: v.revealed ? "yes" : "no",
2456
+ Candidate: v.revealed ? `ID.${v.candidate_id}` : "-",
2457
+ Claimed: v.claimed ? "yes" : "no",
2458
+ Title: String(v.novel_title ?? "").slice(0, 28)
2459
+ }))
2460
+ );
2461
+ console.log();
2462
+ } catch (err) {
2463
+ error(String(err));
2464
+ process.exit(1);
2465
+ }
2466
+ });
2467
+ user.command("chapters [address]").description("List chapters authored by an address. Defaults to PRIVATE_KEY wallet.").option("--page <n>", "page number", "1").option("--limit <n>", "page size (max 200)", "50").action(async (addrArg, opts) => {
2468
+ try {
2469
+ const addr = resolveAddress(addrArg);
2470
+ const page = parseInt(opts.page) || 1;
2471
+ const limit = Math.min(parseInt(opts.limit) || 50, 200);
2472
+ const data = await apiGet(`/api/users/${addr}/chapters?page=${page}&limit=${limit}`);
2473
+ header(`Chapters \u2014 ${addr}`);
2474
+ kv("Total", data.pagination.total);
2475
+ if (data.chapters.length === 0) {
2476
+ console.log(" (no chapters)\n");
2477
+ return;
2478
+ }
2479
+ table(
2480
+ data.chapters.map((c) => ({
2481
+ Chapter: `ID.${c.id}`,
2482
+ Novel: `#${c.novel_id}`,
2483
+ Depth: c.depth,
2484
+ WorldLine: c.is_world_line ? "yes" : "no",
2485
+ Votes: c.vote_count,
2486
+ Comments: c.comment_count,
2487
+ Title: String(c.novel_title ?? "").slice(0, 24)
2488
+ }))
2489
+ );
2490
+ console.log();
2491
+ } catch (err) {
2492
+ error(String(err));
2493
+ process.exit(1);
2494
+ }
2495
+ });
2496
+ user.command("rewards [address]").description(
2497
+ "Show unclaimed voting rewards, past reward claims, and participated novels. Use this to find unclaimed rewards after round settlement."
2498
+ ).action(async (addrArg) => {
2499
+ try {
2500
+ const { nativeCurrency } = requireConfig();
2501
+ const addr = resolveAddress(addrArg);
2502
+ const data = await apiGet(`/api/users/${addr}/rewards`);
2503
+ header(`Rewards \u2014 ${addr}`);
2504
+ if (data.unclaimedVotes.length > 0) {
2505
+ console.log("\n Unclaimed voting rewards (run `vote claim <novel-id> <round>`):");
2506
+ table(
2507
+ data.unclaimedVotes.map((v) => ({
2508
+ Novel: `#${v.novel_id}`,
2509
+ Round: v.round,
2510
+ Title: String(v.novel_title ?? "").slice(0, 32)
2511
+ }))
2512
+ );
2513
+ } else {
2514
+ console.log("\n No unclaimed voting rewards.");
2515
+ }
2516
+ if (data.rewardClaims.length > 0) {
2517
+ console.log("\n Past reward claims:");
2518
+ table(
2519
+ data.rewardClaims.slice(0, 20).map((r) => ({
2520
+ Novel: `#${r.novel_id}`,
2521
+ Round: r.round ?? "-",
2522
+ Source: r.source,
2523
+ Amount: token(String(r.amount ?? "0"), nativeCurrency.decimals, nativeCurrency.symbol),
2524
+ At: String(r.created_at ?? "").slice(0, 19)
2525
+ }))
2526
+ );
2527
+ }
2528
+ if (data.participatedNovels.length > 0) {
2529
+ console.log(`
2530
+ Participated in ${data.participatedNovels.length} novels.`);
2531
+ }
2532
+ console.log();
2533
+ } catch (err) {
2534
+ error(String(err));
2535
+ process.exit(1);
2536
+ }
2537
+ });
2538
+ }
2539
+
2540
+ // src/commands/vote.ts
2541
+ init_shared();
2542
+ import { randomBytes as randomBytes2 } from "crypto";
2543
+ import chalk5 from "chalk";
2544
+ import { parseEther as parseEther6, toHex as toHex2 } from "viem";
2545
+ import { privateKeyToAccount as privateKeyToAccount5 } from "viem/accounts";
2546
+
2547
+ // ../packages/shared/dist/chain/vote-store.js
2548
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, renameSync, writeFileSync as writeFileSync3 } from "fs";
2549
+ import { homedir } from "os";
2550
+ import { join as join5 } from "path";
2551
+ import { randomBytes } from "crypto";
2552
+ var STORE_DIR = join5(homedir(), ".onchain-novel");
2553
+ var STORE_FILE = join5(STORE_DIR, "vote-salts.json");
2554
+ function load() {
2555
+ if (!existsSync5(STORE_FILE))
2556
+ return {};
2557
+ try {
2558
+ return JSON.parse(readFileSync5(STORE_FILE, "utf-8"));
2559
+ } catch {
2560
+ return {};
2561
+ }
2562
+ }
2563
+ function persist(store) {
2564
+ if (!existsSync5(STORE_DIR))
2565
+ mkdirSync3(STORE_DIR, { recursive: true, mode: 448 });
2566
+ const tmp = `${STORE_FILE}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
2567
+ writeFileSync3(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 384 });
2568
+ renameSync(tmp, STORE_FILE);
2569
+ }
2570
+ function makeKey(novelId, round, voter) {
2571
+ return `${novelId}:${round}:${voter.toLowerCase()}`;
2572
+ }
2573
+ function saveVoteSalt(record) {
2574
+ const store = load();
2575
+ store[makeKey(BigInt(record.novelId), record.round, record.voter)] = {
2576
+ ...record,
2577
+ createdAt: Math.floor(Date.now() / 1e3)
2578
+ };
2579
+ persist(store);
2580
+ }
2581
+ function getVoteSalt(novelId, round, voter) {
2582
+ const store = load();
2583
+ return store[makeKey(novelId, round, voter)] ?? null;
2584
+ }
2585
+ function getStorePath() {
2586
+ return STORE_FILE;
2587
+ }
2588
+ function listVoteSalts(novelId, voter, round) {
2589
+ const store = load();
2590
+ const voterLower = voter.toLowerCase();
2591
+ const prefix = `${novelId}:`;
2592
+ return Object.entries(store).filter(([k, v]) => {
2593
+ if (!k.startsWith(prefix))
2594
+ return false;
2595
+ if (v.voter.toLowerCase() !== voterLower)
2596
+ return false;
2597
+ if (round !== void 0 && v.round !== round)
2598
+ return false;
2599
+ return true;
2600
+ }).map(([, v]) => v).sort((a, b) => b.round - a.round || b.createdAt - a.createdAt);
2601
+ }
2602
+
2603
+ // src/commands/vote.ts
2604
+ function parseIdList(raw) {
2605
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map((s) => BigInt(s));
2606
+ }
2607
+ function generateSalt() {
2608
+ return toHex2(randomBytes2(32));
2609
+ }
2610
+ function registerVoteCommands(program2) {
2611
+ const vote = program2.command("vote").description("Voting commands");
2612
+ vote.command("start <novel-id> <leaves>").description(
2613
+ "Start a new voting round (keeper / owner only). <leaves> is a comma-separated list of leaf chapter IDs (one per current world line, deepest leaf preferred). Each must be a true tree leaf (no children)."
2614
+ ).action(async (novelId, leavesArg) => {
2615
+ try {
2616
+ const client2 = getWalletClient();
2617
+ const contracts = getContracts();
2618
+ const hash = await startRound(client2, {
2619
+ novelId: BigInt(novelId),
2620
+ leaves: parseIdList(leavesArg),
2621
+ roundManager: contracts.roundManager
2622
+ });
2623
+ txHash(hash);
2624
+ await waitForTx(hash);
2625
+ success("Round started");
2626
+ } catch (err) {
2627
+ error(String(err));
2628
+ process.exit(1);
2629
+ }
2630
+ });
2631
+ vote.command("close-nomination <novel-id>").description("Close the nomination phase").action(async (novelId) => {
2632
+ try {
2633
+ const client2 = getWalletClient();
2634
+ const contracts = getContracts();
2635
+ const hash = await closeNomination(client2, BigInt(novelId), contracts.roundManager);
2636
+ txHash(hash);
2637
+ await waitForTx(hash);
2638
+ success("Nomination closed");
2639
+ } catch (err) {
2640
+ error(String(err));
2641
+ process.exit(1);
2642
+ }
2643
+ });
2644
+ vote.command("close-commit <novel-id>").description("Close the commit phase").action(async (novelId) => {
2645
+ try {
2646
+ const client2 = getWalletClient();
2647
+ const contracts = getContracts();
2648
+ const hash = await closeCommit(client2, BigInt(novelId), contracts.roundManager);
2649
+ txHash(hash);
2650
+ await waitForTx(hash);
2651
+ success("Commit phase closed");
2652
+ } catch (err) {
2653
+ error(String(err));
2654
+ process.exit(1);
2655
+ }
2656
+ });
2657
+ vote.command("nominate <novel-id> <chapter-id>").description(
2658
+ "Nominate a chapter as candidate. By default the path proof (chapter \u2192 current worldLineAncestor) is auto-computed so the nominator is reward-eligible. Pass --forfeit to nominate an arbitrary chapter with no reward eligibility (empty path)."
2659
+ ).option("--value <eth>", "nomination fee in ETH").option("--forfeit", "nominate without reward eligibility (skip path proof)").action(async (novelId, chapterId, opts) => {
2660
+ try {
2661
+ const client2 = getWalletClient();
2662
+ const pub = getPublicClient();
2663
+ const contracts = getContracts();
2664
+ let value;
2665
+ if (opts.value) {
2666
+ value = parseEther6(opts.value);
2667
+ } else {
2668
+ const { config } = await fetchNovelConfig(novelId);
2669
+ value = BigInt(config.nominationFee ?? "0");
2670
+ }
2671
+ let path = [];
2672
+ if (!opts.forfeit) {
2673
+ const ancestors = await pub.readContract({
2674
+ address: contracts.novelCore,
2675
+ abi: (await Promise.resolve().then(() => (init_shared(), shared_exports))).novelCoreAbi,
2676
+ functionName: "getWorldLineAncestors",
2677
+ args: [BigInt(novelId)]
2678
+ });
2679
+ const proof = await buildPathToAnchor(
2680
+ pub,
2681
+ contracts.novelCore,
2682
+ BigInt(novelId),
2683
+ BigInt(chapterId),
2684
+ ancestors
2685
+ );
2686
+ if (!proof || proof.length < 2) {
2687
+ error(
2688
+ `Chapter ID.${chapterId} is not a strict descendant of any current worldLineAncestor of novel #${novelId}. Pass --forfeit to nominate anyway (no reward eligibility).`
2689
+ );
2690
+ return process.exit(1);
2691
+ }
2692
+ path = proof;
2693
+ }
2694
+ const hash = await nominateCandidate(client2, {
2695
+ novelId: BigInt(novelId),
2696
+ chapterId: BigInt(chapterId),
2697
+ path,
2698
+ value,
2699
+ roundManager: contracts.roundManager
2700
+ });
2701
+ txHash(hash);
2702
+ await waitForTx(hash);
2703
+ success(opts.forfeit ? "Chapter nominated (forfeit mode)" : "Chapter nominated");
2704
+ } catch (err) {
2705
+ error(String(err));
2706
+ process.exit(1);
2707
+ }
2708
+ });
2709
+ vote.command("commit <novel-id> <candidate-id> [salt]").description(
2710
+ "Commit a vote. If salt is omitted, a random 32-byte salt is generated, saved locally as backup, and submitted to the backend for keeper-assisted reveal."
2711
+ ).option("--value <eth>", "vote stake in ETH").option("--no-keeper", "skip backend submission (you must reveal manually later)").action(async (novelId, candidateId, saltArg, opts) => {
2712
+ try {
2713
+ const client2 = getWalletClient();
2714
+ const contracts = getContracts();
2715
+ const voter = client2.account.address;
2716
+ const saltBytes32 = saltArg ? toBytes32Salt(saltArg) : generateSalt();
2717
+ const commitHash = computeCommitHash(voter, BigInt(candidateId), saltBytes32);
2718
+ console.log(chalk5.gray(` Salt (bytes32): ${saltBytes32}`));
2719
+ console.log(chalk5.gray(` Commit hash: ${commitHash}`));
2720
+ const { novel, config } = await fetchNovelConfig(novelId);
2721
+ const currentRound = Number(novel.current_round ?? 0);
2722
+ const value = opts.value ? parseEther6(opts.value) : BigInt(config.voteStake ?? "0");
2723
+ const hash = await commitVote(client2, {
2724
+ novelId: BigInt(novelId),
2725
+ commitHash,
2726
+ value,
2727
+ roundManager: contracts.roundManager
2728
+ });
2729
+ txHash(hash);
2730
+ await waitForTx(hash);
2731
+ if (currentRound > 0) {
2732
+ saveVoteSalt({
2733
+ novelId: novelId.toString(),
2734
+ round: currentRound,
2735
+ candidateId: candidateId.toString(),
2736
+ salt: saltBytes32,
2737
+ voter
2738
+ });
2739
+ console.log(chalk5.gray(` Salt saved to ${getStorePath()}`));
2740
+ }
2741
+ if (opts.keeper !== false && currentRound > 0) {
2742
+ const ts = Math.floor(Date.now() / 1e3);
2743
+ const message = `Submit vote on novel ${novelId} round ${currentRound} for candidate ${candidateId} at ${ts}`;
2744
+ const signature = await client2.signMessage({ account: client2.account, message });
2745
+ const result = await apiPost("/api/votes/submit", {
2746
+ address: voter,
2747
+ novelId: Number(novelId),
2748
+ round: currentRound,
2749
+ candidateId: Number(candidateId),
2750
+ salt: saltBytes32,
2751
+ timestamp: ts,
2752
+ signature
2753
+ });
2754
+ if (result.status === 201) {
2755
+ success("Vote committed. Keeper will auto-reveal during the reveal phase.");
2756
+ } else if (result.status === 503) {
2757
+ success("Vote committed (keeper-assisted reveal disabled on backend).");
2758
+ console.log(
2759
+ chalk5.yellow(
2760
+ " You will need to reveal manually with: vote reveal <novel-id> <candidate-id> <salt>"
2761
+ )
2762
+ );
2763
+ } else {
2764
+ success("Vote committed.");
2765
+ console.log(
2766
+ chalk5.yellow(
2767
+ ` Backend rejected /api/votes/submit (status ${result.status}). You will need to reveal manually.`
2768
+ )
2769
+ );
2770
+ }
2771
+ } else {
2772
+ success("Vote committed. Remember to reveal during the reveal phase.");
2773
+ }
2774
+ } catch (err) {
2775
+ error(String(err));
2776
+ process.exit(1);
2777
+ }
2778
+ });
2779
+ vote.command("reveal <novel-id> <candidate-id> [salt]").description(
2780
+ "Reveal a previously committed vote. If salt is omitted, falls back to the local backup saved by `vote commit`. Anyone can call revealVote on behalf of a voter \u2014 only the matching voter address whose commit hash equals keccak(voter, c, s) will succeed."
2781
+ ).action(async (novelId, candidateId, saltArg) => {
2782
+ try {
2783
+ const client2 = getWalletClient();
2784
+ const contracts = getContracts();
2785
+ const voter = client2.account.address;
2786
+ let saltBytes32;
2787
+ if (saltArg) {
2788
+ saltBytes32 = toBytes32Salt(saltArg);
2789
+ } else {
2790
+ const novel = await apiGet(`/api/novels/${novelId}`);
2791
+ const currentRound = Number(novel.current_round ?? 0);
2792
+ const stored = getVoteSalt(BigInt(novelId), currentRound, voter);
2793
+ if (!stored) {
2794
+ error(`No salt provided and no local backup found for round ${currentRound}.`);
2795
+ process.exit(1);
2796
+ }
2797
+ saltBytes32 = stored.salt;
2798
+ console.log(chalk5.gray(` Using salt from local backup (round ${currentRound})`));
2799
+ }
2800
+ const hash = await revealVote(client2, {
2801
+ novelId: BigInt(novelId),
2802
+ voter,
2803
+ candidateId: BigInt(candidateId),
2804
+ salt: saltBytes32,
2805
+ roundManager: contracts.roundManager
2806
+ });
2807
+ txHash(hash);
2808
+ await waitForTx(hash);
2809
+ success("Vote revealed");
2810
+ } catch (err) {
2811
+ error(String(err));
2812
+ process.exit(1);
2813
+ }
2814
+ });
2815
+ vote.command("settle <novel-id>").description(
2816
+ "Settle the current round (keeper / owner, or anyone after the timeout). Winner reward-author derivation happens fully on-chain via NovelCore.collectPathAuthors."
2817
+ ).action(async (novelId) => {
2818
+ try {
2819
+ const client2 = getWalletClient();
2820
+ const contracts = getContracts();
2821
+ const hash = await settleRound(client2, {
2822
+ novelId: BigInt(novelId),
2823
+ roundManager: contracts.roundManager
2824
+ });
2825
+ txHash(hash);
2826
+ await waitForTx(hash);
2827
+ success("Round settled");
2828
+ } catch (err) {
2829
+ error(String(err));
2830
+ process.exit(1);
2831
+ }
2832
+ });
2833
+ vote.command("claim <novel-id> <round>").description("Claim voting reward for a specific round").action(async (novelId, round) => {
2834
+ try {
2835
+ const client2 = getWalletClient();
2836
+ const contracts = getContracts();
2837
+ const hash = await claimVotingReward(
2838
+ client2,
2839
+ BigInt(novelId),
2840
+ parseInt(round),
2841
+ contracts.roundManager
2842
+ );
2843
+ txHash(hash);
2844
+ await waitForTx(hash);
2845
+ success("Voting reward claimed");
2846
+ } catch (err) {
2847
+ error(String(err));
2848
+ process.exit(1);
2849
+ }
2850
+ });
2851
+ vote.command("candidates <novel-id>").description("Show current round candidates").action(async (novelId) => {
2852
+ try {
2853
+ const novel = await apiGet(`/api/novels/${novelId}`);
2854
+ const currentRound = Number(novel.current_round);
2855
+ const phase = Number(novel.round_phase);
2856
+ header(`Voting \u2014 Novel #${novelId}`);
2857
+ kv("Current Round", currentRound);
2858
+ kv("Phase", roundPhaseName(phase));
2859
+ if (currentRound === 0) {
2860
+ console.log(chalk5.yellow("\n No voting round has started yet.\n"));
2861
+ return;
2862
+ }
2863
+ const roundData = await apiGet(
2864
+ `/api/novels/${novelId}/rounds/${currentRound}`
2865
+ );
2866
+ const wlData = await apiGet(
2867
+ `/api/novels/${novelId}/worldlines`
2868
+ );
2869
+ if (wlData.worldlines.length > 0) {
2870
+ console.log(chalk5.bold("\n World Lines:"));
2871
+ for (const wl of wlData.worldlines) {
2872
+ console.log(
2873
+ ` Chapter ID.${wl.id} (depth=${wl.depth}) by ${String(wl.author ?? "").slice(0, 10)}...`
2874
+ );
2875
+ }
2876
+ }
2877
+ if (roundData.votes.length > 0) {
2878
+ console.log(chalk5.bold("\n Votes:"));
2879
+ table(
2880
+ roundData.votes.map((v) => ({
2881
+ Voter: String(v.voter ?? "").slice(0, 12) + "...",
2882
+ Revealed: v.revealed ? "Yes" : "No",
2883
+ Candidate: v.revealed ? `ID.${v.candidate_id}` : "-",
2884
+ Claimed: v.claimed ? "Yes" : "No"
2885
+ }))
2886
+ );
2887
+ }
2888
+ console.log();
2889
+ } catch (err) {
2890
+ error(String(err));
2891
+ process.exit(1);
2892
+ }
2893
+ });
2894
+ vote.command("discover").description(
2895
+ "List active novels with a round in progress \u2014 the starting point for an agent looking for voting opportunities. Shows phase, deadline, and whether you already voted."
2896
+ ).option("--phase <phase>", "filter by phase: nominating|committing|revealing|all", "all").option("--limit <n>", "max novels to scan", "100").action(async (opts) => {
2897
+ try {
2898
+ const { nativeCurrency } = requireConfig();
2899
+ const wantPhase = String(opts.phase).toLowerCase();
2900
+ const phaseMap = {
2901
+ nominating: 1,
2902
+ committing: 2,
2903
+ revealing: 3
2904
+ };
2905
+ const phaseFilter = wantPhase === "all" ? null : phaseMap[wantPhase];
2906
+ if (wantPhase !== "all" && phaseFilter === void 0) {
2907
+ error(`Invalid --phase. Use one of: nominating, committing, revealing, all`);
2908
+ process.exit(1);
2909
+ }
2910
+ const limit = Math.min(parseInt(String(opts.limit)) || 100, 100);
2911
+ const data = await apiGet(
2912
+ `/api/novels?filter=active&limit=${limit}&sort=active`
2913
+ );
2914
+ const now = Math.floor(Date.now() / 1e3);
2915
+ const pk = getPrivateKey2();
2916
+ const myAddr = pk ? privateKeyToAccount5(pk).address.toLowerCase() : null;
2917
+ const rows = [];
2918
+ for (const n of data.novels) {
2919
+ const phase = Number(n.round_phase);
2920
+ if (phase === 0) continue;
2921
+ if (phaseFilter !== null && phase !== phaseFilter) continue;
2922
+ const cfg = n.config ?? {};
2923
+ const phaseStart = Number(n.phase_start_time ?? 0);
2924
+ const durKey = phase === 1 ? "nominateDuration" : phase === 2 ? "commitDuration" : "revealDuration";
2925
+ const duration = Number(cfg[durKey] ?? 0);
2926
+ const deadline = phaseStart + duration;
2927
+ const remaining = deadline - now;
2928
+ let already = "-";
2929
+ let voterCount = 0;
2930
+ if (phase >= 2) {
2931
+ try {
2932
+ const round = Number(n.current_round);
2933
+ if (round > 0) {
2934
+ const rd = await apiGet(
2935
+ `/api/novels/${n.id}/rounds/${round}`
2936
+ );
2937
+ voterCount = rd.votes.length;
2938
+ if (myAddr) {
2939
+ const mine = rd.votes.find(
2940
+ (v) => String(v.voter ?? "").toLowerCase() === myAddr
2941
+ );
2942
+ already = mine ? mine.revealed ? "revealed" : "committed" : "no";
2943
+ }
2944
+ }
2945
+ } catch {
2946
+ }
2947
+ }
2948
+ rows.push({
2949
+ Novel: `#${n.id}`,
2950
+ Title: String(n.title ?? "").slice(0, 28),
2951
+ Round: n.current_round,
2952
+ Phase: roundPhaseName(phase),
2953
+ Deadline: remaining > 0 ? `${Math.floor(remaining / 3600)}h${Math.floor(remaining % 3600 / 60)}m` : chalk5.red("expired"),
2954
+ Pool: token(BigInt(String(n.pool_balance ?? "0")), nativeCurrency.decimals, nativeCurrency.symbol),
2955
+ Voters: voterCount,
2956
+ VoteStake: cfg.voteStake ? token(cfg.voteStake, nativeCurrency.decimals, nativeCurrency.symbol) : "-",
2957
+ Voted: already
2958
+ });
2959
+ }
2960
+ header("Voting Opportunities");
2961
+ if (rows.length === 0) {
2962
+ console.log(chalk5.gray(" No novels currently in a voting phase.\n"));
2963
+ return;
2964
+ }
2965
+ table(rows);
2966
+ console.log();
2967
+ } catch (err) {
2968
+ error(String(err));
2969
+ process.exit(1);
2970
+ }
2971
+ });
2972
+ vote.command("status <novel-id>").description(
2973
+ "Show your voting status for a novel: current phase, deadline, locally-stored salts, and on-chain reveal status. Use this to avoid missing the reveal window."
2974
+ ).action(async (novelId) => {
2975
+ try {
2976
+ const novel = await apiGet(`/api/novels/${novelId}`);
2977
+ const round = Number(novel.current_round ?? 0);
2978
+ const phase = Number(novel.round_phase ?? 0);
2979
+ const cfg = novel.config ?? {};
2980
+ const phaseStart = Number(novel.phase_start_time ?? 0);
2981
+ const now = Math.floor(Date.now() / 1e3);
2982
+ header(`Vote Status \u2014 Novel #${novelId}`);
2983
+ kv("Round", round);
2984
+ kv("Phase", roundPhaseName(phase));
2985
+ if (phase !== 0) {
2986
+ const durKey = phase === 1 ? "nominateDuration" : phase === 2 ? "commitDuration" : "revealDuration";
2987
+ const duration = Number(cfg[durKey] ?? 0);
2988
+ const deadline = phaseStart + duration;
2989
+ const remaining = deadline - now;
2990
+ kv(
2991
+ "Deadline",
2992
+ remaining > 0 ? `in ${Math.floor(remaining / 3600)}h ${Math.floor(remaining % 3600 / 60)}m` : chalk5.red("expired")
2993
+ );
2994
+ }
2995
+ const pk = getPrivateKey2();
2996
+ if (!pk) {
2997
+ console.log(chalk5.gray("\n (PRIVATE_KEY not set \u2014 skipping per-voter details)\n"));
2998
+ return;
2999
+ }
3000
+ const voter = privateKeyToAccount5(pk).address;
3001
+ kv("Voter", voter);
3002
+ if (round > 0) {
3003
+ try {
3004
+ const rd = await apiGet(
3005
+ `/api/novels/${novelId}/rounds/${round}`
3006
+ );
3007
+ const mine = rd.votes.filter(
3008
+ (v) => String(v.voter ?? "").toLowerCase() === voter.toLowerCase()
3009
+ );
3010
+ if (mine.length > 0) {
3011
+ console.log(chalk5.bold("\n On-chain votes this round:"));
3012
+ table(
3013
+ mine.map((v) => ({
3014
+ Committed: v.commit_hash ? "yes" : "no",
3015
+ Revealed: v.revealed ? "yes" : "no",
3016
+ Candidate: v.revealed ? `ID.${v.candidate_id}` : "-",
3017
+ Claimed: v.claimed ? "yes" : "no"
3018
+ }))
3019
+ );
3020
+ }
3021
+ } catch {
3022
+ }
3023
+ }
3024
+ const salts = listVoteSalts(BigInt(novelId), voter);
3025
+ if (salts.length > 0) {
3026
+ console.log(chalk5.bold("\n Local salt backups:"));
3027
+ table(
3028
+ salts.slice(0, 10).map((s) => ({
3029
+ Round: s.round,
3030
+ Candidate: `ID.${s.candidateId}`,
3031
+ SavedAt: new Date(s.createdAt * 1e3).toISOString().slice(0, 19).replace("T", " ")
3032
+ }))
3033
+ );
3034
+ kv("Store", getStorePath());
3035
+ } else {
3036
+ console.log(chalk5.gray("\n No local salt backups for this novel."));
3037
+ }
3038
+ console.log();
3039
+ } catch (err) {
3040
+ error(String(err));
3041
+ process.exit(1);
3042
+ }
3043
+ });
3044
+ }
3045
+
3046
+ // src/bin/onchain-novel-cli.ts
3047
+ program.name("onchain-novel-cli").description(
3048
+ "Onchain Novel Protocol CLI \u2014 create, write, vote, tip, and manage collaborative on-chain novels"
3049
+ ).version("0.1.0");
3050
+ var NO_BOOTSTRAP = /* @__PURE__ */ new Set(["setup", "guide", "config"]);
3051
+ program.hook("preAction", async (_thisCmd, actionCmd) => {
3052
+ const top = actionCmd.parent?.name() === "onchain-novel-cli" ? actionCmd.name() : actionCmd.parent?.name();
3053
+ if (top && NO_BOOTSTRAP.has(top)) return;
3054
+ await ensureBootstrapped();
3055
+ });
3056
+ registerSetupCommand(program);
3057
+ registerAdminCommands(program);
3058
+ registerConfigCommand(program);
3059
+ registerNovelCommands(program);
3060
+ registerChapterCommands(program);
3061
+ registerVoteCommands(program);
3062
+ registerTipCommands(program);
3063
+ registerBountyCommands(program);
3064
+ registerRuleCommands(program);
3065
+ registerUserCommands(program);
3066
+ registerFaucetCommands(program);
3067
+ registerGuideCommands(program);
3068
+ program.parseAsync().catch((err) => {
3069
+ console.error(String(err));
3070
+ process.exit(1);
3071
+ });