@needmoretruth/nmts-cli 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/commands/extend.d.ts +3 -3
- package/dist/commands/extend.js +52 -161
- package/dist/commands/wallet-storage-ops.d.ts +2 -2
- package/dist/commands/wallet-storage-ops.js +108 -134
- package/dist/commands/wallet-storage.d.ts +3 -8
- package/dist/commands/wallet-storage.js +16 -37
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/storage-control/extend.d.ts +124 -0
- package/dist/storage-control/extend.js +189 -0
- package/dist/storage-control/hints.d.ts +20 -0
- package/dist/storage-control/hints.js +11 -0
- package/dist/storage-control/list.d.ts +55 -0
- package/dist/storage-control/list.js +71 -0
- package/dist/storage-control/reshape.d.ts +120 -0
- package/dist/storage-control/reshape.js +188 -0
- package/dist/storage-control-chain.d.ts +1 -1
- package/dist/storage-control.d.ts +13 -0
- package/dist/storage-control.js +24 -0
- package/dist/wallet-storage-chain.d.ts +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Buying more storage time for one file that is already stored — worked out, signed and handed
|
|
2
|
+
// back rather than printed.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ ONE IMPLEMENTATION, TWO CALLERS. `nmts extend` is a terminal over this: the sentences a person
|
|
5
|
+
// reads, the agreement this machine keeps, the exit code. The SDK is somebody else's program and
|
|
6
|
+
// needs the same act with none of those. What must not be written twice is the ORDER below,
|
|
7
|
+
// because every step of it is about money that does not come back.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT PRICES BEFORE IT SPENDS, ALWAYS. `planExtension` reads, judges and quotes; it holds no key
|
|
10
|
+
// and signs nothing, so a caller that only wants a price never comes near `applyExtension`.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ THE SERVER DOES NOT EXTEND ANYTHING. `POST /v1/items/{id}/extended` means "record an extension
|
|
13
|
+
// the device already signed": the storage is bought by the time it is called, so a failure there
|
|
14
|
+
// is a failure to WRITE THE DATE DOWN and comes back as itself. Reporting it as a failure to
|
|
15
|
+
// extend would invite a second run, and a second run pays again.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ AND THE CHAIN IS THE AUTHORITY ON WHEN A LEASE ENDS, not the server's `expiry_epoch`. The
|
|
18
|
+
// server's answer is used for one thing: knowing WHICH blobs to ask the chain about.
|
|
19
|
+
//
|
|
20
|
+
// ⚠ Nothing here reaches for `node:`, and the chain and the signer are loaded only when no seam was
|
|
21
|
+
// supplied.
|
|
22
|
+
import { request } from "../api.js";
|
|
23
|
+
import { buildIndex, entryAt, fullPathOf, KIND_FILE, normalisePath } from "../drive-paths.js";
|
|
24
|
+
import { NmtsError } from "../errors.js";
|
|
25
|
+
import { budgetFacts, readBudget } from "../extend-budget.js";
|
|
26
|
+
import { daysLeftUntilEpoch, stageOf } from "../expiry.js";
|
|
27
|
+
import { asExtendPreview, chooseEpochs, headroom, soonestEnd, } from "../extend-plan.js";
|
|
28
|
+
import { isRecord } from "../guards.js";
|
|
29
|
+
import { readFileList } from "../manifest.js";
|
|
30
|
+
import { activeWalletOf } from "../shared/lib/drive/manifest-settings.js";
|
|
31
|
+
import { coinAmount, walletAddress } from "../wallet.js";
|
|
32
|
+
/**
|
|
33
|
+
* Work out what extending this file would buy and what it would cost, without signing anything.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ NO KEY IS DERIVED FOR SIGNING AND NO SIGNER IS LOADED. Everything here is a read, which is
|
|
36
|
+
* what lets a price be asked for without an agreement being asked for first.
|
|
37
|
+
*/
|
|
38
|
+
export async function planExtension(input, target, seams) {
|
|
39
|
+
const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
|
|
40
|
+
if (list.manifest === null) {
|
|
41
|
+
throw new NmtsError("This account has no file list, so there is nothing to extend.", { exitCode: 4 });
|
|
42
|
+
}
|
|
43
|
+
const entries = list.manifest.entries;
|
|
44
|
+
const settings = list.manifest.settings;
|
|
45
|
+
const entry = entryAt(entries, normalisePath(target), {
|
|
46
|
+
nothingHappened: "Nothing was signed and nothing was charged.",
|
|
47
|
+
});
|
|
48
|
+
if (entry.kind !== KIND_FILE) {
|
|
49
|
+
throw new NmtsError(`No file at "${fullPathOf(buildIndex(entries), entry)}".`, {
|
|
50
|
+
exitCode: 4,
|
|
51
|
+
nextStep: "That is a folder. Nothing was signed and nothing was charged — storage is bought per " +
|
|
52
|
+
"file, so this takes one file at a time.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const path = fullPathOf(buildIndex(entries), entry);
|
|
56
|
+
// ⛔ THE SERVER SAYS WHICH BLOBS, AND NOTHING ELSE. Its `expiry_epoch` is client-reported and
|
|
57
|
+
// advisory; anything that spends money reads the chain's own answer below.
|
|
58
|
+
const preview = asExtendPreview(await request(input.server, `/v1/items/${encodeURIComponent(entry.id)}/extend-preview`, {
|
|
59
|
+
token: input.apiKey,
|
|
60
|
+
}));
|
|
61
|
+
if (preview.targets.length === 0) {
|
|
62
|
+
throw new NmtsError(`Nothing on "${path}" can be extended from here.`, {
|
|
63
|
+
exitCode: 4,
|
|
64
|
+
nextStep: nothingToExtend(preview),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const reads = await (seams.readChain ?? defaultReads)(input.network);
|
|
68
|
+
const window = await reads.readWindow();
|
|
69
|
+
if (window === null) {
|
|
70
|
+
// ⛔ Not "nothing needs extending". The two look identical from outside and mean opposite things.
|
|
71
|
+
throw new NmtsError(`The ${input.network} storage network could not be read.`, {
|
|
72
|
+
exitCode: 1,
|
|
73
|
+
nextStep: `Nothing was signed and nothing was charged. Which epoch the network is in, and how far ` +
|
|
74
|
+
`ahead it will sell, are facts only the chain has — this will not spend against a ` +
|
|
75
|
+
`guess. Try again, or name a different Sui node in NMTS_SUI_RPC.`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const clock = window.clock;
|
|
79
|
+
const objectIds = preview.targets.map((t) => t.objectId);
|
|
80
|
+
const leases = await reads.readLeases(objectIds);
|
|
81
|
+
const endEpoch = soonestEnd(leases);
|
|
82
|
+
if (endEpoch === null) {
|
|
83
|
+
throw new NmtsError(`The chain holds no storage term for "${path}".`, {
|
|
84
|
+
exitCode: 4,
|
|
85
|
+
nextStep: nothingToExtend(preview),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const stage = stageOf(clock, endEpoch, seams.now);
|
|
89
|
+
if (stage === "lapsed") {
|
|
90
|
+
throw new NmtsError(`The storage term for "${path}" has already ended.`, {
|
|
91
|
+
exitCode: 4,
|
|
92
|
+
nextStep: seams.hints?.lapsed ??
|
|
93
|
+
`Nothing was signed and nothing was charged. A lease is extended before it ends — once it ` +
|
|
94
|
+
`is over there is no storage object left to extend, and the bytes may already be gone. ` +
|
|
95
|
+
`Fetching the file is what says whether they can still be read.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const epochs = chooseEpochs(seams.epochs, headroom(leases, clock.current, window.maxAhead));
|
|
99
|
+
const newEndEpoch = endEpoch + epochs;
|
|
100
|
+
const before = daysLeftUntilEpoch(clock, endEpoch, seams.now);
|
|
101
|
+
const after = daysLeftUntilEpoch(clock, newEndEpoch, seams.now);
|
|
102
|
+
// ⛔ A COST THAT COULD NOT BE COMPUTED MUST NOT BECOME A COST OF ZERO. `quote` rejects rather
|
|
103
|
+
// than defaulting, and that rejection stops this run before anything is agreed to.
|
|
104
|
+
const frost = await reads.quote(leases, epochs);
|
|
105
|
+
const cohort = Math.max(0, ...preview.targets.map((t) => t.sharedItems));
|
|
106
|
+
const unreachable = preview.treasuryParts + preview.untrackedParts;
|
|
107
|
+
// ⛔ WHICH WALLET PAYS comes out of the list this run already read, before the price is measured
|
|
108
|
+
// against a balance: the address below is the one that will sign.
|
|
109
|
+
const wallet = seams.wallet ?? activeWalletOf(settings);
|
|
110
|
+
const address = await walletAddress(input.code, wallet);
|
|
111
|
+
const budget = await readBudget(reads, { address, objectIds, epochs, priceFrost: frost });
|
|
112
|
+
return {
|
|
113
|
+
facts: {
|
|
114
|
+
file: path,
|
|
115
|
+
itemId: entry.id,
|
|
116
|
+
network: input.network,
|
|
117
|
+
epoch: clock.current,
|
|
118
|
+
endEpoch,
|
|
119
|
+
epochs,
|
|
120
|
+
newEndEpoch,
|
|
121
|
+
daysLeft: before,
|
|
122
|
+
daysLeftAfter: after,
|
|
123
|
+
blobs: leases.length,
|
|
124
|
+
priceFrost: frost.toString(),
|
|
125
|
+
priceWal: coinAmount(frost),
|
|
126
|
+
paidFrom: "wallet",
|
|
127
|
+
filesOnTheSameBlobs: cohort,
|
|
128
|
+
partsThatCannotBeExtended: unreachable,
|
|
129
|
+
...budgetFacts(budget),
|
|
130
|
+
},
|
|
131
|
+
itemId: entry.id,
|
|
132
|
+
path,
|
|
133
|
+
objectIds,
|
|
134
|
+
epochs,
|
|
135
|
+
wallet,
|
|
136
|
+
stage,
|
|
137
|
+
budget,
|
|
138
|
+
settings,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Buy it. ⛔ THIS SIGNS, and nothing below this line can be undone by anybody, NMTS included.
|
|
143
|
+
*
|
|
144
|
+
* Nothing here asks whether the caller meant it: the price, both balances and any shortfall are in
|
|
145
|
+
* the plan, and calling this is the answer.
|
|
146
|
+
*/
|
|
147
|
+
export async function applyExtension(input, plan, seams = {}) {
|
|
148
|
+
const sign = seams.sign ?? (await import("../wallet-sign.js")).signExtension;
|
|
149
|
+
const digest = await sign({
|
|
150
|
+
network: input.network,
|
|
151
|
+
code: input.code,
|
|
152
|
+
wallet: plan.wallet,
|
|
153
|
+
objectIds: plan.objectIds,
|
|
154
|
+
epochs: plan.epochs,
|
|
155
|
+
});
|
|
156
|
+
seams.onSigned?.(digest);
|
|
157
|
+
// From here the storage IS extended. Recording it is bookkeeping, and a failure to record must
|
|
158
|
+
// never be reported as a failure to extend — that reading invites a second run, which pays again.
|
|
159
|
+
try {
|
|
160
|
+
const recorded = await request(input.server, `/v1/items/${encodeURIComponent(plan.itemId)}/extended`, { method: "POST", token: input.apiKey, body: { epochs: plan.epochs, tx_digest: digest } });
|
|
161
|
+
return { digest, recorded: true, replay: isRecord(recorded) && recorded["replay"] === true, notRecorded: null };
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
digest,
|
|
166
|
+
recorded: false,
|
|
167
|
+
replay: false,
|
|
168
|
+
notRecorded: error instanceof Error ? error.message : String(error),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Why a file has nothing to extend, said as the two different things it can be. */
|
|
173
|
+
export function nothingToExtend(preview) {
|
|
174
|
+
const parts = [];
|
|
175
|
+
if (preview.treasuryParts > 0) {
|
|
176
|
+
parts.push(`${preview.treasuryParts} part${preview.treasuryParts === 1 ? " is" : "s are"} on storage NMTS ` +
|
|
177
|
+
`paid for, which this account cannot extend`);
|
|
178
|
+
}
|
|
179
|
+
if (preview.untrackedParts > 0) {
|
|
180
|
+
parts.push(`${preview.untrackedParts} part${preview.untrackedParts === 1 ? " has" : "s have"} no ` +
|
|
181
|
+
`recorded storage object, so there is nothing to name on the chain`);
|
|
182
|
+
}
|
|
183
|
+
const why = parts.length === 0 ? "The server lists no storage object for it." : `${parts.join(", and ")}.`;
|
|
184
|
+
return `Nothing was signed and nothing was charged. ${why} Opening the account in a browser shows what it is stored on.`;
|
|
185
|
+
}
|
|
186
|
+
/** The real chain reads. Imported only when no seam was supplied — it loads the storage SDK. */
|
|
187
|
+
async function defaultReads(network) {
|
|
188
|
+
return (await import("../extend-chain.js")).extendReads(network);
|
|
189
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { NmtsError } from "../errors.ts";
|
|
2
|
+
export interface StorageHints {
|
|
3
|
+
/**
|
|
4
|
+
* The `nextStep` for a chain that did not answer. The chain's own cause is added after it, so
|
|
5
|
+
* this is the part before `Cause: …` and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
cannotRead?: string | undefined;
|
|
8
|
+
/** The `nextStep` for a resource this wallet does not hold free. */
|
|
9
|
+
notHeld?: string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The whole refusal for a cut that would keep nothing or everything.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ A FUNCTION BECAUSE THE NUMBER IS THE CHAIN'S: `limit` is the resource's own size, already in
|
|
14
|
+
* the units a person reads, or its own number of epochs. What the refusal calls the thing that
|
|
15
|
+
* was too big — a flag, a field — is the caller's to say.
|
|
16
|
+
*/
|
|
17
|
+
cut?: ((what: "size" | "period", limit: string) => NmtsError) | undefined;
|
|
18
|
+
/** The `nextStep` for a storage term that has already ended. */
|
|
19
|
+
lapsed?: string | undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The words a caller puts in a refusal, where the neutral sentence is not the one to say.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ A TERMINAL NAMES ITS OWN COMMANDS AND A LIBRARY NAMES NONE. "`nmts env` says which network was
|
|
4
|
+
// asked" is the right next step for somebody at a prompt and the wrong one inside somebody
|
|
5
|
+
// else's server, where that program may not exist. So the JUDGEMENT is written once, here, and
|
|
6
|
+
// the sentence that follows it belongs to whoever asked — the same division this whole folder
|
|
7
|
+
// keeps between what is decided and what is printed.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ EVERY FIELD IS OPTIONAL, and what is left out is answered with a sentence that names nothing
|
|
10
|
+
// outside the caller's own program. The SDK passes none of them.
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Network } from "../network.ts";
|
|
2
|
+
import type { StorageResource } from "../shared/lib/storage-control/chain.ts";
|
|
3
|
+
import type { StorageHints } from "./hints.ts";
|
|
4
|
+
/** What the chain said: the resources, and which epoch it is (null when the clock could not be read). */
|
|
5
|
+
export interface StorageRead {
|
|
6
|
+
items: readonly StorageResource[];
|
|
7
|
+
currentEpoch: number | null;
|
|
8
|
+
}
|
|
9
|
+
/** Where a resource stands against the current epoch. */
|
|
10
|
+
export type StorageStatus = "lapsed" | "notYet" | "usable";
|
|
11
|
+
/** One resource, with where it stands. */
|
|
12
|
+
export interface StorageItem extends StorageResource {
|
|
13
|
+
/**
|
|
14
|
+
* ⛔ NULL IS "THE EPOCH COULD NOT BE READ", never "lapsed". Which epoch the network is in is a
|
|
15
|
+
* fact only the chain has, and guessing it would mark a resource somebody paid for as over.
|
|
16
|
+
*/
|
|
17
|
+
status: StorageStatus | null;
|
|
18
|
+
}
|
|
19
|
+
/** One wallet's storage, as anything asking about it needs it. */
|
|
20
|
+
export interface StorageListing {
|
|
21
|
+
address: string;
|
|
22
|
+
network: Network;
|
|
23
|
+
currentEpoch: number | null;
|
|
24
|
+
/** Usable first, then largest, then furthest ahead — left as read when there is no epoch. */
|
|
25
|
+
items: readonly StorageItem[];
|
|
26
|
+
/** The usable sizes added up — null when the epoch could not be read. */
|
|
27
|
+
usableBytes: number | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* How the resources are read.
|
|
31
|
+
*
|
|
32
|
+
* ⚠ A SEAM, NOT AN OPTION — no flag and no caller argument reaches it. A test that talked to a live
|
|
33
|
+
* storage network could not run offline and could never be asked to hold a lapsed resource.
|
|
34
|
+
*/
|
|
35
|
+
export type ReadWalletStorage = (network: Network, address: string) => Promise<StorageRead>;
|
|
36
|
+
/** Every free storage resource one address holds, in the order a person reads them. */
|
|
37
|
+
export declare function listStorage(input: {
|
|
38
|
+
network: Network;
|
|
39
|
+
address: string;
|
|
40
|
+
}, read?: ReadWalletStorage, hints?: StorageHints): Promise<StorageListing>;
|
|
41
|
+
/**
|
|
42
|
+
* The one refusal for a chain that did not answer.
|
|
43
|
+
*
|
|
44
|
+
* ⛔ IT IS NOT AN EMPTY LIST. Holding no resource is normal; failing to read is a reason to look
|
|
45
|
+
* again, and the chain's own words are carried so that whoever reads it knows which it was.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readOrRefuse(read: () => Promise<StorageRead>, hints?: StorageHints): Promise<StorageRead>;
|
|
48
|
+
/**
|
|
49
|
+
* Bytes as a person reads them: binary units, two decimals, whole bytes below a KiB.
|
|
50
|
+
*
|
|
51
|
+
* ⚠ HERE RATHER THAN BESIDE THE SCREEN THAT PRINTS THEM, because the refusals in `reshape.ts` name
|
|
52
|
+
* a resource's size too, and a refusal that said `4294967296` about a resource a listing calls
|
|
53
|
+
* `4.00 GiB` would be two spellings of one number in front of the same person.
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatBytes(bytes: number): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// What a wallet holds in storage — the free resources, ordered and judged, without a screen.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ EVERYTHING HERE IS A READ. What the storage network sells is size and time, not a file;
|
|
4
|
+
// deleting a file from the network returns the remaining time to the wallet that bought it, and
|
|
5
|
+
// that is what this lists. Splitting, joining and handing one over are next door in
|
|
6
|
+
// `reshape.ts`, because each of them signs.
|
|
7
|
+
//
|
|
8
|
+
// ⛔ "NONE" AND "COULD NOT READ" ARE DIFFERENT ANSWERS, and this file is where that is kept: the
|
|
9
|
+
// reader throws rather than answering an empty list, and `readOrRefuse` turns that into one
|
|
10
|
+
// refusal with the chain's own cause in it. Flattening the two would draw a wallet full of
|
|
11
|
+
// storage as a wallet with none, on the screen somebody decides what to buy from.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ AND THE JUDGEMENT IS THE BROWSER'S, not a second copy of it: `shared/lib/storage-control/plan.ts`
|
|
14
|
+
// is copied byte for byte from the browser and says what "usable" means and in what order these
|
|
15
|
+
// are shown. Nothing here reaches for `node:`.
|
|
16
|
+
import { NmtsError } from "../errors.js";
|
|
17
|
+
import { statusOf, totalUsableBytes, usableFirst } from "../shared/lib/storage-control/plan.js";
|
|
18
|
+
/** Every free storage resource one address holds, in the order a person reads them. */
|
|
19
|
+
export async function listStorage(input, read = defaultRead, hints = {}) {
|
|
20
|
+
const got = await readOrRefuse(() => read(input.network, input.address), hints);
|
|
21
|
+
const epoch = got.currentEpoch;
|
|
22
|
+
const ordered = epoch === null ? [...got.items] : usableFirst(got.items, epoch);
|
|
23
|
+
return {
|
|
24
|
+
address: input.address,
|
|
25
|
+
network: input.network,
|
|
26
|
+
currentEpoch: epoch,
|
|
27
|
+
items: ordered.map((r) => ({ ...r, status: epoch === null ? null : statusOf(r, epoch) })),
|
|
28
|
+
usableBytes: epoch === null ? null : totalUsableBytes(ordered, epoch),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The one refusal for a chain that did not answer.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ IT IS NOT AN EMPTY LIST. Holding no resource is normal; failing to read is a reason to look
|
|
35
|
+
* again, and the chain's own words are carried so that whoever reads it knows which it was.
|
|
36
|
+
*/
|
|
37
|
+
export async function readOrRefuse(read, hints = {}) {
|
|
38
|
+
try {
|
|
39
|
+
return await read();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const said = hints.cannotRead ?? "That is not the same as holding none, and nothing was signed.";
|
|
43
|
+
throw new NmtsError("The storage resources could not be read from the chain.", {
|
|
44
|
+
exitCode: 1,
|
|
45
|
+
nextStep: `${said} Cause: ${error instanceof Error ? error.message : String(error)}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The real read. Imported only when no seam was supplied — it loads the storage network's client. */
|
|
50
|
+
async function defaultRead(network, address) {
|
|
51
|
+
return (await import("../wallet-storage-chain.js")).readWalletStorage(network, address);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Bytes as a person reads them: binary units, two decimals, whole bytes below a KiB.
|
|
55
|
+
*
|
|
56
|
+
* ⚠ HERE RATHER THAN BESIDE THE SCREEN THAT PRINTS THEM, because the refusals in `reshape.ts` name
|
|
57
|
+
* a resource's size too, and a refusal that said `4294967296` about a resource a listing calls
|
|
58
|
+
* `4.00 GiB` would be two spellings of one number in front of the same person.
|
|
59
|
+
*/
|
|
60
|
+
export function formatBytes(bytes) {
|
|
61
|
+
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
62
|
+
let value = bytes;
|
|
63
|
+
let unit = "B";
|
|
64
|
+
for (const next of units) {
|
|
65
|
+
if (value < 1024)
|
|
66
|
+
break;
|
|
67
|
+
value /= 1024;
|
|
68
|
+
unit = next;
|
|
69
|
+
}
|
|
70
|
+
return unit === "B" ? `${bytes} B` : `${value.toFixed(2)} ${unit}`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Network } from "../network.ts";
|
|
2
|
+
import type { StorageResource } from "../shared/lib/storage-control/chain.ts";
|
|
3
|
+
import type { StorageOpShape, StorageOpsReads } from "../storage-control-chain.ts";
|
|
4
|
+
import type { WalletAction } from "../wallet-grant.ts";
|
|
5
|
+
import type { SignStorageOp } from "../wallet-sign-seams.ts";
|
|
6
|
+
import type { StorageHints } from "./hints.ts";
|
|
7
|
+
/** The three things that can be done to a resource the wallet holds free. */
|
|
8
|
+
export type StorageOp = "split" | "merge" | "transfer";
|
|
9
|
+
/**
|
|
10
|
+
* One request, in the values it is made of rather than in what anybody typed.
|
|
11
|
+
*
|
|
12
|
+
* ⚠ `keepEpochs` IS A COUNT AND THE CHAIN TAKES AN EPOCH. The resource keeps its first
|
|
13
|
+
* `keepEpochs` epochs; which absolute epoch that is depends on where it starts, which is a fact
|
|
14
|
+
* read from the chain below rather than one a caller should have to work out.
|
|
15
|
+
*/
|
|
16
|
+
export type StorageOpAsk = {
|
|
17
|
+
kind: "splitSize";
|
|
18
|
+
objectId: string;
|
|
19
|
+
keepBytes: number;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "splitEpochs";
|
|
22
|
+
objectId: string;
|
|
23
|
+
keepEpochs: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "merge";
|
|
26
|
+
first: string;
|
|
27
|
+
second: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "transfer";
|
|
30
|
+
objectId: string;
|
|
31
|
+
to: string;
|
|
32
|
+
};
|
|
33
|
+
/** A resource as the chain would hold it. A resource that does not exist yet has no id. */
|
|
34
|
+
export interface StorageShape {
|
|
35
|
+
sizeBytes: number;
|
|
36
|
+
startEpoch: number;
|
|
37
|
+
endEpoch: number;
|
|
38
|
+
}
|
|
39
|
+
/** What one change leaves behind — the numbers, without a word of prose about them. */
|
|
40
|
+
export type StorageOpResult = {
|
|
41
|
+
kind: "split";
|
|
42
|
+
keeps: StorageShape;
|
|
43
|
+
creates: StorageShape;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "merge";
|
|
46
|
+
becomes: StorageShape;
|
|
47
|
+
consumed: string;
|
|
48
|
+
how: "amount" | "periods";
|
|
49
|
+
} | {
|
|
50
|
+
kind: "transfer";
|
|
51
|
+
moves: StorageShape;
|
|
52
|
+
to: string;
|
|
53
|
+
};
|
|
54
|
+
/** One request, judged: what the chain is asked for, what it needs, and what it would leave. */
|
|
55
|
+
export interface StorageOpPlan {
|
|
56
|
+
op: StorageOp;
|
|
57
|
+
/** The transaction shape — what is priced, and what is signed. One builder for both. */
|
|
58
|
+
shape: StorageOpShape;
|
|
59
|
+
/** The wallet unlock this needs: `reshape` for cutting and joining, `give` for handing over. */
|
|
60
|
+
action: WalletAction;
|
|
61
|
+
/** The resources it is about, as the chain has them. */
|
|
62
|
+
named: readonly StorageResource[];
|
|
63
|
+
result: StorageOpResult;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Judge one request against the resources this wallet actually holds.
|
|
67
|
+
*
|
|
68
|
+
* ⛔ EVERY REFUSAL HAPPENS HERE, BEFORE THE CHAIN IS ASKED ANYTHING. Letting the contract refuse it
|
|
69
|
+
* instead leaves somebody having paid gas with no idea why — the rule the browser's own
|
|
70
|
+
* judgement (`shared/lib/storage-control/plan.ts`) was written for.
|
|
71
|
+
*/
|
|
72
|
+
export declare function planStorageOp(held: readonly StorageResource[], ask: StorageOpAsk, hints?: StorageHints): StorageOpPlan;
|
|
73
|
+
/** What one change would do, priced, before anything is signed. */
|
|
74
|
+
export interface StorageOpReview {
|
|
75
|
+
plan: StorageOpPlan;
|
|
76
|
+
/** The wallet whose resources these are — the one that would sign. */
|
|
77
|
+
address: string;
|
|
78
|
+
network: Network;
|
|
79
|
+
currentEpoch: number | null;
|
|
80
|
+
/**
|
|
81
|
+
* The chain fee the dry run measured, in MIST — or null when it could not be measured.
|
|
82
|
+
* ⛔ NEVER ZERO FOR "UNKNOWN": the fee is charged with the signature either way.
|
|
83
|
+
*/
|
|
84
|
+
feeMist: bigint | null;
|
|
85
|
+
}
|
|
86
|
+
/** Stopped at the review, or done and signed. */
|
|
87
|
+
export type ReshapeOutcome = {
|
|
88
|
+
kind: "review";
|
|
89
|
+
review: StorageOpReview;
|
|
90
|
+
} | {
|
|
91
|
+
kind: "done";
|
|
92
|
+
review: StorageOpReview;
|
|
93
|
+
digest: string;
|
|
94
|
+
};
|
|
95
|
+
/** Whose resources these are: the wallet that holds them, and the key that derives it. */
|
|
96
|
+
export interface ReshapeContext {
|
|
97
|
+
network: Network;
|
|
98
|
+
/** ⛔ The NMTS key. It never leaves this process: it derives the wallet and nothing else. */
|
|
99
|
+
code: string;
|
|
100
|
+
/** Which of this key's wallets holds the resources. */
|
|
101
|
+
wallet: number;
|
|
102
|
+
/** That wallet's address — read, priced and signed with as one. */
|
|
103
|
+
address: string;
|
|
104
|
+
}
|
|
105
|
+
/** The chain, the signature, and the two places a caller gets a word in. ⚠ Seams, not options. */
|
|
106
|
+
export interface ReshapeSeams {
|
|
107
|
+
reads?: ((network: Network) => StorageOpsReads) | undefined;
|
|
108
|
+
/** ⛔ SEPARATE FROM THE READS so a test can prove the review stops before this. */
|
|
109
|
+
sign?: SignStorageOp | undefined;
|
|
110
|
+
/** Told once the change is priced, and before anything is signed. */
|
|
111
|
+
onReview?: ((review: StorageOpReview) => void) | undefined;
|
|
112
|
+
/** ⛔ THE GATE. Throwing here stops the run with nothing signed. */
|
|
113
|
+
agree?: ((review: StorageOpReview) => void) | undefined;
|
|
114
|
+
/** Stop at the review. Nothing is signed and no signer is even loaded. */
|
|
115
|
+
dryRun?: boolean | undefined;
|
|
116
|
+
/** What this caller wants said in a refusal instead of the neutral sentence. */
|
|
117
|
+
hints?: StorageHints | undefined;
|
|
118
|
+
}
|
|
119
|
+
/** Read, judge, price and — unless somebody stops it — sign one change to a storage resource. */
|
|
120
|
+
export declare function reshapeStorage(context: ReshapeContext, ask: StorageOpAsk, seams?: ReshapeSeams): Promise<ReshapeOutcome>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Cutting a storage resource, joining two of them, and handing one over — decided, priced, signed
|
|
2
|
+
// and handed back rather than printed.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ ONE IMPLEMENTATION, TWO CALLERS, which is the whole reason this file exists. `nmts wallet
|
|
5
|
+
// storage split|merge|transfer` is a terminal's shape: it prints a review, asks for `--yes` and
|
|
6
|
+
// answers an exit code. The SDK is somebody else's program and needs the same acts with none of
|
|
7
|
+
// those. A second implementation of "what may be joined with what" would be a second place for
|
|
8
|
+
// the contract's rules to be got right, and the copy nobody re-reads is the one that quietly
|
|
9
|
+
// disagrees — about transactions that spend a fee and cannot be undone.
|
|
10
|
+
//
|
|
11
|
+
// ⛔ THE ORDER IS THE SAFETY, and it is here rather than in either caller: ① the resources are READ
|
|
12
|
+
// from the chain and the named ones must be this wallet's ② the browser's rules judge the request
|
|
13
|
+
// and say why not ③ the exact transaction is dry-run for its fee, and a refusal there ends the
|
|
14
|
+
// run ④ the review is handed out ⑤ `dryRun` stops here ⑥ `agree` is the gate — whatever it throws
|
|
15
|
+
// stops the run with nothing signed ⑦ only then the signature.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ HANDING ONE OVER MOVES NO FILE. What goes is size and remaining time; a file's bytes are sealed
|
|
18
|
+
// with keys the NMTS key derives and stay unreadable to whoever receives the resource. It cannot
|
|
19
|
+
// be undone and NMTS cannot recall it.
|
|
20
|
+
//
|
|
21
|
+
// ⚠ NOTHING HERE WRITES TO A STREAM, ASKS ANYBODY ANYTHING OR PICKS AN EXIT CODE, and nothing
|
|
22
|
+
// reaches for `node:` — the chain and the signature are loaded only when no seam was supplied.
|
|
23
|
+
import { NmtsError } from "../errors.js";
|
|
24
|
+
import { canFuse } from "../shared/lib/storage-control/plan.js";
|
|
25
|
+
import { isValidSuiAddress } from "../shared/lib/wallet/send-rules.js";
|
|
26
|
+
import { formatBytes, readOrRefuse } from "./list.js";
|
|
27
|
+
/** Why two resources cannot be joined, in the contract's own terms. */
|
|
28
|
+
const FUSE_WHY = {
|
|
29
|
+
same: "those are the same resource.",
|
|
30
|
+
differentSize: "their periods differ and so do their sizes — the contract joins sizes only over an identical period, and periods only at an identical size.",
|
|
31
|
+
differentPeriod: "their periods differ.",
|
|
32
|
+
notAdjacent: "their sizes are equal but their periods do not touch — one has to end where the other starts.",
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Judge one request against the resources this wallet actually holds.
|
|
36
|
+
*
|
|
37
|
+
* ⛔ EVERY REFUSAL HAPPENS HERE, BEFORE THE CHAIN IS ASKED ANYTHING. Letting the contract refuse it
|
|
38
|
+
* instead leaves somebody having paid gas with no idea why — the rule the browser's own
|
|
39
|
+
* judgement (`shared/lib/storage-control/plan.ts`) was written for.
|
|
40
|
+
*/
|
|
41
|
+
export function planStorageOp(held, ask, hints = {}) {
|
|
42
|
+
const one = (id) => {
|
|
43
|
+
const found = held.find((r) => r.objectId === id);
|
|
44
|
+
if (found === undefined) {
|
|
45
|
+
throw new NmtsError(`This wallet holds no free storage resource ${id}.`, {
|
|
46
|
+
exitCode: 4,
|
|
47
|
+
nextStep: hints.notHeld ??
|
|
48
|
+
"Nothing was signed. A storage listing shows the free ones it holds; a resource bound " +
|
|
49
|
+
"inside a file is not free.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
};
|
|
54
|
+
// ⛔ THE JUDGEMENT IS HERE AND THE WORD FOR IT IS THE CALLER'S. What was too big has a different
|
|
55
|
+
// name at a prompt and in a program, and neither should be told about the other's.
|
|
56
|
+
const tooBig = (what, limit) => hints.cut?.(what, limit) ??
|
|
57
|
+
new NmtsError(what === "size"
|
|
58
|
+
? `A cut by size keeps above 0 and below the resource's ${limit}.`
|
|
59
|
+
: `A cut by period keeps a whole number of epochs above 0 and below the resource's ${limit}.`, { exitCode: 2, nextStep: "Nothing was signed. The rest becomes a second resource, so there has to be a rest." });
|
|
60
|
+
const shapeOf = (r) => ({
|
|
61
|
+
sizeBytes: r.sizeBytes,
|
|
62
|
+
startEpoch: r.startEpoch,
|
|
63
|
+
endEpoch: r.endEpoch,
|
|
64
|
+
});
|
|
65
|
+
if (ask.kind === "splitSize") {
|
|
66
|
+
const r = one(ask.objectId);
|
|
67
|
+
if (ask.keepBytes <= 0 || ask.keepBytes >= r.sizeBytes)
|
|
68
|
+
throw tooBig("size", formatBytes(r.sizeBytes));
|
|
69
|
+
return {
|
|
70
|
+
op: "split",
|
|
71
|
+
shape: { kind: "splitSize", objectId: ask.objectId, keepBytes: ask.keepBytes },
|
|
72
|
+
action: "reshape",
|
|
73
|
+
named: [r],
|
|
74
|
+
result: {
|
|
75
|
+
kind: "split",
|
|
76
|
+
keeps: { ...shapeOf(r), sizeBytes: ask.keepBytes },
|
|
77
|
+
creates: { ...shapeOf(r), sizeBytes: r.sizeBytes - ask.keepBytes },
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (ask.kind === "splitEpochs") {
|
|
82
|
+
const r = one(ask.objectId);
|
|
83
|
+
const span = r.endEpoch - r.startEpoch;
|
|
84
|
+
if (!Number.isSafeInteger(ask.keepEpochs) || ask.keepEpochs <= 0 || ask.keepEpochs >= span) {
|
|
85
|
+
throw tooBig("period", String(span));
|
|
86
|
+
}
|
|
87
|
+
const at = r.startEpoch + ask.keepEpochs;
|
|
88
|
+
return {
|
|
89
|
+
op: "split",
|
|
90
|
+
shape: { kind: "splitEpoch", objectId: ask.objectId, splitEpoch: at },
|
|
91
|
+
action: "reshape",
|
|
92
|
+
named: [r],
|
|
93
|
+
result: {
|
|
94
|
+
kind: "split",
|
|
95
|
+
keeps: { ...shapeOf(r), endEpoch: at },
|
|
96
|
+
creates: { ...shapeOf(r), startEpoch: at },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (ask.kind === "merge") {
|
|
101
|
+
const first = one(ask.first);
|
|
102
|
+
const second = one(ask.second);
|
|
103
|
+
const verdict = canFuse(first, second);
|
|
104
|
+
if (!verdict.can) {
|
|
105
|
+
throw new NmtsError(`These two cannot be joined: ${FUSE_WHY[verdict.why]}`, {
|
|
106
|
+
exitCode: 4,
|
|
107
|
+
nextStep: "Nothing was signed. A storage listing shows each one's size and period.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
op: "merge",
|
|
112
|
+
shape: { kind: "fuse", first: ask.first, second: ask.second, how: verdict.kind },
|
|
113
|
+
action: "reshape",
|
|
114
|
+
named: [first, second],
|
|
115
|
+
result: {
|
|
116
|
+
kind: "merge",
|
|
117
|
+
how: verdict.kind,
|
|
118
|
+
consumed: ask.second,
|
|
119
|
+
becomes: verdict.kind === "amount"
|
|
120
|
+
? { ...shapeOf(first), sizeBytes: first.sizeBytes + second.sizeBytes }
|
|
121
|
+
: {
|
|
122
|
+
sizeBytes: first.sizeBytes,
|
|
123
|
+
startEpoch: Math.min(first.startEpoch, second.startEpoch),
|
|
124
|
+
endEpoch: Math.max(first.endEpoch, second.endEpoch),
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const r = one(ask.objectId);
|
|
130
|
+
// ⛔ THE ADDRESS IS JUDGED BEFORE THE CHAIN IS ASKED. A resource sent to a mistyped address is
|
|
131
|
+
// gone: nothing here, and nobody at NMTS, can recall it.
|
|
132
|
+
if (!isValidSuiAddress(ask.to)) {
|
|
133
|
+
throw new NmtsError("That is not a Sui address: it is 0x followed by 64 hexadecimal characters.", {
|
|
134
|
+
exitCode: 2,
|
|
135
|
+
nextStep: "Nothing was signed. Check the address it should go to.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
op: "transfer",
|
|
140
|
+
shape: { kind: "transfer", objectId: ask.objectId, to: ask.to },
|
|
141
|
+
action: "give",
|
|
142
|
+
named: [r],
|
|
143
|
+
result: { kind: "transfer", moves: shapeOf(r), to: ask.to },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Read, judge, price and — unless somebody stops it — sign one change to a storage resource. */
|
|
147
|
+
export async function reshapeStorage(context, ask, seams = {}) {
|
|
148
|
+
const reads = seams.reads === undefined
|
|
149
|
+
? (await import("../storage-control-chain.js")).storageOpsReads(context.network)
|
|
150
|
+
: seams.reads(context.network);
|
|
151
|
+
// ① The resources, and which epoch it is.
|
|
152
|
+
const read = await readOrRefuse(() => reads.readStorage(context.address), seams.hints ?? {});
|
|
153
|
+
// ② The request, judged.
|
|
154
|
+
const plan = planStorageOp(read.items, ask, seams.hints ?? {});
|
|
155
|
+
const walrusPackageId = await reads.walrusPackageId();
|
|
156
|
+
// ③ The dry run: the fee, or the contract's own refusal.
|
|
157
|
+
const verdict = await reads.dryRun(plan.shape, context.address);
|
|
158
|
+
if (verdict.refusal !== null) {
|
|
159
|
+
throw new NmtsError(`The chain would refuse this: ${verdict.refusal}`, {
|
|
160
|
+
exitCode: 4,
|
|
161
|
+
nextStep: "Nothing was signed and no fee was spent.",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// ④ The review.
|
|
165
|
+
const review = {
|
|
166
|
+
plan,
|
|
167
|
+
address: context.address,
|
|
168
|
+
network: context.network,
|
|
169
|
+
currentEpoch: read.currentEpoch,
|
|
170
|
+
feeMist: verdict.feeMist,
|
|
171
|
+
};
|
|
172
|
+
seams.onReview?.(review);
|
|
173
|
+
// ⑤ Stopped here, with nothing signed.
|
|
174
|
+
if (seams.dryRun === true)
|
|
175
|
+
return { kind: "review", review };
|
|
176
|
+
// ⑥ The gate.
|
|
177
|
+
seams.agree?.(review);
|
|
178
|
+
// ⑦ The signature.
|
|
179
|
+
const sign = seams.sign ?? (await import("../wallet-sign.js")).signStorageOp;
|
|
180
|
+
const digest = await sign({
|
|
181
|
+
network: context.network,
|
|
182
|
+
code: context.code,
|
|
183
|
+
wallet: context.wallet,
|
|
184
|
+
shape: plan.shape,
|
|
185
|
+
walrusPackageId,
|
|
186
|
+
});
|
|
187
|
+
return { kind: "done", review, digest };
|
|
188
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Transaction } from "@mysten/sui/transactions";
|
|
2
2
|
import type { Network } from "./network.ts";
|
|
3
|
-
import type { StorageRead } from "./
|
|
3
|
+
import type { StorageRead } from "./storage-control/list.ts";
|
|
4
4
|
export type StorageOpShape = {
|
|
5
5
|
kind: "splitSize";
|
|
6
6
|
objectId: string;
|