@consensus-tools/consensus-tools 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,503 @@
1
+ import type {
2
+ ConsensusToolsConfig,
3
+ Assignment,
4
+ Bid,
5
+ Job,
6
+ Resolution,
7
+ Submission,
8
+ Vote,
9
+ ConsensusPolicyConfig,
10
+ SlashingPolicy
11
+ } from '../types';
12
+ import type { IStorage } from '../storage/IStorage';
13
+ import { newId } from '../util/ids';
14
+ import { addSeconds, nowIso, isPast } from '../util/time';
15
+ import { resolveConsensus } from './consensus';
16
+ import { calculateSlashAmount } from './slashing';
17
+ import { LedgerEngine } from '../ledger/ledger';
18
+ import { ensureNonNegative, getBalance } from '../ledger/rules';
19
+
20
+ export interface JobFilters {
21
+ status?: string;
22
+ tag?: string;
23
+ mine?: string;
24
+ }
25
+
26
+ export interface JobPostInput {
27
+ title: string;
28
+ description?: string;
29
+ desc?: string;
30
+ inputRef?: string;
31
+ inputs?: Record<string, unknown>;
32
+ mode?: 'SUBMISSION' | 'VOTING';
33
+ policyKey?: string;
34
+ policyConfigJson?: Record<string, unknown>;
35
+ rewardAmount?: number;
36
+ stakeAmount?: number;
37
+ currency?: string;
38
+ opensAt?: string;
39
+ closesAt?: string;
40
+ resolvesAt?: string;
41
+ artifactSchemaJson?: Record<string, unknown>;
42
+ boardId?: string;
43
+ reward?: number;
44
+ tags?: string[];
45
+ priority?: number;
46
+ requiredCapabilities?: string[];
47
+ constraints?: { timeSeconds?: number; budget?: number };
48
+ maxParticipants?: number;
49
+ minParticipants?: number;
50
+ consensusPolicy?: ConsensusPolicyConfig;
51
+ slashingPolicy?: SlashingPolicy;
52
+ expiresSeconds?: number;
53
+ stakeRequired?: number;
54
+ }
55
+
56
+ export interface ClaimInput {
57
+ stakeAmount: number;
58
+ leaseSeconds: number;
59
+ }
60
+
61
+ export interface SubmitInput {
62
+ summary: string;
63
+ artifacts?: Record<string, unknown>;
64
+ artifactRef?: string;
65
+ confidence: number;
66
+ requestedPayout?: number;
67
+ }
68
+
69
+ export interface VoteInput {
70
+ submissionId?: string;
71
+ targetType?: 'SUBMISSION' | 'CHOICE';
72
+ targetId?: string;
73
+ choiceKey?: string;
74
+ weight?: number;
75
+ stakeAmount?: number;
76
+ score?: number;
77
+ rationale?: string;
78
+ }
79
+
80
+ export interface ResolveInput {
81
+ manualWinners?: string[];
82
+ manualSubmissionId?: string;
83
+ }
84
+
85
+ export class JobEngine {
86
+ constructor(
87
+ private readonly storage: IStorage,
88
+ private readonly ledger: LedgerEngine,
89
+ private readonly config: ConsensusToolsConfig,
90
+ private readonly logger?: any
91
+ ) {}
92
+
93
+ async postJob(agentId: string, input: JobPostInput): Promise<Job> {
94
+ const now = nowIso();
95
+ const description = input.description ?? input.desc ?? '';
96
+ const policyFromKey = input.policyKey ? this.config.local.consensusPolicies?.[input.policyKey] : undefined;
97
+ const policyFromJson = (input.policyConfigJson ?? {}) as Partial<ConsensusPolicyConfig>;
98
+ const consensusPolicy: ConsensusPolicyConfig = {
99
+ ...this.config.local.jobDefaults.consensusPolicy,
100
+ ...(policyFromKey ?? {}),
101
+ ...policyFromJson,
102
+ ...(input.consensusPolicy ?? {})
103
+ };
104
+ const job: Job = {
105
+ id: newId('job'),
106
+ boardId: input.boardId,
107
+ creatorPrincipalId: agentId,
108
+ title: input.title,
109
+ desc: input.desc ?? description,
110
+ description,
111
+ inputRef: input.inputRef,
112
+ createdAt: now,
113
+ expiresAt: addSeconds(now, input.expiresSeconds ?? this.config.local.jobDefaults.expiresSeconds),
114
+ opensAt: input.opensAt ?? now,
115
+ closesAt: input.closesAt ?? addSeconds(now, input.expiresSeconds ?? this.config.local.jobDefaults.expiresSeconds),
116
+ resolvesAt: input.resolvesAt,
117
+ createdByAgentId: agentId,
118
+ mode: input.mode ?? 'SUBMISSION',
119
+ policyKey: input.policyKey,
120
+ policyConfigJson: input.policyConfigJson,
121
+ tags: input.tags ?? [],
122
+ priority: input.priority ?? 0,
123
+ requiredCapabilities: input.requiredCapabilities ?? [],
124
+ inputs: input.inputs ?? {},
125
+ constraints: input.constraints ?? {},
126
+ reward: input.reward ?? input.rewardAmount ?? this.config.local.jobDefaults.reward,
127
+ rewardAmount: input.rewardAmount ?? input.reward ?? this.config.local.jobDefaults.reward,
128
+ stakeRequired: input.stakeRequired ?? input.stakeAmount ?? this.config.local.jobDefaults.stakeRequired,
129
+ stakeAmount: input.stakeAmount ?? input.stakeRequired ?? this.config.local.jobDefaults.stakeRequired,
130
+ currency: input.currency ?? 'CREDITS',
131
+ maxParticipants: input.maxParticipants ?? this.config.local.jobDefaults.maxParticipants,
132
+ minParticipants: input.minParticipants ?? this.config.local.jobDefaults.minParticipants,
133
+ consensusPolicy,
134
+ slashingPolicy: input.slashingPolicy ?? this.config.local.jobDefaults.slashingPolicy,
135
+ escrowPolicy: { type: 'mint' },
136
+ artifactSchemaJson: input.artifactSchemaJson,
137
+ status: 'OPEN'
138
+ };
139
+
140
+ await this.storage.update((state) => {
141
+ state.jobs.push(job);
142
+ state.audit.push({
143
+ id: newId('audit'),
144
+ at: now,
145
+ type: 'job_posted',
146
+ jobId: job.id,
147
+ actorAgentId: agentId,
148
+ details: { title: job.title }
149
+ });
150
+ });
151
+
152
+ this.logger?.info?.({ jobId: job.id }, 'consensus-tools: job posted');
153
+ return job;
154
+ }
155
+
156
+ async listJobs(filters: JobFilters = {}): Promise<Job[]> {
157
+ const state = await this.storage.getState();
158
+ const updated = this.applyExpiry(state);
159
+ if (updated) await this.storage.saveState(state);
160
+ return state.jobs.filter((job) => {
161
+ if (filters.status && job.status !== filters.status) return false;
162
+ if (filters.tag && !job.tags.includes(filters.tag)) return false;
163
+ if (filters.mine && job.createdByAgentId !== filters.mine) return false;
164
+ return true;
165
+ });
166
+ }
167
+
168
+ async getJob(jobId: string): Promise<Job | undefined> {
169
+ const state = await this.storage.getState();
170
+ const updated = this.applyExpiry(state);
171
+ if (updated) await this.storage.saveState(state);
172
+ return state.jobs.find((job) => job.id === jobId);
173
+ }
174
+
175
+ async getStatus(jobId: string): Promise<{ job?: Job; claims: Assignment[]; submissions: Submission[]; resolution?: Resolution }>
176
+ {
177
+ const state = await this.storage.getState();
178
+ const updated = this.applyExpiry(state);
179
+ if (updated) await this.storage.saveState(state);
180
+ return {
181
+ job: state.jobs.find((job) => job.id === jobId),
182
+ claims: state.claims.filter((claim) => claim.jobId === jobId),
183
+ submissions: state.submissions.filter((sub) => sub.jobId === jobId),
184
+ resolution: state.resolutions.find((res) => res.jobId === jobId)
185
+ };
186
+ }
187
+
188
+ async claimJob(agentId: string, jobId: string, input: ClaimInput): Promise<Assignment> {
189
+ await this.ledger.ensureInitialCredits(agentId);
190
+ const now = nowIso();
191
+
192
+ return (await this.storage.update((state) => {
193
+ const job = state.jobs.find((j) => j.id === jobId);
194
+ if (!job) throw new Error(`Job not found: ${jobId}`);
195
+ if (job.status === 'RESOLVED' || job.status === 'CANCELLED') {
196
+ throw new Error(`Job is not claimable: ${job.status}`);
197
+ }
198
+ if (isPast(job.expiresAt)) {
199
+ job.status = 'EXPIRED';
200
+ throw new Error('Job has expired');
201
+ }
202
+
203
+ const existing = state.claims.find((c) => c.jobId === jobId && c.agentId === agentId && c.status === 'ACTIVE');
204
+ if (existing) throw new Error('Job already claimed by this agent');
205
+
206
+ const activeClaims = state.claims.filter((c) => c.jobId === jobId && c.status === 'ACTIVE');
207
+ if (activeClaims.length >= job.maxParticipants) throw new Error('Job is full');
208
+
209
+ const stakeAmount = Math.max(input.stakeAmount, job.stakeRequired);
210
+ const currentBalance = getBalance(state.ledger, agentId);
211
+ const nextBalance = currentBalance - Math.abs(stakeAmount);
212
+ ensureNonNegative(nextBalance, `${agentId} stake for ${jobId}`);
213
+
214
+ state.ledger.push({
215
+ id: newId('ledger'),
216
+ at: now,
217
+ type: 'STAKE',
218
+ agentId,
219
+ amount: -Math.abs(stakeAmount),
220
+ jobId
221
+ });
222
+
223
+ state.bids.push({
224
+ agentId,
225
+ jobId,
226
+ stakeAmount,
227
+ stakeAt: now
228
+ } as Bid);
229
+
230
+ const assignment: Assignment = {
231
+ agentId,
232
+ jobId,
233
+ claimAt: now,
234
+ leaseUntil: addSeconds(now, input.leaseSeconds),
235
+ heartbeatAt: now,
236
+ status: 'ACTIVE'
237
+ };
238
+
239
+ job.status = 'IN_PROGRESS';
240
+ state.claims.push(assignment);
241
+ state.audit.push({
242
+ id: newId('audit'),
243
+ at: now,
244
+ type: 'job_claimed',
245
+ jobId,
246
+ actorAgentId: agentId,
247
+ details: { stakeAmount }
248
+ });
249
+ return assignment;
250
+ })).result;
251
+ }
252
+
253
+ async heartbeat(agentId: string, jobId: string): Promise<void> {
254
+ await this.storage.update((state) => {
255
+ const claim = state.claims.find((c) => c.jobId === jobId && c.agentId === agentId && c.status === 'ACTIVE');
256
+ if (!claim) return;
257
+ claim.heartbeatAt = nowIso();
258
+ });
259
+ }
260
+
261
+ async submitJob(agentId: string, jobId: string, input: SubmitInput): Promise<Submission> {
262
+ const now = nowIso();
263
+ return (await this.storage.update((state) => {
264
+ const job = state.jobs.find((j) => j.id === jobId);
265
+ if (!job) throw new Error(`Job not found: ${jobId}`);
266
+ if (job.status === 'RESOLVED' || job.status === 'CANCELLED') throw new Error('Job is closed');
267
+
268
+ const submission: Submission = {
269
+ id: newId('sub'),
270
+ boardId: job.boardId,
271
+ submitterPrincipalId: agentId,
272
+ agentId,
273
+ jobId,
274
+ submittedAt: now,
275
+ createdAt: now,
276
+ artifactRef: input.artifactRef,
277
+ artifacts: input.artifacts ?? {},
278
+ summary: input.summary,
279
+ confidence: input.confidence,
280
+ requestedPayout: input.requestedPayout ?? job.reward,
281
+ status: 'SUBMITTED'
282
+ };
283
+ state.submissions.push(submission);
284
+ job.status = 'SUBMITTED';
285
+ state.audit.push({
286
+ id: newId('audit'),
287
+ at: now,
288
+ type: 'job_submitted',
289
+ jobId,
290
+ actorAgentId: agentId,
291
+ details: { submissionId: submission.id }
292
+ });
293
+ return submission;
294
+ })).result;
295
+ }
296
+
297
+ async vote(agentId: string, jobId: string, input: VoteInput): Promise<Vote> {
298
+ const now = nowIso();
299
+ return (await this.storage.update((state) => {
300
+ const job = state.jobs.find((j) => j.id === jobId);
301
+ if (!job) throw new Error(`Job not found: ${jobId}`);
302
+ if (
303
+ job.mode === 'SUBMISSION' &&
304
+ (job.consensusPolicy.type === 'SINGLE_WINNER' ||
305
+ job.consensusPolicy.type === 'HIGHEST_CONFIDENCE_SINGLE' ||
306
+ job.consensusPolicy.type === 'OWNER_PICK' ||
307
+ job.consensusPolicy.type === 'TOP_K_SPLIT' ||
308
+ job.consensusPolicy.type === 'TRUSTED_ARBITER')
309
+ ) {
310
+ throw new Error('Voting not enabled for this job');
311
+ }
312
+ const targetType = input.targetType ?? (input.submissionId ? 'SUBMISSION' : input.choiceKey ? 'CHOICE' : undefined);
313
+ const targetId = input.targetId ?? input.submissionId;
314
+ if (targetType === 'SUBMISSION') {
315
+ const submission = state.submissions.find((s) => s.id === targetId && s.jobId === jobId);
316
+ if (!submission) throw new Error('Submission not found');
317
+ }
318
+
319
+ const score = input.score ?? input.weight ?? 0;
320
+
321
+ const vote: Vote = {
322
+ id: newId('vote'),
323
+ jobId,
324
+ boardId: job.boardId,
325
+ voterPrincipalId: agentId,
326
+ submissionId: input.submissionId ?? (targetType === 'SUBMISSION' ? targetId : undefined),
327
+ targetType,
328
+ targetId,
329
+ choiceKey: input.choiceKey,
330
+ agentId,
331
+ score,
332
+ weight: input.weight ?? score,
333
+ stakeAmount: input.stakeAmount,
334
+ rationale: input.rationale,
335
+ createdAt: now
336
+ };
337
+ state.votes.push(vote);
338
+ state.audit.push({
339
+ id: newId('audit'),
340
+ at: now,
341
+ type: 'job_voted',
342
+ jobId,
343
+ actorAgentId: agentId,
344
+ details: { submissionId: vote.submissionId, score }
345
+ });
346
+ return vote;
347
+ })).result;
348
+ }
349
+
350
+ async resolveJob(agentId: string, jobId: string, input: ResolveInput = {}): Promise<Resolution> {
351
+ const now = nowIso();
352
+ return (await this.storage.update((state) => {
353
+ const job = state.jobs.find((j) => j.id === jobId);
354
+ if (!job) throw new Error(`Job not found: ${jobId}`);
355
+ if (job.status === 'RESOLVED') throw new Error('Job already resolved');
356
+
357
+ if (job.consensusPolicy.type === 'TRUSTED_ARBITER') {
358
+ const arbiter = job.consensusPolicy.trustedArbiterAgentId;
359
+ if (arbiter && arbiter !== agentId) {
360
+ throw new Error('Only the trusted arbiter can resolve this job');
361
+ }
362
+ }
363
+
364
+ if (job.consensusPolicy.type === 'OWNER_PICK' && job.createdByAgentId !== agentId) {
365
+ throw new Error('Only the job creator can resolve this job');
366
+ }
367
+
368
+ const submissions = state.submissions.filter((s) => s.jobId === jobId);
369
+ const votes = state.votes.filter((v) => v.jobId === jobId);
370
+ const bids = state.bids.filter((b) => b.jobId === jobId);
371
+
372
+ const reputation = (agent: string) => {
373
+ let score = 1;
374
+ for (const entry of state.ledger) {
375
+ if (entry.agentId !== agent) continue;
376
+ if (entry.type === 'PAYOUT') score += entry.amount;
377
+ if (entry.type === 'SLASH') score += entry.amount;
378
+ }
379
+ return Math.max(0.1, score);
380
+ };
381
+
382
+ const consensus = resolveConsensus({
383
+ job,
384
+ submissions,
385
+ votes,
386
+ reputation,
387
+ manualWinnerAgentIds: input.manualWinners,
388
+ manualSubmissionId: input.manualSubmissionId
389
+ });
390
+
391
+ const payouts: Array<{ agentId: string; amount: number }> = [];
392
+ const slashes: Array<{ agentId: string; amount: number; reason: string }> = [];
393
+
394
+ if (consensus.winners.length) {
395
+ const amountPerWinner = job.reward / consensus.winners.length;
396
+ for (const winner of consensus.winners) {
397
+ payouts.push({ agentId: winner, amount: amountPerWinner });
398
+ }
399
+ }
400
+
401
+ const submissionAgents = new Set(submissions.map((s) => s.agentId));
402
+ for (const bid of bids) {
403
+ const stakeAmount = bid.stakeAmount;
404
+ let slashAmount = 0;
405
+ let reason = '';
406
+ const slashingEnabled = this.config.local.slashingEnabled && job.slashingPolicy?.enabled;
407
+ if (slashingEnabled && !submissionAgents.has(bid.agentId)) {
408
+ slashAmount = Math.min(calculateSlashAmount(job, this.config, stakeAmount), stakeAmount);
409
+ reason = 'timeout';
410
+ }
411
+
412
+ if (slashAmount > 0) {
413
+ slashes.push({ agentId: bid.agentId, amount: slashAmount, reason });
414
+ if (stakeAmount > 0) {
415
+ state.ledger.push({
416
+ id: newId('ledger'),
417
+ at: now,
418
+ type: 'UNSTAKE',
419
+ agentId: bid.agentId,
420
+ amount: stakeAmount,
421
+ jobId
422
+ });
423
+ }
424
+ } else if (stakeAmount > 0) {
425
+ state.ledger.push({
426
+ id: newId('ledger'),
427
+ at: now,
428
+ type: 'UNSTAKE',
429
+ agentId: bid.agentId,
430
+ amount: stakeAmount,
431
+ jobId
432
+ });
433
+ }
434
+ }
435
+
436
+ for (const payout of payouts) {
437
+ state.ledger.push({
438
+ id: newId('ledger'),
439
+ at: now,
440
+ type: 'PAYOUT',
441
+ agentId: payout.agentId,
442
+ amount: payout.amount,
443
+ jobId
444
+ });
445
+ }
446
+
447
+ for (const slash of slashes) {
448
+ state.ledger.push({
449
+ id: newId('ledger'),
450
+ at: now,
451
+ type: 'SLASH',
452
+ agentId: slash.agentId,
453
+ amount: -Math.abs(slash.amount),
454
+ jobId,
455
+ reason: slash.reason
456
+ });
457
+ }
458
+
459
+ const resolution: Resolution = {
460
+ jobId,
461
+ resolvedAt: now,
462
+ winners: consensus.winners,
463
+ winningSubmissionIds: consensus.winningSubmissionIds,
464
+ payouts,
465
+ slashes,
466
+ consensusTrace: consensus.consensusTrace,
467
+ finalArtifact: consensus.finalArtifact,
468
+ auditLog: [`resolved_by:${agentId}`]
469
+ };
470
+
471
+ state.resolutions.push(resolution);
472
+ job.status = 'RESOLVED';
473
+ state.audit.push({
474
+ id: newId('audit'),
475
+ at: now,
476
+ type: 'job_resolved',
477
+ jobId,
478
+ actorAgentId: agentId,
479
+ details: { winners: consensus.winners }
480
+ });
481
+
482
+ return resolution;
483
+ })).result;
484
+ }
485
+
486
+ async recordError(message: string, context?: Record<string, unknown>): Promise<void> {
487
+ await this.storage.update((state) => {
488
+ state.errors.push({ id: newId('err'), at: nowIso(), message, context });
489
+ if (state.errors.length > 50) state.errors.shift();
490
+ });
491
+ }
492
+
493
+ private applyExpiry(state: { jobs: Job[] }): boolean {
494
+ let changed = false;
495
+ for (const job of state.jobs) {
496
+ if ((job.status === 'OPEN' || job.status === 'IN_PROGRESS' || job.status === 'SUBMITTED') && isPast(job.expiresAt)) {
497
+ job.status = 'EXPIRED';
498
+ changed = true;
499
+ }
500
+ }
501
+ return changed;
502
+ }
503
+ }
@@ -0,0 +1,11 @@
1
+ import type { ConsensusToolsConfig, Job } from '../types';
2
+
3
+ export function calculateSlashAmount(job: Job, config: ConsensusToolsConfig, stakeAmount: number): number {
4
+ if (!config.local.slashingEnabled) return 0;
5
+ const policy = job.slashingPolicy;
6
+ if (!policy?.enabled) return 0;
7
+ const percent = policy.slashPercent || 0;
8
+ const flat = policy.slashFlat || 0;
9
+ const byPercent = stakeAmount * percent;
10
+ return Math.max(byPercent, flat);
11
+ }
@@ -0,0 +1,140 @@
1
+ import type { ConsensusToolsConfig, LedgerEntry, StorageState } from '../types';
2
+ import type { IStorage } from '../storage/IStorage';
3
+ import { computeBalances, ensureNonNegative, getBalance } from './rules';
4
+ import { newId } from '../util/ids';
5
+ import { nowIso } from '../util/time';
6
+
7
+ export class LedgerEngine {
8
+ constructor(private readonly storage: IStorage, private readonly config: ConsensusToolsConfig, private readonly logger?: any) {}
9
+
10
+ async getBalance(agentId: string): Promise<number> {
11
+ if (this.config.local.ledger.balancesMode === 'override') {
12
+ await this.applyConfigBalances(this.config.local.ledger.balances, 'override');
13
+ }
14
+ const state = await this.storage.getState();
15
+ return getBalance(state.ledger, agentId);
16
+ }
17
+
18
+ async getBalances(): Promise<Record<string, number>> {
19
+ if (this.config.local.ledger.balancesMode === 'override') {
20
+ await this.applyConfigBalances(this.config.local.ledger.balances, 'override');
21
+ }
22
+ const state = await this.storage.getState();
23
+ return computeBalances(state.ledger);
24
+ }
25
+
26
+ async faucet(agentId: string, amount: number, reason = 'faucet'): Promise<LedgerEntry> {
27
+ return this.appendEntry({
28
+ id: newId('ledger'),
29
+ at: nowIso(),
30
+ type: 'FAUCET',
31
+ agentId,
32
+ amount,
33
+ reason
34
+ });
35
+ }
36
+
37
+ async stake(agentId: string, amount: number, jobId?: string): Promise<LedgerEntry> {
38
+ return this.appendEntry({
39
+ id: newId('ledger'),
40
+ at: nowIso(),
41
+ type: 'STAKE',
42
+ agentId,
43
+ amount: -Math.abs(amount),
44
+ jobId
45
+ });
46
+ }
47
+
48
+ async unstake(agentId: string, amount: number, jobId?: string): Promise<LedgerEntry> {
49
+ return this.appendEntry({
50
+ id: newId('ledger'),
51
+ at: nowIso(),
52
+ type: 'UNSTAKE',
53
+ agentId,
54
+ amount: Math.abs(amount),
55
+ jobId
56
+ });
57
+ }
58
+
59
+ async payout(agentId: string, amount: number, jobId?: string): Promise<LedgerEntry> {
60
+ return this.appendEntry({
61
+ id: newId('ledger'),
62
+ at: nowIso(),
63
+ type: 'PAYOUT',
64
+ agentId,
65
+ amount: Math.abs(amount),
66
+ jobId
67
+ });
68
+ }
69
+
70
+ async slash(agentId: string, amount: number, jobId?: string, reason?: string): Promise<LedgerEntry> {
71
+ return this.appendEntry({
72
+ id: newId('ledger'),
73
+ at: nowIso(),
74
+ type: 'SLASH',
75
+ agentId,
76
+ amount: -Math.abs(amount),
77
+ jobId,
78
+ reason
79
+ });
80
+ }
81
+
82
+ async ensureInitialCredits(agentId: string): Promise<void> {
83
+ if (this.config.local.ledger.initialCreditsPerAgent <= 0) return;
84
+ await this.storage.update((state) => {
85
+ const balance = getBalance(state.ledger, agentId);
86
+ if (balance > 0) return;
87
+ state.ledger.push({
88
+ id: newId('ledger'),
89
+ at: nowIso(),
90
+ type: 'FAUCET',
91
+ agentId,
92
+ amount: this.config.local.ledger.initialCreditsPerAgent,
93
+ reason: 'initial_credit'
94
+ });
95
+ });
96
+ }
97
+
98
+ async applyConfigBalances(balances: Record<string, number>, mode: 'initial' | 'override' = 'initial'): Promise<void> {
99
+ const entries = balances || {};
100
+ const agentIds = Object.keys(entries);
101
+ if (!agentIds.length) return;
102
+ await this.storage.update((state) => {
103
+ for (const agentId of agentIds) {
104
+ const target = entries[agentId];
105
+ const current = getBalance(state.ledger, agentId);
106
+ if (mode === 'initial' && current > 0) continue;
107
+ const delta = target - current;
108
+ if (!Number.isFinite(delta) || delta === 0) continue;
109
+ const nextBalance = current + delta;
110
+ ensureNonNegative(nextBalance, `${agentId} config balance`);
111
+ state.ledger.push({
112
+ id: newId('ledger'),
113
+ at: nowIso(),
114
+ type: 'ADJUST',
115
+ agentId,
116
+ amount: delta,
117
+ reason: 'config_balance'
118
+ });
119
+ }
120
+ });
121
+ }
122
+
123
+ private async appendEntry(entry: LedgerEntry): Promise<LedgerEntry> {
124
+ await this.storage.update((state) => {
125
+ const currentBalance = getBalance(state.ledger, entry.agentId);
126
+ const nextBalance = currentBalance + entry.amount;
127
+ ensureNonNegative(nextBalance, `${entry.agentId} after ${entry.type}`);
128
+ state.ledger.push(entry);
129
+ });
130
+ this.logger?.info?.({ entry }, 'consensus-tools: ledger entry');
131
+ if (this.config.local.ledger.balancesMode === 'override') {
132
+ await this.applyConfigBalances(this.config.local.ledger.balances, 'override');
133
+ }
134
+ return entry;
135
+ }
136
+
137
+ async reconcile(state: StorageState): Promise<Record<string, number>> {
138
+ return computeBalances(state.ledger);
139
+ }
140
+ }
@@ -0,0 +1,19 @@
1
+ import type { LedgerEntry } from '../types';
2
+
3
+ export function computeBalances(entries: LedgerEntry[]): Record<string, number> {
4
+ const balances: Record<string, number> = {};
5
+ for (const entry of entries) {
6
+ balances[entry.agentId] = (balances[entry.agentId] || 0) + entry.amount;
7
+ }
8
+ return balances;
9
+ }
10
+
11
+ export function getBalance(entries: LedgerEntry[], agentId: string): number {
12
+ return entries.reduce((sum, entry) => (entry.agentId === agentId ? sum + entry.amount : sum), 0);
13
+ }
14
+
15
+ export function ensureNonNegative(balance: number, context: string): void {
16
+ if (balance < 0) {
17
+ throw new Error(`consensus-tools: insufficient credits for ${context}`);
18
+ }
19
+ }