@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.
- package/LICENSE +201 -0
- package/README.md +434 -0
- package/index.ts +243 -0
- package/openclaw.plugin.json +185 -0
- package/package.json +28 -0
- package/src/cli.ts +954 -0
- package/src/config.ts +306 -0
- package/src/jobs/consensus.ts +191 -0
- package/src/jobs/eligibility.ts +11 -0
- package/src/jobs/engine.ts +503 -0
- package/src/jobs/slashing.ts +11 -0
- package/src/ledger/ledger.ts +140 -0
- package/src/ledger/rules.ts +19 -0
- package/src/network/client.ts +70 -0
- package/src/network/server.ts +143 -0
- package/src/service.ts +26 -0
- package/src/storage/IStorage.ts +31 -0
- package/src/storage/JsonStorage.ts +63 -0
- package/src/storage/SqliteStorage.ts +67 -0
- package/src/testing/consensusTestRunner.ts +0 -0
- package/src/tools.ts +184 -0
- package/src/types.ts +232 -0
- package/src/util/ids.ts +21 -0
- package/src/util/locks.ts +36 -0
- package/src/util/table.ts +35 -0
- package/src/util/time.ts +12 -0
|
@@ -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
|
+
}
|