@needmoretruth/nmts-cli 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/AGENTS.md +11 -12
  2. package/CHANGELOG.md +26 -0
  3. package/README.ko.md +19 -21
  4. package/README.md +21 -22
  5. package/dist/account.js +2 -1
  6. package/dist/arg-options.d.ts +3 -1
  7. package/dist/artifact-about.d.ts +1 -1
  8. package/dist/autonomy.d.ts +3 -10
  9. package/dist/autonomy.js +15 -17
  10. package/dist/bytes.d.ts +15 -0
  11. package/dist/bytes.js +63 -0
  12. package/dist/collision.d.ts +5 -5
  13. package/dist/collision.js +19 -22
  14. package/dist/commands/delete-account.js +1 -1
  15. package/dist/commands/erase.js +1 -1
  16. package/dist/commands/get.js +1 -1
  17. package/dist/commands/listfile.js +1 -1
  18. package/dist/commands/mcp.js +5 -1
  19. package/dist/commands/mode.js +2 -2
  20. package/dist/commands/on-collision.d.ts +1 -1
  21. package/dist/commands/on-collision.js +3 -3
  22. package/dist/commands/platform.d.ts +9 -0
  23. package/dist/commands/platform.js +124 -0
  24. package/dist/commands/pull.js +1 -1
  25. package/dist/commands/push-wallet.js +2 -1
  26. package/dist/commands/push.js +6 -4
  27. package/dist/commands/put-wallet.js +5 -3
  28. package/dist/commands/put.js +8 -5
  29. package/dist/commands/receive.js +1 -1
  30. package/dist/commands/settings.js +1 -1
  31. package/dist/commands/support.js +3 -3
  32. package/dist/commands/unlock.js +2 -2
  33. package/dist/commands/wallet-hall.d.ts +4 -1
  34. package/dist/commands/wallet-hall.js +16 -8
  35. package/dist/commands/wallet-storage-ops.d.ts +4 -1
  36. package/dist/commands/wallet-storage-ops.js +7 -2
  37. package/dist/commands/wallet-swap.d.ts +3 -0
  38. package/dist/commands/wallet-swap.js +8 -4
  39. package/dist/commands/wallet.d.ts +1 -1
  40. package/dist/crypto.d.ts +8 -5
  41. package/dist/crypto.js +15 -61
  42. package/dist/download-sink-node.d.ts +49 -0
  43. package/dist/download-sink-node.js +204 -0
  44. package/dist/download-sink.d.ts +0 -48
  45. package/dist/download-sink.js +6 -199
  46. package/dist/download.js +5 -4
  47. package/dist/engine-node.d.ts +19 -0
  48. package/dist/engine-node.js +77 -0
  49. package/dist/env-vars.d.ts +20 -0
  50. package/dist/env-vars.js +27 -0
  51. package/dist/gate.js +1 -1
  52. package/dist/help.js +9 -4
  53. package/dist/host-contract.d.ts +7 -0
  54. package/dist/host-contract.js +95 -0
  55. package/dist/host-node.d.ts +27 -0
  56. package/dist/host-node.js +192 -0
  57. package/dist/host.d.ts +99 -0
  58. package/dist/host.js +59 -0
  59. package/dist/index.d.ts +6 -49
  60. package/dist/index.js +20 -45
  61. package/dist/main.js +7 -6
  62. package/dist/manifest-chunk-cache.d.ts +3 -3
  63. package/dist/manifest-chunk-cache.js +39 -44
  64. package/dist/manifest-chunk-flow.js +15 -14
  65. package/dist/manifest-write.js +1 -1
  66. package/dist/manifest.d.ts +2 -2
  67. package/dist/manifest.js +37 -34
  68. package/dist/mcp-tools/tiers.js +1 -1
  69. package/dist/network.d.ts +1 -1
  70. package/dist/network.js +6 -2
  71. package/dist/platform-sign.d.ts +143 -0
  72. package/dist/platform-sign.js +220 -0
  73. package/dist/portable.d.ts +59 -0
  74. package/dist/portable.js +82 -0
  75. package/dist/product.d.ts +1 -1
  76. package/dist/product.js +1 -1
  77. package/dist/progress-node.d.ts +2 -0
  78. package/dist/progress-node.js +14 -0
  79. package/dist/progress.d.ts +0 -1
  80. package/dist/progress.js +4 -8
  81. package/dist/redact.js +3 -2
  82. package/dist/registration.js +5 -2
  83. package/dist/risk.d.ts +7 -0
  84. package/dist/risk.js +10 -0
  85. package/dist/run-log.d.ts +4 -4
  86. package/dist/run-log.js +29 -36
  87. package/dist/seal.js +5 -4
  88. package/dist/server.d.ts +1 -1
  89. package/dist/server.js +6 -2
  90. package/dist/setup-questions.js +2 -2
  91. package/dist/upload-file-node.d.ts +3 -0
  92. package/dist/upload-file-node.js +38 -0
  93. package/dist/upload-file.d.ts +2 -2
  94. package/dist/upload-file.js +15 -45
  95. package/dist/upload-price-node.d.ts +8 -0
  96. package/dist/upload-price-node.js +37 -0
  97. package/dist/upload-price.d.ts +0 -8
  98. package/dist/upload-price.js +4 -33
  99. package/dist/upload-steps.js +3 -3
  100. package/dist/upload-store.d.ts +14 -14
  101. package/dist/upload-store.js +68 -75
  102. package/dist/upload-wallet-chain.js +3 -2
  103. package/dist/upload-wallet-put.js +2 -2
  104. package/dist/upload-wallet.js +16 -15
  105. package/dist/upload.js +16 -15
  106. package/dist/wallet-sign-seams.d.ts +12 -0
  107. package/dist/wallet-sign.js +10 -13
  108. package/dist/walrus-write.js +3 -2
  109. package/dist/walrus.d.ts +1 -16
  110. package/dist/walrus.js +11 -22
  111. package/docs/commands/credits.md +1 -1
  112. package/docs/commands/platform.md +14 -0
  113. package/docs/commands/support.md +1 -1
  114. package/docs/commands/trash.md +1 -1
  115. package/docs/commands/wallet.md +11 -7
  116. package/package.json +13 -2
