@hivemind-os/collective-core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
  2. package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
  3. package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
  4. package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
  5. package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
  6. package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
  7. package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
  8. package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
  9. package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
  10. package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
  11. package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
  12. package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
  13. package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
  14. package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
  15. package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
  16. package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
  17. package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
  18. package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
  19. package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
  20. package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
  21. package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
  22. package/.turbo/turbo-build.log +14 -0
  23. package/dist/index.d.ts +1675 -0
  24. package/dist/index.js +8006 -0
  25. package/dist/index.js.map +1 -0
  26. package/package.json +41 -0
  27. package/src/auth/device-flow.ts +108 -0
  28. package/src/auth/ed25519-provider.ts +43 -0
  29. package/src/auth/errors.ts +82 -0
  30. package/src/auth/evm-key.ts +55 -0
  31. package/src/auth/index.ts +8 -0
  32. package/src/auth/session-state.ts +25 -0
  33. package/src/auth/session-store.ts +510 -0
  34. package/src/auth/types.ts +81 -0
  35. package/src/auth/zklogin-provider.ts +902 -0
  36. package/src/blobstore/WALRUS_FINDINGS.md +284 -0
  37. package/src/blobstore/encrypted-store.ts +56 -0
  38. package/src/blobstore/fs-store.ts +91 -0
  39. package/src/blobstore/hybrid-store.ts +144 -0
  40. package/src/blobstore/index.ts +5 -0
  41. package/src/blobstore/interface.ts +33 -0
  42. package/src/blobstore/walrus-spike.ts +345 -0
  43. package/src/blobstore/walrus-store.ts +551 -0
  44. package/src/cache/agent-cache.ts +403 -0
  45. package/src/cache/index.ts +1 -0
  46. package/src/crypto/encryption.ts +152 -0
  47. package/src/crypto/index.ts +2 -0
  48. package/src/crypto/x25519.ts +41 -0
  49. package/src/dispute/client.ts +191 -0
  50. package/src/dispute/index.ts +1 -0
  51. package/src/events/index.ts +2 -0
  52. package/src/events/parser.ts +291 -0
  53. package/src/events/subscription.ts +131 -0
  54. package/src/evm/constants.ts +6 -0
  55. package/src/evm/index.ts +2 -0
  56. package/src/evm/wallet.ts +136 -0
  57. package/src/identity/did.ts +36 -0
  58. package/src/identity/index.ts +4 -0
  59. package/src/identity/keypair.ts +199 -0
  60. package/src/identity/signing.ts +28 -0
  61. package/src/index.ts +22 -0
  62. package/src/internal/parsing.ts +416 -0
  63. package/src/marketplace/client.ts +349 -0
  64. package/src/marketplace/index.ts +1 -0
  65. package/src/metering/hash-chain.ts +94 -0
  66. package/src/metering/index.ts +4 -0
  67. package/src/metering/meter.ts +80 -0
  68. package/src/metering/streaming.ts +196 -0
  69. package/src/metering/verification.ts +104 -0
  70. package/src/payment/index.ts +1 -0
  71. package/src/payment/rail-selector.ts +41 -0
  72. package/src/registry/client.ts +328 -0
  73. package/src/registry/index.ts +1 -0
  74. package/src/relay/consumer-client.ts +497 -0
  75. package/src/relay/index.ts +1 -0
  76. package/src/relay-registry/client.ts +295 -0
  77. package/src/relay-registry/discovery.ts +109 -0
  78. package/src/relay-registry/index.ts +2 -0
  79. package/src/reputation/anchor-client.ts +126 -0
  80. package/src/reputation/event-publisher.ts +67 -0
  81. package/src/reputation/index.ts +5 -0
  82. package/src/reputation/merkle.ts +79 -0
  83. package/src/reputation/score-calculator.ts +133 -0
  84. package/src/reputation/serialization.ts +37 -0
  85. package/src/reputation/store.ts +165 -0
  86. package/src/reputation/validation.ts +135 -0
  87. package/src/routing/circuit-breaker.ts +111 -0
  88. package/src/routing/fan-out.ts +266 -0
  89. package/src/routing/index.ts +4 -0
  90. package/src/routing/performance.ts +244 -0
  91. package/src/routing/selector.ts +225 -0
  92. package/src/spending/index.ts +1 -0
  93. package/src/spending/policy.ts +271 -0
  94. package/src/staking/client.ts +319 -0
  95. package/src/staking/index.ts +1 -0
  96. package/src/sui/client.ts +214 -0
  97. package/src/sui/index.ts +2 -0
  98. package/src/sui/tx-helpers.ts +1070 -0
  99. package/src/task/client.ts +215 -0
  100. package/src/task/index.ts +1 -0
  101. package/src/x402/client.ts +295 -0
  102. package/src/x402/index.ts +1 -0
  103. package/tests/auth/device-flow.test.ts +62 -0
  104. package/tests/auth/ed25519-provider.test.ts +24 -0
  105. package/tests/auth/evm-key.test.ts +31 -0
  106. package/tests/auth/session-store.test.ts +201 -0
  107. package/tests/auth/zklogin-provider.test.ts +366 -0
  108. package/tests/blobstore/encrypted-store.test.ts +78 -0
  109. package/tests/blobstore.test.ts +91 -0
  110. package/tests/cache.test.ts +124 -0
  111. package/tests/crypto/encryption.test.ts +70 -0
  112. package/tests/crypto/x25519.test.ts +47 -0
  113. package/tests/dispute/client.test.ts +238 -0
  114. package/tests/events.test.ts +202 -0
  115. package/tests/evm/wallet.test.ts +101 -0
  116. package/tests/hybrid-store.test.ts +121 -0
  117. package/tests/identity.test.ts +161 -0
  118. package/tests/marketplace.test.ts +308 -0
  119. package/tests/metering/hash-chain.test.ts +32 -0
  120. package/tests/metering/meter.test.ts +23 -0
  121. package/tests/metering/streaming.test.ts +52 -0
  122. package/tests/metering/verification.test.ts +27 -0
  123. package/tests/payment/rail-selector.test.ts +95 -0
  124. package/tests/registry.test.ts +183 -0
  125. package/tests/relay-consumer-client.test.ts +119 -0
  126. package/tests/relay-registry/client.test.ts +261 -0
  127. package/tests/reputation/event-publisher.test.ts +70 -0
  128. package/tests/reputation/merkle.test.ts +44 -0
  129. package/tests/reputation/score-calculator.test.ts +104 -0
  130. package/tests/reputation/store.test.ts +94 -0
  131. package/tests/routing/circuit-breaker.test.ts +45 -0
  132. package/tests/routing/fan-out.test.ts +123 -0
  133. package/tests/routing/performance.test.ts +49 -0
  134. package/tests/routing/selector.test.ts +114 -0
  135. package/tests/spending.test.ts +133 -0
  136. package/tests/staking/client.test.ts +286 -0
  137. package/tests/sui-client.test.ts +85 -0
  138. package/tests/task.test.ts +249 -0
  139. package/tests/tx-helpers.test.ts +70 -0
  140. package/tests/walrus-spike.test.ts +100 -0
  141. package/tests/walrus-store.test.ts +196 -0
  142. package/tests/x402/client.test.ts +116 -0
  143. package/tsconfig.json +9 -0
  144. package/tsup.config.ts +11 -0
  145. package/vitest.config.ts +8 -0
