@vdoninja/ninja-p2p 0.1.2 → 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/.agents/skills/ninja-p2p/SKILL.md +102 -2
- package/.claude/skills/ninja-p2p/SKILL.md +209 -130
- package/.codex/skills/ninja-p2p/SKILL.md +102 -2
- package/CHANGELOG.md +113 -0
- package/README.md +1077 -825
- package/dashboard.html +370 -50
- package/dist/agent-state.js +17 -1
- package/dist/cli-lib.d.ts +40 -0
- package/dist/cli-lib.js +165 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +501 -10
- package/dist/demo.d.ts +37 -0
- package/dist/demo.js +186 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.js +219 -0
- package/dist/file-transfer.d.ts +15 -2
- package/dist/file-transfer.js +249 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/message-bus.d.ts +17 -1
- package/dist/message-bus.js +55 -16
- package/dist/peer-registry.js +7 -0
- package/dist/protocol.d.ts +78 -1
- package/dist/protocol.js +42 -6
- package/dist/shared-folders.js +20 -1
- package/dist/social-stream.d.ts +100 -0
- package/dist/social-stream.js +254 -0
- package/dist/swarm-manager.d.ts +164 -0
- package/dist/swarm-manager.js +957 -0
- package/dist/swarm-session.d.ts +197 -0
- package/dist/swarm-session.js +465 -0
- package/dist/swarm-wire.d.ts +48 -0
- package/dist/swarm-wire.js +86 -0
- package/dist/swarm.d.ts +258 -0
- package/dist/swarm.js +694 -0
- package/dist/vdo-bridge.d.ts +62 -1
- package/dist/vdo-bridge.js +280 -30
- package/dist/vdoninja-sdk-types.d.ts +75 -0
- package/dist/vdoninja-sdk-types.js +10 -0
- package/dist/wake.d.ts +83 -0
- package/dist/wake.js +206 -0
- package/docs/images/agent-room-dashboard.png +0 -0
- package/docs/protocol-and-reliability.md +231 -0
- package/docs/sdk-wishlist.md +236 -0
- package/docs/security.md +197 -0
- package/docs/social-stream-bridge.md +390 -0
- package/package.json +125 -113
package/dist/file-transfer.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { appendFileSync, constants, closeSync, copyFileSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { createMessageId, } from "./protocol.js";
|
|
5
5
|
export const DEFAULT_TRANSFER_CHUNK_SIZE = 12_000;
|
|
6
|
+
/** The simple transfer path buffers at the sender; use swarm transfer above this. */
|
|
7
|
+
export const MAX_BASIC_TRANSFER_SIZE = 256 * 1024 * 1024;
|
|
8
|
+
export const MAX_BASIC_TRANSFER_CHUNK_SIZE = 1024 * 1024;
|
|
9
|
+
export const MAX_BASIC_TRANSFER_CHUNKS = Math.ceil(MAX_BASIC_TRANSFER_SIZE / 1_024);
|
|
10
|
+
export const MAX_INCOMPLETE_TRANSFER_BYTES = 512 * 1024 * 1024;
|
|
11
|
+
export const MAX_INCOMPLETE_TRANSFERS = 16;
|
|
12
|
+
export const INCOMPLETE_TRANSFER_STALE_MS = 24 * 60 * 60_000;
|
|
13
|
+
export const TRANSFER_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
|
14
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
15
|
+
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
6
16
|
export function prepareFileTransferFromPath(filePath, kind = "file") {
|
|
7
17
|
const resolved = path.resolve(filePath);
|
|
18
|
+
const file = statSync(resolved);
|
|
19
|
+
if (!file.isFile())
|
|
20
|
+
throw new Error(`not a file: ${resolved}`);
|
|
21
|
+
if (file.size > MAX_BASIC_TRANSFER_SIZE) {
|
|
22
|
+
throw new Error(`file is ${file.size} bytes; simple transfers are limited to ${MAX_BASIC_TRANSFER_SIZE} bytes ` +
|
|
23
|
+
"(use `ninja-p2p seed` / `fetch` for larger files)");
|
|
24
|
+
}
|
|
8
25
|
const bytes = new Uint8Array(readFileSync(resolved));
|
|
26
|
+
if (bytes.byteLength > MAX_BASIC_TRANSFER_SIZE) {
|
|
27
|
+
throw new Error(`file grew beyond the ${MAX_BASIC_TRANSFER_SIZE}-byte simple-transfer limit while reading`);
|
|
28
|
+
}
|
|
9
29
|
const name = path.basename(resolved);
|
|
10
30
|
return {
|
|
11
31
|
name,
|
|
@@ -24,7 +44,16 @@ export function sendPreparedFileTransfer(bridge, targetStreamId, prepared, chunk
|
|
|
24
44
|
if (!bridge.peers.isConnected(targetStreamId)) {
|
|
25
45
|
throw new Error(`peer is not connected: ${targetStreamId}`);
|
|
26
46
|
}
|
|
27
|
-
|
|
47
|
+
if (!Number.isFinite(chunkSize) || chunkSize <= 0) {
|
|
48
|
+
throw new Error(`invalid chunk size: ${chunkSize}`);
|
|
49
|
+
}
|
|
50
|
+
if (prepared.size > MAX_BASIC_TRANSFER_SIZE) {
|
|
51
|
+
throw new Error(`simple transfers are limited to ${MAX_BASIC_TRANSFER_SIZE} bytes`);
|
|
52
|
+
}
|
|
53
|
+
const normalizedChunkSize = Math.max(1_024, Math.floor(chunkSize));
|
|
54
|
+
if (normalizedChunkSize > MAX_BASIC_TRANSFER_CHUNK_SIZE) {
|
|
55
|
+
throw new Error(`chunk size exceeds ${MAX_BASIC_TRANSFER_CHUNK_SIZE} bytes`);
|
|
56
|
+
}
|
|
28
57
|
const totalChunks = prepared.size === 0 ? 0 : Math.ceil(prepared.size / normalizedChunkSize);
|
|
29
58
|
const transferId = createMessageId();
|
|
30
59
|
const offer = {
|
|
@@ -37,7 +66,9 @@ export function sendPreparedFileTransfer(bridge, targetStreamId, prepared, chunk
|
|
|
37
66
|
chunkSize: normalizedChunkSize,
|
|
38
67
|
totalChunks,
|
|
39
68
|
};
|
|
40
|
-
bridge.bus.
|
|
69
|
+
if (!bridge.bus.trySend(targetStreamId, "file_offer", offer)) {
|
|
70
|
+
throw new Error(`peer did not accept file offer: ${targetStreamId}`);
|
|
71
|
+
}
|
|
41
72
|
for (let index = 0; index < totalChunks; index += 1) {
|
|
42
73
|
const start = index * normalizedChunkSize;
|
|
43
74
|
const end = Math.min(start + normalizedChunkSize, prepared.size);
|
|
@@ -48,27 +79,44 @@ export function sendPreparedFileTransfer(bridge, targetStreamId, prepared, chunk
|
|
|
48
79
|
totalChunks,
|
|
49
80
|
data: bytesToBase64(chunk),
|
|
50
81
|
};
|
|
51
|
-
bridge.bus.
|
|
82
|
+
if (!bridge.bus.trySend(targetStreamId, "file_chunk", payload)) {
|
|
83
|
+
throw new Error(`peer stopped accepting ${prepared.name} at chunk ${index}/${totalChunks}`);
|
|
84
|
+
}
|
|
52
85
|
}
|
|
53
|
-
bridge.bus.
|
|
86
|
+
if (!bridge.bus.trySend(targetStreamId, "file_complete", {
|
|
54
87
|
transferId,
|
|
55
88
|
totalChunks,
|
|
56
89
|
size: prepared.size,
|
|
57
90
|
sha256: prepared.sha256,
|
|
58
|
-
})
|
|
91
|
+
})) {
|
|
92
|
+
throw new Error(`peer did not accept completion for ${prepared.name}`);
|
|
93
|
+
}
|
|
59
94
|
return offer;
|
|
60
95
|
}
|
|
61
96
|
export function sendFileFromPath(bridge, targetStreamId, filePath, kind = "file", chunkSize = DEFAULT_TRANSFER_CHUNK_SIZE) {
|
|
62
97
|
return sendPreparedFileTransfer(bridge, targetStreamId, prepareFileTransferFromPath(filePath, kind), chunkSize);
|
|
63
98
|
}
|
|
64
99
|
export function beginIncomingTransfer(stateDir, from, offer) {
|
|
100
|
+
validateFileOffer(offer);
|
|
101
|
+
if (!from || typeof from.streamId !== "string" || !from.streamId) {
|
|
102
|
+
throw new Error("file offer has no valid sender");
|
|
103
|
+
}
|
|
65
104
|
const paths = getTransferPaths(stateDir, offer.transferId);
|
|
66
105
|
mkdirSync(paths.transfersDir, { recursive: true });
|
|
67
106
|
mkdirSync(paths.downloadsDir, { recursive: true });
|
|
68
107
|
const existing = readTransferManifest(stateDir, offer.transferId);
|
|
69
108
|
if (existing) {
|
|
109
|
+
if (existing.from.streamId !== from.streamId ||
|
|
110
|
+
existing.name !== offer.name ||
|
|
111
|
+
existing.size !== offer.size ||
|
|
112
|
+
existing.sha256 !== offer.sha256 ||
|
|
113
|
+
existing.chunkSize !== offer.chunkSize ||
|
|
114
|
+
existing.totalChunks !== offer.totalChunks) {
|
|
115
|
+
throw new Error(`transfer id is already in use: ${offer.transferId}`);
|
|
116
|
+
}
|
|
70
117
|
return existing;
|
|
71
118
|
}
|
|
119
|
+
enforceIncomingTransferQuota(stateDir, offer.size);
|
|
72
120
|
writeFileSync(paths.tempPath, new Uint8Array(0));
|
|
73
121
|
const safeName = sanitizeFileName(offer.name);
|
|
74
122
|
const manifest = {
|
|
@@ -92,18 +140,35 @@ export function beginIncomingTransfer(stateDir, from, offer) {
|
|
|
92
140
|
writeManifest(paths.manifestPath, manifest);
|
|
93
141
|
return manifest;
|
|
94
142
|
}
|
|
95
|
-
export function appendIncomingTransferChunk(stateDir, payload) {
|
|
143
|
+
export function appendIncomingTransferChunk(stateDir, payload, fromStreamId) {
|
|
144
|
+
validateTransferId(payload?.transferId);
|
|
96
145
|
const manifest = mustReadTransferManifest(stateDir, payload.transferId);
|
|
146
|
+
if (fromStreamId && manifest.from.streamId !== fromStreamId) {
|
|
147
|
+
throw new Error(`chunk sender does not own transfer ${payload.transferId}`);
|
|
148
|
+
}
|
|
97
149
|
if (manifest.completedAt) {
|
|
98
150
|
return manifest;
|
|
99
151
|
}
|
|
152
|
+
if (!Number.isInteger(payload.index) || payload.index < 0 || payload.index >= manifest.totalChunks) {
|
|
153
|
+
throw new Error(`invalid chunk index ${payload.index}`);
|
|
154
|
+
}
|
|
100
155
|
if (payload.index !== manifest.receivedChunks) {
|
|
101
156
|
throw new Error(`unexpected chunk index ${payload.index}; expected ${manifest.receivedChunks}`);
|
|
102
157
|
}
|
|
103
158
|
if (payload.totalChunks !== manifest.totalChunks) {
|
|
104
159
|
throw new Error(`unexpected totalChunks ${payload.totalChunks}; expected ${manifest.totalChunks}`);
|
|
105
160
|
}
|
|
161
|
+
if (typeof payload.data !== "string" || !BASE64_PATTERN.test(payload.data)) {
|
|
162
|
+
throw new Error("chunk data is not valid base64");
|
|
163
|
+
}
|
|
106
164
|
const bytes = base64ToBytes(payload.data);
|
|
165
|
+
const expectedLength = Math.min(manifest.chunkSize, manifest.size - payload.index * manifest.chunkSize);
|
|
166
|
+
if (bytes.byteLength !== expectedLength) {
|
|
167
|
+
throw new Error(`unexpected chunk length ${bytes.byteLength}; expected ${expectedLength}`);
|
|
168
|
+
}
|
|
169
|
+
if (manifest.receivedBytes + bytes.byteLength > manifest.size) {
|
|
170
|
+
throw new Error("chunk would exceed the offered file size");
|
|
171
|
+
}
|
|
107
172
|
appendFileSync(manifest.tempPath, bytes);
|
|
108
173
|
manifest.receivedChunks += 1;
|
|
109
174
|
manifest.receivedBytes += bytes.byteLength;
|
|
@@ -111,9 +176,19 @@ export function appendIncomingTransferChunk(stateDir, payload) {
|
|
|
111
176
|
writeManifest(getTransferPaths(stateDir, payload.transferId).manifestPath, manifest);
|
|
112
177
|
return manifest;
|
|
113
178
|
}
|
|
114
|
-
export function completeIncomingTransfer(stateDir, payload) {
|
|
179
|
+
export function completeIncomingTransfer(stateDir, payload, fromStreamId) {
|
|
180
|
+
validateTransferId(payload?.transferId);
|
|
115
181
|
const manifest = mustReadTransferManifest(stateDir, payload.transferId);
|
|
182
|
+
if (fromStreamId && manifest.from.streamId !== fromStreamId) {
|
|
183
|
+
throw new Error(`completion sender does not own transfer ${payload.transferId}`);
|
|
184
|
+
}
|
|
116
185
|
if (!manifest.completedAt) {
|
|
186
|
+
if (!Number.isInteger(payload.totalChunks) || !Number.isSafeInteger(payload.size)) {
|
|
187
|
+
throw new Error("invalid completion metadata");
|
|
188
|
+
}
|
|
189
|
+
if (typeof payload.sha256 !== "string" || !SHA256_PATTERN.test(payload.sha256)) {
|
|
190
|
+
throw new Error("invalid completion sha256");
|
|
191
|
+
}
|
|
117
192
|
if (payload.totalChunks !== manifest.totalChunks) {
|
|
118
193
|
throw new Error(`unexpected totalChunks ${payload.totalChunks}; expected ${manifest.totalChunks}`);
|
|
119
194
|
}
|
|
@@ -123,12 +198,14 @@ export function completeIncomingTransfer(stateDir, payload) {
|
|
|
123
198
|
if (manifest.receivedBytes !== manifest.size || payload.size !== manifest.size) {
|
|
124
199
|
throw new Error(`transfer size mismatch: received ${manifest.receivedBytes}, expected ${manifest.size}`);
|
|
125
200
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
201
|
+
const file = sha256File(manifest.tempPath);
|
|
202
|
+
if (file.size !== manifest.size) {
|
|
203
|
+
throw new Error(`transfer size mismatch on disk: ${file.size}, expected ${manifest.size}`);
|
|
204
|
+
}
|
|
205
|
+
if (file.sha256 !== manifest.sha256 || payload.sha256 !== manifest.sha256) {
|
|
129
206
|
throw new Error("transfer sha256 mismatch");
|
|
130
207
|
}
|
|
131
|
-
|
|
208
|
+
moveFileExclusive(manifest.tempPath, manifest.savedPath);
|
|
132
209
|
manifest.completedAt = Date.now();
|
|
133
210
|
manifest.updatedAt = manifest.completedAt;
|
|
134
211
|
writeManifest(getTransferPaths(stateDir, payload.transferId).manifestPath, manifest);
|
|
@@ -168,6 +245,25 @@ export function readTransferManifest(stateDir, transferId) {
|
|
|
168
245
|
return null;
|
|
169
246
|
return JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
170
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Remove a transfer that cannot be completed. Sender matching prevents a peer
|
|
250
|
+
* from aborting somebody else's transfer by guessing its id.
|
|
251
|
+
*/
|
|
252
|
+
export function discardIncomingTransfer(stateDir, transferId, expectedSender) {
|
|
253
|
+
try {
|
|
254
|
+
validateTransferId(transferId);
|
|
255
|
+
const manifest = readTransferManifest(stateDir, transferId);
|
|
256
|
+
if (expectedSender && manifest?.from.streamId !== expectedSender)
|
|
257
|
+
return false;
|
|
258
|
+
const paths = getTransferPaths(stateDir, transferId);
|
|
259
|
+
rmSync(paths.tempPath, { force: true });
|
|
260
|
+
rmSync(paths.manifestPath, { force: true });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
171
267
|
function mustReadTransferManifest(stateDir, transferId) {
|
|
172
268
|
const manifest = readTransferManifest(stateDir, transferId);
|
|
173
269
|
if (!manifest) {
|
|
@@ -176,6 +272,7 @@ function mustReadTransferManifest(stateDir, transferId) {
|
|
|
176
272
|
return manifest;
|
|
177
273
|
}
|
|
178
274
|
function getTransferPaths(stateDir, transferId) {
|
|
275
|
+
validateTransferId(transferId);
|
|
179
276
|
const transfersDir = path.join(path.resolve(stateDir), "transfers");
|
|
180
277
|
const downloadsDir = path.join(path.resolve(stateDir), "downloads");
|
|
181
278
|
return {
|
|
@@ -196,11 +293,24 @@ function chooseSavedPath(downloadsDir, safeName, transferId) {
|
|
|
196
293
|
if (!existsSync(direct)) {
|
|
197
294
|
return direct;
|
|
198
295
|
}
|
|
199
|
-
|
|
296
|
+
const tagged = path.join(downloadsDir, `${base}_${transferId}${ext}`);
|
|
297
|
+
if (!existsSync(tagged))
|
|
298
|
+
return tagged;
|
|
299
|
+
for (let suffix = 2; suffix < 100_000; suffix += 1) {
|
|
300
|
+
const candidate = path.join(downloadsDir, `${base}_${transferId}_${suffix}${ext}`);
|
|
301
|
+
if (!existsSync(candidate))
|
|
302
|
+
return candidate;
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`could not choose a free destination for ${safeName}`);
|
|
200
305
|
}
|
|
201
306
|
function sanitizeFileName(name) {
|
|
202
|
-
|
|
203
|
-
|
|
307
|
+
let safe = path.basename(name || "download")
|
|
308
|
+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
|
309
|
+
.replace(/[. ]+$/g, "")
|
|
310
|
+
.slice(0, 180) || "download";
|
|
311
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safe))
|
|
312
|
+
safe = `_${safe}`;
|
|
313
|
+
return safe;
|
|
204
314
|
}
|
|
205
315
|
function guessMimeType(name, kind) {
|
|
206
316
|
const ext = path.extname(name).toLowerCase();
|
|
@@ -233,6 +343,130 @@ export function bytesToBase64(bytes) {
|
|
|
233
343
|
export function base64ToBytes(value) {
|
|
234
344
|
return new Uint8Array(Buffer.from(value, "base64"));
|
|
235
345
|
}
|
|
346
|
+
function validateTransferId(transferId) {
|
|
347
|
+
if (typeof transferId !== "string" || !TRANSFER_ID_PATTERN.test(transferId)) {
|
|
348
|
+
throw new Error("invalid transfer id");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function validateFileOffer(offer) {
|
|
352
|
+
if (!offer || typeof offer !== "object")
|
|
353
|
+
throw new Error("file offer is not an object");
|
|
354
|
+
validateTransferId(offer.transferId);
|
|
355
|
+
if (typeof offer.name !== "string" || !offer.name.trim() || offer.name.length > 1_024 || offer.name.includes("\0")) {
|
|
356
|
+
throw new Error("invalid file name");
|
|
357
|
+
}
|
|
358
|
+
if (typeof offer.mimeType !== "string" || offer.mimeType.length > 256) {
|
|
359
|
+
throw new Error("invalid mime type");
|
|
360
|
+
}
|
|
361
|
+
if (offer.kind !== "file" && offer.kind !== "image")
|
|
362
|
+
throw new Error("invalid transfer kind");
|
|
363
|
+
if (!Number.isSafeInteger(offer.size) || offer.size < 0 || offer.size > MAX_BASIC_TRANSFER_SIZE) {
|
|
364
|
+
throw new Error(`invalid file size; maximum is ${MAX_BASIC_TRANSFER_SIZE} bytes`);
|
|
365
|
+
}
|
|
366
|
+
if (typeof offer.sha256 !== "string" || !SHA256_PATTERN.test(offer.sha256)) {
|
|
367
|
+
throw new Error("invalid file sha256");
|
|
368
|
+
}
|
|
369
|
+
if (!Number.isInteger(offer.chunkSize) ||
|
|
370
|
+
offer.chunkSize < 1_024 ||
|
|
371
|
+
offer.chunkSize > MAX_BASIC_TRANSFER_CHUNK_SIZE) {
|
|
372
|
+
throw new Error("invalid file chunk size");
|
|
373
|
+
}
|
|
374
|
+
if (!Number.isInteger(offer.totalChunks) ||
|
|
375
|
+
offer.totalChunks < 0 ||
|
|
376
|
+
offer.totalChunks > MAX_BASIC_TRANSFER_CHUNKS) {
|
|
377
|
+
throw new Error("invalid file chunk count");
|
|
378
|
+
}
|
|
379
|
+
const expected = offer.size === 0 ? 0 : Math.ceil(offer.size / offer.chunkSize);
|
|
380
|
+
if (offer.totalChunks !== expected) {
|
|
381
|
+
throw new Error(`file chunk count ${offer.totalChunks} does not match size/chunkSize (${expected})`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function moveFileExclusive(source, destination) {
|
|
385
|
+
try {
|
|
386
|
+
linkSync(source, destination);
|
|
387
|
+
try {
|
|
388
|
+
rmSync(source);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// The destination is already a complete independent directory entry.
|
|
392
|
+
// A leftover temp file is cleanup debt, not a failed delivery.
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
const code = error.code;
|
|
398
|
+
if (code === "EEXIST")
|
|
399
|
+
throw new Error(`destination already exists: ${destination}`);
|
|
400
|
+
if (existsSync(destination))
|
|
401
|
+
throw error;
|
|
402
|
+
if (code !== "EXDEV" && code !== "EPERM" && code !== "EACCES")
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
copyFileSync(source, destination, constants.COPYFILE_EXCL);
|
|
406
|
+
try {
|
|
407
|
+
rmSync(source);
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// See the hard-link path above.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function enforceIncomingTransferQuota(stateDir, incomingSize) {
|
|
414
|
+
const transfersDir = path.join(path.resolve(stateDir), "transfers");
|
|
415
|
+
if (!existsSync(transfersDir))
|
|
416
|
+
return;
|
|
417
|
+
let activeCount = 0;
|
|
418
|
+
let promisedBytes = 0;
|
|
419
|
+
const now = Date.now();
|
|
420
|
+
for (const name of readdirSync(transfersDir)) {
|
|
421
|
+
if (!name.endsWith(".json"))
|
|
422
|
+
continue;
|
|
423
|
+
const transferId = name.slice(0, -5);
|
|
424
|
+
if (!TRANSFER_ID_PATTERN.test(transferId))
|
|
425
|
+
continue;
|
|
426
|
+
let manifest;
|
|
427
|
+
try {
|
|
428
|
+
manifest = JSON.parse(readFileSync(path.join(transfersDir, name), "utf8"));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (manifest.completedAt || !existsSync(path.join(transfersDir, `${transferId}.part`)))
|
|
434
|
+
continue;
|
|
435
|
+
if (Number.isFinite(manifest.updatedAt) && now - manifest.updatedAt >= INCOMPLETE_TRANSFER_STALE_MS) {
|
|
436
|
+
discardIncomingTransfer(stateDir, transferId);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
activeCount += 1;
|
|
440
|
+
promisedBytes += Number.isSafeInteger(manifest.size) && manifest.size >= 0
|
|
441
|
+
? manifest.size
|
|
442
|
+
: MAX_BASIC_TRANSFER_SIZE;
|
|
443
|
+
}
|
|
444
|
+
if (activeCount >= MAX_INCOMPLETE_TRANSFERS) {
|
|
445
|
+
throw new Error(`too many incomplete file transfers (${activeCount}/${MAX_INCOMPLETE_TRANSFERS})`);
|
|
446
|
+
}
|
|
447
|
+
if (promisedBytes + incomingSize > MAX_INCOMPLETE_TRANSFER_BYTES) {
|
|
448
|
+
throw new Error(`incomplete file transfers would exceed the ${MAX_INCOMPLETE_TRANSFER_BYTES}-byte quota`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function sha256File(filePath) {
|
|
452
|
+
const fd = openSync(filePath, "r");
|
|
453
|
+
try {
|
|
454
|
+
const hash = createHash("sha256");
|
|
455
|
+
const buffer = new Uint8Array(1024 * 1024);
|
|
456
|
+
let position = 0;
|
|
457
|
+
for (;;) {
|
|
458
|
+
const read = readSync(fd, buffer, 0, buffer.length, position);
|
|
459
|
+
if (read === 0)
|
|
460
|
+
break;
|
|
461
|
+
hash.update(buffer.subarray(0, read));
|
|
462
|
+
position += read;
|
|
463
|
+
}
|
|
464
|
+
return { sha256: hash.digest("hex"), size: position };
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
closeSync(fd);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
236
470
|
export function readSavedTransferBytes(savedPath) {
|
|
237
471
|
return new Uint8Array(readFileSync(savedPath));
|
|
238
472
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { MessageBus, type KeywordTrigger, type MessageBusOptions, type SendDataFn } from "./message-bus.js";
|
|
2
2
|
export { PeerRegistry, type PeerRecord, type PeerRegistryEvents } from "./peer-registry.js";
|
|
3
3
|
export { ensureAgentState, getInboxSummary, isInboxWorthy, listInboxMessages, listQueuedAgentActions, queueAgentAction, readAgentSession, readPeersSnapshot, removeQueuedAgentAction, storeInboxMessage, takeInboxMessages, writeAgentSession, writePeersSnapshot, type AgentSessionState, type InboxMessage, type InboxSummary, type QueuedAgentAction, type QueuedAgentActionInput, } from "./agent-state.js";
|
|
4
|
-
export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, type CompletedTransferResult, type IncomingTransferManifest, type PreparedFileTransfer, } from "./file-transfer.js";
|
|
4
|
+
export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, discardIncomingTransfer, INCOMPLETE_TRANSFER_STALE_MS, MAX_BASIC_TRANSFER_CHUNK_SIZE, MAX_BASIC_TRANSFER_CHUNKS, MAX_BASIC_TRANSFER_SIZE, MAX_INCOMPLETE_TRANSFER_BYTES, MAX_INCOMPLETE_TRANSFERS, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, type CompletedTransferResult, type IncomingTransferManifest, type PreparedFileTransfer, } from "./file-transfer.js";
|
|
5
5
|
export { createEnvelope, createInstanceId, createMessageId, envelopeToWire, type FileAckPayload, type FileChunkPayload, type FileCompletePayload, type FileOfferPayload, type FileTransferKind, generateRoomName, isValidEnvelope, parseEnvelope, type AgentAsk, type AgentProfile, type AnnouncePayload, type MessageEnvelope, type MessageType, type PeerIdentity, type SharedFolderSummary, type SkillUpdatePayload, } from "./protocol.js";
|
|
6
6
|
export { inferTransferKind, listSharedFolderEntries, parseSharedFolderSpecs, resolveSharedFile, toSharedFolderSummaries, type ResolvedSharedFile, type SharedFolderConfig, type SharedFolderEntry, type SharedFolderListing, } from "./shared-folders.js";
|
|
7
7
|
export { VDOBridge, type VDOBridgeOptions } from "./vdo-bridge.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { MessageBus } from "./message-bus.js";
|
|
2
2
|
export { PeerRegistry } from "./peer-registry.js";
|
|
3
3
|
export { ensureAgentState, getInboxSummary, isInboxWorthy, listInboxMessages, listQueuedAgentActions, queueAgentAction, readAgentSession, readPeersSnapshot, removeQueuedAgentAction, storeInboxMessage, takeInboxMessages, writeAgentSession, writePeersSnapshot, } from "./agent-state.js";
|
|
4
|
-
export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, } from "./file-transfer.js";
|
|
4
|
+
export { DEFAULT_TRANSFER_CHUNK_SIZE, appendIncomingTransferChunk, beginIncomingTransfer, bytesToBase64, completeIncomingTransfer, createFailedFileAckPayload, createFileAckPayload, discardIncomingTransfer, INCOMPLETE_TRANSFER_STALE_MS, MAX_BASIC_TRANSFER_CHUNK_SIZE, MAX_BASIC_TRANSFER_CHUNKS, MAX_BASIC_TRANSFER_SIZE, MAX_INCOMPLETE_TRANSFER_BYTES, MAX_INCOMPLETE_TRANSFERS, prepareFileTransferFromPath, readSavedTransferBytes, readTransferManifest, sendFileFromPath, sendPreparedFileTransfer, sha256Hex, } from "./file-transfer.js";
|
|
5
5
|
export { createEnvelope, createInstanceId, createMessageId, envelopeToWire, generateRoomName, isValidEnvelope, parseEnvelope, } from "./protocol.js";
|
|
6
6
|
export { inferTransferKind, listSharedFolderEntries, parseSharedFolderSpecs, resolveSharedFile, toSharedFolderSummaries, } from "./shared-folders.js";
|
|
7
7
|
export { VDOBridge } from "./vdo-bridge.js";
|
package/dist/message-bus.d.ts
CHANGED
|
@@ -16,7 +16,8 @@ export type KeywordTrigger = {
|
|
|
16
16
|
pattern: RegExp;
|
|
17
17
|
handler: (msg: MessageEnvelope) => void;
|
|
18
18
|
};
|
|
19
|
-
|
|
19
|
+
/** Return false when the transport could not accept the message yet. */
|
|
20
|
+
export type SendDataFn = (data: object, target?: unknown) => boolean | void;
|
|
20
21
|
export type MessageBusOptions = {
|
|
21
22
|
/** Max messages kept in history ring buffer. Default: 200. */
|
|
22
23
|
historySize?: number;
|
|
@@ -47,6 +48,21 @@ export declare class MessageBus extends EventEmitter {
|
|
|
47
48
|
getSubscriptions(): string[];
|
|
48
49
|
/** Broadcast a message to all connected peers. */
|
|
49
50
|
broadcast(type: MessageType, payload: unknown, topic?: string): MessageEnvelope;
|
|
51
|
+
/**
|
|
52
|
+
* Send something that is only worth delivering right now, reporting whether
|
|
53
|
+
* the transport took it.
|
|
54
|
+
*
|
|
55
|
+
* Unlike `send`, a failure is not queued for replay and nothing enters the
|
|
56
|
+
* history ring. Both matter for swarm traffic: a chunk request replayed from
|
|
57
|
+
* a queue asks for something the requester already has, and a few seconds of
|
|
58
|
+
* transfer is enough to evict every real message from history.
|
|
59
|
+
*
|
|
60
|
+
* The returned boolean is the point. A caller that assumes a send succeeded
|
|
61
|
+
* will sit waiting out a request timeout for a message that never left.
|
|
62
|
+
*/
|
|
63
|
+
trySend(targetStreamId: string, type: MessageType, payload: unknown): boolean;
|
|
64
|
+
/** Broadcast counterpart of `trySend`: not retained, not replayed. */
|
|
65
|
+
tryBroadcast(type: MessageType, payload: unknown): boolean;
|
|
50
66
|
/** Send a message to a specific peer (by streamId). */
|
|
51
67
|
send(targetStreamId: string, type: MessageType, payload: unknown): MessageEnvelope;
|
|
52
68
|
/** Publish a message to a topic (only peers subscribed to that topic receive it).
|
package/dist/message-bus.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Zero dependencies on stevesbot internals.
|
|
11
11
|
*/
|
|
12
12
|
import { EventEmitter } from "node:events";
|
|
13
|
-
import { createEnvelope, envelopeToWire, } from "./protocol.js";
|
|
13
|
+
import { createEnvelope, envelopeToWire, isTransientType, } from "./protocol.js";
|
|
14
14
|
// ── Class ────────────────────────────────────────────────────────────────────
|
|
15
15
|
export class MessageBus extends EventEmitter {
|
|
16
16
|
identity;
|
|
@@ -65,15 +65,39 @@ export class MessageBus extends EventEmitter {
|
|
|
65
65
|
this.rawSend(envelope, null);
|
|
66
66
|
return envelope;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Send something that is only worth delivering right now, reporting whether
|
|
70
|
+
* the transport took it.
|
|
71
|
+
*
|
|
72
|
+
* Unlike `send`, a failure is not queued for replay and nothing enters the
|
|
73
|
+
* history ring. Both matter for swarm traffic: a chunk request replayed from
|
|
74
|
+
* a queue asks for something the requester already has, and a few seconds of
|
|
75
|
+
* transfer is enough to evict every real message from history.
|
|
76
|
+
*
|
|
77
|
+
* The returned boolean is the point. A caller that assumes a send succeeded
|
|
78
|
+
* will sit waiting out a request timeout for a message that never left.
|
|
79
|
+
*/
|
|
80
|
+
trySend(targetStreamId, type, payload) {
|
|
81
|
+
if (!this.peers.isConnected(targetStreamId))
|
|
82
|
+
return false;
|
|
83
|
+
const envelope = createEnvelope(this.identity, type, payload, { to: targetStreamId });
|
|
84
|
+
return this.rawSend(envelope, targetStreamId);
|
|
85
|
+
}
|
|
86
|
+
/** Broadcast counterpart of `trySend`: not retained, not replayed. */
|
|
87
|
+
tryBroadcast(type, payload) {
|
|
88
|
+
const envelope = createEnvelope(this.identity, type, payload);
|
|
89
|
+
return this.rawSend(envelope, null);
|
|
90
|
+
}
|
|
68
91
|
/** Send a message to a specific peer (by streamId). */
|
|
69
92
|
send(targetStreamId, type, payload) {
|
|
70
93
|
const envelope = createEnvelope(this.identity, type, payload, { to: targetStreamId });
|
|
71
|
-
this
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
94
|
+
// Transient types are still deliverable this way, but never retained or
|
|
95
|
+
// queued, so a caller reaching for the wrong method cannot flood history.
|
|
96
|
+
if (!isTransientType(type))
|
|
97
|
+
this.addToHistory(envelope);
|
|
98
|
+
if (!this.peers.isConnected(targetStreamId) || !this.rawSend(envelope, targetStreamId)) {
|
|
99
|
+
if (!isTransientType(type))
|
|
100
|
+
this.enqueueOffline(targetStreamId, envelope);
|
|
77
101
|
}
|
|
78
102
|
return envelope;
|
|
79
103
|
}
|
|
@@ -142,13 +166,21 @@ export class MessageBus extends EventEmitter {
|
|
|
142
166
|
const queue = this.offlineQueues.get(streamId);
|
|
143
167
|
if (!queue || queue.length === 0)
|
|
144
168
|
return [];
|
|
145
|
-
|
|
146
|
-
// Send each queued message wrapped as history_replay
|
|
169
|
+
const flushed = [];
|
|
147
170
|
for (const msg of queue) {
|
|
148
|
-
|
|
149
|
-
|
|
171
|
+
// Preserve the original type and ID. Wrapping queued commands as
|
|
172
|
+
// history_replay prevented normal command/file handlers from seeing them.
|
|
173
|
+
if (!this.rawSend(msg, streamId))
|
|
174
|
+
break;
|
|
175
|
+
flushed.push(msg);
|
|
176
|
+
}
|
|
177
|
+
if (flushed.length === queue.length) {
|
|
178
|
+
this.offlineQueues.delete(streamId);
|
|
179
|
+
}
|
|
180
|
+
else if (flushed.length > 0) {
|
|
181
|
+
this.offlineQueues.set(streamId, queue.slice(flushed.length));
|
|
150
182
|
}
|
|
151
|
-
return
|
|
183
|
+
return flushed;
|
|
152
184
|
}
|
|
153
185
|
/** Get the number of queued messages for a peer. */
|
|
154
186
|
getOfflineQueueSize(streamId) {
|
|
@@ -174,25 +206,31 @@ export class MessageBus extends EventEmitter {
|
|
|
174
206
|
// ── Internals ────────────────────────────────────────────────────────────
|
|
175
207
|
rawSend(envelope, target) {
|
|
176
208
|
if (!this.sendDataFn)
|
|
177
|
-
return;
|
|
209
|
+
return false;
|
|
178
210
|
const wire = envelopeToWire(envelope);
|
|
211
|
+
let accepted;
|
|
179
212
|
if (target) {
|
|
180
213
|
// Prefer uuid targeting once we know it; streamID targeting can be ambiguous
|
|
181
214
|
// before announce/rekey completes on some SDK connection paths.
|
|
182
215
|
const peer = this.peers.getPeer(target);
|
|
183
216
|
if (peer?.uuid) {
|
|
184
|
-
this.sendDataFn(wire, { uuid: peer.uuid });
|
|
217
|
+
accepted = this.sendDataFn(wire, { uuid: peer.uuid });
|
|
185
218
|
}
|
|
186
219
|
else {
|
|
187
|
-
this.sendDataFn(wire, { streamID: target });
|
|
220
|
+
accepted = this.sendDataFn(wire, { streamID: target });
|
|
188
221
|
}
|
|
189
222
|
}
|
|
190
223
|
else {
|
|
191
224
|
// Broadcast to all
|
|
192
|
-
this.sendDataFn(wire);
|
|
225
|
+
accepted = this.sendDataFn(wire);
|
|
193
226
|
}
|
|
227
|
+
// Void preserves compatibility with existing transports written before
|
|
228
|
+
// acceptance results were supported. Only an explicit false is a failure.
|
|
229
|
+
return accepted !== false;
|
|
194
230
|
}
|
|
195
231
|
addToHistory(envelope) {
|
|
232
|
+
if (isTransientType(envelope.type))
|
|
233
|
+
return;
|
|
196
234
|
// Don't store heartbeat or file chunk traffic in history.
|
|
197
235
|
if (envelope.type === "ping" || envelope.type === "pong" || envelope.type === "file_chunk")
|
|
198
236
|
return;
|
|
@@ -221,6 +259,7 @@ export class MessageBus extends EventEmitter {
|
|
|
221
259
|
if (!text)
|
|
222
260
|
return;
|
|
223
261
|
for (const trigger of this.triggers) {
|
|
262
|
+
trigger.pattern.lastIndex = 0;
|
|
224
263
|
if (trigger.pattern.test(text)) {
|
|
225
264
|
try {
|
|
226
265
|
trigger.handler(envelope);
|
package/dist/peer-registry.js
CHANGED
|
@@ -18,6 +18,13 @@ export class PeerRegistry extends EventEmitter {
|
|
|
18
18
|
addPeer(streamId, uuid) {
|
|
19
19
|
const existing = this.peers.get(streamId);
|
|
20
20
|
if (existing) {
|
|
21
|
+
const duplicateForUuid = this.resolve(uuid);
|
|
22
|
+
if (duplicateForUuid && duplicateForUuid !== existing) {
|
|
23
|
+
this.peers.delete(duplicateForUuid.streamId);
|
|
24
|
+
this.uuidToStream.delete(duplicateForUuid.uuid);
|
|
25
|
+
}
|
|
26
|
+
if (existing.uuid !== uuid)
|
|
27
|
+
this.uuidToStream.delete(existing.uuid);
|
|
21
28
|
existing.uuid = uuid;
|
|
22
29
|
existing.connected = true;
|
|
23
30
|
existing.connectedAt = Date.now();
|
package/dist/protocol.d.ts
CHANGED
|
@@ -5,7 +5,19 @@
|
|
|
5
5
|
* WebRTC data channels. This module has zero dependencies on stevesbot
|
|
6
6
|
* internals — it can be extracted and reused by any bot.
|
|
7
7
|
*/
|
|
8
|
-
export type MessageType = "chat" | "announce" | "skill_update" | "command" | "command_response" | "file_offer" | "file_chunk" | "file_complete" | "file_ack" | "event" | "ping" | "pong" | "ack" | "history_replay" | "history_request";
|
|
8
|
+
export type MessageType = "chat" | "announce" | "skill_update" | "command" | "command_response" | "file_offer" | "file_chunk" | "file_complete" | "file_ack" | "event" | "ping" | "pong" | "ack" | "history_replay" | "history_request" | "swarm_offer" | "swarm_manifest_request" | "swarm_manifest_page" | "swarm_announce" | "swarm_request" | "swarm_chunk" | "swarm_have";
|
|
9
|
+
/**
|
|
10
|
+
* Messages that are only worth delivering right now.
|
|
11
|
+
*
|
|
12
|
+
* Transfer traffic is high volume and stateful: a chunk request replayed from
|
|
13
|
+
* an offline queue minutes later asks for something the requester already has,
|
|
14
|
+
* while replaying half of a simple file transfer cannot restore its missing
|
|
15
|
+
* half. A history ring full of chunks also evicts every real message a peer
|
|
16
|
+
* asking for history actually wanted. Swarm state is re-announced; a rejected
|
|
17
|
+
* simple transfer fails explicitly and can be retried from the start.
|
|
18
|
+
*/
|
|
19
|
+
export declare const TRANSIENT_MESSAGE_TYPES: ReadonlySet<MessageType>;
|
|
20
|
+
export declare function isTransientType(type: MessageType): boolean;
|
|
9
21
|
export type PeerIdentity = {
|
|
10
22
|
streamId: string;
|
|
11
23
|
role: string;
|
|
@@ -32,6 +44,71 @@ export type AgentProfile = {
|
|
|
32
44
|
asks?: AgentAsk[];
|
|
33
45
|
shares?: SharedFolderSummary[];
|
|
34
46
|
};
|
|
47
|
+
/** Announces that a file exists in the swarm and describes how it is chunked. */
|
|
48
|
+
export type SwarmOfferPayload = {
|
|
49
|
+
fileId: string;
|
|
50
|
+
name: string;
|
|
51
|
+
mimeType: string;
|
|
52
|
+
size: number;
|
|
53
|
+
chunkSize: number;
|
|
54
|
+
totalChunks: number;
|
|
55
|
+
/** sha256 over the raw 32-byte chunk hashes, in chunk order. */
|
|
56
|
+
chunkHashesHash?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Small manifests ride inline. Large manifests are requested in bounded
|
|
59
|
+
* pages, otherwise one offer eventually exceeds the negotiated SCTP message
|
|
60
|
+
* limit and silently becomes impossible to send.
|
|
61
|
+
*/
|
|
62
|
+
chunkHashes?: string[];
|
|
63
|
+
manifestPageSize?: number;
|
|
64
|
+
manifestPages?: number;
|
|
65
|
+
};
|
|
66
|
+
/** Ask a source for selected pages of a large chunk-hash manifest. */
|
|
67
|
+
export type SwarmManifestRequestPayload = {
|
|
68
|
+
fileId: string;
|
|
69
|
+
pageSize: number;
|
|
70
|
+
pages: number[];
|
|
71
|
+
};
|
|
72
|
+
/** One bounded page of chunk hashes. */
|
|
73
|
+
export type SwarmManifestPagePayload = {
|
|
74
|
+
fileId: string;
|
|
75
|
+
page: number;
|
|
76
|
+
totalPages: number;
|
|
77
|
+
pageSize: number;
|
|
78
|
+
chunkHashesHash: string;
|
|
79
|
+
hashes: string[];
|
|
80
|
+
};
|
|
81
|
+
/** A peer's full chunk bitfield, base64 encoded. */
|
|
82
|
+
export type SwarmAnnouncePayload = {
|
|
83
|
+
fileId: string;
|
|
84
|
+
totalChunks: number;
|
|
85
|
+
chunks: string;
|
|
86
|
+
};
|
|
87
|
+
export type SwarmRequestPayload = {
|
|
88
|
+
fileId: string;
|
|
89
|
+
index: number;
|
|
90
|
+
/**
|
|
91
|
+
* Set when the requester can receive the chunk as raw bytes on the binary
|
|
92
|
+
* lane. Carrying it on the request rather than negotiating up front means
|
|
93
|
+
* there is no capability table to keep in sync and no window where a peer
|
|
94
|
+
* that just joined is assumed to be one thing or the other — each request
|
|
95
|
+
* states how its own reply should come back.
|
|
96
|
+
*/
|
|
97
|
+
bin?: 1;
|
|
98
|
+
};
|
|
99
|
+
export type SwarmChunkPayload = {
|
|
100
|
+
fileId: string;
|
|
101
|
+
index: number;
|
|
102
|
+
data: string;
|
|
103
|
+
};
|
|
104
|
+
/** Incremental "I now hold these chunks", so peers learn without a full re-announce. */
|
|
105
|
+
export type SwarmHavePayload = {
|
|
106
|
+
fileId: string;
|
|
107
|
+
/** Batched indexes. Preferred; one message can carry a whole burst. */
|
|
108
|
+
indexes?: number[];
|
|
109
|
+
/** Single index, kept so a peer running an older build is still understood. */
|
|
110
|
+
index?: number;
|
|
111
|
+
};
|
|
35
112
|
export type FileTransferKind = "file" | "image";
|
|
36
113
|
export type FileOfferPayload = {
|
|
37
114
|
transferId: string;
|