@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
@@ -51,12 +51,58 @@ export interface AccountSettings {
51
51
  * = never agreed: raising the tip above 0 asks for that agreement once, and later changes do not.
52
52
  */
53
53
  tipConsentAt?: number;
54
+ /**
55
+ * WHICH WALLET PAYS — the index the NMTS key derives it at (NCF-3 §1.3, `walletSeed(N)`). Absent
56
+ * = 0, the wallet every account has had since the beginning.
57
+ *
58
+ * ⛔ HERE, IN THE SEALED LIST, AND NOT BESIDE THE derived/imported SWITCH. That switch is about
59
+ * this DEVICE (which key this browser opens), so it lives in device storage; the number is about
60
+ * the ACCOUNT — it decides which address the storage is paid from, and a phone and a laptop that
61
+ * disagreed about it would spend from two different balances for one person.
62
+ *
63
+ * Whole, 0 to just under 2^31: the format derives a wallet at every index and this is the range
64
+ * an index is written in.
65
+ */
66
+ activeWallet?: number;
67
+ /**
68
+ * HOW MANY WALLETS THIS ACCOUNT HAS MADE — the list the wallets screen draws, 1 to 1000. Absent
69
+ * = 1, the one wallet an account starts with.
70
+ *
71
+ * ⚠ IT IS A COUNT, NOT A SET. Numbers come from the key, so a wallet cannot be deleted and the
72
+ * list is always 0…count-1; "making the next wallet" is this number going up by one, and a scan
73
+ * that finds a funded wallet further out pulls it up to that number + 1.
74
+ */
75
+ walletCount?: number;
54
76
  }
55
77
  /** The sanity bounds a stored text scale must sit in to be USED. One place; codec and UI agree. */
56
78
  export declare const TEXT_SCALE_MIN_PCT = 80;
57
79
  export declare const TEXT_SCALE_MAX_PCT = 160;
58
80
  /** Follow the device. Not written to the wire — absence is the only spelling of it. */
59
81
  export declare const TEXT_SCALE_DEFAULT_PCT = 100;
