@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,551 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
|
|
4
|
+
import pino from 'pino';
|
|
5
|
+
|
|
6
|
+
import { BlobIntegrityError, type BlobMetadata, type BlobStore, type StoredBlob } from './interface.js';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_WALRUS_PUBLISHER_URL = 'https://publisher.walrus-testnet.walrus.space';
|
|
9
|
+
export const DEFAULT_WALRUS_AGGREGATOR_URL = 'https://aggregator.walrus-testnet.walrus.space';
|
|
10
|
+
export const DEFAULT_WALRUS_EPOCHS = 5;
|
|
11
|
+
export const DEFAULT_WALRUS_MAX_BLOB_SIZE = 10 * 1024 * 1024;
|
|
12
|
+
export const DEFAULT_WALRUS_RETRY_ATTEMPTS = 3;
|
|
13
|
+
export const DEFAULT_WALRUS_RETRY_DELAY_MS = 1_000;
|
|
14
|
+
export const DEFAULT_WALRUS_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
const WALRUS_STORAGE_ID_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
17
|
+
const WALRUS_REFERENCE_PATTERN = /^walrus:([A-Za-z0-9_-]{43}):([a-f0-9]{64})$/;
|
|
18
|
+
const WALRUS_STORAGE_ID_BYTES = 32;
|
|
19
|
+
const logger = pino({ name: '@hivemind-os/collective-core:blobstore:walrus' });
|
|
20
|
+
|
|
21
|
+
export interface WalrusLogger {
|
|
22
|
+
debug(bindings: Record<string, unknown>, message?: string): void;
|
|
23
|
+
info(bindings: Record<string, unknown>, message?: string): void;
|
|
24
|
+
warn(bindings: Record<string, unknown>, message?: string): void;
|
|
25
|
+
error(bindings: Record<string, unknown>, message?: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WalrusBlobStoreConfig {
|
|
29
|
+
publisherUrl: string;
|
|
30
|
+
aggregatorUrl: string;
|
|
31
|
+
epochs?: number;
|
|
32
|
+
maxBlobSize?: number;
|
|
33
|
+
retryAttempts?: number;
|
|
34
|
+
retryDelayMs?: number;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
fetchImpl?: typeof fetch;
|
|
37
|
+
logger?: WalrusLogger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WalrusBlobMetadata extends BlobMetadata {
|
|
41
|
+
storageBlobId: string;
|
|
42
|
+
objectId?: string;
|
|
43
|
+
deletable?: boolean;
|
|
44
|
+
endEpoch?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WalrusBlobReference {
|
|
48
|
+
blobId: string;
|
|
49
|
+
storageBlobId: string;
|
|
50
|
+
contentHash?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface WalrusBlobObject {
|
|
54
|
+
id: string;
|
|
55
|
+
blobId: string;
|
|
56
|
+
size: number;
|
|
57
|
+
deletable: boolean;
|
|
58
|
+
storage?: {
|
|
59
|
+
endEpoch?: number;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface WalrusNewlyCreatedResponse {
|
|
64
|
+
newlyCreated: {
|
|
65
|
+
blobObject: WalrusBlobObject;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface WalrusAlreadyCertifiedResponse {
|
|
70
|
+
alreadyCertified: {
|
|
71
|
+
blobId: string;
|
|
72
|
+
endEpoch?: number;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type WalrusStoreApiResponse = WalrusNewlyCreatedResponse | WalrusAlreadyCertifiedResponse;
|
|
77
|
+
|
|
78
|
+
export class WalrusRequestError extends Error {
|
|
79
|
+
constructor(
|
|
80
|
+
message: string,
|
|
81
|
+
readonly url: string,
|
|
82
|
+
readonly transient: boolean,
|
|
83
|
+
readonly status?: number,
|
|
84
|
+
readonly body?: string,
|
|
85
|
+
options?: ErrorOptions,
|
|
86
|
+
) {
|
|
87
|
+
super(message, options);
|
|
88
|
+
this.name = 'WalrusRequestError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class WalrusNetworkError extends WalrusRequestError {
|
|
93
|
+
constructor(message: string, url: string, options?: ErrorOptions) {
|
|
94
|
+
super(message, url, true, undefined, undefined, options);
|
|
95
|
+
this.name = 'WalrusNetworkError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class WalrusTimeoutError extends WalrusRequestError {
|
|
100
|
+
constructor(message: string, url: string, readonly timeoutMs: number, options?: ErrorOptions) {
|
|
101
|
+
super(message, url, true, undefined, undefined, options);
|
|
102
|
+
this.name = 'WalrusTimeoutError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class WalrusHttpError extends WalrusRequestError {
|
|
107
|
+
constructor(message: string, url: string, status: number, body: string) {
|
|
108
|
+
super(message, url, status >= 500 || status === 408 || status === 429, status, body);
|
|
109
|
+
this.name = 'WalrusHttpError';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class WalrusResponseError extends WalrusRequestError {
|
|
114
|
+
constructor(message: string, url: string, body: string, options?: ErrorOptions) {
|
|
115
|
+
super(message, url, false, undefined, body, options);
|
|
116
|
+
this.name = 'WalrusResponseError';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class WalrusBlobTooLargeError extends Error {
|
|
121
|
+
constructor(readonly size: number, readonly maxBlobSize: number) {
|
|
122
|
+
super(`Walrus blob size ${size} exceeds the configured maximum of ${maxBlobSize} bytes.`);
|
|
123
|
+
this.name = 'WalrusBlobTooLargeError';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class WalrusBlobStore implements BlobStore {
|
|
128
|
+
private readonly config: Required<Omit<WalrusBlobStoreConfig, 'logger' | 'fetchImpl'>> & Pick<WalrusBlobStoreConfig, 'logger' | 'fetchImpl'>;
|
|
129
|
+
private readonly metadata = new Map<string, WalrusBlobMetadata>();
|
|
130
|
+
|
|
131
|
+
constructor(config: WalrusBlobStoreConfig) {
|
|
132
|
+
this.config = {
|
|
133
|
+
publisherUrl: normalizeWalrusUrl(config.publisherUrl || DEFAULT_WALRUS_PUBLISHER_URL),
|
|
134
|
+
aggregatorUrl: normalizeWalrusUrl(config.aggregatorUrl || DEFAULT_WALRUS_AGGREGATOR_URL),
|
|
135
|
+
epochs: config.epochs ?? DEFAULT_WALRUS_EPOCHS,
|
|
136
|
+
maxBlobSize: config.maxBlobSize ?? DEFAULT_WALRUS_MAX_BLOB_SIZE,
|
|
137
|
+
retryAttempts: Math.max(1, config.retryAttempts ?? DEFAULT_WALRUS_RETRY_ATTEMPTS),
|
|
138
|
+
retryDelayMs: Math.max(1, config.retryDelayMs ?? DEFAULT_WALRUS_RETRY_DELAY_MS),
|
|
139
|
+
timeoutMs: Math.max(1, config.timeoutMs ?? DEFAULT_WALRUS_TIMEOUT_MS),
|
|
140
|
+
fetchImpl: config.fetchImpl,
|
|
141
|
+
logger: config.logger,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
validatePositiveInteger(this.config.epochs, 'epochs');
|
|
145
|
+
validatePositiveInteger(this.config.maxBlobSize, 'maxBlobSize');
|
|
146
|
+
validatePositiveInteger(this.config.timeoutMs, 'timeoutMs');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async store(data: Uint8Array): Promise<StoredBlob> {
|
|
150
|
+
if (data.byteLength > this.config.maxBlobSize) {
|
|
151
|
+
throw new WalrusBlobTooLargeError(data.byteLength, this.config.maxBlobSize);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contentHash = computeSha256(data);
|
|
155
|
+
const startedAt = Date.now();
|
|
156
|
+
const started = performance.now();
|
|
157
|
+
const storeUrl = new URL(`${this.config.publisherUrl}/v1/blobs`);
|
|
158
|
+
storeUrl.searchParams.set('epochs', String(this.config.epochs));
|
|
159
|
+
storeUrl.searchParams.set('permanent', 'true');
|
|
160
|
+
|
|
161
|
+
const response = await this.executeWithRetry('store', storeUrl.toString(), async () => {
|
|
162
|
+
const walrusResponse = await this.fetchWithTimeout(storeUrl.toString(), {
|
|
163
|
+
method: 'PUT',
|
|
164
|
+
headers: {
|
|
165
|
+
'content-type': 'application/octet-stream',
|
|
166
|
+
},
|
|
167
|
+
body: data,
|
|
168
|
+
});
|
|
169
|
+
const body = await walrusResponse.text();
|
|
170
|
+
|
|
171
|
+
if (!walrusResponse.ok) {
|
|
172
|
+
throw new WalrusHttpError(
|
|
173
|
+
`Walrus store failed with status ${walrusResponse.status}: ${body}`,
|
|
174
|
+
storeUrl.toString(),
|
|
175
|
+
walrusResponse.status,
|
|
176
|
+
body,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return parseWalrusStorePayload(body, storeUrl.toString());
|
|
181
|
+
}, { size: data.byteLength });
|
|
182
|
+
|
|
183
|
+
const blobId = createWalrusBlobReference(response.storageBlobId, contentHash);
|
|
184
|
+
const stored: WalrusBlobMetadata & StoredBlob = {
|
|
185
|
+
blobId,
|
|
186
|
+
hash: contentHash,
|
|
187
|
+
checksum: contentHash,
|
|
188
|
+
contentHash,
|
|
189
|
+
size: response.size ?? data.byteLength,
|
|
190
|
+
storedAt: startedAt,
|
|
191
|
+
storageBlobId: response.storageBlobId,
|
|
192
|
+
objectId: response.objectId,
|
|
193
|
+
deletable: response.deletable,
|
|
194
|
+
endEpoch: response.endEpoch,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
this.rememberMetadata(stored);
|
|
198
|
+
this.log('info', {
|
|
199
|
+
operation: 'store',
|
|
200
|
+
blobId,
|
|
201
|
+
storageBlobId: response.storageBlobId,
|
|
202
|
+
size: data.byteLength,
|
|
203
|
+
durationMs: Math.round(performance.now() - started),
|
|
204
|
+
}, 'Stored Walrus blob');
|
|
205
|
+
|
|
206
|
+
return stored;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async fetch(blobId: string): Promise<Uint8Array | null> {
|
|
210
|
+
const reference = parseWalrusBlobReference(blobId);
|
|
211
|
+
const started = performance.now();
|
|
212
|
+
const url = `${this.config.aggregatorUrl}/v1/blobs/${reference.storageBlobId}`;
|
|
213
|
+
|
|
214
|
+
const data = await this.executeWithRetry('fetch', url, async () => {
|
|
215
|
+
const response = await this.fetchWithTimeout(url, { method: 'GET' });
|
|
216
|
+
if (response.status === 404) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const body = await response.text();
|
|
222
|
+
throw new WalrusHttpError(
|
|
223
|
+
`Walrus fetch failed with status ${response.status}: ${body}`,
|
|
224
|
+
url,
|
|
225
|
+
response.status,
|
|
226
|
+
body,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
231
|
+
}, { blobId: reference.storageBlobId });
|
|
232
|
+
|
|
233
|
+
if (!data) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const actualHash = computeSha256(data);
|
|
238
|
+
const expectedHash = reference.contentHash ?? this.metadata.get(blobId)?.contentHash ?? this.metadata.get(reference.storageBlobId)?.contentHash;
|
|
239
|
+
if (expectedHash && actualHash !== expectedHash) {
|
|
240
|
+
this.log('error', {
|
|
241
|
+
operation: 'fetch',
|
|
242
|
+
blobId,
|
|
243
|
+
storageBlobId: reference.storageBlobId,
|
|
244
|
+
expectedHash,
|
|
245
|
+
actualHash,
|
|
246
|
+
}, 'Walrus blob failed integrity verification');
|
|
247
|
+
throw new BlobIntegrityError(
|
|
248
|
+
`Walrus blob ${blobId} failed SHA-256 verification.`,
|
|
249
|
+
blobId,
|
|
250
|
+
expectedHash,
|
|
251
|
+
actualHash,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const metadata = this.metadata.get(blobId) ?? this.metadata.get(reference.storageBlobId);
|
|
256
|
+
if (!metadata) {
|
|
257
|
+
this.rememberMetadata({
|
|
258
|
+
blobId: reference.contentHash ? blobId : createWalrusBlobReference(reference.storageBlobId, actualHash),
|
|
259
|
+
contentHash: actualHash,
|
|
260
|
+
size: data.byteLength,
|
|
261
|
+
storedAt: Date.now(),
|
|
262
|
+
storageBlobId: reference.storageBlobId,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.log('info', {
|
|
267
|
+
operation: 'fetch',
|
|
268
|
+
blobId,
|
|
269
|
+
storageBlobId: reference.storageBlobId,
|
|
270
|
+
size: data.byteLength,
|
|
271
|
+
durationMs: Math.round(performance.now() - started),
|
|
272
|
+
}, 'Fetched Walrus blob');
|
|
273
|
+
|
|
274
|
+
return data;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async exists(blobId: string): Promise<boolean> {
|
|
278
|
+
const reference = parseWalrusBlobReference(blobId);
|
|
279
|
+
const url = `${this.config.aggregatorUrl}/v1/blobs/${reference.storageBlobId}`;
|
|
280
|
+
|
|
281
|
+
return await this.executeWithRetry('exists', url, async () => {
|
|
282
|
+
const response = await this.fetchWithTimeout(url, {
|
|
283
|
+
method: 'GET',
|
|
284
|
+
headers: {
|
|
285
|
+
Range: 'bytes=0-0',
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (response.status === 404) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (response.status === 200 || response.status === 206) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = await response.text();
|
|
298
|
+
throw new WalrusHttpError(
|
|
299
|
+
`Walrus exists check failed with status ${response.status}: ${body}`,
|
|
300
|
+
url,
|
|
301
|
+
response.status,
|
|
302
|
+
body,
|
|
303
|
+
);
|
|
304
|
+
}, { blobId: reference.storageBlobId });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async getMetadata(blobId: string): Promise<BlobMetadata | null> {
|
|
308
|
+
const directMatch = this.metadata.get(blobId);
|
|
309
|
+
if (directMatch) {
|
|
310
|
+
return directMatch;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
return this.metadata.get(parseWalrusBlobReference(blobId).storageBlobId) ?? null;
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async delete(blobId: string): Promise<void> {
|
|
321
|
+
void blobId;
|
|
322
|
+
throw new Error(
|
|
323
|
+
'Walrus deletion is not available through the public HTTP API. Deleting or extending blobs requires owning the Walrus Blob object and using the CLI or on-chain APIs.',
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async executeWithRetry<T>(
|
|
328
|
+
operation: 'store' | 'fetch' | 'exists',
|
|
329
|
+
url: string,
|
|
330
|
+
action: () => Promise<T>,
|
|
331
|
+
context: Record<string, unknown> = {},
|
|
332
|
+
): Promise<T> {
|
|
333
|
+
let attempt = 0;
|
|
334
|
+
while (attempt < this.config.retryAttempts) {
|
|
335
|
+
attempt += 1;
|
|
336
|
+
try {
|
|
337
|
+
return await action();
|
|
338
|
+
} catch (error) {
|
|
339
|
+
const walrusError = classifyWalrusError(error, url, this.config.timeoutMs);
|
|
340
|
+
if (!walrusError.transient || attempt >= this.config.retryAttempts) {
|
|
341
|
+
this.log('error', {
|
|
342
|
+
operation,
|
|
343
|
+
attempt,
|
|
344
|
+
retryAttempts: this.config.retryAttempts,
|
|
345
|
+
transient: walrusError.transient,
|
|
346
|
+
url,
|
|
347
|
+
err: walrusError,
|
|
348
|
+
...context,
|
|
349
|
+
}, 'Walrus request failed');
|
|
350
|
+
throw walrusError;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const delayMs = this.config.retryDelayMs * (2 ** (attempt - 1));
|
|
354
|
+
this.log('warn', {
|
|
355
|
+
operation,
|
|
356
|
+
attempt,
|
|
357
|
+
retryAttempts: this.config.retryAttempts,
|
|
358
|
+
delayMs,
|
|
359
|
+
url,
|
|
360
|
+
err: walrusError,
|
|
361
|
+
...context,
|
|
362
|
+
}, 'Retrying Walrus request');
|
|
363
|
+
await sleep(delayMs);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error(`Walrus ${operation} exhausted retries for ${url}.`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
|
371
|
+
const walrusFetch = this.config.fetchImpl ?? globalThis.fetch;
|
|
372
|
+
if (!walrusFetch) {
|
|
373
|
+
throw new Error('Global fetch is not available in this runtime.');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const controller = new AbortController();
|
|
377
|
+
const timeout = setTimeout(() => {
|
|
378
|
+
controller.abort();
|
|
379
|
+
}, this.config.timeoutMs);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
return await walrusFetch(url, {
|
|
383
|
+
...init,
|
|
384
|
+
signal: controller.signal,
|
|
385
|
+
});
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (controller.signal.aborted) {
|
|
388
|
+
throw new WalrusTimeoutError(
|
|
389
|
+
`Walrus request timed out after ${this.config.timeoutMs}ms for ${url}.`,
|
|
390
|
+
url,
|
|
391
|
+
this.config.timeoutMs,
|
|
392
|
+
{ cause: error as Error },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
throw new WalrusNetworkError(
|
|
397
|
+
`Walrus request failed for ${url}: ${(error as Error).message}`,
|
|
398
|
+
url,
|
|
399
|
+
{ cause: error as Error },
|
|
400
|
+
);
|
|
401
|
+
} finally {
|
|
402
|
+
clearTimeout(timeout);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private rememberMetadata(metadata: WalrusBlobMetadata): void {
|
|
407
|
+
this.metadata.set(metadata.blobId, metadata);
|
|
408
|
+
this.metadata.set(metadata.storageBlobId, metadata);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private log(level: keyof WalrusLogger, bindings: Record<string, unknown>, message: string): void {
|
|
412
|
+
const targetLogger = this.config.logger ?? logger;
|
|
413
|
+
targetLogger[level](bindings, message);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function createWalrusBlobReference(storageBlobId: string, contentHash: string): string {
|
|
418
|
+
assertWalrusStorageBlobId(storageBlobId);
|
|
419
|
+
if (!/^[a-f0-9]{64}$/.test(contentHash)) {
|
|
420
|
+
throw new Error(`Invalid SHA-256 content hash: ${contentHash}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return `walrus:${storageBlobId}:${contentHash}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function parseWalrusBlobReference(blobId: string): WalrusBlobReference {
|
|
427
|
+
const encodedMatch = WALRUS_REFERENCE_PATTERN.exec(blobId);
|
|
428
|
+
if (encodedMatch) {
|
|
429
|
+
assertWalrusStorageBlobId(encodedMatch[1]);
|
|
430
|
+
return {
|
|
431
|
+
blobId,
|
|
432
|
+
storageBlobId: encodedMatch[1],
|
|
433
|
+
contentHash: encodedMatch[2],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
assertWalrusStorageBlobId(blobId);
|
|
438
|
+
return {
|
|
439
|
+
blobId,
|
|
440
|
+
storageBlobId: blobId,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function parseWalrusStorePayload(
|
|
445
|
+
body: string,
|
|
446
|
+
url: string,
|
|
447
|
+
): Omit<WalrusBlobMetadata, 'blobId' | 'contentHash' | 'storedAt' | 'size'> & { size?: number } {
|
|
448
|
+
let response: unknown;
|
|
449
|
+
try {
|
|
450
|
+
response = JSON.parse(body);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
throw new WalrusResponseError(`Walrus store returned invalid JSON from ${url}.`, url, body, {
|
|
453
|
+
cause: error as Error,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
return parseWalrusStoreResponse(response as WalrusStoreApiResponse);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
throw new WalrusResponseError(
|
|
461
|
+
error instanceof Error ? error.message : 'Unexpected Walrus store response payload.',
|
|
462
|
+
url,
|
|
463
|
+
body,
|
|
464
|
+
error instanceof Error ? { cause: error } : undefined,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function parseWalrusStoreResponse(
|
|
470
|
+
response: WalrusStoreApiResponse,
|
|
471
|
+
): Omit<WalrusBlobMetadata, 'blobId' | 'contentHash' | 'storedAt' | 'size'> & { size?: number } {
|
|
472
|
+
if ('newlyCreated' in response) {
|
|
473
|
+
const { blobObject } = response.newlyCreated;
|
|
474
|
+
assertWalrusStorageBlobId(blobObject.blobId);
|
|
475
|
+
return {
|
|
476
|
+
storageBlobId: blobObject.blobId,
|
|
477
|
+
objectId: blobObject.id,
|
|
478
|
+
size: blobObject.size,
|
|
479
|
+
deletable: blobObject.deletable,
|
|
480
|
+
endEpoch: blobObject.storage?.endEpoch,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if ('alreadyCertified' in response) {
|
|
485
|
+
assertWalrusStorageBlobId(response.alreadyCertified.blobId);
|
|
486
|
+
return {
|
|
487
|
+
storageBlobId: response.alreadyCertified.blobId,
|
|
488
|
+
endEpoch: response.alreadyCertified.endEpoch,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
throw new Error('Unexpected Walrus store response payload.');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function assertWalrusStorageBlobId(blobId: string): void {
|
|
496
|
+
if (!WALRUS_STORAGE_ID_PATTERN.test(blobId)) {
|
|
497
|
+
throw new Error(`Invalid Walrus blob ID: ${blobId}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const bytes = Buffer.from(blobId, 'base64url');
|
|
501
|
+
if (bytes.length !== WALRUS_STORAGE_ID_BYTES) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`Expected Walrus blob IDs to decode to ${WALRUS_STORAGE_ID_BYTES} bytes, got ${bytes.length}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function normalizeWalrusUrl(url: string): string {
|
|
509
|
+
return url.replace(/\/+$/, '');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function validatePositiveInteger(value: number, field: string): void {
|
|
513
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
514
|
+
throw new Error(`${field} must be a positive integer, received ${value}.`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function computeSha256(data: Uint8Array): string {
|
|
519
|
+
return createHash('sha256').update(data).digest('hex');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function classifyWalrusError(error: unknown, url: string, timeoutMs: number): WalrusRequestError {
|
|
523
|
+
if (error instanceof WalrusRequestError) {
|
|
524
|
+
return error;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (error instanceof BlobIntegrityError) {
|
|
528
|
+
return new WalrusRequestError(error.message, url, false, undefined, undefined, { cause: error });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
532
|
+
return new WalrusTimeoutError(
|
|
533
|
+
`Walrus request timed out after ${timeoutMs}ms for ${url}.`,
|
|
534
|
+
url,
|
|
535
|
+
timeoutMs,
|
|
536
|
+
{ cause: error },
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (error instanceof Error) {
|
|
541
|
+
return new WalrusNetworkError(error.message, url, { cause: error });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return new WalrusNetworkError(`Unknown Walrus request failure for ${url}.`, url);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function sleep(ms: number): Promise<void> {
|
|
548
|
+
return new Promise((resolvePromise) => {
|
|
549
|
+
setTimeout(resolvePromise, ms);
|
|
550
|
+
});
|
|
551
|
+
}
|