@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.
- package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
- package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
- package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
- package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
- package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
- package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
- package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
- package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
- package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
- package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
- package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
- package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
- package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
- package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
- package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
- package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
- package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
- package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
- package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
- package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
- package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
- package/.turbo/turbo-build.log +14 -0
- package/dist/index.d.ts +1675 -0
- package/dist/index.js +8006 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/auth/device-flow.ts +108 -0
- package/src/auth/ed25519-provider.ts +43 -0
- package/src/auth/errors.ts +82 -0
- package/src/auth/evm-key.ts +55 -0
- package/src/auth/index.ts +8 -0
- package/src/auth/session-state.ts +25 -0
- package/src/auth/session-store.ts +510 -0
- package/src/auth/types.ts +81 -0
- package/src/auth/zklogin-provider.ts +902 -0
- package/src/blobstore/WALRUS_FINDINGS.md +284 -0
- package/src/blobstore/encrypted-store.ts +56 -0
- package/src/blobstore/fs-store.ts +91 -0
- package/src/blobstore/hybrid-store.ts +144 -0
- package/src/blobstore/index.ts +5 -0
- package/src/blobstore/interface.ts +33 -0
- package/src/blobstore/walrus-spike.ts +345 -0
- package/src/blobstore/walrus-store.ts +551 -0
- package/src/cache/agent-cache.ts +403 -0
- package/src/cache/index.ts +1 -0
- package/src/crypto/encryption.ts +152 -0
- package/src/crypto/index.ts +2 -0
- package/src/crypto/x25519.ts +41 -0
- package/src/dispute/client.ts +191 -0
- package/src/dispute/index.ts +1 -0
- package/src/events/index.ts +2 -0
- package/src/events/parser.ts +291 -0
- package/src/events/subscription.ts +131 -0
- package/src/evm/constants.ts +6 -0
- package/src/evm/index.ts +2 -0
- package/src/evm/wallet.ts +136 -0
- package/src/identity/did.ts +36 -0
- package/src/identity/index.ts +4 -0
- package/src/identity/keypair.ts +199 -0
- package/src/identity/signing.ts +28 -0
- package/src/index.ts +22 -0
- package/src/internal/parsing.ts +416 -0
- package/src/marketplace/client.ts +349 -0
- package/src/marketplace/index.ts +1 -0
- package/src/metering/hash-chain.ts +94 -0
- package/src/metering/index.ts +4 -0
- package/src/metering/meter.ts +80 -0
- package/src/metering/streaming.ts +196 -0
- package/src/metering/verification.ts +104 -0
- package/src/payment/index.ts +1 -0
- package/src/payment/rail-selector.ts +41 -0
- package/src/registry/client.ts +328 -0
- package/src/registry/index.ts +1 -0
- package/src/relay/consumer-client.ts +497 -0
- package/src/relay/index.ts +1 -0
- package/src/relay-registry/client.ts +295 -0
- package/src/relay-registry/discovery.ts +109 -0
- package/src/relay-registry/index.ts +2 -0
- package/src/reputation/anchor-client.ts +126 -0
- package/src/reputation/event-publisher.ts +67 -0
- package/src/reputation/index.ts +5 -0
- package/src/reputation/merkle.ts +79 -0
- package/src/reputation/score-calculator.ts +133 -0
- package/src/reputation/serialization.ts +37 -0
- package/src/reputation/store.ts +165 -0
- package/src/reputation/validation.ts +135 -0
- package/src/routing/circuit-breaker.ts +111 -0
- package/src/routing/fan-out.ts +266 -0
- package/src/routing/index.ts +4 -0
- package/src/routing/performance.ts +244 -0
- package/src/routing/selector.ts +225 -0
- package/src/spending/index.ts +1 -0
- package/src/spending/policy.ts +271 -0
- package/src/staking/client.ts +319 -0
- package/src/staking/index.ts +1 -0
- package/src/sui/client.ts +214 -0
- package/src/sui/index.ts +2 -0
- package/src/sui/tx-helpers.ts +1070 -0
- package/src/task/client.ts +215 -0
- package/src/task/index.ts +1 -0
- package/src/x402/client.ts +295 -0
- package/src/x402/index.ts +1 -0
- package/tests/auth/device-flow.test.ts +62 -0
- package/tests/auth/ed25519-provider.test.ts +24 -0
- package/tests/auth/evm-key.test.ts +31 -0
- package/tests/auth/session-store.test.ts +201 -0
- package/tests/auth/zklogin-provider.test.ts +366 -0
- package/tests/blobstore/encrypted-store.test.ts +78 -0
- package/tests/blobstore.test.ts +91 -0
- package/tests/cache.test.ts +124 -0
- package/tests/crypto/encryption.test.ts +70 -0
- package/tests/crypto/x25519.test.ts +47 -0
- package/tests/dispute/client.test.ts +238 -0
- package/tests/events.test.ts +202 -0
- package/tests/evm/wallet.test.ts +101 -0
- package/tests/hybrid-store.test.ts +121 -0
- package/tests/identity.test.ts +161 -0
- package/tests/marketplace.test.ts +308 -0
- package/tests/metering/hash-chain.test.ts +32 -0
- package/tests/metering/meter.test.ts +23 -0
- package/tests/metering/streaming.test.ts +52 -0
- package/tests/metering/verification.test.ts +27 -0
- package/tests/payment/rail-selector.test.ts +95 -0
- package/tests/registry.test.ts +183 -0
- package/tests/relay-consumer-client.test.ts +119 -0
- package/tests/relay-registry/client.test.ts +261 -0
- package/tests/reputation/event-publisher.test.ts +70 -0
- package/tests/reputation/merkle.test.ts +44 -0
- package/tests/reputation/score-calculator.test.ts +104 -0
- package/tests/reputation/store.test.ts +94 -0
- package/tests/routing/circuit-breaker.test.ts +45 -0
- package/tests/routing/fan-out.test.ts +123 -0
- package/tests/routing/performance.test.ts +49 -0
- package/tests/routing/selector.test.ts +114 -0
- package/tests/spending.test.ts +133 -0
- package/tests/staking/client.test.ts +286 -0
- package/tests/sui-client.test.ts +85 -0
- package/tests/task.test.ts +249 -0
- package/tests/tx-helpers.test.ts +70 -0
- package/tests/walrus-spike.test.ts +100 -0
- package/tests/walrus-store.test.ts +196 -0
- package/tests/x402/client.test.ts +116 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import pino from 'pino';
|
|
2
|
+
|
|
3
|
+
import { RelayNodeStatus, type RelayListFilters, type RelayNode } from '@hivemind-os/collective-types';
|
|
4
|
+
import type { SuiEvent, SuiTransactionBlockResponse } from '@mysten/sui/client';
|
|
5
|
+
import type { Signer } from '@mysten/sui/cryptography';
|
|
6
|
+
|
|
7
|
+
import { isRecord, normalizeMoveValue, parseRelayNodeFields } from '../internal/parsing.js';
|
|
8
|
+
import { MeshSuiClient } from '../sui/client.js';
|
|
9
|
+
import {
|
|
10
|
+
buildDeactivateRelayTx,
|
|
11
|
+
buildHeartbeatRelayTx,
|
|
12
|
+
buildRegisterRelayTx,
|
|
13
|
+
} from '../sui/tx-helpers.js';
|
|
14
|
+
import { StakingClient } from '../staking/client.js';
|
|
15
|
+
|
|
16
|
+
const logger = pino({ name: '@hivemind-os/collective-core:relay-registry' });
|
|
17
|
+
|
|
18
|
+
export interface RelayRegistryContractConfig {
|
|
19
|
+
packageId: string;
|
|
20
|
+
heartbeatFreshnessMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_RELAY_HEARTBEAT_FRESHNESS_MS = 5 * 60_000;
|
|
24
|
+
|
|
25
|
+
export class RelayRegistryClient {
|
|
26
|
+
private readonly stakingClient: StakingClient;
|
|
27
|
+
private readonly heartbeatFreshnessMs: number;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly suiClient: MeshSuiClient,
|
|
31
|
+
private readonly config: RelayRegistryContractConfig,
|
|
32
|
+
) {
|
|
33
|
+
this.stakingClient = new StakingClient(suiClient, config);
|
|
34
|
+
this.heartbeatFreshnessMs = config.heartbeatFreshnessMs ?? DEFAULT_RELAY_HEARTBEAT_FRESHNESS_MS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async registerRelay(params: {
|
|
38
|
+
endpoint: string;
|
|
39
|
+
stakeId: string;
|
|
40
|
+
capabilities: string[];
|
|
41
|
+
region: string;
|
|
42
|
+
routingFeeBps: number;
|
|
43
|
+
signer: Signer;
|
|
44
|
+
}): Promise<{ relayId: string; txDigest: string }> {
|
|
45
|
+
const tx = buildRegisterRelayTx({
|
|
46
|
+
packageId: this.config.packageId,
|
|
47
|
+
endpoint: params.endpoint,
|
|
48
|
+
stakeId: params.stakeId,
|
|
49
|
+
capabilities: params.capabilities,
|
|
50
|
+
region: params.region,
|
|
51
|
+
routingFeeBps: params.routingFeeBps,
|
|
52
|
+
});
|
|
53
|
+
const response = await this.suiClient.executeTransaction(tx, params.signer);
|
|
54
|
+
const relayId = readRelayId(response, `${this.config.packageId}::relay_registry::RelayRegistered`);
|
|
55
|
+
if (!relayId) {
|
|
56
|
+
logger.warn({ response }, 'Relay registration succeeded without a RelayNode object change.');
|
|
57
|
+
throw new Error('Unable to determine relay id from transaction response.');
|
|
58
|
+
}
|
|
59
|
+
return { relayId, txDigest: response.digest };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async heartbeat(params: { relayId: string; signer: Signer }): Promise<{ lastHeartbeat: number; txDigest: string }> {
|
|
63
|
+
const tx = buildHeartbeatRelayTx({ packageId: this.config.packageId, relayId: params.relayId });
|
|
64
|
+
const response = await this.suiClient.executeTransaction(tx, params.signer);
|
|
65
|
+
const event = findEvent(response.events, `${this.config.packageId}::relay_registry::RelayHeartbeat`);
|
|
66
|
+
return {
|
|
67
|
+
lastHeartbeat: requireEventNumber(event, 'RelayHeartbeat', 'last_heartbeat', 'lastHeartbeat'),
|
|
68
|
+
txDigest: response.digest,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async recordRouting(params: { relayId: string; feeAmountMist: bigint; signer: Signer }): Promise<{ txDigest: string }> {
|
|
73
|
+
void params;
|
|
74
|
+
throw new Error('Relay routing metrics are package-internal and cannot be reported by external operators.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async deactivateRelay(params: { relayId: string; signer: Signer }): Promise<{ txDigest: string }> {
|
|
78
|
+
const tx = buildDeactivateRelayTx({ packageId: this.config.packageId, relayId: params.relayId });
|
|
79
|
+
const response = await this.suiClient.executeTransaction(tx, params.signer);
|
|
80
|
+
return { txDigest: response.digest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async listRelays(filters: RelayListFilters = {}): Promise<RelayNode[]> {
|
|
84
|
+
const relayIds = await this.collectRelayIds();
|
|
85
|
+
const relays = await Promise.all([...relayIds].map(async (relayId) => await this.getRelay(relayId)));
|
|
86
|
+
return relays
|
|
87
|
+
.filter((relay): relay is RelayNode => Boolean(relay))
|
|
88
|
+
.filter((relay) => matchesRelayFilters(relay, filters))
|
|
89
|
+
.sort(compareRelays);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getRelay(relayId: string): Promise<RelayNode | null> {
|
|
93
|
+
try {
|
|
94
|
+
const object = await this.suiClient.getObject<Record<string, unknown>>(relayId);
|
|
95
|
+
return await this.enrichRelay(parseRelayNodeFields(object, relayId));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (isObjectMissingError(error)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getRelaysByRegion(region: string): Promise<RelayNode[]> {
|
|
105
|
+
return await this.listRelays({ region });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async collectRelayIds(): Promise<Set<string>> {
|
|
109
|
+
const relayIds = new Set<string>();
|
|
110
|
+
const eventType = `${this.config.packageId}::relay_registry::RelayRegistered`;
|
|
111
|
+
let cursor = null;
|
|
112
|
+
|
|
113
|
+
do {
|
|
114
|
+
const page = await this.suiClient.queryEvents(eventType, cursor, 100);
|
|
115
|
+
for (const event of page.events) {
|
|
116
|
+
const payload = normalizeEvent(event);
|
|
117
|
+
const relayId = asString(readEventField(payload, 'relay_id', 'relayId'));
|
|
118
|
+
if (relayId) {
|
|
119
|
+
relayIds.add(relayId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
cursor = page.nextCursor;
|
|
123
|
+
if (!page.hasMore) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
} while (cursor);
|
|
127
|
+
|
|
128
|
+
return relayIds;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async enrichRelay(relay: RelayNode): Promise<RelayNode> {
|
|
132
|
+
const heartbeatAgeMs = Math.max(Date.now() - relay.lastHeartbeat, 0);
|
|
133
|
+
try {
|
|
134
|
+
const stake = await this.stakingClient.getStakePosition(relay.stakePositionId);
|
|
135
|
+
return {
|
|
136
|
+
...relay,
|
|
137
|
+
stakeAmountMist: stake?.balanceMist,
|
|
138
|
+
heartbeatAgeMs,
|
|
139
|
+
isHeartbeatFresh: heartbeatAgeMs <= this.heartbeatFreshnessMs,
|
|
140
|
+
};
|
|
141
|
+
} catch {
|
|
142
|
+
return {
|
|
143
|
+
...relay,
|
|
144
|
+
heartbeatAgeMs,
|
|
145
|
+
isHeartbeatFresh: heartbeatAgeMs <= this.heartbeatFreshnessMs,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readRelayId(response: SuiTransactionBlockResponse, eventType: string): string | undefined {
|
|
152
|
+
const event = findEvent(response.events, eventType);
|
|
153
|
+
return asString(readEventField(event, 'relay_id', 'relayId')) || extractObjectId(response, /::relay_registry::RelayNode$/);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractObjectId(response: SuiTransactionBlockResponse, objectTypePattern: RegExp): string | undefined {
|
|
157
|
+
const change = (response.objectChanges as Array<Record<string, unknown>> | null | undefined)?.find(
|
|
158
|
+
(entry) =>
|
|
159
|
+
(entry.type === 'created' || entry.type === 'transferred' || entry.type === 'mutated') &&
|
|
160
|
+
typeof entry.objectType === 'string' &&
|
|
161
|
+
objectTypePattern.test(entry.objectType) &&
|
|
162
|
+
typeof entry.objectId === 'string',
|
|
163
|
+
);
|
|
164
|
+
return change?.objectId as string | undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findEvent(events: SuiTransactionBlockResponse['events'], eventType: string): Record<string, unknown> {
|
|
168
|
+
const payload = events
|
|
169
|
+
?.map((event) => ({ type: event.type, payload: normalizeEvent(event) }))
|
|
170
|
+
.find((event) => event.type === eventType)?.payload;
|
|
171
|
+
return payload ?? {};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeEvent(event: SuiEvent): Record<string, unknown> {
|
|
175
|
+
const normalized = normalizeMoveValue(event.parsedJson);
|
|
176
|
+
return isRecord(normalized) ? normalized : {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readEventField(record: Record<string, unknown>, ...keys: string[]): unknown {
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
if (key in record) {
|
|
182
|
+
return record[key];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function asString(value: unknown): string {
|
|
189
|
+
return typeof value === 'string' ? value : '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function asNumber(value: unknown): number {
|
|
193
|
+
if (typeof value === 'number') {
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
if (typeof value === 'bigint') {
|
|
197
|
+
return Number(value);
|
|
198
|
+
}
|
|
199
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
200
|
+
return Number(value);
|
|
201
|
+
}
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function requireEventNumber(record: Record<string, unknown>, eventName: string, ...keys: string[]): number {
|
|
206
|
+
const raw = readEventField(record, ...keys);
|
|
207
|
+
if (raw == null || raw === '') {
|
|
208
|
+
throw new Error(`${eventName} event did not include a valid ${keys[0]}.`);
|
|
209
|
+
}
|
|
210
|
+
const value = asNumber(raw);
|
|
211
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
212
|
+
throw new Error(`${eventName} event did not include a valid ${keys[0]}.`);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function matchesRelayFilters(relay: RelayNode, filters: RelayListFilters): boolean {
|
|
218
|
+
const activeOnly = filters.status === undefined && filters.activeOnly === undefined ? true : (filters.activeOnly ?? false);
|
|
219
|
+
const statuses = normalizeStatuses(filters.status, activeOnly);
|
|
220
|
+
if (!statuses.includes(relay.status)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
if (filters.operator && relay.operator.toLowerCase() !== filters.operator.toLowerCase()) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
if (filters.region && relay.region.toLowerCase() !== filters.region.toLowerCase()) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (filters.stakePositionId && relay.stakePositionId.toLowerCase() !== filters.stakePositionId.toLowerCase()) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
if (filters.endpoint && relay.endpoint.toLowerCase() !== filters.endpoint.toLowerCase()) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
if (filters.capability) {
|
|
236
|
+
const capability = filters.capability.toLowerCase();
|
|
237
|
+
if (!relay.capabilities.some((entry) => entry.toLowerCase() === capability)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (filters.heartbeatWithinMs !== undefined && (relay.heartbeatAgeMs ?? Number.POSITIVE_INFINITY) > filters.heartbeatWithinMs) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function normalizeStatuses(status: RelayListFilters['status'], activeOnly: boolean): RelayNodeStatus[] {
|
|
248
|
+
if (Array.isArray(status)) {
|
|
249
|
+
return status;
|
|
250
|
+
}
|
|
251
|
+
if (status !== undefined) {
|
|
252
|
+
return [status];
|
|
253
|
+
}
|
|
254
|
+
return activeOnly
|
|
255
|
+
? [RelayNodeStatus.ACTIVE]
|
|
256
|
+
: [RelayNodeStatus.ACTIVE, RelayNodeStatus.INACTIVE, RelayNodeStatus.SLASHED];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function compareRelays(left: RelayNode, right: RelayNode): number {
|
|
260
|
+
return (
|
|
261
|
+
compareBoolean(left.status === RelayNodeStatus.ACTIVE, right.status === RelayNodeStatus.ACTIVE) ||
|
|
262
|
+
compareBoolean(left.isHeartbeatFresh ?? false, right.isHeartbeatFresh ?? false) ||
|
|
263
|
+
compareNumber(left.routingFeeBps, right.routingFeeBps, true) ||
|
|
264
|
+
compareBigInt(left.stakeAmountMist ?? 0n, right.stakeAmountMist ?? 0n) ||
|
|
265
|
+
compareNumber(left.lastHeartbeat, right.lastHeartbeat)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function compareBoolean(left: boolean, right: boolean): number {
|
|
270
|
+
if (left === right) {
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
return left ? -1 : 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function compareBigInt(left: bigint, right: bigint): number {
|
|
277
|
+
if (left === right) {
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
return left > right ? -1 : 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function compareNumber(left: number, right: number, ascending = false): number {
|
|
284
|
+
if (left === right) {
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
if (ascending) {
|
|
288
|
+
return left < right ? -1 : 1;
|
|
289
|
+
}
|
|
290
|
+
return left > right ? -1 : 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isObjectMissingError(error: unknown): boolean {
|
|
294
|
+
return error instanceof Error && /not found|does not contain move object data/i.test(error.message);
|
|
295
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { RelayNodeStatus, type RelayNode } from '@hivemind-os/collective-types';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_RELAY_HEARTBEAT_FRESHNESS_MS, type RelayRegistryClient } from './client.js';
|
|
4
|
+
|
|
5
|
+
export interface RelayDiscoveryOptions {
|
|
6
|
+
cacheTtlMs?: number;
|
|
7
|
+
heartbeatFreshnessMs?: number;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface RelayCacheEntry {
|
|
12
|
+
relays: RelayNode[];
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RelayDiscovery {
|
|
17
|
+
private readonly cacheTtlMs: number;
|
|
18
|
+
private readonly heartbeatFreshnessMs: number;
|
|
19
|
+
private readonly now: () => number;
|
|
20
|
+
private cache?: RelayCacheEntry;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly registryClient: Pick<RelayRegistryClient, 'listRelays'>,
|
|
24
|
+
options: RelayDiscoveryOptions = {},
|
|
25
|
+
) {
|
|
26
|
+
this.cacheTtlMs = options.cacheTtlMs ?? 30_000;
|
|
27
|
+
this.heartbeatFreshnessMs = options.heartbeatFreshnessMs ?? DEFAULT_RELAY_HEARTBEAT_FRESHNESS_MS;
|
|
28
|
+
this.now = options.now ?? (() => Date.now());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async findBestRelay(capability: string, region?: string): Promise<RelayNode | null> {
|
|
32
|
+
const normalizedCapability = capability.trim().toLowerCase();
|
|
33
|
+
if (!normalizedCapability) {
|
|
34
|
+
throw new Error('capability must be a non-empty string.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const normalizedRegion = region?.trim() || undefined;
|
|
38
|
+
const relays = (await this.getRelayList())
|
|
39
|
+
.filter((relay) => relay.status === RelayNodeStatus.ACTIVE)
|
|
40
|
+
.filter((relay) => relay.capabilities.some((entry) => entry.toLowerCase() === normalizedCapability));
|
|
41
|
+
|
|
42
|
+
if (relays.length === 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return [...relays].sort(
|
|
47
|
+
(left, right) => scoreRelay(right, normalizedRegion, this.heartbeatFreshnessMs, this.now())
|
|
48
|
+
- scoreRelay(left, normalizedRegion, this.heartbeatFreshnessMs, this.now()),
|
|
49
|
+
)[0] ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
invalidateCache(): void {
|
|
53
|
+
this.cache = undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async getRelayList(): Promise<RelayNode[]> {
|
|
57
|
+
const now = this.now();
|
|
58
|
+
if (this.cache && this.cache.expiresAt > now) {
|
|
59
|
+
return this.cache.relays.map((relay) => ({ ...relay }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const relays = (await this.registryClient.listRelays({ activeOnly: true })).map((relay) => ({ ...relay }));
|
|
63
|
+
this.cache = {
|
|
64
|
+
relays,
|
|
65
|
+
expiresAt: now + this.cacheTtlMs,
|
|
66
|
+
};
|
|
67
|
+
return relays.map((relay) => ({ ...relay }));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scoreRelay(relay: RelayNode, region: string | undefined, heartbeatFreshnessMs: number, now: number): number {
|
|
72
|
+
const feeScore = Math.max(0, 1 - relay.routingFeeBps / 10_000);
|
|
73
|
+
const regionScore = scoreRegion(relay.region, region);
|
|
74
|
+
const stakeScore = scoreStake(relay.stakeAmountMist ?? 0n);
|
|
75
|
+
const freshnessScore = scoreHeartbeat(relay.heartbeatAgeMs ?? Math.max(now - relay.lastHeartbeat, 0), heartbeatFreshnessMs);
|
|
76
|
+
|
|
77
|
+
return feeScore * 0.35 + regionScore * 0.25 + stakeScore * 0.2 + freshnessScore * 0.2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function scoreRegion(relayRegion: string, desiredRegion: string | undefined): number {
|
|
81
|
+
if (!desiredRegion) {
|
|
82
|
+
return 0.5;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const normalizedRelayRegion = relayRegion.trim().toLowerCase();
|
|
86
|
+
const normalizedDesiredRegion = desiredRegion.trim().toLowerCase();
|
|
87
|
+
if (normalizedRelayRegion === normalizedDesiredRegion) {
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const relayPrefix = normalizedRelayRegion.split(/[-_\s]/)[0];
|
|
92
|
+
const desiredPrefix = normalizedDesiredRegion.split(/[-_\s]/)[0];
|
|
93
|
+
return relayPrefix && relayPrefix === desiredPrefix ? 0.65 : 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scoreStake(stakeAmountMist: bigint): number {
|
|
97
|
+
const capped = stakeAmountMist > 1_000_000_000_000n ? 1_000_000_000_000n : stakeAmountMist;
|
|
98
|
+
return Number(capped) / Number(1_000_000_000_000n);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scoreHeartbeat(heartbeatAgeMs: number, heartbeatFreshnessMs: number): number {
|
|
102
|
+
if (heartbeatAgeMs <= heartbeatFreshnessMs) {
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (heartbeatAgeMs >= heartbeatFreshnessMs * 4) {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
return 1 - (heartbeatAgeMs - heartbeatFreshnessMs) / (heartbeatFreshnessMs * 3);
|
|
109
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ReputationAnchor, ReputationEvent } from '@hivemind-os/collective-types';
|
|
2
|
+
import type { Signer } from '@mysten/sui/cryptography';
|
|
3
|
+
|
|
4
|
+
import type { BlobStore } from '../blobstore/interface.js';
|
|
5
|
+
import { bytesToHex, bytesToString } from '../internal/parsing.js';
|
|
6
|
+
import { MeshSuiClient } from '../sui/client.js';
|
|
7
|
+
import { buildPublishReputationAnchorTx } from '../sui/tx-helpers.js';
|
|
8
|
+
|
|
9
|
+
import { buildMerkleTree } from './merkle.js';
|
|
10
|
+
|
|
11
|
+
export interface ContractConfig {
|
|
12
|
+
packageId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ReputationAnchorClient {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly suiClient: MeshSuiClient,
|
|
18
|
+
private readonly config: ContractConfig,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async publishAnchor(
|
|
22
|
+
events: ReputationEvent[],
|
|
23
|
+
blobStore: BlobStore,
|
|
24
|
+
signer: Signer,
|
|
25
|
+
): Promise<{ anchorId: string; merkleRoot: string; txDigest: string }> {
|
|
26
|
+
if (events.length === 0) {
|
|
27
|
+
throw new Error('At least one reputation event is required to publish an anchor.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { root } = buildMerkleTree(events);
|
|
31
|
+
const stored = await blobStore.store(new TextEncoder().encode(JSON.stringify(events)));
|
|
32
|
+
const timestamps = events.map((event) => Date.parse(event.timestamp)).filter(Number.isFinite);
|
|
33
|
+
const tx = buildPublishReputationAnchorTx({
|
|
34
|
+
packageId: this.config.packageId,
|
|
35
|
+
merkleRoot: [...root],
|
|
36
|
+
eventCount: events.length,
|
|
37
|
+
blobId: stored.blobId,
|
|
38
|
+
fromTimestamp: timestamps.length > 0 ? Math.min(...timestamps) : 0,
|
|
39
|
+
toTimestamp: timestamps.length > 0 ? Math.max(...timestamps) : 0,
|
|
40
|
+
});
|
|
41
|
+
const response = await this.suiClient.executeTransaction(tx, signer);
|
|
42
|
+
const anchorId = extractObjectId(response.objectChanges, /::reputation::ReputationAnchor$/);
|
|
43
|
+
if (!anchorId) {
|
|
44
|
+
throw new Error('Unable to determine reputation anchor id from transaction response.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
anchorId,
|
|
49
|
+
merkleRoot: Buffer.from(root).toString('hex'),
|
|
50
|
+
txDigest: response.digest,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getAnchors(author?: string, limit = 20): Promise<ReputationAnchor[]> {
|
|
55
|
+
const eventType = `${this.config.packageId}::reputation::AnchorPublished`;
|
|
56
|
+
const anchors: ReputationAnchor[] = [];
|
|
57
|
+
let cursor = null;
|
|
58
|
+
|
|
59
|
+
do {
|
|
60
|
+
const page = await this.suiClient.queryEvents(eventType, cursor, Math.max(limit * 2, 20));
|
|
61
|
+
const candidates = page.events.filter((event) => {
|
|
62
|
+
const payload = event.parsedJson as { author?: string } | null | undefined;
|
|
63
|
+
return !author || payload?.author === author;
|
|
64
|
+
});
|
|
65
|
+
const fetched = await Promise.all(candidates.map((event) => this.fetchAnchor(asString((event.parsedJson as { anchor_id?: string })?.anchor_id))));
|
|
66
|
+
anchors.push(...fetched.filter((anchor): anchor is ReputationAnchor => Boolean(anchor)));
|
|
67
|
+
if (anchors.length >= limit) {
|
|
68
|
+
return anchors.slice(0, limit);
|
|
69
|
+
}
|
|
70
|
+
cursor = page.nextCursor;
|
|
71
|
+
if (!page.hasMore) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
} while (cursor);
|
|
75
|
+
|
|
76
|
+
return anchors.slice(0, limit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async fetchAnchor(anchorId: string): Promise<ReputationAnchor | null> {
|
|
80
|
+
if (!anchorId) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const object = await this.suiClient.getObject<Record<string, unknown>>(anchorId);
|
|
86
|
+
return {
|
|
87
|
+
anchorId,
|
|
88
|
+
author: asString(object.author),
|
|
89
|
+
merkleRoot: bytesToHex(asBytes(object.merkle_root)),
|
|
90
|
+
eventCount: Number(object.event_count ?? 0),
|
|
91
|
+
blobId: bytesToString(object.blob_id),
|
|
92
|
+
fromTimestamp: Number(object.from_timestamp ?? 0),
|
|
93
|
+
toTimestamp: Number(object.to_timestamp ?? 0),
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractObjectId(
|
|
102
|
+
objectChanges: Array<Record<string, unknown>> | null | undefined,
|
|
103
|
+
objectTypePattern: RegExp,
|
|
104
|
+
): string | undefined {
|
|
105
|
+
return objectChanges?.find(
|
|
106
|
+
(change) =>
|
|
107
|
+
(change.type === 'created' || change.type === 'transferred' || change.type === 'mutated') &&
|
|
108
|
+
typeof change.objectType === 'string' &&
|
|
109
|
+
objectTypePattern.test(change.objectType) &&
|
|
110
|
+
typeof change.objectId === 'string',
|
|
111
|
+
)?.objectId as string | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function asString(value: unknown): string {
|
|
115
|
+
return typeof value === 'string' ? value : '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asBytes(value: unknown): Uint8Array {
|
|
119
|
+
if (value instanceof Uint8Array) {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === 'number')) {
|
|
123
|
+
return new Uint8Array(value);
|
|
124
|
+
}
|
|
125
|
+
return new Uint8Array();
|
|
126
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { ReputationEvent, ReputationEventType } from '@hivemind-os/collective-types';
|
|
4
|
+
|
|
5
|
+
import type { AuthProvider } from '../auth/types.js';
|
|
6
|
+
import type { BlobStore } from '../blobstore/interface.js';
|
|
7
|
+
|
|
8
|
+
import { serializeReputationEvent, serializeReputationEventPayload } from './serialization.js';
|
|
9
|
+
import { assertValidReputationEvent } from './validation.js';
|
|
10
|
+
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
export class ReputationEventPublisher {
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly blobStore: BlobStore,
|
|
16
|
+
private readonly identity: AuthProvider,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async createEvent(params: {
|
|
20
|
+
type: ReputationEventType;
|
|
21
|
+
subject: string;
|
|
22
|
+
taskId: string;
|
|
23
|
+
outcome: string;
|
|
24
|
+
capability: string;
|
|
25
|
+
rating?: number;
|
|
26
|
+
latencyMs?: number;
|
|
27
|
+
paymentAmount?: { amount: string; currency: string };
|
|
28
|
+
}): Promise<ReputationEvent> {
|
|
29
|
+
const unsignedEvent = {
|
|
30
|
+
eventId: `rep_evt_${randomUUID().replace(/-/g, '')}`,
|
|
31
|
+
type: params.type,
|
|
32
|
+
subject: params.subject,
|
|
33
|
+
author: this.identity.getDID(),
|
|
34
|
+
taskId: params.taskId,
|
|
35
|
+
outcome: normalizeOutcome(params.outcome),
|
|
36
|
+
rating: params.rating,
|
|
37
|
+
capability: params.capability,
|
|
38
|
+
paymentAmount: params.paymentAmount,
|
|
39
|
+
latencyMs: params.latencyMs,
|
|
40
|
+
timestamp: new Date().toISOString(),
|
|
41
|
+
nonce: randomBytes(16).toString('hex'),
|
|
42
|
+
} satisfies Omit<ReputationEvent, 'signature'>;
|
|
43
|
+
|
|
44
|
+
const signed = await this.identity.signPersonalMessage(serializeReputationEventPayload(unsignedEvent));
|
|
45
|
+
return assertValidReputationEvent({
|
|
46
|
+
...unsignedEvent,
|
|
47
|
+
signature: decoder.decode(signed.signature),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async publishEvent(event: ReputationEvent): Promise<void> {
|
|
52
|
+
await this.blobStore.store(serializeReputationEvent(event));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeOutcome(outcome: string): ReputationEvent['outcome'] {
|
|
57
|
+
switch (outcome) {
|
|
58
|
+
case 'success':
|
|
59
|
+
case 'failure':
|
|
60
|
+
case 'timeout':
|
|
61
|
+
case 'cancelled':
|
|
62
|
+
case 'disputed':
|
|
63
|
+
return outcome;
|
|
64
|
+
default:
|
|
65
|
+
throw new Error(`Unsupported reputation outcome: ${outcome}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type { ReputationEvent } from '@hivemind-os/collective-types';
|
|
4
|
+
|
|
5
|
+
import { serializeReputationEvent } from './serialization.js';
|
|
6
|
+
|
|
7
|
+
export function buildMerkleTree(events: ReputationEvent[]): {
|
|
8
|
+
root: Uint8Array;
|
|
9
|
+
proof: (index: number) => Uint8Array[];
|
|
10
|
+
} {
|
|
11
|
+
if (events.length === 0) {
|
|
12
|
+
throw new Error('Cannot build a Merkle tree from an empty event set.');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const leaves = events.map((event) => hash(serializeReputationEvent(event)));
|
|
16
|
+
const levels: Uint8Array[][] = [leaves];
|
|
17
|
+
|
|
18
|
+
while (levels[levels.length - 1]!.length > 1) {
|
|
19
|
+
const current = levels[levels.length - 1]!;
|
|
20
|
+
const next: Uint8Array[] = [];
|
|
21
|
+
for (let index = 0; index < current.length; index += 2) {
|
|
22
|
+
const left = current[index]!;
|
|
23
|
+
const right = current[index + 1] ?? left;
|
|
24
|
+
next.push(hash(concat(left, right)));
|
|
25
|
+
}
|
|
26
|
+
levels.push(next);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
root: levels[levels.length - 1]![0]!,
|
|
31
|
+
proof: (index: number) => createProof(levels, index),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function verifyMerkleProof(
|
|
36
|
+
event: ReputationEvent,
|
|
37
|
+
proof: Uint8Array[],
|
|
38
|
+
root: Uint8Array,
|
|
39
|
+
index: number,
|
|
40
|
+
): boolean {
|
|
41
|
+
let computed = hash(serializeReputationEvent(event));
|
|
42
|
+
let offset = index;
|
|
43
|
+
|
|
44
|
+
for (const sibling of proof) {
|
|
45
|
+
computed = offset % 2 === 0 ? hash(concat(computed, sibling)) : hash(concat(sibling, computed));
|
|
46
|
+
offset = Math.floor(offset / 2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Buffer.from(computed).equals(Buffer.from(root));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createProof(levels: Uint8Array[][], index: number): Uint8Array[] {
|
|
53
|
+
const leaves = levels[0] ?? [];
|
|
54
|
+
if (!Number.isInteger(index) || index < 0 || index >= leaves.length) {
|
|
55
|
+
throw new Error(`Merkle proof index ${index} is out of range.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const proof: Uint8Array[] = [];
|
|
59
|
+
let offset = index;
|
|
60
|
+
for (let level = 0; level < levels.length - 1; level += 1) {
|
|
61
|
+
const nodes = levels[level]!;
|
|
62
|
+
const siblingIndex = offset % 2 === 0 ? offset + 1 : offset - 1;
|
|
63
|
+
proof.push(nodes[siblingIndex] ?? nodes[offset]!);
|
|
64
|
+
offset = Math.floor(offset / 2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return proof;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hash(data: Uint8Array): Uint8Array {
|
|
71
|
+
return createHash('sha256').update(data).digest();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function concat(left: Uint8Array, right: Uint8Array): Uint8Array {
|
|
75
|
+
const combined = new Uint8Array(left.length + right.length);
|
|
76
|
+
combined.set(left, 0);
|
|
77
|
+
combined.set(right, left.length);
|
|
78
|
+
return combined;
|
|
79
|
+
}
|