@needmoretruth/nmts-cli 0.17.2

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 (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
@@ -0,0 +1,76 @@
1
+ // The real chain reads behind `nmts wallet` — the only file in this command that loads the Sui SDK.
2
+ //
3
+ // ⛔ SPLIT OUT SO THE COMMAND CAN BE TESTED. `wallet.ts` decides what a balance means and what is
4
+ // said when there is none; this only knows how to ask. Keeping the SDK out of the deciding half
5
+ // is what lets `node --test` drive every branch — including the ones a live chain would only
6
+ // produce by being broken.
7
+ //
8
+ // ⛔ IT READS AND CANNOT DO ANYTHING ELSE. No signer is built here, no transaction is assembled,
9
+ // and the two calls below are the whole of this file's contact with the network: what one
10
+ // address holds, and whether a coin type exists. A balance is public data — anybody can ask the
11
+ // same question about the same address — so nothing about this run leaves that was not already
12
+ // published by the chain itself.
13
+ import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
14
+ import { suiRpcHost } from "./walrus.js";
15
+ /**
16
+ * How long one balance question gets.
17
+ *
18
+ * A read that stalls is a read that failed, and the caller has something honest to print either
19
+ * way. Long enough for a slow public node, short enough that an agent waiting on this does not
20
+ * conclude the tool has hung.
21
+ */
22
+ export const BALANCE_TIMEOUT_MS = 20_000;
23
+ /**
24
+ * Read balances for one address on one network.
25
+ *
26
+ * ⚠ THE NODE IS NOT TRUSTED WITH ANYTHING, and it does not have to be: what it answers is printed
27
+ * as a number and nothing is decided on it. A node that lies about a balance produces a wrong
28
+ * number on a screen — where its owner will see it disagree with every other wallet they own —
29
+ * and it cannot produce a wrong address, because the address never came from it.
30
+ */
31
+ export function chainReader(network, address) {
32
+ // The network name reaches the SDK as well as the URL, which is what keeps a mirror pointed at
33
+ // the wrong chain from being discovered later, as an answer that quietly made no sense.
34
+ const client = new SuiJsonRpcClient({ network, url: suiRpcHost(network) });
35
+ return {
36
+ async totalOf(coinType) {
37
+ const { totalBalance } = await client.getBalance({
38
+ owner: address,
39
+ coinType,
40
+ signal: AbortSignal.timeout(BALANCE_TIMEOUT_MS),
41
+ });
42
+ return baseUnitsOf(totalBalance);
43
+ },
44
+ async knowsCoinType(coinType) {
45
+ // Null is the answer for a type that does not exist — measured against the public mainnet
46
+ // node this tool reads from, 2026-08-24, which is also where the balance-of-a-made-up-coin
47
+ // measurement in `wallet.ts` comes from.
48
+ const metadata = await client.getCoinMetadata({
49
+ coinType,
50
+ signal: AbortSignal.timeout(BALANCE_TIMEOUT_MS),
51
+ });
52
+ return metadata !== null;
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * A total off the wire, as base units.
58
+ *
59
+ * ⛔ A STRING, AND IT STAYS EXACT. Balances run past what a JavaScript number holds without losing
60
+ * digits, so the value is never converted through one. Anything that is not a whole,
61
+ * non-negative count of base units is refused rather than rounded into something printable:
62
+ * this tool would rather say it could not read the balance than print a number the chain did
63
+ * not say.
64
+ */
65
+ function baseUnitsOf(total) {
66
+ let units;
67
+ try {
68
+ units = BigInt(total);
69
+ }
70
+ catch {
71
+ throw new Error("the chain answered with a balance this tool cannot read as a number");
72
+ }
73
+ if (units < 0n)
74
+ throw new Error("the chain answered with a negative balance, which is not a balance");
75
+ return units;
76
+ }
package/dist/wallet.js ADDED
@@ -0,0 +1,209 @@
1
+ // The wallet an account code derives: its address, and what a chain says it holds.
2
+ //
3
+ // ⛔ NOTHING HERE SIGNS OR SPENDS, AND NOTHING HERE HANDS OUT A KEY. The only secret this module
4
+ // touches is the 32-byte wallet seed; it lives for the length of one address computation and is
5
+ // wiped on every path out, failures included. No function returns a seed, a private key or a
6
+ // keypair, so a later caller cannot reach one without deciding to add one.
7
+ //
8
+ // ⛔ THE DERIVATION IS THE ENGINE'S, NOT THIS FILE'S. `wallet_seed_for` is the same WebAssembly
9
+ // function the browser app calls, off the same wallet root, at the same index — so the address
10
+ // printed here is the address the account already has. A second derivation written in
11
+ // TypeScript would be free to drift from the first, and the way that surfaces is somebody
12
+ // funding an address their account cannot spend from.
13
+ //
14
+ // ⛔ A BALANCE THAT COULD NOT BE READ IS NOT ZERO. On a screen the two look identical; they mean
15
+ // opposite things — "this wallet is empty" and "nobody here knows". Every result below says
16
+ // which of the two it is, and a caller has to open the result to get at a number at all.
17
+ import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
18
+ import { assertUsableCode } from "./account.js";
19
+ import { DERIVED, loadCrypto } from "./crypto.js";
20
+ import { NmtsError } from "./errors.js";
21
+ /**
22
+ * The wallet this account code opens by itself.
23
+ *
24
+ * ⛔ IT IS WALLET 0 BECAUSE THAT IS THE ONE THE BROWSER OPENS. Every wallet, including this one,
25
+ * comes out of `wallet_seed_for` — there is no special case for the first — so the index is the
26
+ * whole of the difference between "the account's wallet" and somebody else's.
27
+ *
28
+ * ⛔ EXPORTED SO THE SIGNER CANNOT PICK ITS OWN. `extend-sign.ts` derives a keypair from the same
29
+ * root and has to reach the SAME wallet as the address printed here; a second literal `0` over
30
+ * there would be a second answer to a question with one right one, and the failure is silent —
31
+ * a signature from an address nobody funded. `extend-sign.test.ts` compares the two.
32
+ */
33
+ export const BUILT_IN_WALLET_INDEX = 0;
34
+ /**
35
+ * How many base units make one coin. SUI counts in MIST and WAL counts in FROST; both are 1e9.
36
+ *
37
+ * The two constants are written as digits and scale rather than as one number so they cannot
38
+ * disagree with each other.
39
+ */
40
+ const BASE_UNIT_DIGITS = 9;
41
+ const BASE_UNITS_PER_COIN = 10n ** BigInt(BASE_UNIT_DIGITS);
42
+ /** The chain's own coin. Its type is fixed by the chain itself and takes no network. */
43
+ export const SUI_COIN_TYPE = "0x2::sui::SUI";
44
+ /**
45
+ * The storage network's coin, per chain.
46
+ *
47
+ * ⛔ A WRONG VALUE HERE READS AS AN EMPTY WALLET RATHER THAN AS A FAULT. Measured against the
48
+ * public mainnet node this tool reads from, 2026-08-24: asking for a coin type that does not
49
+ * exist answers `totalBalance: "0"` — the same answer a real empty balance gives. That
50
+ * measurement is why `readBalances` never reports a zero WAL balance without first asking the
51
+ * chain whether it has heard of the type at all; see `ChainReader.knowsCoinType`.
52
+ *
53
+ * ⚠ THIS IS A SECOND COPY. The browser app carries the same two strings for the same reason, and
54
+ * no machine holds the two level — this package deliberately imports nothing from that tree.
55
+ * What keeps them honest is the check above: a copy that went stale stops answering zero and
56
+ * starts saying it could not be read.
57
+ */
58
+ export const WAL_COIN_TYPES = {
59
+ testnet: "0x8270feb7375eee355e64fdb69c50abb6b5f9393a722883c1cf45f8e26048810a::wal::WAL",
60
+ mainnet: "0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL",
61
+ };
62
+ /** The WAL coin type for one network. */
63
+ export function walCoinType(network) {
64
+ return WAL_COIN_TYPES[network];
65
+ }
66
+ /**
67
+ * The Sui address of one 32-byte wallet seed.
68
+ *
69
+ * ⛔ IT IS THE SDK'S COMPUTATION, NOT ONE WRITTEN HERE. An address is BLAKE2b-256 over a scheme
70
+ * byte and the public key; hand-rolling that would put a second answer in the world for a
71
+ * question that must have exactly one, and the failure would be silent — a well-formed address
72
+ * nobody can spend from.
73
+ *
74
+ * ⚠ THE KEYPAIR HOLDS THE SECRET AND CANNOT BE WIPED. `@mysten/sui` keeps the key inside its own
75
+ * object, with no method that clears it; the same is true in the browser's worker. What is
76
+ * controlled here is lifetime: it is built, asked one question, and dropped, and the caller's
77
+ * copy of the seed is zeroed. Nothing retains it.
78
+ *
79
+ * Exported because the vectors that hold this to the other implementations of the same derivation
80
+ * are (seed, address) pairs, and a test cannot check them through the account-code entry point.
81
+ */
82
+ export function addressFromSeed(seed) {
83
+ return Ed25519Keypair.fromSecretKey(seed).toSuiAddress();
84
+ }
85
+ /**
86
+ * The address of the wallet this account code derives. Offline: nothing is asked of anybody.
87
+ *
88
+ * The same address on every network — an account has one wallet, and which chain it is looked up
89
+ * on is a separate question from what it is called.
90
+ */
91
+ export async function walletAddress(code) {
92
+ // ⛔ The one refusal text for a malformed code lives in `account.ts`. Checking here means a typo
93
+ // fails the same way it fails everywhere else in this tool rather than as an engine error.
94
+ await assertUsableCode(code);
95
+ const glue = await loadCrypto();
96
+ let bytes;
97
+ try {
98
+ bytes = glue.account_code_parse(code);
99
+ }
100
+ catch {
101
+ // ⛔ Unreachable in practice — the same parser accepted this input a line ago. It is caught
102
+ // anyway because the alternative is an engine message going out verbatim, and an engine
103
+ // message about a code can carry the code (see `errors.ts`).
104
+ throw new NmtsError("The account code could not be read on this machine.", { exitCode: 1 });
105
+ }
106
+ // ⛔ THIS BUFFER IS EVERY KEY IN THE ACCOUNT, not just the wallet root: the sign-in secret, the
107
+ // key that opens the files, the key that opens the file list. It is wiped below along with
108
+ // everything sliced out of it, on the failing paths as well as the good one.
109
+ const derived = glue.kdf_derive(bytes);
110
+ try {
111
+ return addressFromDerived(glue, derived);
112
+ }
113
+ finally {
114
+ derived.fill(0);
115
+ bytes.fill(0);
116
+ }
117
+ }
118
+ /**
119
+ * The built-in wallet's address, from a buffer the caller already holds.
120
+ *
121
+ * ⛔ IT TAKES KEY MATERIAL AND RETURNS A STRING. `derived` belongs to the caller and is left
122
+ * alone; the two secrets this function makes — the wallet root it slices out and the seed the
123
+ * engine expands from it — are wiped before it returns, on the failing path as well as the
124
+ * good one. Neither one leaves.
125
+ */
126
+ export function addressFromDerived(glue, derived) {
127
+ const [from, to] = DERIVED.walletRoot;
128
+ const root = derived.slice(from, to);
129
+ let seed = null;
130
+ try {
131
+ seed = glue.wallet_seed_for(root, BUILT_IN_WALLET_INDEX);
132
+ return addressFromSeed(seed);
133
+ }
134
+ finally {
135
+ root.fill(0);
136
+ seed?.fill(0);
137
+ }
138
+ }
139
+ /** Why a coin came back unread, from whatever was thrown. */
140
+ function reasonOf(error) {
141
+ return error instanceof Error ? error.message : String(error);
142
+ }
143
+ /**
144
+ * Read both balances, keeping "could not be read" apart from "empty" in every branch.
145
+ *
146
+ * ⚠ THE REASON A READ FAILED IS SAFE TO PRINT, and that is worth saying because in this tool it
147
+ * usually is not (`errors.ts`). Nothing secret is in one of these requests: what goes out is a
148
+ * public address and a coin type, so what comes back cannot quote a secret.
149
+ *
150
+ * ⛔ ONE COIN'S FAILURE DOES NOT TAKE THE OTHER DOWN. They are separate requests about separate
151
+ * coins, and an answer for one of them is worth more than a matching pair of silences.
152
+ */
153
+ export async function readBalances(reader, walType) {
154
+ const [sui, wal] = await Promise.all([readSui(reader), readWal(reader, walType)]);
155
+ return { sui, wal };
156
+ }
157
+ async function readSui(reader) {
158
+ // ⛔ NO COIN-TYPE CHECK FOR THIS ONE, and the asymmetry is deliberate: `0x2::sui::SUI` is the
159
+ // chain's own coin, named by the chain rather than by this build, so a zero here is a zero.
160
+ try {
161
+ return { read: true, baseUnits: await reader.totalOf(SUI_COIN_TYPE) };
162
+ }
163
+ catch (error) {
164
+ return { read: false, why: reasonOf(error) };
165
+ }
166
+ }
167
+ async function readWal(reader, walType) {
168
+ let total;
169
+ try {
170
+ total = await reader.totalOf(walType);
171
+ }
172
+ catch (error) {
173
+ return { read: false, why: reasonOf(error) };
174
+ }
175
+ // A positive balance is its own proof that the coin type is real; only zero is ambiguous.
176
+ if (total > 0n)
177
+ return { read: true, baseUnits: total };
178
+ try {
179
+ if (await reader.knowsCoinType(walType))
180
+ return { read: true, baseUnits: total };
181
+ return {
182
+ read: false,
183
+ why: "this chain does not know the WAL coin type this version of the tool was built with, so a zero balance here would mean nothing",
184
+ };
185
+ }
186
+ catch (error) {
187
+ // ⛔ A FAILED CHECK IS NOT A CONFIRMED ZERO. Reporting the zero anyway would be reporting a
188
+ // number whose one supporting question went unanswered.
189
+ return { read: false, why: `the balance came back as zero and could not be confirmed: ${reasonOf(error)}` };
190
+ }
191
+ }
192
+ /**
193
+ * Base units as a person reads them — the exact value, never rounded.
194
+ *
195
+ * ⛔ NOT `toFixed`, NOT A SHORTENED FORM. A wallet holding 0.000000004 SUI is not holding "0.00",
196
+ * and money that reads as zero when it is not is the one rounding error nobody forgives. The
197
+ * fractional part is trimmed of trailing zeros only, which removes nothing that was there.
198
+ *
199
+ * Callers hand it a total the chain reported; a negative one is refused where it is read, so this
200
+ * never sees one.
201
+ */
202
+ export function coinAmount(baseUnits) {
203
+ const whole = baseUnits / BASE_UNITS_PER_COIN;
204
+ const fraction = baseUnits % BASE_UNITS_PER_COIN;
205
+ if (fraction === 0n)
206
+ return whole.toString();
207
+ const digits = fraction.toString().padStart(BASE_UNIT_DIGITS, "0").replace(/0+$/, "");
208
+ return `${whole}.${digits}`;
209
+ }
@@ -0,0 +1,188 @@
1
+ // The real Walrus protocol client — the ONE file in this tool that loads the storage network's SDK.
2
+ //
3
+ // ⛔ SPLIT OUT FROM `upload.ts` ON PURPOSE. That file decides what gets bought and when; this one
4
+ // only knows how to talk to the network. Keeping the SDK out of it is what lets `node --test`
5
+ // drive every spending decision, including all of its failure branches, against fakes.
6
+ //
7
+ // ⛔ THERE IS NO SIGNER HERE AND NOTHING IS BUILT AS A TRANSACTION. On the credit rail the server
8
+ // registers the blob and pays the relay's tip out of the treasury; this tool computes the id
9
+ // locally and hands over the bytes. The one thing it must get right is telling the relay WHICH
10
+ // transaction paid — see the note on `sendTip` below, which is not what its name suggests.
11
+ import { SuiJsonRpcClient } from "@mysten/sui/jsonRpc";
12
+ import { walrus } from "@mysten/walrus";
13
+ import { epochClock } from "./expiry.js";
14
+ // ⛔ The re-export below is what callers use; this local name is the same function,
15
+ // imported so the reader above can call it without importing from itself.
16
+ import { epochStartedMs as readEpochStart } from "./shared/lib/extend/epochs.js";
17
+ import { NmtsError } from "./errors.js";
18
+ import { countingFetch } from "./progress.js";
19
+ import { relayHost, suiRpcHost } from "./walrus.js";
20
+ /**
21
+ * How long the relay gets for one blob PUT, sized to the body.
22
+ *
23
+ * The SDK's own default is 30 seconds, which a multi-megabyte body cannot finish on an ordinary
24
+ * connection — and a timeout here happens AFTER the storage is paid for.
25
+ */
26
+ export function relayTimeoutMs(bodyBytes) {
27
+ const perMiB = 20_000;
28
+ const floor = 60_000;
29
+ return Math.max(floor, Math.ceil((bodyBytes / 2 ** 20) * perMiB));
30
+ }
31
+ /**
32
+ * ⛔ `sendTip` IS A FLAG HERE, NOT A BUDGET. The SDK decides whether to tell the relay which
33
+ * transaction paid its tip by asking `!!config.uploadRelay.sendTip` — with the field absent it
34
+ * sends the bytes WITHOUT the nonce and the transaction id, and the relay refuses them because
35
+ * as far as it can tell nobody paid. On this rail the treasury already paid, inside the
36
+ * register transaction; this tool holds no wallet and cannot pay anything whatever this number
37
+ * says. It is a ceiling on a payment that has no way of happening.
38
+ *
39
+ * ⚠ Which also means it does NOT have to agree with the browser's ceiling, and no gate holds the
40
+ * two level: the browser's number bounds a real payment made by a real wallet.
41
+ */
42
+ const TIP_CEILING_UNUSED_MIST = 1;
43
+ function extend(network, relayUrl, bodyBytes, onSent) {
44
+ const base = new SuiJsonRpcClient({
45
+ // ⛔ The network name reaches the SDK as well as the URL. A mirror pointed at the wrong chain
46
+ // would otherwise be discovered as a blob the storage nodes refuse, after the money moved.
47
+ network: network === "mainnet" ? "mainnet" : "testnet",
48
+ url: suiRpcHost(network),
49
+ });
50
+ return base.$extend(walrus({
51
+ uploadRelay: {
52
+ host: relayUrl,
53
+ sendTip: { max: TIP_CEILING_UNUSED_MIST },
54
+ timeout: relayTimeoutMs(bodyBytes),
55
+ // ⛔ THE ONLY WAY TO SEE AN UPLOAD MOVE. The SDK does not report progress, and the relay
56
+ // PUT is the one step of an upload that takes real time — a person watching a large
57
+ // file with no feedback cannot tell a slow upload from a hung one, and neither can an
58
+ // agent deciding whether to give up.
59
+ ...(onSent ? { fetch: countingFetch(onSent) } : {}),
60
+ },
61
+ }));
62
+ }
63
+ function fail(what, error) {
64
+ throw new NmtsError(`${what}: ${error instanceof Error ? error.message : String(error)}`);
65
+ }
66
+ /**
67
+ * Build a protocol client bound to ONE relay, for ONE upload of a known size.
68
+ *
69
+ * `onSent` is called as the request body leaves — that is the honest measure of an upload, and it
70
+ * is the only phase of one that anything can report on.
71
+ */
72
+ export function createBlobProtocol(network, bodyBytes, onSent) {
73
+ const relayUrl = relayHost(network);
74
+ const client = extend(network, relayUrl, bodyBytes, onSent);
75
+ return {
76
+ relayUrl,
77
+ async computeMetadata({ bytes, nonce }) {
78
+ // ⛔ The shard count is NOT supplied. It is a property of the live storage network, the
79
+ // erasure coding depends on it, and a number written down here would be right until the
80
+ // day it was not — at which point every id this tool computed would be refused after the
81
+ // credits had moved. The SDK reads it from the chain.
82
+ const meta = await client.walrus
83
+ .computeBlobMetadata({
84
+ bytes,
85
+ // Omitted rather than passed as undefined so the SDK's own "make a random one" path runs
86
+ // on a first encode; supplied on a retry so the digest reproduces bit for bit.
87
+ ...(nonce ? { nonce } : {}),
88
+ })
89
+ .catch((error) => fail("The storage network's encoder could not prepare this file", error));
90
+ return {
91
+ blobId: meta.blobId,
92
+ rootHash: meta.rootHash,
93
+ nonce: meta.nonce,
94
+ // The SDK hands this back as a thunk, because computing it costs a hash of the encoded
95
+ // blob. Awaited once here so nothing downstream has to know it was ever lazy.
96
+ blobDigest: await meta.blobDigest(),
97
+ };
98
+ },
99
+ async uploadToRelay({ blobId, bytes, nonce, registerTxDigest, blobObjectId }) {
100
+ const { certificate } = await client.walrus
101
+ .writeBlobToUploadRelay({
102
+ blobId,
103
+ blob: bytes,
104
+ nonce,
105
+ txDigest: registerTxDigest,
106
+ blobObjectId,
107
+ // Not a choice made here: the chain service hard-codes it and refuses a registration
108
+ // that says otherwise, so a different value would make the relay reject these bytes.
109
+ deletable: true,
110
+ })
111
+ .catch((error) => fail("The upload relay refused the bytes", error));
112
+ return {
113
+ signers: certificate.signers,
114
+ serialized_message_b64: Buffer.from(certificate.serializedMessage).toString("base64url"),
115
+ signature_b64: Buffer.from(certificate.signature).toString("base64url"),
116
+ };
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * The storage network's current epoch, or `null` when it could not be read.
122
+ *
123
+ * ⚠ ADVISORY ONLY. It becomes the `expiry_epoch` the server records beside the file, which the
124
+ * chain — not this number — is the authority on. `null` is written as 0, meaning "not recorded",
125
+ * which is honest; inventing a number would put a date in the drive that nothing stands behind.
126
+ */
127
+ export async function readCurrentEpoch(network) {
128
+ try {
129
+ const base = new SuiJsonRpcClient({
130
+ network: network === "mainnet" ? "mainnet" : "testnet",
131
+ url: suiRpcHost(network),
132
+ }).$extend(walrus({}));
133
+ // The epoch lives on the COMMITTEE, not beside it: the system state describes capacity and
134
+ // the deny lists as well, and only the committee is stamped with which epoch it serves.
135
+ const state = await base.walrus.systemState();
136
+ const epoch = state.committee.epoch;
137
+ return typeof epoch === "number" ? epoch : null;
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ /**
144
+ * The storage network's epoch clock: which epoch, how long one lasts, and when this one began.
145
+ *
146
+ * ⛔ THE EPOCH LENGTH IS READ, NEVER ASSUMED. It is one day on one network and fourteen on the
147
+ * other, so a constant borrowed from either would turn "fourteen days left" into "196 days
148
+ * left" on the wrong one — beside a sentence about a file being deleted.
149
+ *
150
+ * ⚠ `startedMs` is usually ABSENT and that is normal, not a failure: the network only carries the
151
+ * moment an epoch settled while it is not changing epochs. Everything downstream treats its
152
+ * absence as "the day count is a lower bound", which is the safe direction.
153
+ *
154
+ * `null` means the clock could not be read at all. ⛔ The caller must say so rather than draw a
155
+ * drive with nothing expiring — an unread clock and an account in no danger look identical from
156
+ * the outside and are the opposite of each other.
157
+ */
158
+ export async function readEpochWindow(network) {
159
+ try {
160
+ const base = new SuiJsonRpcClient({
161
+ network: network === "mainnet" ? "mainnet" : "testnet",
162
+ url: suiRpcHost(network),
163
+ }).$extend(walrus({}));
164
+ const [system, staking] = await Promise.all([base.walrus.systemState(), base.walrus.stakingState()]);
165
+ // Both numbers can arrive as strings — Sui reports 64-bit values that way — so they are
166
+ // converted here and the constructor below refuses whatever did not survive it.
167
+ const epoch = system.committee.epoch;
168
+ const duration = staking.epoch_duration;
169
+ return epochClock(Number(epoch), Number(duration), readEpochStart(staking.epoch_state));
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ /**
176
+ * When the current epoch began, or null.
177
+ *
178
+ * ⛔ THE NARROWING ITSELF IS NOT WRITTEN HERE (2026-08-25). It used to be, and it accepted a single
179
+ * enum case while the network sits in a different one for nearly all of every epoch — so the
180
+ * anchor was thrown away almost always and this tool reported "N days or more" where it could
181
+ * have reported a date. The browser had the same bug in its own copy of the same judgement,
182
+ * which is the point: two narrowings are two answers. It now lives beside the arithmetic that
183
+ * depends on it, in the file both programs copy from.
184
+ *
185
+ * ⛔ STILL EXPORTED FROM HERE. `extend-chain.ts` reads the same state for a different reason and
186
+ * imports this name; re-exporting keeps one import path for callers in this package.
187
+ */
188
+ export { epochStartedMs } from "./shared/lib/extend/epochs.js";
package/dist/walrus.js ADDED
@@ -0,0 +1,156 @@
1
+ // Reading bytes back from the Walrus storage network.
2
+ //
3
+ // ⛔ THE AGGREGATOR IS NOT TRUSTED, and nothing here pretends otherwise. Whatever these hosts hand
4
+ // back goes straight into the NCF-3 stream decryptor, which authenticates every chunk under the
5
+ // file's own key. A host that returns the wrong bytes — by mistake or on purpose — produces a
6
+ // decryption failure, never a quietly wrong file. What an aggregator CAN do is refuse to serve,
7
+ // or serve nothing, which is why more than one is listed.
8
+ //
9
+ // ⛔ THE HOST LIST IS WRITTEN IN THREE LANGUAGES and `deploy/check-walrus-hosts.mjs` set-compares
10
+ // them: here, the browser build, and the standalone recovery tool. One copy going stale shows
11
+ // up as "file not found", which reads as "the file is gone" — so the machine holds them level
12
+ // rather than a person remembering to.
13
+ import { NmtsError } from "./errors.js";
14
+ /** Curated Walrus aggregator (read) endpoints per network, preference order. */
15
+ export const AGGREGATOR_HOSTS = {
16
+ testnet: ["https://aggregator.walrus-testnet.walrus.space"],
17
+ mainnet: ["https://aggregator.walrus-mainnet.walrus.space"],
18
+ };
19
+ /**
20
+ * Curated Walrus upload-relay (write) endpoints per network, preference order.
21
+ *
22
+ * ⛔ A RELAY IS NOT AN AGGREGATOR, and the difference is money. The relay is named inside the
23
+ * register transaction the server pays a tip in, so the bytes have to go to the SAME host the
24
+ * reservation was made for. That is why a write picks its host BEFORE the storage is bought and
25
+ * then never moves: failing over to a second relay would push bytes nobody paid that relay for.
26
+ */
27
+ export const RELAY_HOSTS = {
28
+ testnet: ["https://upload-relay.testnet.walrus.space"],
29
+ mainnet: ["https://upload-relay.mainnet.walrus.space"],
30
+ };
31
+ /**
32
+ * Sui JSON-RPC endpoints per network — public mirrors, because the official full nodes retired
33
+ * JSON-RPC on both networks (browser measurements 2026-07-29 testnet, 2026-08-03 mainnet).
34
+ *
35
+ * ⛔ READ-ONLY, AND NOT TRUSTED WITH ANYTHING. This tool asks one question here: how many shards
36
+ * the storage network currently has, which the erasure coding needs. A wrong answer produces a
37
+ * blob id the storage network refuses, so it fails loudly at the relay rather than quietly
38
+ * storing something unreadable. No key is ever sent to one of these, and nothing is signed.
39
+ */
40
+ export const SUI_RPC_HOSTS = {
41
+ testnet: "https://rpc-testnet.suiscan.xyz",
42
+ mainnet: "https://rpc-mainnet.suiscan.xyz",
43
+ };
44
+ /** How long one host gets before the next is tried. A read that stalls is a read that failed. */
45
+ export const READ_TIMEOUT_MS = 60_000;
46
+ /**
47
+ * Point reads at somebody else's aggregator, or at a development stack.
48
+ *
49
+ * ⚠ It replaces the list rather than adding to it, and that is deliberate: a run should read from
50
+ * where it was told to read, not from there AND the public hosts. Comma-separated for more
51
+ * than one, tried in the order given.
52
+ */
53
+ export const AGGREGATOR_ENV_VAR = "NMTS_AGGREGATOR";
54
+ /**
55
+ * Push writes through somebody else's relay, or through a development stack.
56
+ *
57
+ * ⚠ ONE host, not a list. Unlike reads there is nothing to fail over to — see `RELAY_HOSTS`.
58
+ */
59
+ export const RELAY_ENV_VAR = "NMTS_RELAY";
60
+ /** Ask a different Sui JSON-RPC node the shard-count question. */
61
+ export const SUI_RPC_ENV_VAR = "NMTS_SUI_RPC";
62
+ /** The relay this run writes through: the environment's if it named one, else the network's. */
63
+ export function relayHost(network) {
64
+ const named = process.env[RELAY_ENV_VAR]?.trim();
65
+ if (named)
66
+ return named;
67
+ const host = RELAY_HOSTS[network]?.[0];
68
+ if (host === undefined) {
69
+ throw new NmtsError(`No upload relay is known for the ${network} storage network.`, {
70
+ nextStep: `Name one in ${RELAY_ENV_VAR} to upload anyway.`,
71
+ });
72
+ }
73
+ return host;
74
+ }
75
+ /** The Sui JSON-RPC node this run asks about shard count. */
76
+ export function suiRpcHost(network) {
77
+ const named = process.env[SUI_RPC_ENV_VAR]?.trim();
78
+ if (named)
79
+ return named;
80
+ const host = SUI_RPC_HOSTS[network];
81
+ if (host === undefined) {
82
+ throw new NmtsError(`No Sui RPC endpoint is known for the ${network} network.`, {
83
+ nextStep: `Name one in ${SUI_RPC_ENV_VAR} to upload anyway.`,
84
+ });
85
+ }
86
+ return host;
87
+ }
88
+ function fromEnvironment() {
89
+ const raw = process.env[AGGREGATOR_ENV_VAR];
90
+ if (raw === undefined)
91
+ return null;
92
+ const hosts = raw.split(",").map((h) => h.trim()).filter((h) => h !== "");
93
+ return hosts.length > 0 ? hosts : null;
94
+ }
95
+ function hostsFor(network, options) {
96
+ if (options.hosts !== undefined && options.hosts.length > 0)
97
+ return options.hosts;
98
+ const chosen = fromEnvironment();
99
+ if (chosen !== null)
100
+ return chosen;
101
+ const known = AGGREGATOR_HOSTS[network];
102
+ if (known === undefined) {
103
+ throw new NmtsError(`No storage-network hosts are known for "${network}".`, {
104
+ exitCode: 2,
105
+ nextStep: "Use --network mainnet or --network testnet.",
106
+ });
107
+ }
108
+ return known;
109
+ }
110
+ /**
111
+ * Fetch one stored object, trying each host in order.
112
+ *
113
+ * ⛔ It reports what it tried. A read that fails everywhere is either a blob that is gone, a
114
+ * network that is wrong, or hosts that are all down, and those need different next steps — a
115
+ * bare "not found" would send somebody looking for the wrong one.
116
+ */
117
+ async function readFrom(network, pathOf, what, options) {
118
+ const hosts = hostsFor(network, options);
119
+ const tried = [];
120
+ for (const host of hosts) {
121
+ const url = pathOf(host.replace(/\/$/, ""));
122
+ const timer = AbortSignal.timeout(options.timeoutMs ?? READ_TIMEOUT_MS);
123
+ const signal = options.signal === undefined ? timer : AbortSignal.any([timer, options.signal]);
124
+ try {
125
+ const response = await fetch(url, { signal, redirect: "follow" });
126
+ if (!response.ok) {
127
+ tried.push(`${host} → ${response.status}`);
128
+ continue;
129
+ }
130
+ return new Uint8Array(await response.arrayBuffer());
131
+ }
132
+ catch (error) {
133
+ // ⛔ The reason is kept, not flattened to "failed". A timeout and a refused connection mean
134
+ // different things, and the last host's reason is what the person reads.
135
+ tried.push(`${host} → ${error instanceof Error ? error.message : "no answer"}`);
136
+ }
137
+ }
138
+ throw new NmtsError(`${what} could not be read from the ${network} storage network.`, {
139
+ exitCode: 5,
140
+ nextStep: `Tried: ${tried.join(" · ")}. If every host answered 404, either the bytes are gone or ` +
141
+ `this is the wrong network — the same identifier does not exist on both.`,
142
+ });
143
+ }
144
+ /** Whole-blob read: `GET {aggregator}/v1/blobs/{blobId}`. */
145
+ export function readBlob(network, blobId, options = {}) {
146
+ return readFrom(network, (host) => `${host}/v1/blobs/${encodeURIComponent(blobId)}`, `Blob ${blobId}`, options);
147
+ }
148
+ /**
149
+ * Quilt-patch read: `GET {aggregator}/v1/blobs/by-quilt-patch-id/{patchId}`.
150
+ *
151
+ * A quilt is one stored blob holding many small files; the patch id addresses one of them. Files
152
+ * under 64 MiB share a quilt, so this is the common path rather than the exotic one.
153
+ */
154
+ export function readQuiltPatch(network, patchId, options = {}) {
155
+ return readFrom(network, (host) => `${host}/v1/blobs/by-quilt-patch-id/${encodeURIComponent(patchId)}`, `Quilt patch ${patchId}`, options);
156
+ }