@needmoretruth/nmts-cli 0.34.4 → 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 (156) hide show
  1. package/AGENTS.md +11 -12
  2. package/CHANGELOG.md +43 -0
  3. package/README.ko.md +33 -35
  4. package/README.md +21 -22
  5. package/dist/account.js +2 -1
  6. package/dist/arg-options.d.ts +169 -0
  7. package/dist/arg-options.js +10 -0
  8. package/dist/args.d.ts +2 -157
  9. package/dist/args.js +2 -0
  10. package/dist/artifact-about.d.ts +1 -1
  11. package/dist/autonomy.d.ts +3 -10
  12. package/dist/autonomy.js +15 -17
  13. package/dist/bytes.d.ts +15 -0
  14. package/dist/bytes.js +63 -0
  15. package/dist/collision.d.ts +5 -5
  16. package/dist/collision.js +19 -22
  17. package/dist/commands/delete-account.js +1 -1
  18. package/dist/commands/erase.js +1 -1
  19. package/dist/commands/extend.d.ts +2 -0
  20. package/dist/commands/extend.js +8 -1
  21. package/dist/commands/get.js +1 -1
  22. package/dist/commands/listfile.js +1 -1
  23. package/dist/commands/mcp.js +5 -1
  24. package/dist/commands/mode.js +2 -2
  25. package/dist/commands/on-collision.d.ts +1 -1
  26. package/dist/commands/on-collision.js +3 -3
  27. package/dist/commands/platform.d.ts +9 -0
  28. package/dist/commands/platform.js +124 -0
  29. package/dist/commands/pull.js +1 -1
  30. package/dist/commands/push-wallet.js +8 -1
  31. package/dist/commands/push.d.ts +2 -0
  32. package/dist/commands/push.js +6 -4
  33. package/dist/commands/put-payer.d.ts +8 -0
  34. package/dist/commands/put-payer.js +41 -0
  35. package/dist/commands/put-wallet.d.ts +9 -3
  36. package/dist/commands/put-wallet.js +108 -186
  37. package/dist/commands/put.d.ts +5 -7
  38. package/dist/commands/put.js +11 -31
  39. package/dist/commands/receive.js +1 -1
  40. package/dist/commands/settings.js +1 -1
  41. package/dist/commands/support.js +3 -3
  42. package/dist/commands/unlock.js +2 -2
  43. package/dist/commands/wallet-donate.d.ts +3 -0
  44. package/dist/commands/wallet-donate.js +6 -2
  45. package/dist/commands/wallet-hall.d.ts +4 -1
  46. package/dist/commands/wallet-hall.js +16 -8
  47. package/dist/commands/wallet-list.d.ts +18 -0
  48. package/dist/commands/wallet-list.js +116 -0
  49. package/dist/commands/wallet-send.d.ts +4 -0
  50. package/dist/commands/wallet-send.js +6 -2
  51. package/dist/commands/wallet-storage-ops.d.ts +4 -1
  52. package/dist/commands/wallet-storage-ops.js +7 -2
  53. package/dist/commands/wallet-swap.d.ts +3 -0
  54. package/dist/commands/wallet-swap.js +8 -4
  55. package/dist/commands/wallet-use.d.ts +7 -0
  56. package/dist/commands/wallet-use.js +55 -0
  57. package/dist/commands/wallet.d.ts +4 -0
  58. package/dist/commands/wallet.js +20 -8
  59. package/dist/crypto.d.ts +8 -5
  60. package/dist/crypto.js +15 -61
  61. package/dist/download-sink-node.d.ts +49 -0
  62. package/dist/download-sink-node.js +204 -0
  63. package/dist/download-sink.d.ts +0 -48
  64. package/dist/download-sink.js +6 -199
  65. package/dist/download.js +5 -4
  66. package/dist/engine-node.d.ts +19 -0
  67. package/dist/engine-node.js +77 -0
  68. package/dist/env-vars.d.ts +20 -0
  69. package/dist/env-vars.js +27 -0
  70. package/dist/extend-plan.d.ts +2 -0
  71. package/dist/gate.js +1 -1
  72. package/dist/help.js +12 -5
  73. package/dist/host-contract.d.ts +7 -0
  74. package/dist/host-contract.js +95 -0
  75. package/dist/host-node.d.ts +27 -0
  76. package/dist/host-node.js +192 -0
  77. package/dist/host.d.ts +99 -0
  78. package/dist/host.js +59 -0
  79. package/dist/index.d.ts +6 -40
  80. package/dist/index.js +20 -32
  81. package/dist/main.js +11 -10
  82. package/dist/manifest-chunk-cache.d.ts +3 -3
  83. package/dist/manifest-chunk-cache.js +39 -44
  84. package/dist/manifest-chunk-flow.js +15 -14
  85. package/dist/manifest-write.js +1 -1
  86. package/dist/manifest.d.ts +2 -2
  87. package/dist/manifest.js +37 -34
  88. package/dist/mcp-tools/tiers.js +1 -1
  89. package/dist/network.d.ts +1 -1
  90. package/dist/network.js +6 -2
  91. package/dist/platform-sign.d.ts +143 -0
  92. package/dist/platform-sign.js +220 -0
  93. package/dist/portable.d.ts +59 -0
  94. package/dist/portable.js +82 -0
  95. package/dist/product.d.ts +1 -1
  96. package/dist/product.js +1 -1
  97. package/dist/progress-node.d.ts +2 -0
  98. package/dist/progress-node.js +14 -0
  99. package/dist/progress.d.ts +0 -1
  100. package/dist/progress.js +4 -8
  101. package/dist/redact.js +3 -2
  102. package/dist/registration.js +5 -2
  103. package/dist/risk.d.ts +11 -0
  104. package/dist/risk.js +16 -0
  105. package/dist/run-log.d.ts +4 -4
  106. package/dist/run-log.js +29 -36
  107. package/dist/seal.js +5 -4
  108. package/dist/server.d.ts +1 -1
  109. package/dist/server.js +6 -2
  110. package/dist/setup-questions.js +2 -2
  111. package/dist/shared/lib/drive/manifest-ops.d.ts +8 -30
  112. package/dist/shared/lib/drive/manifest-ops.js +7 -39
  113. package/dist/shared/lib/drive/manifest-settings-patch.d.ts +52 -0
  114. package/dist/shared/lib/drive/manifest-settings-patch.js +110 -0
  115. package/dist/shared/lib/drive/manifest-settings.d.ts +55 -4
  116. package/dist/shared/lib/drive/manifest-settings.js +66 -28
  117. package/dist/shared/lib/wallet/discover.d.ts +54 -0
  118. package/dist/shared/lib/wallet/discover.js +66 -0
  119. package/dist/standing-tip.d.ts +3 -0
  120. package/dist/standing-tip.js +1 -0
  121. package/dist/upload-file-node.d.ts +3 -0
  122. package/dist/upload-file-node.js +38 -0
  123. package/dist/upload-file.d.ts +2 -2
  124. package/dist/upload-file.js +15 -45
  125. package/dist/upload-price-node.d.ts +8 -0
  126. package/dist/upload-price-node.js +37 -0
  127. package/dist/upload-price.d.ts +0 -8
  128. package/dist/upload-price.js +4 -33
  129. package/dist/upload-steps.js +3 -3
  130. package/dist/upload-store.d.ts +14 -14
  131. package/dist/upload-store.js +68 -75
  132. package/dist/upload-wallet-chain.js +3 -2
  133. package/dist/upload-wallet-put.d.ts +118 -0
  134. package/dist/upload-wallet-put.js +221 -0
  135. package/dist/upload-wallet.d.ts +3 -0
  136. package/dist/upload-wallet.js +18 -15
  137. package/dist/upload.js +16 -15
  138. package/dist/wallet-list-chain.d.ts +4 -0
  139. package/dist/wallet-list-chain.js +21 -0
  140. package/dist/wallet-pay-index.d.ts +21 -0
  141. package/dist/wallet-pay-index.js +68 -0
  142. package/dist/wallet-sign-seams.d.ts +69 -0
  143. package/dist/wallet-sign-seams.js +16 -0
  144. package/dist/wallet-sign.d.ts +3 -53
  145. package/dist/wallet-sign.js +22 -18
  146. package/dist/wallet.d.ts +13 -13
  147. package/dist/wallet.js +15 -15
  148. package/dist/walrus-write.js +3 -2
  149. package/dist/walrus.d.ts +1 -16
  150. package/dist/walrus.js +11 -22
  151. package/docs/commands/credits.md +1 -1
  152. package/docs/commands/platform.md +14 -0
  153. package/docs/commands/support.md +1 -1
  154. package/docs/commands/trash.md +1 -1
  155. package/docs/commands/wallet.md +26 -8
  156. package/package.json +13 -2