@@ -9,25 +9,21 @@
9
9
  //
10
10
  // So they are written down BEFORE the money moves. That ordering is the whole design.
11
11
  //
12
- // ⛔ WHAT IS ON DISK IS ALREADY PUBLIC. The `.bin` is the sealed NCF-3 stream — the exact bytes
13
- // about to be handed to a public storage network. It is still written 0600, because "already
14
- // public" is about the CONTENT and the file's presence would otherwise say which files this
15
- // account uploaded and when.
12
+ // ⛔ WHAT IS KEPT IS ALREADY PUBLIC. The `.bin` half is the sealed NCF-3 stream — the exact bytes
13
+ // about to be handed to a public storage network. The host still keeps it as privately as it
14
+ // can (0600 on this machine), because "already public" is about the CONTENT and the record's
15
+ // presence would otherwise say which files this account uploaded and when.
16
16
  //
17
- // ⛔ THE FILE NAME IS NOT A CONTENT FINGERPRINT. Keying by SHA-256 of the plaintext would leave a
18
- // directory of hashes matchable against published hash sets — the very thing sealing the
19
- // content hash avoids. The key mixes the account's data key in, so it identifies the file only
20
- // to somebody who already holds the account.
21
- import { createHash } from "node:crypto";
22
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
23
- import { join } from "node:path";
24
- import { configDir, modesAreEnforced } from "./credentials.js";
25
- import { chmodSync } from "node:fs";
17
+ // ⛔ THE KEY IS NOT A CONTENT FINGERPRINT. Keying by SHA-256 of the plaintext would leave a store
18
+ // of hashes matchable against published hash sets — the very thing sealing the content hash
19
+ // avoids. The key mixes the account's data key in, so it identifies the file only to somebody
20
+ // who already holds the account.
21
+ import { sha256 } from "@noble/hashes/sha2.js";
22
+ import { fromUtf8, toBase64Url, utf8 } from "./bytes.js";
26
23
  import { NmtsError } from "./errors.js";
27
- /** Where unfinished uploads live. */
28
- export function uploadsDir() {
29
- return join(configDir(), "uploads");
30
- }
24
+ import { host } from "./host.js";
25
+ /** The area of the host's state these records live in. One key per file below it. */
26
+ const AREA = "uploads";
31
27
  /**
32
28
  * A stable, account-scoped name for one file's upload attempt.
33
29
  *
@@ -71,12 +67,12 @@ export function reservationKeyStreamed(dataKey, plaintextChunks, name, destinati
71
67
  * what lets both come out of a single pass instead of two reads of a very large file.
72
68
  */
73
69
  export function startReservationKey(dataKey) {
74
- return createHash("sha256").update(dataKey);
70
+ return sha256.create().update(dataKey);
75
71
  }
76
72
  /** Finish it. The name and the destination go in last, exactly as the one-shot form does. */
77
73
  export function finishReservationKey(hash, name, destination) {
78
- const digest = hash.update(new TextEncoder().encode(`\u0000${name}\u0000${destination}`)).digest();
79
- return Buffer.from(digest).toString("base64url").slice(0, 32);
74
+ const digest = hash.update(utf8(`\u0000${name}\u0000${destination}`)).digest();
75
+ return toBase64Url(digest).slice(0, 32);
80
76
  }
81
77
  /**
82
78
  * The record name for ONE part of a file.
@@ -89,9 +85,9 @@ export function finishReservationKey(hash, name, destination) {
89
85
  export function partKey(fileKey, partIndex) {
90
86
  return `${fileKey}~p${partIndex}`;
91
87
  }
92
- function paths(key) {
93
- const dir = uploadsDir();
94
- return { json: join(dir, `${key}.json`), bin: join(dir, `${key}.bin`) };
88
+ /** The two keys one part's reservation occupies: its record, and the sealed bytes it bought. */
89
+ function keysFor(key) {
90
+ return { json: `${AREA}/${key}.json`, bin: `${AREA}/${key}.bin` };
95
91
  }