82
+ /**
83
+ * The index range a wallet number is written in: whole, 0 to just under 2^31.
84
+ *
85
+ * ⛔ THE CEILING IS THE WIRE'S, NOT THE FORMAT'S. `walletSeed(N)` is defined for every N the
86
+ * engine can be handed; what is bounded here is what this build will WRITE and read back, so a
87
+ * number some other build miswrote cannot come back as something no screen can draw.
88
+ */
89
+ export declare const WALLET_INDEX_LIMIT: number;
90
+ /** The wallet an account pays from when nobody chose. Not written to the wire — absence spells it. */
91
+ export declare const ACTIVE_WALLET_DEFAULT = 0;
92
+ /** The most wallets one account's list holds. */
93
+ export declare const WALLET_COUNT_MAX = 1000;
94
+ /** What an account's list holds before anybody made a second one. Absence spells it. */
95
+ export declare const WALLET_COUNT_DEFAULT = 1;
96
+ /** Which wallet this account pays from. Absence is wallet 0, never "unknown". */
97
+ export declare function activeWalletOf(settings: AccountSettings | null | undefined): number;
98
+ /**
99
+ * How many wallets this account's list holds.
100
+ *
101
+ * ⛔ THE INVARIANT LIVES HERE: the paying wallet is always IN the list. A stored count that does
102
+ * not reach the active number is raised to hold it — never the other way round, because lowering
103
+ * it would hide a wallet somebody is paying from, and a wallet cannot be deleted anyway.
104
+ */
105
+ export declare function walletCountOf(settings: AccountSettings | null | undefined): number;
60
106
  export interface WireSettings {
61
107
  /** developerMode. */
62
108
  dm?: 1;
@@ -84,6 +130,15 @@ export interface WireSettings {
84
130
  tp?: number;
85
131
  /** tipConsentAt. */
86
132
  tc?: number;
133
+ /** activeWallet, present only when it is not wallet 0. */
134
+ aw?: number;
135
+ /**
136
+ * walletCount, present only when the account has made more than one.
137
+ *
138
+ * ⚠ A count above the ceiling is DROPPED on both sides, and nothing is lost by that: the read
139
+ * below raises the count to hold `aw` again, so the paying wallet stays in the list either way.
140
+ */
141
+ wc?: number;
87
142
  }
88
143
  /** The most a standing tip can be: the whole payment. Above the dial's 10 % it is typed and confirmed. */
89
144
  export declare const TIP_TENTHS_MAX = 1000;
@@ -107,7 +162,3 @@ export declare function settingsToWire(s: AccountSettings | undefined): WireSett
107
162
  * to the device's own size, which is always readable.
108
163
  */
109
164
  export declare function settingsFromWire(w: unknown): AccountSettings | undefined;
110
- /** Folds a deposit patch into a settings copy: out-of-range is clamped, the full deposit clears. */
111
- export declare function applyDepositPatch(next: AccountSettings, depositDefault?: number): void;
112
- /** Folds a tip patch into a settings copy: 0 clears, above the cap is capped, fractions are rounded. */
113
- export declare function applyTipPatch(next: AccountSettings, tipTenths?: number, tipConsentAt?: number): void;
@@ -14,6 +14,48 @@ export const TEXT_SCALE_MIN_PCT = 80;
14
14
  export const TEXT_SCALE_MAX_PCT = 160;
15
15
  /** Follow the device. Not written to the wire — absence is the only spelling of it. */
16
16
  export const TEXT_SCALE_DEFAULT_PCT = 100;
17
+ /**
18
+ * The index range a wallet number is written in: whole, 0 to just under 2^31.
19
+ *
20
+ * ⛔ THE CEILING IS THE WIRE'S, NOT THE FORMAT'S. `walletSeed(N)` is defined for every N the
21
+ * engine can be handed; what is bounded here is what this build will WRITE and read back, so a
22
+ * number some other build miswrote cannot come back as something no screen can draw.
23
+ */
24
+ export const WALLET_INDEX_LIMIT = 2 ** 31;
25
+ /** The wallet an account pays from when nobody chose. Not written to the wire — absence spells it. */
26
+ export const ACTIVE_WALLET_DEFAULT = 0;
27
+ /** The most wallets one account's list holds. */
28
+ export const WALLET_COUNT_MAX = 1000;
29
+ /** What an account's list holds before anybody made a second one. Absence spells it. */
30
+ export const WALLET_COUNT_DEFAULT = 1;
31
+ /** Is this a wallet index this build writes and reads? */
32
+ function usableWalletIndex(value) {
33
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value < WALLET_INDEX_LIMIT;
34
+ }
35
+ /** Is this a wallet count this build writes and reads? */
36
+ function usableWalletCount(value) {
37
+ return (typeof value === "number" &&
38
+ Number.isInteger(value) &&
39
+ value >= WALLET_COUNT_DEFAULT &&
40
+ value <= WALLET_COUNT_MAX);
41
+ }
42
+ /** Which wallet this account pays from. Absence is wallet 0, never "unknown". */
43
+ export function activeWalletOf(settings) {
44
+ const stored = settings?.activeWallet;
45
+ return usableWalletIndex(stored) ? stored : ACTIVE_WALLET_DEFAULT;
46
+ }
47
+ /**
48
+ * How many wallets this account's list holds.
49
+ *
50
+ * ⛔ THE INVARIANT LIVES HERE: the paying wallet is always IN the list. A stored count that does
51
+ * not reach the active number is raised to hold it — never the other way round, because lowering
52
+ * it would hide a wallet somebody is paying from, and a wallet cannot be deleted anyway.
53
+ */
54
+ export function walletCountOf(settings) {
55
+ const stored = settings?.walletCount;
56
+ const held = usableWalletCount(stored) ? stored : WALLET_COUNT_DEFAULT;
57
+ return Math.max(held, activeWalletOf(settings) + 1);
58
+ }
17
59
  /** The most a standing tip can be: the whole payment. Above the dial's 10 % it is typed and confirmed. */
18
60
  export const TIP_TENTHS_MAX = 1000;
19
61
  /**
@@ -59,12 +101,18 @@ export function settingsToWire(s) {
59
101
  if (typeof s.tipConsentAt === "number" && Number.isFinite(s.tipConsentAt) && s.tipConsentAt > 0) {
60
102
  w.tc = Math.round(s.tipConsentAt);
61
103
  }
104
+ if (usableWalletIndex(s.activeWallet) && s.activeWallet !== ACTIVE_WALLET_DEFAULT)
105
+ w.aw = s.activeWallet;
106
+ if (usableWalletCount(s.walletCount) && s.walletCount !== WALLET_COUNT_DEFAULT)
107
+ w.wc = s.walletCount;
62
108
  return w.dm !== undefined ||
63
109
  w.tx !== undefined ||
64
110
  w.pd !== undefined ||
65
111
  w.dd !== undefined ||
66
112
  w.tp !== undefined ||
67
- w.tc !== undefined
113
+ w.tc !== undefined ||
114
+ w.aw !== undefined ||
115
+ w.wc !== undefined
68
116
  ? w
69
117
  : null;
70
118
  }
@@ -84,6 +132,8 @@ export function settingsFromWire(w) {
84
132
  const dd = Reflect.get(w, "dd");
85
133
  const tp = Reflect.get(w, "tp");
86
134
  const tc = Reflect.get(w, "tc");
135
+ const aw = Reflect.get(w, "aw");
136
+ const wc = Reflect.get(w, "wc");
87
137
  const s = {};
88
138
  if (dm === 1)
89
139
  s.developerMode = true;
@@ -109,38 +159,26 @@ export function settingsFromWire(w) {
109
159
  s.tipTenths = tp;
110
160
  if (typeof tc === "number" && Number.isFinite(tc) && tc > 0)
111
161
  s.tipConsentAt = Math.round(tc);
162
+ // A wallet number outside the range is DROPPED, not clamped: paying from a wallet some other
163
+ // build miswrote would spend from an address this person has never seen, and wallet 0 is the one
164
+ // every account already has.
165
+ if (usableWalletIndex(aw) && aw !== ACTIVE_WALLET_DEFAULT)
166
+ s.activeWallet = aw;
167
+ if (usableWalletCount(wc) && wc !== WALLET_COUNT_DEFAULT)
168
+ s.walletCount = wc;
169
+ // ⛔ AND THE INVARIANT IS RESTORED HERE, UPWARDS ONLY (`walletCountOf`). A list whose count does
170
+ // not reach the paying wallet would draw a screen the paying wallet is missing from.
171
+ const counted = walletCountOf(s);
172
+ if (counted !== WALLET_COUNT_DEFAULT)
173
+ s.walletCount = counted;
112
174
  return s.developerMode !== undefined ||
113
175
  s.textScalePct !== undefined ||
114
176
  s.paddingMode !== undefined ||
115
177
  s.depositDefault !== undefined ||
116
178
  s.tipTenths !== undefined ||
117
- s.tipConsentAt !== undefined
179
+ s.tipConsentAt !== undefined ||
180
+ s.activeWallet !== undefined ||
181
+ s.walletCount !== undefined
118
182
  ? s
119
183
  : undefined;
120
184
  }
121
- /** Folds a deposit patch into a settings copy: out-of-range is clamped, the full deposit clears. */
122
- export function applyDepositPatch(next, depositDefault) {
123
- if (depositDefault === undefined || !Number.isFinite(depositDefault))
124
- return;
125
- const credits = Math.round(Math.min(DEPOSIT_MAX_CREDITS, Math.max(0, depositDefault)));
126
- if (credits === DEPOSIT_DEFAULT_CREDITS)
127
- delete next.depositDefault;
128
- else
129
- next.depositDefault = credits;
130
- }
131
- /** Folds a tip patch into a settings copy: 0 clears, above the cap is capped, fractions are rounded. */
132
- export function applyTipPatch(next, tipTenths, tipConsentAt) {
133
- if (tipTenths !== undefined && Number.isFinite(tipTenths)) {
134
- const t = Math.round(Math.min(TIP_TENTHS_MAX, Math.max(0, tipTenths)));
135
- if (t === 0)
136
- delete next.tipTenths;
137
- else
138
- next.tipTenths = t;
139
- }
140
- if (tipConsentAt !== undefined && Number.isFinite(tipConsentAt)) {
141
- if (tipConsentAt <= 0)
142
- delete next.tipConsentAt;
143
- else
144
- next.tipConsentAt = Math.round(tipConsentAt);
145
- }
146
- }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * How many unused wallets in a row end the walk.
3
+ *
4
+ * ⛔ TWENTY, THE SAME NUMBER EVERY OTHER WALLET USES. Somebody who has funded a wallet in another
5
+ * tool and comes looking for it here has been taught what a scan finds by that tool; a different
6
+ * number here would make the same key look like it holds different wallets in two programs.
7
+ */
8
+ export declare const WALLET_SCAN_GAP = 20;
9
+ /** What a walk found: the wallets that are in use, lowest first, and how many were asked about. */
10
+ export interface WalletScan {
11
+ /** Every index the probe said was in use, ascending. */
12
+ readonly used: readonly number[];
13
+ /** How many times the probe was called — what the walk cost, in questions. */
14
+ readonly scanned: number;
15
+ }
16
+ /**
17
+ * Is this wallet IN USE? Answered by the caller, because what counts as "in use" is a question
18
+ * about a chain and this file has none: a balance in either coin, or any transaction the address
19
+ * has ever been in.
20
+ *
21
+ * ⚠ A PROBE THAT FAILS SHOULD THROW, not answer `false`. "Nobody could ask" and "nothing is there"
22
+ * are opposite facts, and answering the second for the first would quietly end the walk early and
23
+ * report a wallet with money in it as one that does not exist.
24
+ */
25
+ export type WalletProbe = (index: number) => Promise<boolean>;
26
+ /**
27
+ * Walk the wallets of one NMTS key from 0 upwards, and stop when the walk has learned enough.
28
+ *
29
+ * TWO RULES, AND THEY ARE DIFFERENT RULES:
30
+ * · every wallet the account has already MADE is asked about, whatever the answers are. Those
31
+ * wallets are on the person's screen; a walk that skipped one because its neighbour was empty
32
+ * would be reporting about a wallet it never asked about.
33
+ * · past those, the walk keeps going as long as it keeps finding wallets in use, and ends after
34
+ * `gap` unused ones in a row.
35
+ *
36
+ * ⚠ THE RUN CARRIES OVER from the made wallets into the walk past them. A list of thirty wallets
37
+ * nobody ever funded is thirty unused answers, and starting the count again at the end of it
38
+ * would ask for twenty more that nobody has any reason to expect anything from.
39
+ */
40
+ export declare function discoverWallets(probe: WalletProbe, { count, gap }: {
41
+ count: number;
42
+ gap?: number;
43
+ }): Promise<WalletScan>;
44
+ /**
45
+ * How far a walk can reach as it stands — the number a progress line counts towards.
46
+ *
47
+ * ⛔ IT MOVES, because the walk's end moves: finding a wallet in use buys another `gap` questions
48
+ * past it. A progress line drawn against the number the walk STARTED with would sit at "20/20"
49
+ * while the walk kept going, which reads as a screen that has stopped answering.
50
+ */
51
+ export declare function walletScanReach({ count, gap }: {
52
+ count: number;
53
+ gap?: number;
54
+ }, highestUsed: number | null): number;
@@ -0,0 +1,66 @@
1
+ // Finding the wallets an NMTS key has already used — the scan every HD wallet does, and where it
2
+ // stops. ⚠ PUBLISHED — copied byte-for-byte into the `nmts` command-line package; keep comments
3
+ // self-contained English.
4
+ //
5
+ // ⛔ PURE, AND THAT IS THE WHOLE POINT. One NMTS key derives a wallet at every index (NCF-3 §1.3),
6
+ // so "which of them exist" is not a question anybody can answer by looking: nothing is
7
+ // registered anywhere, and an unused wallet and a wallet nobody has funded yet are the same
8
+ // thing. The answer is a WALK — ask about one number, then the next — and the only real decision
9
+ // in it is when to stop. That decision is arithmetic, so it is written here, once, with no
10
+ // chain, no fetch and no clock in it; the browser and the `nmts` command each bring their own
11
+ // `probe` and get the same answer out.
12
+ //
13
+ // ⛔ WHY IT STOPS AT ALL. The index space is unbounded, so a walk that asked for proof of absence
14
+ // would never end. Stopping after a run of unused wallets is what every HD wallet does (the
15
+ // convention BIP-44 §Address gap limit wrote down), and the cost of the rule is stated rather
16
+ // than hidden: a wallet funded further out than the gap is NOT found by a scan. It is not lost —
17
+ // numbers come from the key, so asking for that number directly still opens it.
18
+ /**
19
+ * How many unused wallets in a row end the walk.
20
+ *
21
+ * ⛔ TWENTY, THE SAME NUMBER EVERY OTHER WALLET USES. Somebody who has funded a wallet in another
22
+ * tool and comes looking for it here has been taught what a scan finds by that tool; a different
23
+ * number here would make the same key look like it holds different wallets in two programs.
24
+ */
25
+ export const WALLET_SCAN_GAP = 20;
26
+ /**
27
+ * Walk the wallets of one NMTS key from 0 upwards, and stop when the walk has learned enough.
28
+ *
29
+ * TWO RULES, AND THEY ARE DIFFERENT RULES:
30
+ * · every wallet the account has already MADE is asked about, whatever the answers are. Those
31
+ * wallets are on the person's screen; a walk that skipped one because its neighbour was empty
32
+ * would be reporting about a wallet it never asked about.
33
+ * · past those, the walk keeps going as long as it keeps finding wallets in use, and ends after
34
+ * `gap` unused ones in a row.
35
+ *
36
+ * ⚠ THE RUN CARRIES OVER from the made wallets into the walk past them. A list of thirty wallets
37
+ * nobody ever funded is thirty unused answers, and starting the count again at the end of it
38
+ * would ask for twenty more that nobody has any reason to expect anything from.
39
+ */
40
+ export async function discoverWallets(probe, { count, gap = WALLET_SCAN_GAP }) {
41
+ const used = [];
42
+ let scanned = 0;
43
+ let unusedInARow = 0;
44
+ for (let index = 0; index < count || unusedInARow < gap; index += 1) {
45
+ const inUse = await probe(index);
46
+ scanned += 1;
47
+ if (inUse) {
48
+ used.push(index);
49
+ unusedInARow = 0;
50
+ }
51
+ else {
52
+ unusedInARow += 1;
53
+ }
54
+ }
55
+ return { used, scanned };
56
+ }
57
+ /**
58
+ * How far a walk can reach as it stands — the number a progress line counts towards.
59
+ *
60
+ * ⛔ IT MOVES, because the walk's end moves: finding a wallet in use buys another `gap` questions
61
+ * past it. A progress line drawn against the number the walk STARTED with would sit at "20/20"
62
+ * while the walk kept going, which reads as a screen that has stopped answering.
63
+ */
64
+ export function walletScanReach({ count, gap = WALLET_SCAN_GAP }, highestUsed) {
65
+ return Math.max(count, highestUsed === null ? 0 : highestUsed + 1) + gap;
66
+ }
@@ -15,6 +15,9 @@ export interface StandingTipInput {
15
15
  } | undefined;
16
16
  /** What was just paid for storage, in WAL base units. */
17
17
  paidWalFrost: bigint;
18
+ /** ⛔ The wallet the storage was just paid from — a gift from a different one would come out of
19
+ * a balance nobody was looking at, and the payment above named this one. */
20
+ wallet: number;
18
21
  say: (line: string) => void;
19
22
  /** Seams for tests. */
20
23
  readDonation?: (server: string) => Promise<DonationConfig>;
@@ -37,6 +37,7 @@ export async function standingTipAfter(input) {
37
37
  const digest = await sign({
38
38
  network: input.network,
39
39
  code: input.code,
40
+ wallet: input.wallet,
40
41
  shape: { coin: "WAL", amountBaseUnits: amount, destination: config.devAddress, walType: walCoinType(input.network) },
41
42
  });
42
43
  input.say(` Your standing ${percentText(tenths)} % gift — ${coinAmount(amount)} WAL — went to the developer. Transaction ${digest}`);
@@ -0,0 +1,3 @@
1
+ import { type PlaintextSource } from "./upload-file.ts";
2
+ /** Read a file off the disk, a chunk at a time. */
3
+ export declare function fileSource(path: string, size: number): PlaintextSource;
@@ -0,0 +1,38 @@
1
+ // A file on THIS MACHINE, read a chunk at a time, as a plaintext source.
2
+ //
3
+ // ⛔ THE SEAM IS `upload-file.ts`'S AND THIS IS THE ONE IMPLEMENTATION THAT NEEDS A DISK. A caller
4
+ // in a browser hands in a `Blob` instead (the SDK's `blobSource`), and everything downstream —
5
+ // the part plan, the sealing, the reservation records — is the same code for both.
6
+ import { open } from "node:fs/promises";
7
+ import { NmtsError } from "./errors.js";
8
+ import { READ_CHUNK_BYTES } from "./upload-file.js";
9
+ /** Read a file off the disk, a chunk at a time. */
10
+ export function fileSource(path, size) {
11
+ return {
12
+ size,
13
+ async *read(offset, length) {
14
+ const handle = await open(path, "r");
15
+ try {
16
+ const buffer = new Uint8Array(Math.min(READ_CHUNK_BYTES, length));
17
+ let at = 0;
18
+ while (at < length) {
19
+ const want = Math.min(buffer.length, length - at);
20
+ const { bytesRead } = await handle.read(buffer, 0, want, offset + at);
21
+ if (bytesRead === 0) {
22
+ // ⛔ SHORT IS NOT DONE. The plan was made from the size this file had when it was
23
+ // measured; a read that ends early means it shrank underneath us, and sealing what
24
+ // arrived would declare a length the bytes do not match.
25
+ throw new NmtsError(`${path} ended after ${at} of ${length} bytes.`, {
26
+ nextStep: "Nothing was sent. The file changed while it was being read.",
27
+ });
28
+ }
29
+ at += bytesRead;
30
+ yield new Uint8Array(buffer.subarray(0, bytesRead));
31
+ }
32
+ }
33
+ finally {
34
+ await handle.close();
35
+ }
36
+ },
37
+ };
38
+ }
@@ -2,6 +2,8 @@ import { type CryptoGlue } from "./crypto.ts";
2
2
  import { type PaddingRule } from "./shared/lib/crypto/size-padding.ts";
3
3
  import { entryOf } from "./upload.ts";
4
4
  import type { BlobProtocol, PaidPart, UploadApi, UploadInput, UploadResult, UploadStep } from "./upload-wire.ts";
5
+ /** How much plaintext is handed to the engine at a time. Matches the format's own chunk size. */
6
+ export declare const READ_CHUNK_BYTES: number;
5
7
  /**
6
8
  * Where the plaintext comes from.
7
9
  *
@@ -15,8 +17,6 @@ export interface PlaintextSource {
15
17
  /** Read `[offset, offset + length)`, in pieces small enough to hold. */
16
18
  read(offset: number, length: number): AsyncIterable<Uint8Array>;
17
19
  }
18
- /** Read a file off the disk, a chunk at a time. */
19
- export declare function fileSource(path: string, size: number): PlaintextSource;
20
20
  export interface FileUploadInput {
21
21
  api: UploadApi;
22
22
  protocol: BlobProtocol;
@@ -15,8 +15,8 @@
15
15
  // the reservation key, and the SHA-256 the account will check the contents against — because
16
16
  // both are needed BEFORE the first part is sealed. The second pass is the sealing itself. The
17
17
  // alternative is holding the file, which is the thing this module exists to avoid.
18
- import { createHash } from "node:crypto";
19
- import { open } from "node:fs/promises";
18
+ import { sha256 } from "@noble/hashes/sha2.js";
19
+ import { fromBase64Url } from "./bytes.js";
20
20
  import { AAD } from "./crypto.js";
21
21
  import { NmtsError } from "./errors.js";
22
22
  import { fileSecrets, sealPart } from "./seal.js";
@@ -27,37 +27,7 @@ import { buyAndPushPart, entryOf } from "./upload.js";
27
27
  import { commitItem } from "./upload-steps.js";
28
28
  import { finishReservationKey, partKey, readItemRecord, readReservationBytes, readReservationRecord, startReservationKey, } from "./upload-store.js";
29
29
  /** How much plaintext is handed to the engine at a time. Matches the format's own chunk size. */
30
- const READ_CHUNK_BYTES = 4 * 2 ** 20;
31
- /** Read a file off the disk, a chunk at a time. */
32
- export function fileSource(path, size) {
33
- return {
34
- size,
35
- async *read(offset, length) {
36
- const handle = await open(path, "r");
37
- try {
38
- const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, length));
39
- let at = 0;
40
- while (at < length) {
41
- const want = Math.min(buffer.length, length - at);
42
- const { bytesRead } = await handle.read(buffer, 0, want, offset + at);
43
- if (bytesRead === 0) {
44
- // ⛔ SHORT IS NOT DONE. The plan was made from the size this file had when it was
45
- // measured; a read that ends early means it shrank underneath us, and sealing what
46
- // arrived would declare a length the bytes do not match.
47
- throw new NmtsError(`${path} ended after ${at} of ${length} bytes.`, {
48
- nextStep: "Nothing was sent. The file changed while it was being read.",
49
- });
50
- }
51
- at += bytesRead;
52
- yield new Uint8Array(buffer.subarray(0, bytesRead));
53
- }
54
- }
55
- finally {
56
- await handle.close();
57
- }
58
- },
59
- };
60
- }
30
+ export const READ_CHUNK_BYTES = 4 * 2 ** 20;
61
31
  /**
62
32
  * Upload one file and return what the caller must write into the account's file list.
63
33
  *
@@ -83,15 +53,15 @@ export async function uploadFile(input) {
83
53
  // `POST /v1/items` exists and is paid for; all that can still be missing is the account's own
84
54
  // list. Asking the server about every part again would be a round trip per part to learn
85
55
  // something the record already says.
86
- const committed = readItemRecord(fileKey);
56
+ const committed = await readItemRecord(fileKey);
87
57
  if (committed?.itemId !== undefined) {
88
- const entry = recordedEntry(fileKey, plan.length, source.size);
58
+ const entry = await recordedEntry(fileKey, plan.length, source.size);
89
59
  if (entry !== null) {
90
60
  return { itemId: committed.itemId, resumed: true, ledgerIds: [], fileKey, parts: plan.length, entry };
91
61
  }
92
62
  }
93
63
  // ── the file's secrets: from the record if one exists, otherwise made now ──
94
- const secrets = openSecrets(input, fileKey, plan.length, contentDigest);
64
+ const secrets = await openSecrets(input, fileKey, plan.length, contentDigest);
95
65
  try {
96
66
  const entry = {
97
67
  name: input.name,
@@ -103,7 +73,7 @@ export async function uploadFile(input) {
103
73
  const paid = [];
104
74
  for (const range of plan) {
105
75
  const key = partKey(fileKey, range.partIndex);
106
- const stored = readReservationRecord(key);
76
+ const stored = await readReservationRecord(key);
107
77
  // ⛔ A PART THAT IS WRITTEN DOWN IS NEVER SEALED AGAIN. Its bytes are a particular sealing
108
78
  // the treasury may already have paid to register; a fresh one is a different blob.
109
79
  // ⛔ THE LAST PART ONLY. Every reader recovers the parts' real lengths from the file's size
@@ -119,7 +89,7 @@ export async function uploadFile(input) {
119
89
  : range.length;
120
90
  const sealed = stored === null
121
91
  ? await sealPartOf(input, secrets.dek, range, plan.length, sealFrom)
122
- : readReservationBytes(key);
92
+ : await readReservationBytes(key);
123
93
  paid.push(await (input.buy ?? buyAndPushPart)({
124
94
  api: input.api,
125
95
  protocol: input.protocol,
@@ -162,12 +132,12 @@ export function partKeysOf(fileKey, parts) {
162
132
  }
163
133
  /** Read the whole file once, feeding the key hash and returning the content digest. */