@@ -0,0 +1,349 @@
1
+ import pino from 'pino';
2
+
3
+ import type { Bid, BidRecommendation, NetworkConfig, Task } from '@hivemind-os/collective-types';
4
+ import { BidStatus, TaskStatus } from '@hivemind-os/collective-types';
5
+ import type { SuiEvent, SuiTransactionBlockResponse } from '@mysten/sui/client';
6
+ import type { Signer } from '@mysten/sui/cryptography';
7
+
8
+ import { isRecord, normalizeMoveValue, parseBidFields, parseTaskFields } from '../internal/parsing.js';
9
+ import { ReputationScoreCalculator } from '../reputation/score-calculator.js';
10
+ import { RegistryClient } from '../registry/client.js';
11
+ import { MeshSuiClient } from '../sui/client.js';
12
+ import { buildAcceptBidTx, buildPlaceBidTx, buildRejectBidTx, buildWithdrawBidTx } from '../sui/tx-helpers.js';
13
+ import { TaskClient } from '../task/client.js';
14
+
15
+ const logger = pino({ name: '@hivemind-os/collective-core:marketplace' });
16
+ const DEFAULT_REPUTATION_WEIGHT = 1_000_000n;
17
+ const DEFAULT_PRICE_WEIGHT = 1n;
18
+ const DEFAULT_BROWSE_LIMIT = 20;
19
+ const SCORE_SCALE = 1_000_000n;
20
+
21
+ export interface BrowseOpenTasksFilters {
22
+ category?: string;
23
+ minPriceMist?: bigint;
24
+ maxPriceMist?: bigint;
25
+ limit?: number;
26
+ }
27
+
28
+ export class MarketplaceClient {
29
+ private readonly taskClient: TaskClient;
30
+ private readonly registryClient: RegistryClient;
31
+ private readonly scoreCalculator = new ReputationScoreCalculator();
32
+
33
+ constructor(
34
+ private readonly suiClient: MeshSuiClient,
35
+ private readonly config: NetworkConfig,
36
+ ) {
37
+ this.taskClient = new TaskClient(suiClient, config);
38
+ this.registryClient = new RegistryClient(suiClient, config);
39
+ }
40
+
41
+ async postOpenTask(params: {
42
+ capability: string;
43
+ category: string;
44
+ inputBlobId: string;
45
+ agreementHash?: string;
46
+ priceMist: bigint;
47
+ disputeWindowMs: number;
48
+ expiryHours: number;
49
+ signer: Signer;
50
+ }): Promise<{ txDigest: string; taskId: string }> {
51
+ return await this.taskClient.postTask({
52
+ capability: params.capability,
53
+ category: params.category,
54
+ inputBlobId: params.inputBlobId,
55
+ agreementHash: params.agreementHash,
56
+ priceMist: params.priceMist,
57
+ disputeWindowMs: params.disputeWindowMs,
58
+ expiryHours: params.expiryHours,
59
+ keypair: params.signer,
60
+ });
61
+ }
62
+
63
+ async placeBid(params: {
64
+ taskId: string;
65
+ bidPriceMist: bigint;
66
+ signer: Signer;
67
+ reputationScore?: bigint | number;
68
+ evidenceBlob?: string;
69
+ }): Promise<{ bidId: string; txDigest: string; reputationScore: bigint }> {
70
+ const reputationScore = params.reputationScore == null
71
+ ? await this.deriveReputationScore(params.signer.getPublicKey().toSuiAddress())
72
+ : toNonNegativeBigInt(params.reputationScore, 'params.reputationScore');
73
+
74
+ const tx = buildPlaceBidTx({
75
+ packageId: this.config.packageId,
76
+ taskId: params.taskId,
77
+ bidPriceMist: params.bidPriceMist,
78
+ reputationScore,
79
+ evidenceBlob: params.evidenceBlob,
80
+ });
81
+ const response = await this.suiClient.executeTransaction(tx, params.signer);
82
+ const bidId = extractObjectId(response, /::marketplace::Bid$/);
83
+ if (!bidId) {
84
+ logger.warn({ response }, 'Bid placement succeeded without a Bid object change.');
85
+ throw new Error('Unable to determine bid id from transaction response.');
86
+ }
87
+
88
+ return { bidId, txDigest: response.digest, reputationScore };
89
+ }
90
+
91
+ async acceptBid(params: {
92
+ taskId: string;
93
+ bidId: string;
94
+ signer: Signer;
95
+ rejectCompeting?: boolean;
96
+ }): Promise<{ txDigest: string; rejectedBidIds: string[] }> {
97
+ const rejectedBidIds = params.rejectCompeting === false
98
+ ? []
99
+ : (await this.getBidsForTask(params.taskId))
100
+ .filter((bid) => bid.id !== params.bidId && bid.status === BidStatus.ACTIVE)
101
+ .map((bid) => bid.id);
102
+
103
+ const tx = buildAcceptBidTx({
104
+ packageId: this.config.packageId,
105
+ taskId: params.taskId,
106
+ bidId: params.bidId,
107
+ otherBidIds: rejectedBidIds,
108
+ });
109
+ const response = await this.suiClient.executeTransaction(tx, params.signer);
110
+ return { txDigest: response.digest, rejectedBidIds };
111
+ }
112
+
113
+ async withdrawBid(params: { bidId: string; signer: Signer }): Promise<{ txDigest: string }> {
114
+ const tx = buildWithdrawBidTx({ packageId: this.config.packageId, bidId: params.bidId });
115
+ const response = await this.suiClient.executeTransaction(tx, params.signer);
116
+ return { txDigest: response.digest };
117
+ }
118
+
119
+ async rejectBid(params: { taskId: string; bidId: string; signer: Signer }): Promise<{ txDigest: string }> {
120
+ const tx = buildRejectBidTx({ packageId: this.config.packageId, taskId: params.taskId, bidId: params.bidId });
121
+ const response = await this.suiClient.executeTransaction(tx, params.signer);
122
+ return { txDigest: response.digest };
123
+ }
124
+
125
+ async getBid(bidId: string): Promise<Bid | null> {
126
+ try {
127
+ const object = await this.suiClient.getObject<Record<string, unknown>>(bidId);
128
+ return parseBidFields(object, bidId);
129
+ } catch (error) {
130
+ if (isObjectMissingError(error)) {
131
+ return null;
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ async getBidsForTask(taskId: string): Promise<Bid[]> {
138
+ const eventType = `${this.config.packageId}::marketplace::BidPlaced`;
139
+ const bidIds = new Set<string>();
140
+ let cursor = null;
141
+
142
+ do {
143
+ const page = await this.suiClient.queryEvents(eventType, cursor, 100);
144
+ for (const event of page.events) {
145
+ const payload = normalizeEvent(event);
146
+ if (asString(readField(payload, 'task_id', 'taskId')) !== taskId) {
147
+ continue;
148
+ }
149
+ const bidId = asString(readField(payload, 'bid_id', 'bidId'));
150
+ if (bidId) {
151
+ bidIds.add(bidId);
152
+ }
153
+ }
154
+
155
+ cursor = page.nextCursor;
156
+ if (!page.hasMore) {
157
+ break;
158
+ }
159
+ } while (cursor);
160
+
161
+ const bids = await Promise.all([...bidIds].map(async (bidId) => await this.getBid(bidId)));
162
+ return bids
163
+ .filter((bid): bid is Bid => Boolean(bid))
164
+ .sort((left, right) => left.createdAt - right.createdAt || compareBigInt(left.bidPrice, right.bidPrice));
165
+ }
166
+
167
+ async browseOpenTasks(filters: BrowseOpenTasksFilters = {}): Promise<Task[]> {
168
+ const eventType = `${this.config.packageId}::task::TaskPosted`;
169
+ const limit = normalizeLimit(filters.limit, DEFAULT_BROWSE_LIMIT, 'filters.limit');
170
+ const category = normalizeOptionalCategory(filters.category);
171
+ if (filters.minPriceMist != null && filters.maxPriceMist != null && filters.minPriceMist > filters.maxPriceMist) {
172
+ throw new Error('filters.minPriceMist must be less than or equal to filters.maxPriceMist.');
173
+ }
174
+ const taskIds = new Set<string>();
175
+ const tasks: Task[] = [];
176
+ let cursor = null;
177
+
178
+ do {
179
+ const page = await this.suiClient.queryEvents(eventType, cursor, Math.max(limit * 3, 20));
180
+ for (const event of page.events) {
181
+ const postedTask = parseTaskFields(normalizeEvent(event));
182
+ if (!postedTask.id || taskIds.has(postedTask.id)) {
183
+ continue;
184
+ }
185
+ if (category && postedTask.category.toLowerCase() !== category.toLowerCase()) {
186
+ continue;
187
+ }
188
+
189
+ const current = await this.taskClient.getTask(postedTask.id);
190
+ if (!current || current.status !== TaskStatus.OPEN || Date.now() >= current.expiresAt) {
191
+ continue;
192
+ }
193
+ if (category && current.category.toLowerCase() !== category.toLowerCase()) {
194
+ continue;
195
+ }
196
+ if (filters.minPriceMist != null && current.price < filters.minPriceMist) {
197
+ continue;
198
+ }
199
+ if (filters.maxPriceMist != null && current.price > filters.maxPriceMist) {
200
+ continue;
201
+ }
202
+
203
+ taskIds.add(current.id);
204
+ tasks.push(current);
205
+ if (tasks.length >= limit) {
206
+ return tasks.sort((left, right) => right.createdAt - left.createdAt);
207
+ }
208
+ }
209
+
210
+ cursor = page.nextCursor;
211
+ if (!page.hasMore) {
212
+ break;
213
+ }
214
+ } while (cursor);
215
+
216
+ return tasks.sort((left, right) => right.createdAt - left.createdAt);
217
+ }
218
+
219
+ async getRecommendedBid(
220
+ taskId: string,
221
+ options: { reputationWeight?: bigint | number; priceWeight?: bigint | number } = {},
222
+ ): Promise<BidRecommendation | null> {
223
+ const bids = (await this.getBidsForTask(taskId)).filter((bid) => bid.status === BidStatus.ACTIVE);
224
+ if (bids.length === 0) {
225
+ return null;
226
+ }
227
+
228
+ const reputationWeight = options.reputationWeight == null
229
+ ? DEFAULT_REPUTATION_WEIGHT
230
+ : toNonNegativeBigInt(options.reputationWeight, 'options.reputationWeight');
231
+ const priceWeight = options.priceWeight == null
232
+ ? DEFAULT_PRICE_WEIGHT
233
+ : toNonNegativeBigInt(options.priceWeight, 'options.priceWeight');
234
+ const ranked = bids
235
+ .map((bid) => ({
236
+ bid,
237
+ score: calculateBidSelectionScore(bid.reputationScore, bid.bidPrice, reputationWeight, priceWeight),
238
+ reputationWeight,
239
+ priceWeight,
240
+ }))
241
+ .sort((left, right) => {
242
+ if (left.score !== right.score) {
243
+ return left.score > right.score ? -1 : 1;
244
+ }
245
+ return compareBigInt(left.bid.bidPrice, right.bid.bidPrice) || left.bid.createdAt - right.bid.createdAt;
246
+ });
247
+
248
+ return ranked[0] ?? null;
249
+ }
250
+
251
+ private async deriveReputationScore(owner: string): Promise<bigint> {
252
+ const card = await this.registryClient.getAgentCardByOwner(owner);
253
+ if (!card) {
254
+ return 0n;
255
+ }
256
+
257
+ const score = this.scoreCalculator.computeScore(card, []);
258
+ const stakeBonus = score.stakeAmount / 1_000_000_000n;
259
+ const boundedStakeBonus = stakeBonus > 10_000n ? 10_000n : stakeBonus;
260
+ const reputationScore = Math.max(
261
+ Math.round(score.successRate * 1_000) + (score.totalTasks * 10) - (score.totalDisputes * 25),
262
+ 0,
263
+ );
264
+ return BigInt(reputationScore) + boundedStakeBonus;
265
+ }
266
+ }
267
+
268
+ function calculateBidSelectionScore(
269
+ reputationScore: bigint,
270
+ bidPrice: bigint,
271
+ reputationWeight: bigint,
272
+ priceWeight: bigint,
273
+ ): bigint {
274
+ return (reputationScore * reputationWeight * SCORE_SCALE) / ((bidPrice * priceWeight) + 1n);
275
+ }
276
+
277
+ function extractObjectId(response: SuiTransactionBlockResponse, objectTypePattern: RegExp): string | undefined {
278
+ const change = (response.objectChanges as Array<Record<string, unknown>> | null | undefined)?.find(
279
+ (entry) =>
280
+ (entry.type === 'created' || entry.type === 'transferred' || entry.type === 'mutated')
281
+ && typeof entry.objectType === 'string'
282
+ && objectTypePattern.test(entry.objectType)
283
+ && typeof entry.objectId === 'string',
284
+ );
285
+ return change?.objectId as string | undefined;
286
+ }
287
+
288
+ function normalizeEvent(event: SuiEvent): Record<string, unknown> {
289
+ const normalized = normalizeMoveValue(event.parsedJson);
290
+ return isRecord(normalized) ? normalized : {};
291
+ }
292
+
293
+ function readField(record: Record<string, unknown>, ...keys: string[]): unknown {
294
+ for (const key of keys) {
295
+ if (key in record) {
296
+ return record[key];
297
+ }
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ function asString(value: unknown): string {
303
+ return typeof value === 'string' ? value : '';
304
+ }
305
+
306
+ function compareBigInt(left: bigint, right: bigint): number {
307
+ if (left === right) {
308
+ return 0;
309
+ }
310
+ return left < right ? -1 : 1;
311
+ }
312
+
313
+ function toNonNegativeBigInt(value: bigint | number, field: string): bigint {
314
+ if (typeof value === 'bigint') {
315
+ if (value < 0n) {
316
+ throw new Error(`${field} must be non-negative.`);
317
+ }
318
+ return value;
319
+ }
320
+ if (!Number.isSafeInteger(value) || value < 0) {
321
+ throw new Error(`${field} must be a non-negative safe integer.`);
322
+ }
323
+ return BigInt(value);
324
+ }
325
+
326
+ function normalizeLimit(value: number | undefined, fallback: number, field: string): number {
327
+ if (value == null) {
328
+ return fallback;
329
+ }
330
+ if (!Number.isSafeInteger(value) || value <= 0) {
331
+ throw new Error(`${field} must be a positive safe integer.`);
332
+ }
333
+ return value;
334
+ }
335
+
336
+ function normalizeOptionalCategory(value: string | undefined): string | undefined {
337
+ if (value == null) {
338
+ return undefined;
339
+ }
340
+ const trimmed = value.trim();
341
+ if (trimmed.length === 0) {
342
+ throw new Error('filters.category must be a non-empty string when provided.');
343
+ }
344
+ return trimmed;
345
+ }
346
+
347
+ function isObjectMissingError(error: unknown): boolean {
348
+ return error instanceof Error && /not found|does not contain move object data/i.test(error.message);
349
+ }
@@ -0,0 +1 @@
1
+ export * from './client.js';
@@ -0,0 +1,94 @@
1
+ import { sha256 } from '@noble/hashes/sha256';
2
+
3
+ import type { HashChainProof } from '@hivemind-os/collective-types';
4
+
5
+ const encoder = new TextEncoder();
6
+ const DEFAULT_SEED_PREFIX = 'agentic-mesh:metering:v1';
7
+ const HASH_HEX_PATTERN = /^[a-f0-9]{64}$/i;
8
+
9
+ export const DEFAULT_METERING_CHUNK_SIZE = 1024;
10
+
11
+ export class HashChain {
12
+ private currentHash: Uint8Array;
13
+ private readonly intermediateHashes: string[] = [];
14
+
15
+ constructor(private readonly seed: Uint8Array = encoder.encode(DEFAULT_SEED_PREFIX)) {
16
+ this.currentHash = hashBytes(this.seed);
17
+ }
18
+
19
+ addUnit(data: Uint8Array): string {
20
+ this.currentHash = hashBytes(concatBytes(this.currentHash, data));
21
+ const hash = bytesToHex(this.currentHash);
22
+ this.intermediateHashes.push(hash);
23
+ return hash;
24
+ }
25
+
26
+ getRoot(): string {
27
+ return bytesToHex(this.currentHash);
28
+ }
29
+
30
+ getProof(): HashChainProof {
31
+ return {
32
+ root: this.getRoot(),
33
+ intermediateHashes: [...this.intermediateHashes],
34
+ unitCount: this.intermediateHashes.length,
35
+ };
36
+ }
37
+
38
+ static verifyChain(proof: HashChainProof, unitData: Uint8Array[], seed: Uint8Array = encoder.encode(DEFAULT_SEED_PREFIX)): boolean {
39
+ if (
40
+ proof.unitCount !== unitData.length
41
+ || proof.intermediateHashes.length !== unitData.length
42
+ || !HASH_HEX_PATTERN.test(proof.root)
43
+ || proof.intermediateHashes.some((entry) => !HASH_HEX_PATTERN.test(entry))
44
+ ) {
45
+ return false;
46
+ }
47
+
48
+ let currentHash = hashBytes(seed);
49
+ for (const [index, unit] of unitData.entries()) {
50
+ currentHash = hashBytes(concatBytes(currentHash, unit));
51
+ if (bytesToHex(currentHash) !== proof.intermediateHashes[index]) {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ return bytesToHex(currentHash) === proof.root;
57
+ }
58
+ }
59
+
60
+ export function createMeteringSeed(taskId: string): Uint8Array {
61
+ return encoder.encode(`${DEFAULT_SEED_PREFIX}:${taskId}`);
62
+ }
63
+
64
+ export function splitIntoMeteringUnits(data: Uint8Array, chunkSize = DEFAULT_METERING_CHUNK_SIZE): Uint8Array[] {
65
+ if (!Number.isSafeInteger(chunkSize) || chunkSize <= 0) {
66
+ throw new Error('chunkSize must be a positive safe integer.');
67
+ }
68
+
69
+ if (data.length === 0) {
70
+ return [];
71
+ }
72
+
73
+ const units: Uint8Array[] = [];
74
+ for (let offset = 0; offset < data.length; offset += chunkSize) {
75
+ units.push(data.slice(offset, Math.min(offset + chunkSize, data.length)));
76
+ }
77
+
78
+ return units;
79
+ }
80
+
81
+ function hashBytes(data: Uint8Array): Uint8Array {
82
+ return sha256(data);
83
+ }
84
+
85
+ function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array {
86
+ const result = new Uint8Array(left.length + right.length);
87
+ result.set(left, 0);
88
+ result.set(right, left.length);
89
+ return result;
90
+ }
91
+
92
+ function bytesToHex(value: Uint8Array): string {
93
+ return Buffer.from(value).toString('hex');
94
+ }
@@ -0,0 +1,4 @@
1
+ export * from './hash-chain.js';
2
+ export * from './meter.js';
3
+ export * from './streaming.js';
4
+ export * from './verification.js';
@@ -0,0 +1,80 @@
1
+ import type { HashChainProof, MeteringReport } from '@hivemind-os/collective-types';
2
+
3
+ import { HashChain, createMeteringSeed } from './hash-chain.js';
4
+
5
+ const MAX_U64 = (1n << 64n) - 1n;
6
+ const MAX_RECORDED_UNITS = Number.MAX_SAFE_INTEGER;
7
+
8
+ export interface UsageMeterOptions {
9
+ taskId: string;
10
+ maxPrice: bigint;
11
+ unitPrice?: bigint;
12
+ seed?: Uint8Array;
13
+ }
14
+
15
+ export class UsageMeter {
16
+ private readonly hashChain: HashChain;
17
+ private actualUnits = 0;
18
+ private readonly taskId: string;
19
+ private readonly maxPrice: bigint;
20
+ private readonly unitPrice: bigint;
21
+
22
+ constructor(options: UsageMeterOptions) {
23
+ const taskId = options.taskId.trim();
24
+ const maxPrice = options.maxPrice;
25
+ const unitPrice = options.unitPrice ?? 0n;
26
+ if (!taskId) {
27
+ throw new Error('taskId is required.');
28
+ }
29
+ if (maxPrice < 0n || unitPrice < 0n) {
30
+ throw new Error('maxPrice and unitPrice must be non-negative.');
31
+ }
32
+ if (maxPrice > MAX_U64 || unitPrice > MAX_U64) {
33
+ throw new Error('maxPrice and unitPrice must fit in an unsigned 64-bit integer.');
34
+ }
35
+
36
+ this.taskId = taskId;
37
+ this.maxPrice = maxPrice;
38
+ this.unitPrice = unitPrice;
39
+ this.hashChain = new HashChain(options.seed ?? createMeteringSeed(taskId));
40
+ }
41
+
42
+ recordUnit(data: Uint8Array): number {
43
+ if (this.actualUnits >= MAX_RECORDED_UNITS) {
44
+ throw new Error('Too many metering units recorded.');
45
+ }
46
+
47
+ this.hashChain.addUnit(data);
48
+ this.actualUnits += 1;
49
+ return this.actualUnits;
50
+ }
51
+
52
+ getActualUnits(): number {
53
+ return this.actualUnits;
54
+ }
55
+
56
+ getVerificationHash(): string {
57
+ return this.hashChain.getRoot();
58
+ }
59
+
60
+ getProof(): HashChainProof {
61
+ return this.hashChain.getProof();
62
+ }
63
+
64
+ getCost(unitPrice = this.unitPrice): bigint {
65
+ const uncapped = BigInt(this.actualUnits) * unitPrice;
66
+ return uncapped > this.maxPrice ? this.maxPrice : uncapped;
67
+ }
68
+
69
+ getReport(): MeteringReport {
70
+ const actualCost = this.getCost();
71
+ return {
72
+ taskId: this.taskId,
73
+ actualUnits: this.actualUnits,
74
+ actualCost,
75
+ maxPrice: this.maxPrice,
76
+ refundAmount: this.maxPrice - actualCost,
77
+ verificationHash: this.getVerificationHash(),
78
+ };
79
+ }
80
+ }