96
92
  function isReservation(value) {
97
93
  if (typeof value !== "object" || value === null)
@@ -114,23 +110,25 @@ function isReservation(value) {
114
110
  * resume that only needs to commit still reads every byte of a very large upload off the disk.
115
111
  * They are fetched separately, by the one step that actually pushes them.
116
112
  */
117
- export function readReservationRecord(key) {
118
- const { json, bin } = paths(key);
119
- if (!existsSync(json) || !existsSync(bin))
113
+ export async function readReservationRecord(key) {
114
+ const { json, bin } = keysFor(key);
115
+ const state = host().state;
116
+ const held = await state.read(json);
117
+ if (held === undefined || (await state.read(bin)) === undefined)
120
118
  return null;
121
119
  let parsed;
122
120
  try {
123
- parsed = JSON.parse(readFileSync(json, "utf8"));
121
+ parsed = JSON.parse(fromUtf8(held));
124
122
  }
125
123
  catch {
126
124
  // ⛔ Unreadable is not the same as absent, and treating it as absent would buy storage twice.
127
- throw new NmtsError(`An unfinished upload record at ${json} could not be read.`, {
125
+ throw new NmtsError(`The unfinished upload record ${json} could not be read.`, {
128
126
  nextStep: "It names storage this account may already have paid for. Move it aside rather than " +
129
127
  "deleting it if the upload matters, then try again.",
130
128
  });
131
129
  }
132
130
  if (!isReservation(parsed)) {
133
- throw new NmtsError(`The unfinished upload record at ${json} is not in a shape this version knows.`, {
131
+ throw new NmtsError(`The unfinished upload record ${json} is not in a shape this version knows.`, {
134
132
  nextStep: "Move it aside and try again. Nothing was sent.",
135
133
  });
136
134
  }
@@ -143,38 +141,36 @@ export function readReservationRecord(key) {
143
141
  * different blob from the one the treasury registered — the relay refuses them, forever, and
144
142
  * the credits are gone.
145
143
  */
146
- export function readReservationBytes(key) {
147
- const { bin } = paths(key);
148
- return new Uint8Array(readFileSync(bin));
144
+ export async function readReservationBytes(key) {
145
+ const { bin } = keysFor(key);
146
+ const held = await host().state.read(bin);
147
+ if (held === undefined) {
148
+ throw new NmtsError(`The sealed bytes of ${bin} are gone.`, {
149
+ nextStep: "Nothing was sent. The storage they bought cannot be filled by re-sealing — run the " +
150
+ "upload again to buy storage for a fresh sealing of the same file.",
151
+ });
152
+ }
153
+ return held;
149
154
  }
150
155
  /** The record and its bytes together, for the callers that need both. */
151
- export function readReservation(key) {
152
- const record = readReservationRecord(key);
156
+ export async function readReservation(key) {
157
+ const record = await readReservationRecord(key);
153
158
  if (record === null)
154
159
  return null;
155
- return { record, sealed: readReservationBytes(key) };
160
+ return { record, sealed: await readReservationBytes(key) };
156
161
  }
157
162
  /** Write the record and its sealed bytes. Called BEFORE the reserve, and again after it answers. */
158
- export function writeReservation(key, record, sealed) {
159
- const dir = uploadsDir();
160
- mkdirSync(dir, { recursive: true, mode: 0o700 });
161
- if (modesAreEnforced())
162
- chmodSync(dir, 0o700);
163
- const { json, bin } = paths(key);
164
- // WRITTEN ASIDE AND RENAMED OVER, never truncated in place. A rename within one directory is
165
- // atomic, so a reader sees the old record or the new one and never a half-written one. The
166
- // write that matters is the LAST one — the one that adds the item id — because a truncated
167
- // file there is the only local pointer to a file that is already paid for and committed, and
168
- // losing it makes that file invisible.
169
- atomically(bin, sealed);
170
- atomically(json, Buffer.from(`${JSON.stringify(record, null, 2)}\n`, "utf8"));
171
- }
172
- function atomically(target, bytes) {
173
- const scratch = `${target}.${process.pid}.tmp`;
174
- writeFileSync(scratch, bytes, { mode: 0o600 });
175
- if (modesAreEnforced())
176
- chmodSync(scratch, 0o600);
177
- renameSync(scratch, target);
163
+ export async function writeReservation(key, record, sealed) {
164
+ const { json, bin } = keysFor(key);
165
+ // THE BYTES GO DOWN FIRST, AND THE RECORD IS WHAT MAKES THE PAIR COUNT. A reader treats a
166
+ // record without its bytes as no reservation at all, so a run that stopped between these two
167
+ // writes leaves nothing that can be resumed — rather than a record pointing at bytes that are
168
+ // not there. Each write on its own is all-or-nothing; that is the host's promise, and the
169
+ // write that matters most is the LAST one, because a half-written record is the only local
170
+ // pointer to a file that is already paid for.
171
+ const state = host().state;
172
+ await state.write(bin, sealed);
173
+ await state.write(json, utf8(`${JSON.stringify(record, null, 2)}\n`));
178
174
  }
179
175
  /**
180
176
  * Forget a reservation.
@@ -183,11 +179,11 @@ function atomically(target, bytes) {
183
179
  * upload already succeeded" was not true of every caller. What IS true of all of them is that
184
180
  * nothing further depends on the record, which is why it never throws.
185
181
  */
186
- export function clearReservation(key) {
187
- const { json, bin } = paths(key);
188
- for (const path of [json, bin]) {
182
+ export async function clearReservation(key) {
183
+ const { json, bin } = keysFor(key);
184
+ for (const name of [json, bin]) {
189
185
  try {
190
- rmSync(path, { force: true });
186
+ await host().state.remove(name);
191
187
  }
192
188
  catch {
193
189
  // ⚠ A record that cannot be removed is left where it is. That is not free -- a record
@@ -197,8 +193,8 @@ export function clearReservation(key) {
197
193
  }
198
194
  }
199
195
  }
200
- function itemPath(fileKey) {
201
- return join(uploadsDir(), `${fileKey}.item.json`);
196
+ function itemKey(fileKey) {
197
+ return `${AREA}/${fileKey}.item.json`;
202
198
  }
203
199
  function isItemRecord(value) {
204
200
  if (typeof value !== "object" || value === null)
@@ -209,41 +205,38 @@ function isItemRecord(value) {
209
205
  return id === undefined || typeof id === "string";
210
206
  }
211
207
  /** What is known about this file's commit, or `null` when it has not been attempted. */
212
- export function readItemRecord(fileKey) {
213
- const path = itemPath(fileKey);
214
- if (!existsSync(path))
208
+ export async function readItemRecord(fileKey) {
209
+ const path = itemKey(fileKey);
210
+ const held = await host().state.read(path);
211
+ if (held === undefined)
215
212
  return null;
216
213
  let parsed;
217
214
  try {
218
- parsed = JSON.parse(readFileSync(path, "utf8"));
215
+ parsed = JSON.parse(fromUtf8(held));
219
216
  }
220
217
  catch {
221
218
  // ⛔ Same reasoning as an unreadable reservation: unreadable is not absent. This file is the
222
219
  // only local pointer to storage that is already bought and possibly already committed.
223
- throw new NmtsError(`An unfinished upload record at ${path} could not be read.`, {
220
+ throw new NmtsError(`The unfinished upload record ${path} could not be read.`, {
224
221
  nextStep: "It names a file this account may already have paid for. Move it aside rather than " +
225
222
  "deleting it if the upload matters, then try again.",
226
223
  });
227
224
  }
228
225
  if (!isItemRecord(parsed)) {
229
- throw new NmtsError(`The unfinished upload record at ${path} is not in a shape this version knows.`, {
226
+ throw new NmtsError(`The unfinished upload record ${path} is not in a shape this version knows.`, {
230
227
  nextStep: "Move it aside and try again. Nothing was sent.",
231
228
  });
232
229
  }
233
230
  return parsed;
234
231
  }
235
232
  /** Write the file-level record. Called before the commit, and again once it has an id. */
236
- export function writeItemRecord(fileKey, record) {
237
- const dir = uploadsDir();
238
- mkdirSync(dir, { recursive: true, mode: 0o700 });
239
- if (modesAreEnforced())
240
- chmodSync(dir, 0o700);
241
- atomically(itemPath(fileKey), Buffer.from(`${JSON.stringify(record, null, 2)}\n`, "utf8"));
233
+ export async function writeItemRecord(fileKey, record) {
234
+ await host().state.write(itemKey(fileKey), utf8(`${JSON.stringify(record, null, 2)}\n`));
242
235
  }
243
236
  /** Forget the file-level record. Never throws, for the same reason `clearReservation` does not. */
244
- export function clearItemRecord(fileKey) {
237
+ export async function clearItemRecord(fileKey) {
245
238
  try {
246
- rmSync(itemPath(fileKey), { force: true });
239
+ await host().state.remove(itemKey(fileKey));
247
240
  }
248
241
  catch {
249
242
  // ⚠ Left where it is. A stale one carrying an item id would be resumed rather than
@@ -21,6 +21,7 @@ import { bcs } from "@mysten/sui/bcs";
21
21
  import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
22
22
  import { Transaction } from "@mysten/sui/transactions";
23
23
  import { blobIdFromInt, blobIdToInt, MAINNET_WALRUS_PACKAGE_CONFIG, TESTNET_WALRUS_PACKAGE_CONFIG, walrus, } from "@mysten/walrus";
24
+ import { fromBase64Url } from "./bytes.js";
24
25
  import { NmtsError } from "./errors.js";
25
26
  import { extendReads, netGasFee, readBlobLease } from "./extend-chain.js";
26
27
  import { readOwnedStorage, readStorageType } from "./shared/lib/storage-control/chain.js";
@@ -104,8 +105,8 @@ export function certifyTransaction(client, input) {
104
105
  deletable: true,
105
106
  certificate: {
106
107
  signers: input.certificate.signers,
107
- serializedMessage: new Uint8Array(Buffer.from(input.certificate.serialized_message_b64, "base64url")),
108
- signature: new Uint8Array(Buffer.from(input.certificate.signature_b64, "base64url")),
108
+ serializedMessage: fromBase64Url(input.certificate.serialized_message_b64),
109
+ signature: fromBase64Url(input.certificate.signature_b64),
109
110
  },
110
111
  });
111
112
  }
@@ -197,9 +197,9 @@ export async function walletPut(ctx, file, seams = {}) {
197
197
  // ⛔ ONLY NOW, AND EVERY PART — the same order the credit rail keeps and for the same reason: a
198
198
  // paid-for file the list does not name is invisible, and the records are what let a second
199
199
  // call finish the job without signing again.
200
- clearItemRecord(result.fileKey);
200
+ await clearItemRecord(result.fileKey);
201
201
  for (const record of partKeysOf(result.fileKey, result.parts))
202
- clearReservation(record);
202
+ await clearReservation(record);
203
203
  if (added.replaced)
204
204
  await setTrashed(ctx.server, ctx.apiKey, added.replaced.id, true);
205
205
  return {
@@ -18,6 +18,7 @@
18
18
  // ⛔ THE SIGNATURES ARE A SEAM. `wallet-sign.ts` is loaded by the command, after the agreement,
19
19
  // and handed in; the tests hand in recorders and prove that `--dry-run` and a shortfall never
20
20
  // reach them.
21
+ import { fromBase64Url, toBase64Url } from "./bytes.js";
21
22
  import { NmtsError } from "./errors.js";
22
23
  import { readReservationBytes, readReservationRecord, writeReservation } from "./upload-store.js";
23
24
  import { UploadError } from "./upload-wire.js";
@@ -30,14 +31,14 @@ export function walletRail(ctx) {
30
31
  }
31
32
  async function buyAndPushPartWithWallet(ctx, input) {
32
33
  const { protocol, key, sealed, onStep } = input;
33
- const existing = readReservationRecord(key);
34
+ const existing = await readReservationRecord(key);
34
35
  if (existing !== null && existing.paidFrom !== "wallet") {
35
36
  throw new UploadError({
36
37
  phase: "reserve",
37
38
  message: "This upload was started with credits paying, and this run would pay from the wallet.",
38
39
  paid: existing.ledgerId !== undefined,
39
- nextStep: "Run it again without --pay wallet to finish it, or move the records in the uploads " +
40
- "directory aside to start over. Nothing was sent.",
40
+ nextStep: "Run it again without --pay wallet to finish it, or clear the unfinished upload records " +
41
+ "to start over. Nothing was sent.",
41
42
  });
42
43
  }
43
44
  if (existing !== null && (existing.partIndex !== input.part.index || existing.partTotal !== input.part.total)) {
@@ -46,8 +47,8 @@ async function buyAndPushPartWithWallet(ctx, input) {
46
47
  message: `This upload was started as part ${existing.partIndex + 1} of ${existing.partTotal} and this ` +
47
48
  `run is treating it as part ${input.part.index + 1} of ${input.part.total}.`,
48
49
  paid: existing.registerTxDigest !== undefined,
49
- nextStep: "Run it again with the part size the first attempt used, or move the records in the " +
50
- "uploads directory aside to start over. Nothing was sent.",
50
+ nextStep: "Run it again with the part size the first attempt used, or clear the unfinished upload " +
51
+ "records to start over. Nothing was sent.",
51
52
  });
52
53
  }
53
54
  // ── already certified: nothing left to sign or send ──
@@ -58,9 +59,9 @@ async function buyAndPushPartWithWallet(ctx, input) {
58
59
  // ── registered by an earlier run: push and certify, sign nothing twice ──
59
60
  if (existing?.registerTxDigest !== undefined && existing.blobObjectId !== undefined) {
60
61
  onStep?.({ step: "resuming", ledgerId: 0, state: "registered" });
61
- const certificate = await push(input, existing, existing.registerTxDigest, existing.blobObjectId, readReservationBytes(key));
62
+ const certificate = await push(input, existing, existing.registerTxDigest, existing.blobObjectId, await readReservationBytes(key));
62
63
  await certify(ctx, input, key, existing, certificate);
63
- return paidPart(readReservationRecord(key) ?? existing, true);
64
+ return paidPart((await readReservationRecord(key)) ?? existing, true);
64
65
  }
65
66
  // ── fresh, or interrupted before the signature ──
66
67
  onStep?.({ step: "encoding", bytes: sealed.length });
@@ -68,7 +69,7 @@ async function buyAndPushPartWithWallet(ctx, input) {
68
69
  try {
69
70
  meta = await protocol.computeMetadata({
70
71
  bytes: sealed,
71
- nonce: existing ? new Uint8Array(Buffer.from(existing.nonceB64, "base64url")) : undefined,
72
+ nonce: existing ? fromBase64Url(existing.nonceB64) : undefined,
72
73
  });
73
74
  }
74
75
  catch (error) {
@@ -83,8 +84,8 @@ async function buyAndPushPartWithWallet(ctx, input) {
83
84
  attempt: existing?.attempt ?? 0,
84
85
  paidFrom: "wallet",
85
86
  blobId: meta.blobId,
86
- nonceB64: Buffer.from(meta.nonce).toString("base64url"),
87
- rootHashB64: Buffer.from(meta.rootHash).toString("base64url"),
87
+ nonceB64: toBase64Url(meta.nonce),
88
+ rootHashB64: toBase64Url(meta.rootHash),
88
89
  relayUrl: input.relayUrl,
89
90
  epochs: input.epochs,
90
91
  sealedLen: sealed.length,
@@ -98,7 +99,7 @@ async function buyAndPushPartWithWallet(ctx, input) {
98
99
  parentId: input.entry.parentId,
99
100
  };
100
101
  // ⛔ BEFORE THE SIGNATURE. See the module header.
101
- writeReservation(key, record, sealed);
102
+ await writeReservation(key, record, sealed);
102
103
  onStep?.({ step: "reserving" });
103
104
  const quote = ctx.quotes[input.part.index];
104
105
  if (quote === undefined)
@@ -133,14 +134,14 @@ async function buyAndPushPartWithWallet(ctx, input) {
133
134
  record.registerTxDigest = registered.digest;
134
135
  record.blobObjectId = registered.blobObjectId;
135
136
  record.endEpoch = registered.endEpoch;
136
- writeReservation(key, record, sealed);
137
+ await writeReservation(key, record, sealed);
137
138
  ctx.onSpend({
138
139
  walFrost: quote.writeFrost + (ctx.storage.kind === "buy" ? quote.storageFrost : 0n),
139
140
  suiMist: quote.tipMist + (ctx.feeMist ?? 0n),
140
141
  });
141
142
  const certificate = await push(input, record, registered.digest, registered.blobObjectId, sealed);
142
143
  await certify(ctx, input, key, record, certificate);
143
- return paidPart(readReservationRecord(key) ?? record, false);
144
+ return paidPart((await readReservationRecord(key)) ?? record, false);
144
145
  }
145
146
  /** The bytes to the relay named in the register transaction. The storage is already bought. */
146
147
  async function push(input, record, registerTxDigest, blobObjectId, sealed) {
@@ -149,7 +150,7 @@ async function push(input, record, registerTxDigest, blobObjectId, sealed) {
149
150
  return await input.protocol.uploadToRelay({
150
151
  blobId: record.blobId,
151
152
  bytes: sealed,
152
- nonce: new Uint8Array(Buffer.from(record.nonceB64, "base64url")),
153
+ nonce: fromBase64Url(record.nonceB64),
153
154
  registerTxDigest,
154
155
  blobObjectId,
155
156
  });
@@ -192,7 +193,7 @@ async function certify(ctx, input, key, record, certificate) {
192
193
  "certifies again, which costs gas and nothing else.",
193
194
  });
194
195
  }
195
- writeReservation(key, { ...record, certifyTxDigest: digest }, readReservationBytes(key));
196
+ await writeReservation(key, { ...record, certifyTxDigest: digest }, await readReservationBytes(key));
196
197
  // The certify fee is not measured beforehand, so the ledger is told about the tip and the
197
198
  // register fee only — the review said so.
198
199
  }
package/dist/upload.js CHANGED
@@ -26,6 +26,7 @@
26
26
  // ⛔ EVERY SEAM IS INJECTED. The storage-network protocol and the api are interfaces, so the tests
27
27
  // drive the real decisions — including every failure branch — without a network and without
28
28
  // spending anything. The seams themselves are in `upload-wire.ts`.
29
+ import { fromBase64Url, toBase64Url } from "./bytes.js";
29
30
  import { NmtsError } from "./errors.js";
30
31
  import { ServerError } from "./api.js";
31
32
  import { readReservationBytes, readReservationRecord, writeReservation, } from "./upload-store.js";
@@ -53,7 +54,7 @@ function why(error) {
53
54
  */
54
55
  export async function buyAndPushPart(input) {
55
56
  const { api, protocol, key, sealed, onStep } = input;
56
- const existing = readReservationRecord(key);
57
+ const existing = await readReservationRecord(key);
57
58
  // ⛔ THE STORED PLACEMENT WINS THE ARGUMENT, AND A DISAGREEMENT STOPS THE RUN. The bytes on disk
58
59
  // were sealed as part i of n and paid for as that; pushing them while this run believes the
59
60
  // file splits some other way would file storage under the wrong position in the file. It
@@ -64,8 +65,8 @@ export async function buyAndPushPart(input) {
64
65
  phase: "reserve",
65
66
  message: "This upload was started with the wallet paying, and this run would pay with credits.",
66
67
  paid: existing.registerTxDigest !== undefined,
67
- nextStep: "Run it again with --pay wallet to finish it, or move the records in the uploads directory " +
68
- "aside to start over with credits. Nothing was sent.",
68
+ nextStep: "Run it again with --pay wallet to finish it, or clear the unfinished upload records to " +
69
+ "start over with credits. Nothing was sent.",
69
70
  });
70
71
  }
71
72
  if (existing !== null) {
@@ -76,8 +77,8 @@ export async function buyAndPushPart(input) {
76
77
  message: `This upload was started as part ${record.partIndex + 1} of ${record.partTotal} and this ` +
77
78
  `run is treating it as part ${input.part.index + 1} of ${input.part.total}.`,
78
79
  paid: record.ledgerId !== undefined,
79
- nextStep: "Run it again with the part size the first attempt used, or move the records in the " +
80
- "uploads directory aside to start over. Nothing was sent.",
80
+ nextStep: "Run it again with the part size the first attempt used, or clear the unfinished upload " +
81
+ "records to start over. Nothing was sent.",
81
82
  });
82
83
  }
83
84
  }
@@ -111,7 +112,7 @@ export async function buyAndPushPart(input) {
111
112
  // whatever state it is in — `failed` included. A cleared record means the next run
112
113
  // rebuilds the same key, is handed the same dead row, and is told to start over into it.
113
114
  // Forever. Counting up is what starting over actually means.
114
- writeReservation(key, { ...stripReservation(record), attempt: record.attempt + 1 }, readReservationBytes(key));
115
+ await writeReservation(key, { ...stripReservation(record), attempt: record.attempt + 1 }, await readReservationBytes(key));
115
116
  throw new UploadError({
116
117
  phase: "reserve",
117
118
  message: `The credit reservation for this file ended as "${status.state}" and cannot be used.`,
@@ -131,12 +132,12 @@ export async function buyAndPushPart(input) {
131
132
  await pushPart(input, {
132
133
  ledgerId,
133
134
  blobId: record.blobId,
134
- nonce: Buffer.from(record.nonceB64, "base64url"),
135
+ nonce: fromBase64Url(record.nonceB64),
135
136
  registerTxDigest: status.register_tx_digest,
136
137
  blobObjectId: status.blob_object_id,
137
138
  // ⛔ THE STORED BYTES AND THE STORED RELAY. Not the caller's — see the module header.
138
139
  // Read here, and only here: a part that came back certified never needs them at all.
139
- sealed: readReservationBytes(key),
140
+ sealed: await readReservationBytes(key),
140
141
  relayUrl: record.relayUrl,
141
142
  });
142
143
  return paidPart(record, ledgerId, true);
@@ -149,7 +150,7 @@ export async function buyAndPushPart(input) {
149
150
  bytes: sealed,
150
151
  // Re-feeding a stored nonce is what makes the retry reproduce the digest a paid tip was
151
152
  // computed for. A fresh random one would strand the reservation.
152
- nonce: existing ? new Uint8Array(Buffer.from(existing.nonceB64, "base64url")) : undefined,
153
+ nonce: existing ? fromBase64Url(existing.nonceB64) : undefined,
153
154
  });
154
155
  }
155
156
  catch (error) {
@@ -160,14 +161,14 @@ export async function buyAndPushPart(input) {
160
161
  nextStep: "Nothing was sent and nothing was spent.",
161
162
  });
162
163
  }
163
- const nonceB64 = Buffer.from(meta.nonce).toString("base64url");
164
+ const nonceB64 = toBase64Url(meta.nonce);
164
165
  const record = {
165
166
  // ⛔ The attempt number is CARRIED FORWARD, not reset. A record that survives a dead
166
167
  // reservation is exactly the case that needs a different idempotency key.
167
168
  attempt: existing?.attempt ?? 0,
168
169
  blobId: meta.blobId,
169
170
  nonceB64,
170
- rootHashB64: Buffer.from(meta.rootHash).toString("base64url"),
171
+ rootHashB64: toBase64Url(meta.rootHash),
171
172
  relayUrl: input.relayUrl,
172
173
  epochs: input.epochs,
173
174
  sealedLen: sealed.length,
@@ -181,7 +182,7 @@ export async function buyAndPushPart(input) {
181
182
  parentId: input.entry.parentId,
182
183
  };
183
184
  // ⛔ BEFORE THE MONEY. See the module header.
184
- writeReservation(key, record, sealed);
185
+ await writeReservation(key, record, sealed);
185
186
  onStep?.({ step: "reserving" });
186
187
  let reply;
187
188
  try {
@@ -196,7 +197,7 @@ export async function buyAndPushPart(input) {
196
197
  deposit_credits: input.depositCredits,
197
198
  relay: {
198
199
  host: input.relayUrl,
199
- blob_digest_b64: Buffer.from(meta.blobDigest).toString("base64url"),
200
+ blob_digest_b64: toBase64Url(meta.blobDigest),
200
201
  nonce_b64: nonceB64,
201
202
  },
202
203
  });
@@ -217,7 +218,7 @@ export async function buyAndPushPart(input) {
217
218
  if (!isLive(reply.state)) {
218
219
  // Same reason as the resumed branch above: keep the record, count up, so the next run does not
219
220
  // ask under a key the server has already settled.
220
- writeReservation(key, { ...stripReservation(record), attempt: record.attempt + 1 }, sealed);
221
+ await writeReservation(key, { ...stripReservation(record), attempt: record.attempt + 1 }, sealed);
221
222
  throw new UploadError({
222
223
  phase: "reserve",
223
224
  message: `The reservation came back as "${reply.state}", which cannot become storage.`,
@@ -230,7 +231,7 @@ export async function buyAndPushPart(input) {
230
231
  record.registerTxDigest = reply.register_tx_digest;
231
232
  if (reply.blob_object_id)
232
233
  record.blobObjectId = reply.blob_object_id;
233
- writeReservation(key, record, sealed);
234
+ await writeReservation(key, record, sealed);
234
235
  if (reply.state === "certified") {
235
236
  return paidPart(record, reply.ledger_id, false);
236
237
  }
@@ -8,6 +8,14 @@ import type { Network } from "./network.ts";
8
8
  export type SignMessage = (input: {
9
9
  /** ⛔ The NMTS key. It never leaves this machine: it derives the wallet and nothing else. */
10
10
  code: string;
11
+ /**
12
+ * Which of this key's wallets proves the name.
13
+ *
14
+ * ⛔ IT IS THE PAYING WALLET BECAUSE THAT IS THE WALLET THE GIFT CAME FROM. A hall entry is an
15
+ * address, and `wallet donate` sends from the account's own number; signing with any other
16
+ * wallet would offer the server a name for an address that has never given anything.
17
+ */
18
+ wallet: number;
11
19
  message: string;
12
20
  }) => Promise<string>;
13
21
  /** The seam `commands/wallet-send.ts` signs through. Returns the transaction digest. */
@@ -24,6 +32,8 @@ export type SignSwap = (input: {
24
32
  network: Network;
25
33
  /** ⛔ The NMTS key. It never leaves this machine: it derives the wallet and nothing else. */
26
34
  code: string;
35
+ /** Which of this key's wallets swaps — the account's own number (`wallet-pay-index.ts`). */
36
+ wallet: number;
27
37
  shape: SwapShape;
28
38
  }) => Promise<string>;
29
39
  /** The seam the wallet rail registers through. */
@@ -52,6 +62,8 @@ export type SignBlobCertify = (input: {
52
62
  export type SignStorageOp = (input: {
53
63
  network: Network;
54
64
  code: string;
65
+ /** Which of this key's wallets holds the resource — the account's own number. */
66
+ wallet: number;
55
67
  shape: StorageOpShape;
56
68
  walrusPackageId: string;
57
69
  }) => Promise<string>;
@@ -189,10 +189,10 @@ export const signTransfer = async ({ network, code, wallet, shape }) => {
189
189
  * makes. `signPersonalMessage` and not `sign`: that intent is the domain separator which stops
190
190
  * signed text from ever being read as a transaction this wallet authorised.
191
191
  */
192
- export const signMessage = async ({ code, message }) => {
193
- // THE FIRST WALLET SIGNS THE NAME — the address that stands in the hall is that wallet's, and
194
- // the account's choice of paying wallet does not reach this command yet.
195
- const keypair = await keypairFor(code, BUILT_IN_WALLET_INDEX);
192
+ export const signMessage = async ({ code, wallet, message }) => {
193
+ // The wallet the caller resolved signs it: the hall entry is that wallet's address, and it is
194
+ // the wallet a gift left from, so any other one would name an address that gave nothing.
195
+ const keypair = await keypairFor(code, wallet);
196
196
  const { signature } = await keypair.signPersonalMessage(new TextEncoder().encode(message));
197
197
  return signature;
198
198
  };
@@ -207,12 +207,9 @@ export const signMessage = async ({ code, message }) => {
207
207
  *
208
208
  * ⚠ A FAILURE HERE IS NOT PROOF THAT NOTHING HAPPENED — the same words as the extension above.
209
209
  */
210
- export const signSwap = async ({ network, code, shape }) => {
210
+ export const signSwap = async ({ network, code, wallet, shape }) => {
211
211
  const client = walrusClient(network);
212
- // SWAPS FROM THE FIRST WALLET — the coins being swapped are that wallet's, and the account's
213
- // choice of paying wallet does not reach this command yet. The day it does, this line and the
214
- // review above it carry the same number.
215
- const keypair = await keypairFor(code, BUILT_IN_WALLET_INDEX);
212
+ const keypair = await keypairFor(code, wallet);
216
213
  const tx = swapTransaction({ ...shape, network, sender: keypair.toSuiAddress() });
217
214
  const result = await client.signAndExecuteTransaction({
218
215
  transaction: tx,
@@ -303,11 +300,11 @@ export const signBlobCertify = async ({ network, code, wallet, relayUrl, ...shap
303
300
  * so what is signed is what was reviewed. A failed execution still has a digest and still
304
301
  * spent its gas, so the status is read and a failure is said as one.
305
302
  */
306
- export const signStorageOp = async ({ network, code, shape, walrusPackageId }) => {
303
+ export const signStorageOp = async ({ network, code, wallet, shape, walrusPackageId }) => {
307
304
  const client = walrusClient(network);
308
- // RESHAPES THE FIRST WALLET'S STORAGE that wallet holds the resource; the paying-wallet choice
309
- // does not reach this command yet.
310
- const keypair = await keypairFor(code, BUILT_IN_WALLET_INDEX);
305
+ // The resource the review named was read from this wallet's address, so this wallet is the one
306
+ // that can reshape or hand it over.
307
+ const keypair = await keypairFor(code, wallet);
311
308
  const tx = storageOpTransaction(shape, { walrusPackageId, sender: keypair.toSuiAddress() });
312
309
  const result = await client.signAndExecuteTransaction({ transaction: tx, signer: keypair, options: { showEffects: true } });
313
310
  const effects = result.effects;
@@ -10,6 +10,7 @@
10
10
  // transaction paid — see the note on `sendTip` below, which is not what its name suggests.
11
11
  import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
12
12
  import { walrus } from "@mysten/walrus";
13
+ import { toBase64Url } from "./bytes.js";
13
14
  import { epochClock } from "./expiry.js";
14
15
  // ⛔ The re-export below is what callers use; this local name is the same function,
15
16
  // imported so the reader above can call it without importing from itself.
@@ -112,8 +113,8 @@ export function createBlobProtocol(network, bodyBytes, onSent) {
112
113
  .catch((error) => fail("The upload relay refused the bytes", error));
113
114
  return {
114
115
  signers: certificate.signers,
115
- serialized_message_b64: Buffer.from(certificate.serializedMessage).toString("base64url"),
116
- signature_b64: Buffer.from(certificate.signature).toString("base64url"),
116
+ serialized_message_b64: toBase64Url(certificate.serializedMessage),
117
+ signature_b64: toBase64Url(certificate.signature),
117
118
  };
118
119
  },
119
120
  };
package/dist/walrus.d.ts CHANGED
@@ -28,22 +28,7 @@ export declare const RELAY_HOSTS: Readonly<Record<string, readonly string[]>>;
28
28
  export declare const SUI_RPC_HOSTS: Readonly<Record<string, readonly string[]>>;
29
29
  /** How long one host gets before the next is tried. A read that stalls is a read that failed. */
30
30
  export declare const READ_TIMEOUT_MS = 60000;
31
- /**
32
- * Point reads at somebody else's aggregator, or at a development stack.
33
- *
34
- * ⚠ It replaces the list rather than adding to it, and that is deliberate: a run should read from
35
- * where it was told to read, not from there AND the public hosts. Comma-separated for more
36
- * than one, tried in the order given.
37
- */
38
- export declare const AGGREGATOR_ENV_VAR = "NMTS_AGGREGATOR";
39
- /**
40
- * Push writes through somebody else's relay, or through a development stack.
41
- *
42
- * ⚠ ONE host, not a list. Unlike reads there is nothing to fail over to — see `RELAY_HOSTS`.
43
- */
44
- export declare const RELAY_ENV_VAR = "NMTS_RELAY";
45
- /** Ask a different Sui JSON-RPC node the shard-count question. */
46
- export declare const SUI_RPC_ENV_VAR = "NMTS_SUI_RPC";
31
+ export { AGGREGATOR_ENV_VAR, RELAY_ENV_VAR, SUI_RPC_ENV_VAR } from "./env-vars.ts";
47
32
  /** The relay this run writes through: the environment's if it named one, else the network's. */
48
33
  export declare function relayHost(network: string): string;
49
34
  /**