@@ -0,0 +1,221 @@
1
+ // A WALLET-PAID upload with no terminal in it: one file sealed here, its storage bought by the
2
+ // wallet the NMTS key derives instead of by credits, the sealed bytes pushed, the file named in
3
+ // the account's sealed list.
4
+ //
5
+ // ⛔ WHY THIS IS NOT IN `commands/`. Paying for storage from somebody's own coins existed only as
6
+ // `nmts put --pay wallet`, and a command cannot be called from a library: it parses arguments,
7
+ // prints, asks a person and exits. Everything below takes its inputs as values and reports
8
+ // through a return value and thrown `NmtsError`s, which is what lets the SDK — and somebody's
9
+ // own server — pay for an upload from their own coins without inheriting a terminal.
10
+ //
11
+ // ⛔ IT PRICES BEFORE IT SIGNS, ALWAYS, in the order `extend` set: the parts are planned, the chain
12
+ // quotes each part in WAL and the relay's tip in SUI, the register transaction is dry-run for
13
+ // its fee, both balances are read — and only then is `onReview` told the numbers, a known
14
+ // shortfall refused, and `agree` given its say. A dry run stops at the review and never loads
15
+ // the signing module.
16
+ //
17
+ // ⛔ `agree` IS THE ONE GATE BETWEEN A PROGRAM AND SOMEBODY'S WALLET, and it is a parameter
18
+ // because who may say yes differs: the command holds the review against this machine's wallet
19
+ // agreement, and a library call IS the agreement, made by the caller who wrote the call.
20
+ // Everything before it is a read.
21
+ //
22
+ // ⛔ THE SERVER IS TOLD THE STORAGE IS THE PERSON'S BY SAYING NOTHING ELSE. `POST /v1/items` forces
23
+ // treasury ownership only on a part that names a certified reservation; a wallet-paid part names
24
+ // none and carries the blob object and the end epoch instead, exactly as the browser's own
25
+ // wallet-paid commit does (`upload-steps.ts`).
26
+ //
27
+ // ⛔ A HELD RESOURCE IS OFFERED, NEVER CHOSEN FOR THE CALLER — the owner's rule for storage
28
+ // resources. Without `storage` this buys new storage and the review counts the free resources
29
+ // the wallet holds; with it the review says in bytes what is cut free or bound with the file.
30
+ //
31
+ // ⚠ NO SPENDING LEDGER AND NO GIFT LIVE HERE. Both are the command's: the ledger counts what one
32
+ // machine signed under one agreement, and a standing gift is a choice a person made in their own
33
+ // account. A library that kept either would be deciding for a caller who never asked it to.
34
+ import { DERIVED } from "./crypto.js";
35
+ import { NmtsError } from "./errors.js";
36
+ import { setTrashed } from "./item-trash.js";
37
+ import { addEntry } from "./manifest-write.js";
38
+ import { sealedLenFor } from "./seal.js";
39
+ import { statusOf } from "./shared/lib/storage-control/plan.js";
40
+ import { createUploadApi } from "./upload-api.js";
41
+ import { partKeysOf, uploadFile } from "./upload-file.js";
42
+ import { CREDIT_BYTES, planAndPrice } from "./upload-price.js";
43
+ import { clearItemRecord, clearReservation } from "./upload-store.js";
44
+ import { walletRail } from "./upload-wallet.js";
45
+ import { chooseUploadEpochs, daysOf, parseStorageAsk, pickResource, uploadBudget, uploadShortfallNextStep, } from "./upload-wallet-plan.js";
46
+ import { walletAddress } from "./wallet.js";
47
+ import { createBlobProtocol } from "./walrus-write.js";
48
+ /**
49
+ * Price, agree, sign, upload and record ONE file, paid from the wallet the NMTS key derives.
50
+ *
51
+ * ⛔ THE SIGNING MODULE IS LOADED ONLY AFTER `agree` HAS RETURNED. A dry run and a refusal never
52
+ * bring the code that can spend into memory.
53
+ */
54
+ export async function walletPut(ctx, file, seams = {}) {
55
+ const { plan, sealedBytes, sealFor } = planAndPrice(file.source.size, ctx.partSize, ctx.rule);
56
+ const sealedLens = plan.map((range) => sealedLenFor(sealFor(range)));
57
+ const storageAsk = parseStorageAsk(seams.storage);
58
+ if (storageAsk !== null && plan.length > 1) {
59
+ throw new NmtsError(`A held storage resource holds one blob, and this file is ${plan.length} parts.`, {
60
+ exitCode: 4,
61
+ nextStep: `Nothing was signed. Leave the storage resource off, or raise the part size so the file is one part.`,
62
+ });
63
+ }
64
+ const protocol = (seams.protocol ?? createBlobProtocol)(ctx.network, sealedBytes, (sent, total) => seams.onProgress?.(sent, total));
65
+ const reads = await (seams.readChain ?? defaultReads)(ctx.network, protocol.relayUrl);
66
+ const window = await reads.readWindow();
67
+ if (window === null) {
68
+ throw new NmtsError(`The ${ctx.network} storage network could not be read.`, {
69
+ exitCode: 1,
70
+ nextStep: `Nothing was signed and nothing was sent. Which epoch the network is in, and how far ahead it will sell, are facts only the chain has — this will not spend against a guess.`,
71
+ });
72
+ }
73
+ const epochs = chooseUploadEpochs(seams.epochs, window);
74
+ const endEpoch = window.clock.current + epochs;
75
+ const quotes = await reads.quoteParts(sealedLens, epochs);
76
+ // ⛔ THE ADDRESS THAT IS PRICED IS THE ADDRESS THAT SIGNS — the same number the rail carries.
77
+ const address = await walletAddress(ctx.code, ctx.wallet);
78
+ let storage = { kind: "buy" };
79
+ let heldResources = null;
80
+ if (storageAsk !== null) {
81
+ const firstQuote = quotes[0];
82
+ const firstLen = sealedLens[0];
83
+ if (firstQuote === undefined || firstLen === undefined)
84
+ throw new NmtsError("unreachable: a plan with no part");
85
+ let resources;
86
+ try {
87
+ resources = await reads.readStorage(address);
88
+ }
89
+ catch (error) {
90
+ throw new NmtsError("The storage resources this wallet holds could not be read.", {
91
+ exitCode: 1,
92
+ nextStep: `Nothing was signed. That is not the same as holding none. Cause: ${error instanceof Error ? error.message : String(error)}`,
93
+ });
94
+ }
95
+ const encoded = await reads.encodedLength(address, firstLen);
96
+ storage = pickResource(storageAsk, resources, encoded, window.clock.current, endEpoch, firstQuote.writeFrost);
97
+ }
98
+ else {
99
+ // ⚠ A read that fails is null, not zero: this number only offers a choice, and losing it must
100
+ // not lose the upload.
101
+ heldResources = await reads.readStorage(address).then((rows) => rows.filter((r) => statusOf(r, window.clock.current) === "usable").length, () => null);
102
+ }
103
+ const [purse, feeMist] = await Promise.all([
104
+ reads.readWallet(address),
105
+ reads.estimateRegisterGas({
106
+ sender: address,
107
+ sealedLen: Math.max(...sealedLens),
108
+ epochs,
109
+ storage: storage.kind === "buy"
110
+ ? storage
111
+ : { kind: "reuse", objectId: storage.objectId, cutToBytes: storage.cutToBytes, writeFrost: storage.writeFrost },
112
+ }),
113
+ ]);
114
+ const budget = uploadBudget({ address, purse, feeMist, quotes, storage });
115
+ const review = {
116
+ name: file.name,
117
+ bytes: file.source.size,
118
+ sealedBytes,
119
+ parts: plan.length,
120
+ epochs,
121
+ days: daysOf(window, epochs),
122
+ endEpoch,
123
+ tipMist: quotes.reduce((sum, q) => sum + q.tipMist, 0n),
124
+ storage,
125
+ heldResources,
126
+ budget,
127
+ };
128
+ seams.onReview?.(review);
129
+ if (seams.dryRun === true)
130
+ return { kind: "review", review };
131
+ // ⛔ A WALLET KNOWN TO BE SHORT IS REFUSED BEFORE THE AGREEMENT IS ASKED FOR (`extend-budget.ts`).
132
+ if (budget.shortfall !== null) {
133
+ throw new NmtsError(budget.shortfall, { exitCode: 4, nextStep: uploadShortfallNextStep(budget) });
134
+ }
135
+ seams.agree?.(review);
136
+ const sign = seams.sign ?? (await signers());
137
+ const onSpend = seams.onSpend ?? (() => undefined);
138
+ const derived = ctx.crypt.kdf_derive(ctx.crypt.account_code_parse(ctx.code));
139
+ const dataKey = derived.slice(DERIVED.dataKey[0], DERIVED.dataKey[1]);
140
+ derived.fill(0);
141
+ let result;
142
+ try {
143
+ result = await uploadFile({
144
+ api: seams.api ?? createUploadApi(ctx.server, ctx.apiKey),
145
+ protocol,
146
+ crypt: ctx.crypt,
147
+ dataKey,
148
+ source: file.source,
149
+ name: file.name,
150
+ parentId: file.parentId,
151
+ destination: file.destination,
152
+ relayUrl: protocol.relayUrl,
153
+ epochs,
154
+ currentEpoch: window.clock.current,
155
+ partSize: ctx.partSize,
156
+ padding: { rule: ctx.rule, unitBytes: CREDIT_BYTES },
157
+ ...(seams.onStep === undefined ? {} : { onStep: seams.onStep }),
158
+ buy: walletRail({
159
+ network: ctx.network,
160
+ code: ctx.code,
161
+ wallet: ctx.wallet,
162
+ relayUrl: protocol.relayUrl,
163
+ epochs,
164
+ storage,
165
+ quotes,
166
+ feeMist,
167
+ signRegister: sign.register,
168
+ signCertify: sign.certify,
169
+ onSpend,
170
+ }),
171
+ });
172
+ }
173
+ finally {
174
+ dataKey.fill(0);
175
+ }
176
+ const now = Date.now();
177
+ // ⛔ FROM `result.entry`, NOT FROM THIS CALL. The key that opens the stored bytes is the key they
178
+ // were sealed with, which on a resume belongs to the call that sealed them.
179
+ const added = await addEntry({
180
+ server: ctx.server,
181
+ apiKey: ctx.apiKey,
182
+ code: ctx.code,
183
+ accountId: ctx.accountId,
184
+ ...(ctx.onCollision !== undefined ? { onCollision: ctx.onCollision } : {}),
185
+ entry: {
186
+ id: result.itemId,
187
+ parentId: file.parentId,
188
+ kind: 1,
189
+ name: result.entry.name,
190
+ size: result.entry.plaintextLen,
191
+ createdAt: now,
192
+ updatedAt: now,
193
+ dekWrapped: result.entry.dekWrapped,
194
+ contentHashCt: result.entry.contentHashCt,
195
+ },
196
+ });
197
+ // ⛔ ONLY NOW, AND EVERY PART — the same order the credit rail keeps and for the same reason: a
198
+ // paid-for file the list does not name is invisible, and the records are what let a second
199
+ // call finish the job without signing again.
200
+ await clearItemRecord(result.fileKey);
201
+ for (const record of partKeysOf(result.fileKey, result.parts))
202
+ await clearReservation(record);
203
+ if (added.replaced)
204
+ await setTrashed(ctx.server, ctx.apiKey, added.replaced.id, true);
205
+ return {
206
+ kind: "uploaded",
207
+ review,
208
+ itemId: result.itemId,
209
+ savedAs: added.name,
210
+ replaced: added.replaced?.id ?? null,
211
+ fileListVersion: added.seq,
212
+ resumed: result.resumed,
213
+ };
214
+ }
215
+ async function defaultReads(network, relayUrl) {
216
+ return (await import("./upload-wallet-chain.js")).walletUploadReads(network, relayUrl);
217
+ }
218
+ async function signers() {
219
+ const signing = await import("./wallet-sign.js");
220
+ return { register: signing.signBlobRegister, certify: signing.signBlobCertify };
221
+ }
@@ -7,6 +7,9 @@ export interface WalletRailContext {
7
7
  network: Network;
8
8
  /** ⛔ The NMTS key. Held for the signatures and never written anywhere. */
9
9
  code: string;
10
+ /** Which of this key's wallets pays — the account's own number, resolved before anything was
11
+ * priced (`wallet-pay-index.ts`), so the address in the review is the address that signs. */
12
+ wallet: number;
10
13
  relayUrl: string;
11
14
  epochs: number;
12
15
  /** Where the storage comes from. A held resource serves one blob, so it applies to a one-part file. */
@@ -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)
@@ -108,6 +109,7 @@ async function buyAndPushPartWithWallet(ctx, input) {
108
109
  registered = await ctx.signRegister({
109
110
  network: ctx.network,
110
111
  code: ctx.code,
112
+ wallet: ctx.wallet,
111
113
  relayUrl: input.relayUrl,
112
114
  epochs: ctx.epochs,
113
115
  storage: ctx.storage.kind === "buy" ? { kind: "buy" } : ctx.storage,
@@ -132,14 +134,14 @@ async function buyAndPushPartWithWallet(ctx, input) {
132
134
  record.registerTxDigest = registered.digest;
133
135
  record.blobObjectId = registered.blobObjectId;
134
136
  record.endEpoch = registered.endEpoch;
135
- writeReservation(key, record, sealed);
137
+ await writeReservation(key, record, sealed);
136
138
  ctx.onSpend({
137
139
  walFrost: quote.writeFrost + (ctx.storage.kind === "buy" ? quote.storageFrost : 0n),
138
140
  suiMist: quote.tipMist + (ctx.feeMist ?? 0n),
139
141
  });
140
142
  const certificate = await push(input, record, registered.digest, registered.blobObjectId, sealed);
141
143
  await certify(ctx, input, key, record, certificate);
142
- return paidPart(readReservationRecord(key) ?? record, false);
144
+ return paidPart((await readReservationRecord(key)) ?? record, false);
143
145
  }
144
146
  /** The bytes to the relay named in the register transaction. The storage is already bought. */
145
147
  async function push(input, record, registerTxDigest, blobObjectId, sealed) {
@@ -148,7 +150,7 @@ async function push(input, record, registerTxDigest, blobObjectId, sealed) {
148
150
  return await input.protocol.uploadToRelay({
149
151
  blobId: record.blobId,
150
152
  bytes: sealed,
151
- nonce: new Uint8Array(Buffer.from(record.nonceB64, "base64url")),
153
+ nonce: fromBase64Url(record.nonceB64),
152
154
  registerTxDigest,
153
155
  blobObjectId,
154
156
  });
@@ -173,6 +175,7 @@ async function certify(ctx, input, key, record, certificate) {
173
175
  digest = await ctx.signCertify({
174
176
  network: ctx.network,
175
177
  code: ctx.code,
178
+ wallet: ctx.wallet,
176
179
  relayUrl: record.relayUrl,
177
180
  blobId: record.blobId,
178
181
  blobObjectId: record.blobObjectId,
@@ -190,7 +193,7 @@ async function certify(ctx, input, key, record, certificate) {
190
193
  "certifies again, which costs gas and nothing else.",
191
194
  });
192
195
  }
193
- writeReservation(key, { ...record, certifyTxDigest: digest }, readReservationBytes(key));
196
+ await writeReservation(key, { ...record, certifyTxDigest: digest }, await readReservationBytes(key));
194
197
  // The certify fee is not measured beforehand, so the ledger is told about the tip and the
195
198
  // register fee only — the review said so.
196
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
  }
@@ -0,0 +1,4 @@
1
+ import type { Network } from "./network.ts";
2
+ /** How long the two questions get together. A read that stalls is a read that failed. */
3
+ export declare const HISTORY_TIMEOUT_MS = 20000;
4
+ export declare function hasHistory(network: Network, address: string): Promise<boolean>;
@@ -0,0 +1,21 @@
1
+ // The live read behind `nmts wallet list`'s walk: has this address ever been in a transaction?
2
+ //
3
+ // ⛔ READ ONLY, and one question in each direction. The RPC filter has no OR, so "from" and "to"
4
+ // are asked separately — a wallet that only ever RECEIVED is in use too, and a walk that asked
5
+ // about balances alone would report a wallet somebody emptied as one that does not exist.
6
+ //
7
+ // ⚠ ONE transaction is enough to answer, so one is what is asked for. This runs once per wallet
8
+ // the walk looks at, which is why it asks for as little as the chain will sell.
9
+ import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
10
+ import { suiRpcTransport } from "./sui-rpc.js";
11
+ /** How long the two questions get together. A read that stalls is a read that failed. */
12
+ export const HISTORY_TIMEOUT_MS = 20_000;
13
+ export async function hasHistory(network, address) {
14
+ const client = new SuiJsonRpcClient({ network, transport: suiRpcTransport(network) });
15
+ const signal = AbortSignal.timeout(HISTORY_TIMEOUT_MS);
16
+ const sent = await client.queryTransactionBlocks({ filter: { FromAddress: address }, limit: 1, signal });
17
+ if (sent.data.length > 0)
18
+ return true;
19
+ const received = await client.queryTransactionBlocks({ filter: { ToAddress: address }, limit: 1, signal });
20
+ return received.data.length > 0;
21
+ }
@@ -0,0 +1,21 @@
1
+ export interface PayingWalletInput {
2
+ server?: string | undefined;
3
+ network?: string | undefined;
4
+ /** `--wallet N` — this run only, and it never writes the account's choice. */
5
+ wallet?: string | undefined;
6
+ /**
7
+ * ⚠ A SEAM, NOT AN OPTION: how the account's number is read. No flag reaches it. It exists
8
+ * because the alternative is a test that signs in and reads a live file list to check that a
9
+ * review was printed.
10
+ */
11
+ readActiveWallet?: () => Promise<number>;
12
+ }
13
+ /**
14
+ * A wallet number as a person typed it.
15
+ *
16
+ * ⛔ REFUSED, NEVER ROUNDED. `--wallet 1.5` and `--wallet -1` are command lines to correct; taking
17
+ * either of them to a neighbouring wallet would spend from an address nobody named.
18
+ */
19
+ export declare function walletIndexOf(raw: string): number;
20
+ /** Which wallet this run pays from: the flag if it was given, otherwise the account's own number. */
21
+ export declare function payingWalletIndex(input: PayingWalletInput): Promise<number>;
@@ -0,0 +1,68 @@
1
+ // WHICH WALLET PAYS — the number this account keeps in its sealed file list, and the one flag that
2
+ // overrides it for a single run.
3
+ //
4
+ // ⛔ WHY IT IS NOT A CONSTANT ANY MORE (2026-09-16). One NMTS key derives a wallet at
5
+ // every index (NCF-3 §1.3) and the account chooses which of them pays, so that a phone, a
6
+ // laptop and this tool all spend from the same address. That choice lives where no server can
7
+ // read it: inside the sealed list, beside the padding rule.
8
+ //
9
+ // ⛔ AND IT REFUSES RATHER THAN GUESSING. If the list cannot be read, this tool does not fall back
10
+ // to the first wallet: that would sign with a wallet the person may not have funded, print a
11
+ // review naming an address nobody chose, and — with `--yes` — spend from it. "I do not know
12
+ // which wallet should pay" is an answer; wallet 0 is not.
13
+ //
14
+ // ⚠ THE NUMBER IS RESOLVED BEFORE ANYTHING IS PRICED. Every command that spends prints a review
15
+ // with the paying address in it, so the address that is read, priced and shown has to be the
16
+ // address that signs. Asking for the number at signing time would let those two differ.
17
+ import { NmtsError } from "./errors.js";
18
+ import { readFileList } from "./manifest.js";
19
+ import { BINARY_NAME } from "./product.js";
20
+ import { openSession } from "./session.js";
21
+ import { activeWalletOf, WALLET_INDEX_LIMIT } from "./shared/lib/drive/manifest-settings.js";
22
+ /**
23
+ * A wallet number as a person typed it.
24
+ *
25
+ * ⛔ REFUSED, NEVER ROUNDED. `--wallet 1.5` and `--wallet -1` are command lines to correct; taking
26
+ * either of them to a neighbouring wallet would spend from an address nobody named.
27
+ */
28
+ export function walletIndexOf(raw) {
29
+ const text = raw.trim();
30
+ const value = Number(text);
31
+ if (!/^[0-9]+$/u.test(text) || !Number.isSafeInteger(value) || value >= WALLET_INDEX_LIMIT) {
32
+ throw new NmtsError(`A wallet number is a whole number from 0 to ${WALLET_INDEX_LIMIT - 1}, not "${raw}".`, {
33
+ exitCode: 2,
34
+ nextStep: `\`${BINARY_NAME} wallet list\` shows this key's wallets and their numbers.`,
35
+ });
36
+ }
37
+ return value;
38
+ }
39
+ /** Which wallet this run pays from: the flag if it was given, otherwise the account's own number. */
40
+ export async function payingWalletIndex(input) {
41
+ const said = input.wallet;
42
+ if (said !== undefined && said !== "")
43
+ return walletIndexOf(said);
44
+ const read = input.readActiveWallet ?? (() => fromSealedList(input));
45
+ try {
46
+ return await read();
47
+ }
48
+ catch (error) {
49
+ // ⛔ A refusal this tool already worded (no API key, a locked code) is passed on as it stands —
50
+ // it names the thing to fix, and wrapping it would bury that under a second sentence.
51
+ if (error instanceof NmtsError)
52
+ throw error;
53
+ throw new NmtsError(`Which wallet should pay is written in this account's file list, and it could not be read: ` +
54
+ `${error instanceof Error ? error.message : String(error)}`, {
55
+ exitCode: 1,
56
+ nextStep: `Nothing was signed. Try again when the list can be read, or name the wallet for this ` +
57
+ `one run with \`--wallet N\` — \`${BINARY_NAME} wallet list\` shows the numbers.`,
58
+ });
59
+ }
60
+ }
61
+ /** The account's own number, out of the sealed file list. One request, and no flag reaches it. */
62
+ async function fromSealedList(input) {
63
+ const session = await openSession({ server: input.server, network: input.network });
64
+ const list = await readFileList(session.server, session.apiKey, session.code, session.accountId);
65
+ // An account that has never written a setting pays from the first wallet — that is a READ
66
+ // answer, not a fallback: the list opened, and it said nothing about a wallet.
67
+ return activeWalletOf(list.manifest?.settings);
68
+ }