@fizzyflow/endless-vector 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ import { Transaction, coinWithBalance } from '@mysten/sui/transactions';
2
+ import { bcs } from '@mysten/sui/bcs';
3
+
4
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000000000000000000000000000';
5
+
6
+ /**
7
+ * @typedef {import('@mysten/sui/grpc').SuiGrpcClient} SuiGrpcClient
8
+ * @typedef {import('@mysten/walrus').WalrusClient} WalrusClient
9
+ * @typedef {import('./EndlessVector.js').default} EndlessVector
10
+ */
11
+
12
+ /**
13
+ * Walrus blob read/write companion for EndlessVector.
14
+ * Attached as `endlessVector.walrus` on every EndlessVector instance.
15
+ * Keeps walrus-specific state separate from the core vector logic.
16
+ */
17
+ export default class EndlessVectorWalrus {
18
+ /**
19
+ * @param {Object} params
20
+ * @param {EndlessVector} params.endlessVector - parent EndlessVector instance
21
+ * @param {WalrusClient} [params.walrusClient] - @mysten/walrus WalrusClient instance
22
+ * @param {string} [params.publisherUrl] - Walrus publisher HTTP URL (fallback if no walrusClient)
23
+ * @param {string} [params.aggregatorUrl] - Walrus aggregator HTTP URL for reads
24
+ * @param {string} [params.senderAddress] - Sui address of the transaction sender, required for walrusClient writes
25
+ */
26
+ constructor(params = {}) {
27
+ /** @type {EndlessVector} */
28
+ this._endlessVector = params.endlessVector || null;
29
+ /** @type {?WalrusClient} */
30
+ this._walrusClient = params.walrusClient || null;
31
+ /** @type {?string} */
32
+ this._publisherUrl = params.publisherUrl || null;
33
+ /** @type {?string} */
34
+ this._aggregatorUrl = params.aggregatorUrl || null;
35
+ /** @type {?string} */
36
+ this._senderAddress = params.senderAddress || null;
37
+ }
38
+
39
+ /**
40
+ * Reads blob bytes from Walrus for a blob item.
41
+ * Uses walrusClient if available, otherwise falls back to aggregatorUrl.
42
+ * Called automatically by EndlessVectorItem.bytes() for blob items.
43
+ * @param {Object} blobData - raw gRPC blob fields from EndlessWalrusItem
44
+ * @returns {Promise<Uint8Array>}
45
+ * @throws {Error} If no Walrus read transport is configured
46
+ */
47
+ async readBlobBytes(blobData) {
48
+ const blobId = blobData?.blob_id ?? blobData?.blobId;
49
+ if (!blobId) throw new Error('Cannot read blob: blob_id not found in blob data');
50
+
51
+ if (this._aggregatorUrl) {
52
+ // gRPC returns blob_id as a decimal u256 string; encode to base64url for the aggregator.
53
+ const blobIdEncoded = EndlessVectorWalrus._encodeBlobId(blobId);
54
+ const res = await fetch(`${this._aggregatorUrl}/v1/blobs/${blobIdEncoded}`);
55
+ if (!res.ok) throw new Error(`Walrus aggregator returned ${res.status} for blob ${blobIdEncoded}`);
56
+ return new Uint8Array(await res.arrayBuffer());
57
+ }
58
+
59
+ if (this._walrusClient) {
60
+ return await this._walrusClient.readBlob({ blobId });
61
+ }
62
+
63
+ throw new Error('Blob items require walrusClient or aggregatorUrl to be read');
64
+ }
65
+
66
+ /**
67
+ * Encodes a blob ID (decimal u256 string or already-encoded string) to Walrus base64url format.
68
+ * If the value is not a pure decimal string, returns it unchanged (already encoded).
69
+ * @param {string} value
70
+ * @returns {string}
71
+ */
72
+ static _encodeBlobId(value) {
73
+ let bytes;
74
+ if (value instanceof Uint8Array) {
75
+ bytes = value;
76
+ } else if (Array.isArray(value)) {
77
+ bytes = new Uint8Array(value);
78
+ } else if (/^\d+$/.test(String(value))) {
79
+ let n = BigInt(value);
80
+ bytes = new Uint8Array(32);
81
+ for (let i = 0; i < 32; i++) {
82
+ bytes[i] = Number(n & 0xffn);
83
+ n >>= 8n;
84
+ }
85
+ } else {
86
+ return value;
87
+ }
88
+ let b64 = '';
89
+ for (let i = 0; i < bytes.length; i += 3) {
90
+ const a = bytes[i], b = bytes[i + 1] || 0, c = bytes[i + 2] || 0;
91
+ const triplet = (a << 16) | (b << 8) | c;
92
+ const chars = i + 2 < bytes.length ? 4 : (i + 1 < bytes.length ? 3 : 2);
93
+ const encoded = [
94
+ triplet >> 18 & 63, triplet >> 12 & 63, triplet >> 6 & 63, triplet & 63
95
+ ].slice(0, chars).map(v => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'[v]).join('');
96
+ b64 += encoded;
97
+ }
98
+ return b64;
99
+ }
100
+
101
+ /**
102
+ * Simulates `tx` (devInspect — no signing, no gas, ownership checks disabled) and returns
103
+ * the BCS bytes of the first command's first return value. Used by the on-chain view
104
+ * helpers below.
105
+ * @param {Transaction} tx - a transaction whose first command is the view moveCall
106
+ * @param {string} label - used in error messages
107
+ * @returns {Promise<Uint8Array>}
108
+ * @private
109
+ */
110
+ async _simulateReturnBytes(tx, label) {
111
+ const ev = this._endlessVector;
112
+ // A sender is required to build the transaction for simulation; any address works
113
+ // since `checksEnabled: false` skips ownership/gas validation for these reads.
114
+ const sender = this._senderAddress || ev.suiClient?.address || ZERO_ADDRESS;
115
+ tx.setSenderIfNotSet(sender);
116
+
117
+ const sim = await ev.suiClient.simulateTransaction({
118
+ transaction: tx,
119
+ include: { commandResults: true },
120
+ checksEnabled: false,
121
+ });
122
+
123
+ if (sim.FailedTransaction) {
124
+ throw new Error(`${label} simulation failed`);
125
+ }
126
+
127
+ const returnValue = sim.commandResults?.[0]?.returnValues?.[0];
128
+ if (!returnValue?.bcs) {
129
+ throw new Error(`${label} simulation returned no value`);
130
+ }
131
+ return new Uint8Array(returnValue.bcs);
132
+ }
133
+
134
+ /**
135
+ * Reads the minimum Walrus storage `end_epoch` across every Blob held by this vector
136
+ * (current items + history segments + non-burned archive segments), by calling the
137
+ * `endless_walrus::min_blob_end_epoch` view function via transaction simulation
138
+ * (devInspect). No transaction is signed or submitted, so this works on a read-only
139
+ * vector and costs no gas.
140
+ *
141
+ * The on-chain function returns `Option<u32>`; this resolves to `null` when the vector
142
+ * holds no blobs, or the smallest end epoch (a `number`) otherwise.
143
+ *
144
+ * @returns {Promise<number|null>} Minimum blob end epoch, or `null` if there are no blobs
145
+ * @throws {Error} If packageId or the vector id is not set, or the simulation fails
146
+ */
147
+ async minBlobEndEpoch() {
148
+ const ev = this._endlessVector;
149
+ if (!ev._packageId) {
150
+ throw new Error('packageId is required to read min_blob_end_epoch');
151
+ }
152
+ if (!ev.id) {
153
+ throw new Error('vector id is required to read min_blob_end_epoch');
154
+ }
155
+
156
+ const tx = new Transaction();
157
+ tx.moveCall({
158
+ target: `${ev._packageId}::endless_walrus::min_blob_end_epoch`,
159
+ arguments: [tx.object(ev.id)],
160
+ });
161
+
162
+ // Move return type is Option<u32>: BCS is [0] for none, or [1, <u32-le>] for some.
163
+ const bytes = await this._simulateReturnBytes(tx, 'min_blob_end_epoch');
164
+ const decoded = bcs.option(bcs.u32()).parse(bytes);
165
+ return decoded === null ? null : Number(decoded);
166
+ }
167
+
168
+ /**
169
+ * The Walrus System shared object id, resolved from the configured WalrusClient.
170
+ * @returns {Promise<string>}
171
+ * @throws {Error} If no walrusClient is configured
172
+ * @private
173
+ */
174
+ async _getSystemObjectId() {
175
+ if (!this._walrusClient) {
176
+ throw new Error('walrusClient is required to resolve the Walrus System object');
177
+ }
178
+ if (!this.__systemObjectId) {
179
+ const systemObject = await this._walrusClient.systemObject();
180
+ this.__systemObjectId = systemObject.id?.id ?? systemObject.id;
181
+ }
182
+ return this.__systemObjectId;
183
+ }
184
+
185
+ /**
186
+ * The current `storage_price_per_unit_size` from on-chain system state (FROST per
187
+ * 1 MiB storage unit per epoch).
188
+ * @returns {Promise<bigint>}
189
+ * @private
190
+ */
191
+ async _getStoragePricePerUnit() {
192
+ if (!this._walrusClient) {
193
+ throw new Error('walrusClient is required to read the storage price');
194
+ }
195
+ const systemState = await this._walrusClient.systemState();
196
+ return BigInt(systemState.storage_price_per_unit_size);
197
+ }
198
+
199
+ /**
200
+ * The Move type of a WAL coin (e.g. `0x…::wal::WAL`), derived from the
201
+ * `extend_blobs_to_epoch` Move function signature (its `payment: &mut Coin<WAL>` param).
202
+ * Cached after the first lookup.
203
+ * @returns {Promise<string>}
204
+ * @private
205
+ */
206
+ async _getWalCoinType() {
207
+ const ev = this._endlessVector;
208
+ if (this.__walCoinType) return this.__walCoinType;
209
+
210
+ const { function: normalized } = await ev.suiClient.getMoveFunction({
211
+ packageId: ev._packageId,
212
+ moduleName: 'endless_walrus',
213
+ name: 'extend_blobs_to_epoch',
214
+ });
215
+
216
+ // params: (&mut EndlessWalrusVector, &mut System, u32 target, &mut Coin<WAL>)
217
+ const param = normalized?.parameters?.[3];
218
+ const typeArg = param?.body?.$kind === 'datatype' ? param.body.datatype.typeParameters?.[0] : undefined;
219
+ const walCoinType = typeArg?.$kind === 'datatype' ? typeArg.datatype.typeName : null;
220
+ if (!walCoinType) {
221
+ throw new Error('could not resolve WAL coin type from extend_blobs_to_epoch signature');
222
+ }
223
+
224
+ this.__walCoinType = walCoinType;
225
+ return walCoinType;
226
+ }
227
+
228
+ /**
229
+ * Reads the exact WAL cost (in FROST) to bring every blob in this vector up to
230
+ * `targetEndEpoch` via {@link extendBlobsToEpoch}, by calling the
231
+ * `endless_walrus::extend_blobs_cost_to_epoch` view function via simulation (devInspect).
232
+ * Returns `0n` when nothing needs extending.
233
+ *
234
+ * @param {number} targetEndEpoch - the storage end epoch every blob should reach
235
+ * @returns {Promise<bigint>} Required payment in FROST
236
+ * @throws {Error} If packageId/vector id are unset or walrusClient is missing
237
+ */
238
+ async extendBlobsCostToEpoch(targetEndEpoch) {
239
+ const ev = this._endlessVector;
240
+ if (!ev._packageId) {
241
+ throw new Error('packageId is required to read extend_blobs_cost_to_epoch');
242
+ }
243
+ if (!ev.id) {
244
+ throw new Error('vector id is required to read extend_blobs_cost_to_epoch');
245
+ }
246
+
247
+ const [systemObjectId, pricePerUnit] = await Promise.all([
248
+ this._getSystemObjectId(),
249
+ this._getStoragePricePerUnit(),
250
+ ]);
251
+
252
+ const tx = new Transaction();
253
+ tx.moveCall({
254
+ target: `${ev._packageId}::endless_walrus::extend_blobs_cost_to_epoch`,
255
+ arguments: [
256
+ tx.object(ev.id),
257
+ tx.object(systemObjectId),
258
+ tx.pure.u32(targetEndEpoch),
259
+ tx.pure.u64(pricePerUnit),
260
+ ],
261
+ });
262
+
263
+ const bytes = await this._simulateReturnBytes(tx, 'extend_blobs_cost_to_epoch');
264
+ return BigInt(bcs.u64().parse(bytes)); // bcs.u64 parses to a string; normalize to bigint
265
+ }
266
+
267
+ /**
268
+ * Builds (without executing) a transaction that extends every blob in this vector up to
269
+ * `targetEndEpoch` in a single `extend_blobs_to_epoch_entry` call. The payment coin is
270
+ * resolved automatically from the sender's WAL balance and the leftover is returned to
271
+ * the sender, unless a `walCoin` is supplied.
272
+ *
273
+ * @param {number} targetEndEpoch - storage end epoch every blob should reach
274
+ * @param {Object} [params={}]
275
+ * @param {bigint} [params.cost] - precomputed cost (FROST); skips the on-chain cost read
276
+ * @param {import('@mysten/sui/transactions').TransactionObjectArgument} [params.walCoin] - WAL coin to pay from; if omitted, one is sourced from the sender's balance
277
+ * @param {Transaction} [params.txToAppendTo=null]
278
+ * @returns {Promise<Transaction>}
279
+ * @throws {Error} If packageId is not set
280
+ */
281
+ async getExtendBlobsToEpochTransaction(targetEndEpoch, params = {}) {
282
+ const ev = this._endlessVector;
283
+ if (!ev._packageId) {
284
+ throw new Error('packageId is required to compose extend_blobs_to_epoch transaction');
285
+ }
286
+
287
+ const systemObjectId = await this._getSystemObjectId();
288
+ const tx = params.txToAppendTo ?? new Transaction();
289
+
290
+ // Resolve the payment coin: caller-supplied, or sourced from the sender's WAL balance
291
+ // for exactly the required cost.
292
+ let walCoin = params.walCoin ?? null;
293
+ let returnCoin = false;
294
+ if (!walCoin) {
295
+ const cost = params.cost ?? await this.extendBlobsCostToEpoch(targetEndEpoch);
296
+ const walCoinType = await this._getWalCoinType();
297
+ walCoin = tx.add(coinWithBalance({ balance: cost, type: walCoinType }));
298
+ returnCoin = true;
299
+ }
300
+
301
+ tx.moveCall({
302
+ target: `${ev._packageId}::endless_walrus::extend_blobs_to_epoch_entry`,
303
+ arguments: [
304
+ tx.object(ev.id),
305
+ tx.object(systemObjectId),
306
+ tx.pure.u32(targetEndEpoch),
307
+ walCoin,
308
+ ],
309
+ });
310
+
311
+ // The payment is borrowed (&mut), so the coin object survives the call; return any
312
+ // unspent balance to the sender so it is not left dangling.
313
+ if (returnCoin) {
314
+ const sender = this._senderAddress || ev.suiClient?.address;
315
+ if (!sender) throw new Error('senderAddress is required to return the leftover WAL coin');
316
+ tx.transferObjects([walCoin], sender);
317
+ }
318
+
319
+ return tx;
320
+ }
321
+
322
+ /**
323
+ * Extends every blob in this vector up to `targetEndEpoch` in a single transaction,
324
+ * signing and executing it via the parent vector. Blobs already valid through the target
325
+ * (and expired blobs, which Walrus cannot extend) are skipped on-chain.
326
+ *
327
+ * @param {number} targetEndEpoch - storage end epoch every blob should reach
328
+ * @param {Object} [params={}] - forwarded to {@link getExtendBlobsToEpochTransaction} and execution
329
+ * @param {bigint} [params.cost] - precomputed cost (FROST)
330
+ * @param {import('@mysten/sui/transactions').TransactionObjectArgument} [params.walCoin]
331
+ * @param {number} [params.timeout]
332
+ * @param {number} [params.pollIntervalMs]
333
+ * @returns {Promise<number|null>} The new minimum blob end epoch after extension
334
+ * @throws {Error} If the parent vector is not writable
335
+ */
336
+ async extendBlobsToEpoch(targetEndEpoch, params = {}) {
337
+ const ev = this._endlessVector;
338
+ if (!ev.isWritable) {
339
+ throw new Error('EndlessVector is not writable, packageId and signAndExecuteTransaction are required');
340
+ }
341
+
342
+ const tx = await this.getExtendBlobsToEpochTransaction(targetEndEpoch, params);
343
+ await ev.executeAndWaitForTransaction(tx, params);
344
+ ev.reInitialize();
345
+
346
+ return await this.minBlobEndEpoch();
347
+ }
348
+
349
+ /**
350
+ * Creates a transaction to push a pre-existing on-chain Blob object into this vector.
351
+ * Use this when you already have a certified Blob object ID.
352
+ *
353
+ * @param {string} blobObjectId - Sui object ID of the certified Blob
354
+ * @param {Transaction} [txToAppendTo=null]
355
+ * @returns {Transaction}
356
+ * @throws {Error} If packageId is not set
357
+ */
358
+ getPushBlobTransaction(blobObjectId, txToAppendTo = null) {
359
+ const ev = this._endlessVector;
360
+ if (!ev._packageId) {
361
+ throw new Error('packageId is required to compose push_back_blob transaction');
362
+ }
363
+
364
+ const tx = txToAppendTo ?? new Transaction();
365
+
366
+ tx.moveCall({
367
+ target: `${ev._packageId}::endless_walrus::push_back_blob`,
368
+ arguments: [tx.object(ev.id), tx.object(blobObjectId)],
369
+ });
370
+
371
+ return tx;
372
+ }
373
+
374
+ /**
375
+ * Uploads bytes to Walrus, certifies the blob on-chain, then appends it to this vector.
376
+ *
377
+ * Requires either walrusClient (preferred) or publisherUrl.
378
+ * Uses the parent vector's signAndExecuteTransaction for all on-chain steps.
379
+ *
380
+ * @param {Uint8Array} data - Bytes to store in Walrus
381
+ * @param {Object} [params={}]
382
+ * @param {number} [params.epochs=3] - Walrus storage epochs
383
+ * @param {boolean} [params.deletable=false]
384
+ * @param {number} [params.timeout=30000]
385
+ * @param {number} [params.pollIntervalMs=200]
386
+ * @returns {Promise<{ blobId: string, blobObjectId: string }>}
387
+ * @throws {Error} If parent vector is not writable or no Walrus write transport configured
388
+ */
389
+ async pushBlob(data, params = {}) {
390
+ const ev = this._endlessVector;
391
+ if (!ev.isWritable) {
392
+ throw new Error('EndlessVector is not writable, packageId and signAndExecuteTransaction are required');
393
+ }
394
+
395
+ const { epochs = 3, deletable = false, timeout = 30000, pollIntervalMs = 200 } = params;
396
+
397
+ let blobId, blobObjectId;
398
+
399
+ if (this._walrusClient) {
400
+ ({ blobId, blobObjectId } = await this._writeViaWalrusClient(data, { epochs, deletable, timeout, pollIntervalMs }));
401
+ } else if (this._publisherUrl) {
402
+ ({ blobId, blobObjectId } = await this._writeViaPublisherUrl(data, { epochs }));
403
+ } else {
404
+ throw new Error('pushBlob requires walrusClient or publisherUrl');
405
+ }
406
+
407
+ const tx = this.getPushBlobTransaction(blobObjectId);
408
+ await ev.executeAndWaitForTransaction(tx, { timeout, pollIntervalMs });
409
+
410
+ ev.reInitialize();
411
+
412
+ return { blobId, blobObjectId };
413
+ }
414
+
415
+ /**
416
+ * @param {Uint8Array} data
417
+ * @param {{ epochs: number, deletable: boolean }} options
418
+ * @returns {Promise<{ blobId: string, blobObjectId: string }>}
419
+ */
420
+ async _writeViaWalrusClient(data, { epochs, deletable, timeout = 30000, pollIntervalMs = 200 }) {
421
+ const ev = this._endlessVector;
422
+ const owner = this._senderAddress || ev.suiClient?.address;
423
+
424
+ const flow = this._walrusClient.writeBlobFlow({ blob: data });
425
+ await flow.encode();
426
+
427
+ const registerTx = flow.register({ epochs, owner, deletable });
428
+ const registerResult = await ev._signAndExecuteTransaction(registerTx);
429
+ const registerDigest = typeof registerResult === 'string' ? registerResult : registerResult?.digest;
430
+ if (!registerDigest) throw new Error('Walrus register transaction returned no digest');
431
+ console.log('[EndlessVectorWalrus] register digest:', registerDigest);
432
+
433
+ await flow.upload({ digest: registerDigest });
434
+
435
+ const certifyTx = flow.certify();
436
+ await ev.executeAndWaitForTransaction(certifyTx, { timeout, pollIntervalMs });
437
+
438
+ const blob = await flow.getBlob();
439
+ return { blobId: blob.blobId, blobObjectId: blob.blobObjectId };
440
+ }
441
+
442
+ /**
443
+ * @param {Uint8Array} data
444
+ * @param {{ epochs: number }} options
445
+ * @returns {Promise<{ blobId: string, blobObjectId: string }>}
446
+ */
447
+ async _writeViaPublisherUrl(data, { epochs }) {
448
+ const url = `${this._publisherUrl}/v1/blobs?epochs=${epochs}`;
449
+ const res = await fetch(url, {
450
+ method: 'PUT',
451
+ body: data,
452
+ headers: { 'Content-Type': 'application/octet-stream' },
453
+ });
454
+ if (!res.ok) throw new Error(`Walrus publisher returned ${res.status}`);
455
+
456
+ const json = await res.json();
457
+ const info = json.newlyCreated ?? json.alreadyCertified;
458
+ const blobId = info?.blobObject?.blobId ?? info?.blobId;
459
+ const blobObjectId = info?.blobObject?.id;
460
+
461
+ if (!blobId || !blobObjectId) {
462
+ throw new Error('Walrus publisher response missing blobId or blobObjectId');
463
+ }
464
+
465
+ return { blobId, blobObjectId };
466
+ }
467
+ }