164
134
  async function hashWhole(source, keyHash) {
165
- const content = createHash("sha256");
135
+ const content = sha256.create();
166
136
  for await (const chunk of source.read(0, source.size)) {
167
137
  keyHash.update(chunk);
168
138
  content.update(chunk);
169
139
  }
170
- return new Uint8Array(content.digest());
140
+ return content.digest();
171
141
  }
172
142
  /**
173
143
  * The file's key and sealed content hash — from a written-down part if there is one.
@@ -176,12 +146,12 @@ async function hashWhole(source, keyHash) {
176
146
  * the bytes already on the network cannot be re-sealed. Unwrapping the recorded one is what
177
147
  * makes the parts still to come belong to the same file.
178
148
  */
179
- function openSecrets(input, fileKey, parts, contentDigest) {
149
+ async function openSecrets(input, fileKey, parts, contentDigest) {
180
150
  for (const key of partKeysOf(fileKey, parts)) {
181
- const record = readReservationRecord(key);
151
+ const record = await readReservationRecord(key);
182
152
  if (record === null)
183
153
  continue;
184
- const wrapped = new Uint8Array(Buffer.from(record.dekWrapped, "base64url"));
154
+ const wrapped = fromBase64Url(record.dekWrapped);
185
155
  const dek = input.crypt.envelope_open(input.dataKey, new TextEncoder().encode(AAD.dekWrap), wrapped);
186
156
  return { dek, dekWrapped: record.dekWrapped, contentHashCt: record.contentHashCt };
187
157
  }
@@ -226,9 +196,9 @@ export { entryOf };
226
196
  * sealed with. A run that wrote its own freshly generated one into the list would produce a
227
197
  * file that is paid for, present, correctly named and impossible to open.
228
198
  */
229
- function recordedEntry(fileKey, parts, size) {
199
+ async function recordedEntry(fileKey, parts, size) {
230
200
  for (const key of partKeysOf(fileKey, parts)) {
231
- const record = readReservationRecord(key);
201
+ const record = await readReservationRecord(key);
232
202
  if (record === null)
233
203
  continue;
234
204
  return {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * How big the local file is, with the two refusals that are worth their own words.
3
+ *
4
+ * ⛔ IT IS NOT READ HERE. A file large enough to need several parts is a file too large to hold,
5
+ * and the size is all that is needed to plan the upload and quote its price. The bytes are read
6
+ * later, a slice at a time, by the part that is being sealed.
7
+ */
8
+ export declare function measureLocal(path: string): number;
@@ -0,0 +1,37 @@
1
+ // How big a file on THIS MACHINE is — the one thing the upload price needs that a browser has no
2
+ // equivalent of.
3
+ //
4
+ // ⛔ THE ARITHMETIC IS NOT HERE. `upload-price.ts` turns a size into parts and a price and runs
5
+ // anywhere; this is the one call that asks a disk, and it is separate so that the price is
6
+ // reachable from a page where bytes arrive as a `Blob` and their length is already known.
7
+ import { statSync } from "node:fs";
8
+ import { NmtsError } from "./errors.js";
9
+ /**
10
+ * How big the local file is, with the two refusals that are worth their own words.
11
+ *
12
+ * ⛔ IT IS NOT READ HERE. A file large enough to need several parts is a file too large to hold,
13
+ * and the size is all that is needed to plan the upload and quote its price. The bytes are read
14
+ * later, a slice at a time, by the part that is being sealed.
15
+ */
16
+ export function measureLocal(path) {
17
+ let stat;
18
+ try {
19
+ stat = statSync(path);
20
+ }
21
+ catch {
22
+ throw new NmtsError(`There is no file at ${path}.`, { exitCode: 4 });
23
+ }
24
+ if (stat.isDirectory()) {
25
+ throw new NmtsError(`${path} is a folder.`, {
26
+ exitCode: 4,
27
+ nextStep: "This version uploads one file at a time.",
28
+ });
29
+ }
30
+ if (stat.size === 0) {
31
+ throw new NmtsError(`${path} is empty.`, {
32
+ exitCode: 4,
33
+ nextStep: "The storage network has nothing to store and would refuse the reservation.",
34
+ });
35
+ }
36
+ return stat.size;
37
+ }
@@ -18,14 +18,6 @@ export declare const CREDIT_BYTES: number;
18
18
  * shows afterwards. The server is still the authority; this is a quote, not a promise.
19
19
  */
20
20
  export declare function creditsFor(sealedBytes: number, epochs?: number): number;
21
- /**
22
- * How big the local file is, with the two refusals that are worth their own words.
23
- *
24
- * ⛔ IT IS NOT READ HERE. A file large enough to need several parts is a file too large to hold,
25
- * and the size is all that is needed to plan the upload and quote its price. The bytes are read
26
- * later, a slice at a time, by the part that is being sealed.
27
- */
28
- export declare function measureLocal(path: string): number;
29
21
  /**
30
22
  * How much of the file goes into one part.
31
23
  *
@@ -1,10 +1,10 @@
1
1
  // What an upload will cost, and how the file is cut up to pay for it.
2
2
  //
3
3
  // ⛔ SPLIT OUT OF `put.ts` SO THAT FILE STAYS READABLE IN ONE SITTING. Everything here is
4
- // arithmetic over numbers — no network, no crypto, no disk beyond one `stat` — which is what
5
- // lets `--dry-run` answer without reading a very large file, and what lets a test drive the
6
- // price without an account.
7
- import { statSync } from "node:fs";
4
+ // arithmetic over numbers — no network, no crypto and no disk at all — which is what lets
5
+ // `--dry-run` answer without reading a very large file, and what lets a test drive the price
6
+ // without an account. Measuring a file on this machine is Node's, and is in
7
+ // `upload-price-node.ts`.
8
8
  import { NmtsError } from "./errors.js";
9
9
  import { DEFAULT_PART_BYTES, NCF3_SHAPE, sealedLenFor } from "./seal.js";
10
10
  import { paddedPlaintextLen } from "./shared/lib/crypto/size-padding.js";
@@ -30,35 +30,6 @@ export function creditsFor(sealedBytes, epochs = UPLOAD_EPOCHS) {
30
30
  const mib = Math.ceil(sealedBytes / CREDIT_BYTES);
31
31
  return Math.ceil((mib * epochs) / UPLOAD_EPOCHS);
32
32
  }
33
- /**
34
- * How big the local file is, with the two refusals that are worth their own words.
35
- *
36
- * ⛔ IT IS NOT READ HERE. A file large enough to need several parts is a file too large to hold,
37
- * and the size is all that is needed to plan the upload and quote its price. The bytes are read
38
- * later, a slice at a time, by the part that is being sealed.
39
- */
40
- export function measureLocal(path) {
41
- let stat;
42
- try {
43
- stat = statSync(path);
44
- }
45
- catch {
46
- throw new NmtsError(`There is no file at ${path}.`, { exitCode: 4 });
47
- }
48
- if (stat.isDirectory()) {
49
- throw new NmtsError(`${path} is a folder.`, {
50
- exitCode: 4,
51
- nextStep: "This version uploads one file at a time.",
52
- });
53
- }
54
- if (stat.size === 0) {
55
- throw new NmtsError(`${path} is empty.`, {
56
- exitCode: 4,
57
- nextStep: "The storage network has nothing to store and would refuse the reservation.",
58
- });
59
- }
60
- return stat.size;
61
- }
62
33
  /**
63
34
  * How much of the file goes into one part.
64
35
  *