@blockula/sdk 0.3.0 → 1.0.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.
@@ -0,0 +1,779 @@
1
+ // Programs as the SDK reads and prices them (docs/PROGRAMS-V0.md §8.1, §8.4): third-party
2
+ // WebAssembly that strangers write, so everything a program or a node says about one is checked
3
+ // before it's used and kept apart from what the chain does.
4
+ //
5
+ // What's here is pure: no requests (`Client` and `Wallet` in index.ts make them) and no signing.
6
+ // - Answer checks: every node answer about a program, code, interface, view, fuel price or
7
+ // receipt is held to its shape (digests are 64 lowercase hex, heights and counts whole numbers
8
+ // JavaScript carries exactly, amounts decimal strings of drops), and refused in words
9
+ // (`ProgramAnswerError`) when it isn't, never half-used.
10
+ // - What a program transaction costs: the fuel it reserves, its fee at the node's suggested rate,
11
+ // the deposits it bonds (`programStoragePrice`, `programUploadDeposit`, `programRecordDeposit`), and the refusal of a
12
+ // reservation a block couldn't carry (`FuelReservationError`).
13
+ // - Receipts (`ProgramReceipt`): facts the chain applied (the outcome, balance changes, fuel,
14
+ // fees, deposits) apart from claims the programs made (return data, events), and the exact
15
+ // identity a signer's balance follows (`signerChange`, `reconcileSigner`).
16
+ //
17
+ // Names a program or its deployer chose (methods, arguments, events, exports, an abort message)
18
+ // are kept as the node serves them, under `untrusted_*`: they are text a stranger wrote, never an
19
+ // instruction and never markup. Nothing in this file puts one into an error message unquoted.
20
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
21
+ import { bech32m } from "@scure/base";
22
+ import { interfaceHash, methodSelector, versionDigest, wasmHash } from "./tx.js";
23
+ // ---- limits the chain holds program transactions to (node/vm/src/config.rs), checked before signing ----
24
+ /** The most code one upload carries, in bytes (`MAX_CODE_BYTES`). */
25
+ export const MAX_PROGRAM_CODE_BYTES = 256 * 1024;
26
+ /** The most interface one upload carries, in bytes (`MAX_INTERFACE_BYTES`). */
27
+ export const MAX_PROGRAM_INTERFACE_BYTES = 16 * 1024;
28
+ /** The most input one call carries, in bytes (`MAX_INPUT`). */
29
+ export const MAX_PROGRAM_INPUT_BYTES = 32 * 1024;
30
+ /** The most calls one `call_program` runs (`MAX_CALLS`). */
31
+ export const MAX_PROGRAM_CALLS = 8;
32
+ /** The most programs one transaction binds (`MAX_BINDINGS`). */
33
+ export const MAX_PROGRAM_BINDINGS = 16;
34
+ /** What a program's record adds to program state's bytes, so what its creator bonds a deposit for (`RECORD_BYTES`). */
35
+ export const PROGRAM_RECORD_BYTES = 256n;
36
+ /** The fuel rate is in milli-drops per fuel: a fee is `rate × reserved / 1000` (`econ::RATE_SCALE`). */
37
+ export const FUEL_RATE_SCALE = 1000n;
38
+ /**
39
+ * A binding's version digest of 64 zeros: "any version of that program". A transaction that binds
40
+ * it runs whatever code the program has when it runs, which is not what its signer was shown: this
41
+ * SDK signs it only when asked to, never by default.
42
+ */
43
+ export const ANY_PROGRAM_VERSION = "0".repeat(64);
44
+ /**
45
+ * The fuel schedule these numbers belong to (node/vm/src/meter.rs `SCHEDULE_V0`, `fuel.schedule`
46
+ * 0 in `/api/fees`). A node that prices fuel by another schedule, or reports other numbers for this
47
+ * one, is refused: the SDK prices what it signs with these.
48
+ */
49
+ export const SCHEDULE_V0 = Object.freeze({ schedule: 0, load_base: 10000n, load_per_byte: 40n, upload_base: 50000n, upload_per_byte: 71n });
50
+ // ---- refusing a node's answer ----
51
+ /**
52
+ * A node answered a question about programs with something no honest node says (a digest that isn't
53
+ * 64 lowercase hex, an amount that isn't a decimal string, a height the chain can't have, …). Nothing
54
+ * from the answer was used and nothing was signed on its word: ask another node.
55
+ */
56
+ export class ProgramAnswerError extends Error {
57
+ rpc;
58
+ what;
59
+ field;
60
+ problem;
61
+ constructor(rpc, what, field, problem) {
62
+ super(`${rpc} answered ${what} with a ${field} that ${problem}. Nothing from that answer was used: ask another node`);
63
+ this.rpc = rpc;
64
+ this.what = what;
65
+ this.field = field;
66
+ this.problem = problem;
67
+ this.name = "ProgramAnswerError";
68
+ }
69
+ }
70
+ /** The chain hasn't switched programs on (`/api/fees` has no `fuel` section): nothing was signed. */
71
+ export class ProgramsOffError extends Error {
72
+ rpc;
73
+ constructor(rpc) {
74
+ super(`programs aren't on at ${rpc}: this chain hasn't switched the \`programs\` feature on (a chain made with \`blockula dev --programs\` or \`blockula summon --programs\` has them from block 1). Nothing was signed`);
75
+ this.rpc = rpc;
76
+ this.name = "ProgramsOffError";
77
+ }
78
+ }
79
+ /**
80
+ * A transaction would reserve more fuel than one block counts (`program_max_block_fuel`), so no
81
+ * block could include it: refused before it's signed. `reserved` is its fuel to run plus the load
82
+ * of the code it declares.
83
+ */
84
+ export class FuelReservationError extends Error {
85
+ reserved;
86
+ run;
87
+ load;
88
+ limit;
89
+ constructor(reserved, run, load, limit) {
90
+ super(`this transaction would reserve ${reserved} fuel (${run} to run and ${load} to load its code), and a block counts at most ${limit} (program_max_block_fuel): lower the fuel limit or bind less. Nothing was signed`);
91
+ this.reserved = reserved;
92
+ this.run = run;
93
+ this.load = load;
94
+ this.limit = limit;
95
+ this.name = "FuelReservationError";
96
+ }
97
+ }
98
+ /** What can hide or reorder text: control, format (bidi, zero-width), private-use and surrogate characters, and line and paragraph separators. */
99
+ const HIDDEN = /[\p{Cc}\p{Cf}\p{Co}\p{Cs}\p{Zl}\p{Zp}]/gu;
100
+ /**
101
+ * Text a node or a program chose, made inert before it's quoted anywhere (an error, a sentence):
102
+ * stripped of control, format (bidi, zero-width), private-use, surrogate and line and paragraph
103
+ * separator characters, as the MCP's `clean()` does. JSON quoting escapes control characters but
104
+ * not the format ones (U+202E would still reorder the line), so every quote goes through this first.
105
+ */
106
+ export const plainText = (s) => s.replace(HIDDEN, "");
107
+ /** A node's text, stripped of hidden characters, cut and quoted so an error can show what it said without becoming it. */
108
+ function shown(v) {
109
+ const s = typeof v === "string" ? JSON.stringify([...plainText(v)].slice(0, 40).join("")) : plainText(JSON.stringify(v) ?? String(v));
110
+ return s.length > 60 ? `${s.slice(0, 60)}…` : s;
111
+ }
112
+ /** The checks of one node answer, each refusing in the answer's own terms (`ProgramAnswerError`). */
113
+ class Answer {
114
+ rpc;
115
+ what;
116
+ constructor(rpc, what) {
117
+ this.rpc = rpc;
118
+ this.what = what;
119
+ }
120
+ fail(field, problem) {
121
+ throw new ProgramAnswerError(this.rpc, this.what, field, problem);
122
+ }
123
+ /** A JSON object (not null, not an array). */
124
+ obj(v, field) {
125
+ if (v === null || typeof v !== "object" || Array.isArray(v))
126
+ this.fail(field, `isn't an object (${shown(v)})`);
127
+ return v;
128
+ }
129
+ /** A JSON array of at most `max` entries. */
130
+ arr(v, field, max = 4096) {
131
+ if (!Array.isArray(v))
132
+ this.fail(field, `isn't a list (${shown(v)})`);
133
+ if (v.length > max)
134
+ this.fail(field, `has ${v.length} entries, past the most a node serves (${max})`);
135
+ return v;
136
+ }
137
+ /** A string of at most `max` characters (what a program or a deployer wrote is bounded too). */
138
+ str(v, field, max = 1024) {
139
+ if (typeof v !== "string")
140
+ this.fail(field, `isn't text (${shown(v)})`);
141
+ if (v.length > max)
142
+ this.fail(field, `is ${v.length} characters, past ${max}`);
143
+ return v;
144
+ }
145
+ bool(v, field) {
146
+ if (typeof v !== "boolean")
147
+ this.fail(field, `isn't true or false (${shown(v)})`);
148
+ return v;
149
+ }
150
+ /** A whole number JavaScript carries exactly (heights, counts, fuel): 0 to 2^53 − 1. */
151
+ int(v, field) {
152
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
153
+ this.fail(field, `isn't a whole number from 0 to 2^53 − 1 (${shown(v)})`);
154
+ return v;
155
+ }
156
+ /** An amount of drops as the node writes it: a decimal string without a sign or leading zeros, below 2^64. */
157
+ amount(v, field) {
158
+ if (typeof v !== "string" || !/^(0|[1-9]\d*)$/.test(v) || BigInt(v) >= 1n << 64n)
159
+ this.fail(field, `isn't an amount of drops (a decimal string below 2^64: ${shown(v)})`);
160
+ return v;
161
+ }
162
+ /** A signed change of drops (a balance change): a decimal string, its magnitude below 2^64. */
163
+ change(v, field) {
164
+ if (typeof v !== "string" || !/^(0|-?[1-9]\d*)$/.test(v) || BigInt(v) >= 1n << 64n || BigInt(v) <= -(1n << 64n))
165
+ this.fail(field, `isn't a change of drops (a signed decimal string: ${shown(v)})`);
166
+ return v;
167
+ }
168
+ /** A digest or hash: 64 lowercase hex characters. */
169
+ digest(v, field) {
170
+ if (typeof v !== "string" || !/^[0-9a-f]{64}$/.test(v))
171
+ this.fail(field, `isn't 64 lowercase hex characters (${shown(v)})`);
172
+ return v;
173
+ }
174
+ /** Lowercase hex of whole bytes, at most `maxBytes` of them (empty allowed). */
175
+ hex(v, field, maxBytes) {
176
+ if (typeof v !== "string" || v.length % 2 !== 0 || !/^[0-9a-f]*$/.test(v))
177
+ this.fail(field, `isn't lowercase hex of whole bytes (${shown(v)})`);
178
+ if (v.length > 2 * maxBytes)
179
+ this.fail(field, `is ${v.length / 2} bytes, past ${maxBytes}`);
180
+ return v;
181
+ }
182
+ address(v, field) {
183
+ if (!isAddr(v))
184
+ this.fail(field, `isn't an address (${shown(v)})`);
185
+ return v;
186
+ }
187
+ /** `{ address, name, short }`, as the node labels an account: the name is a stranger's text. */
188
+ label(v, field) {
189
+ const o = this.obj(v, field);
190
+ return { address: this.address(o.address, `${field}.address`), name: o.name === null || o.name === undefined ? null : this.str(o.name, `${field}.name`, 128), short: this.str(o.short, `${field}.short`, 64) };
191
+ }
192
+ }
193
+ /** Whether `v` is a canonical address under a known prefix (kept here so this module has no import cycle). */
194
+ function isAddr(v) {
195
+ if (typeof v !== "string" || v.length > 90 || v !== v.toLowerCase())
196
+ return false;
197
+ try {
198
+ const { prefix, words } = bech32m.decode(v);
199
+ return (prefix === "boo" || prefix === "tboo") && bech32m.fromWords(words).length === 32;
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ /** `boo1ab…cdef`: the short form of an address for sentences. */
206
+ function shortAddress(a) {
207
+ return a.length > 18 ? `${a.slice(0, 10)}…${a.slice(-6)}` : a;
208
+ }
209
+ // ---- arguments, refused before any request or signature ----
210
+ /** A program's address as a caller gives it: never a name, which moves with its owner (docs/PROGRAMS-V0.md §8.1). */
211
+ export function programAddressArg(s, what = "a program") {
212
+ if (!isAddr(s))
213
+ throw new Error(`${what} is named by its address (boo1… or tboo1…), never by a .boo name: ${shown(s)} isn't one`);
214
+ return s;
215
+ }
216
+ /** A code, interface or version digest as a caller gives it: 64 lowercase hex characters. */
217
+ export function programHashArg(s, what) {
218
+ if (typeof s !== "string" || !/^[0-9a-f]{64}$/.test(s))
219
+ throw new Error(`${what} is 64 lowercase hex characters, not ${shown(s)}`);
220
+ return s;
221
+ }
222
+ /** A method name: an identifier, as the chain's interfaces name them (`[a-z][a-z0-9_]{0,31}`). */
223
+ export function programMethodArg(s) {
224
+ if (typeof s !== "string" || !IDENT.test(s))
225
+ throw new Error(`a method name is a lowercase letter, then lowercase letters, digits and underscores, at most 32: ${shown(s)} isn't one`);
226
+ return s;
227
+ }
228
+ /** Bytes as a caller gives them (code, an interface, an input): raw, or lowercase hex of whole bytes; at most `max`. */
229
+ export function programBytesArg(v, what, max) {
230
+ let bytes;
231
+ if (v instanceof Uint8Array)
232
+ bytes = v;
233
+ else if (typeof v === "string" && v.length % 2 === 0 && /^[0-9a-f]*$/.test(v))
234
+ bytes = hexToBytes(v);
235
+ else
236
+ throw new Error(`${what} is bytes, or lowercase hex of whole bytes, not ${shown(v)}`);
237
+ if (bytes.length > max)
238
+ throw new Error(`${what} is ${bytes.length} bytes, past the ${max} the chain takes`);
239
+ return bytes;
240
+ }
241
+ /** A call's input as the request and the transaction carry it: lowercase hex, at most `MAX_PROGRAM_INPUT_BYTES`. */
242
+ export const programInputArg = (v) => bytesToHex(programBytesArg(v, "an input", MAX_PROGRAM_INPUT_BYTES));
243
+ /** A fuel amount a caller gives: a whole number from 1 to 2^53 − 1 (the chain's own bound is lower). */
244
+ export function positiveFuel(n) {
245
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1)
246
+ throw new Error(`fuel is a whole number of at least 1, not ${shown(n)}`);
247
+ return n;
248
+ }
249
+ const SCALARS = new Set(["u8", "u16", "u32", "u64", "u128", "i64", "i128", "bool", "fang", "address", "name", "hash32"]);
250
+ /** An interface type as the node serves it, refusing anything outside the closed vocabulary (and deeper than the format allows: 4 layers). */
251
+ function checkType(a, v, field, depth = 0) {
252
+ // `depth` counts the option and vec layers around this type: an interface allows four (§3.2).
253
+ if (depth > 4)
254
+ a.fail(field, "nests deeper than an interface type can");
255
+ if (typeof v === "string") {
256
+ if (!SCALARS.has(v))
257
+ a.fail(field, `isn't a type an interface can hold (${shown(v)})`);
258
+ return v;
259
+ }
260
+ const o = a.obj(v, field);
261
+ const keys = Object.keys(o);
262
+ if (keys.length !== 1)
263
+ a.fail(field, `isn't one type (${shown(keys)})`);
264
+ switch (keys[0]) {
265
+ case "bytes": return { bytes: a.int(o.bytes, `${field}.bytes`) };
266
+ case "text": return { text: a.int(o.text, `${field}.text`) };
267
+ case "option": return { option: checkType(a, o.option, `${field}.option`, depth + 1) };
268
+ case "vec": {
269
+ const pair = a.arr(o.vec, `${field}.vec`, 2);
270
+ if (pair.length !== 2)
271
+ a.fail(`${field}.vec`, "isn't a type and a most");
272
+ return { vec: [checkType(a, pair[0], `${field}.vec[0]`, depth + 1), a.int(pair[1], `${field}.vec[1]`)] };
273
+ }
274
+ default: return a.fail(field, `isn't a type an interface can hold (${shown(keys[0])})`);
275
+ }
276
+ }
277
+ const IDENT = /^[a-z][a-z0-9_]{0,31}$/;
278
+ function checkMethod(a, v, field) {
279
+ const o = a.obj(v, field);
280
+ const untrusted_name = a.str(o.untrusted_name, `${field}.untrusted_name`, 64);
281
+ // The chain's identifiers are `[a-z][a-z0-9_]{0,31}`: any other text is no method of any program.
282
+ if (!IDENT.test(untrusted_name))
283
+ a.fail(`${field}.untrusted_name`, `isn't an identifier a method can have (${shown(untrusted_name)})`);
284
+ const selector = a.str(o.selector, `${field}.selector`, 16);
285
+ if (selector !== methodSelector(untrusted_name))
286
+ a.fail(`${field}.selector`, `isn't the selector of ${shown(untrusted_name)}`);
287
+ const kind = o.kind;
288
+ if (kind !== "call" && kind !== "view" && kind !== "init")
289
+ a.fail(`${field}.kind`, `isn't call, view or init (${shown(kind)})`);
290
+ const args = a.arr(o.args, `${field}.args`, 16).map((x, i) => {
291
+ const arg = a.obj(x, `${field}.args[${i}]`);
292
+ const name = a.str(arg.untrusted_name, `${field}.args[${i}].untrusted_name`, 64);
293
+ if (!IDENT.test(name))
294
+ a.fail(`${field}.args[${i}].untrusted_name`, `isn't an identifier an argument can have (${shown(name)})`);
295
+ return { untrusted_name: name, type: checkType(a, arg.type, `${field}.args[${i}].type`) };
296
+ });
297
+ return { untrusted_name, selector, kind: kind, payable: a.bool(o.payable, `${field}.payable`), args, returns: o.returns === null || o.returns === undefined ? null : checkType(a, o.returns, `${field}.returns`) };
298
+ }
299
+ /** `GET /api/program/{address}` checked against what was asked (`address`) and the chain's clock (`clock`: refuses a height the chain can't have reached). */
300
+ export function checkProgram(rpc, address, raw, clock = () => { }) {
301
+ const a = new Answer(rpc, `GET /api/program/${isAddr(address) ? address : shown(address)}`);
302
+ const o = a.obj(raw, "answer");
303
+ if (a.address(o.address, "address") !== address)
304
+ a.fail("address", `isn't the program that was asked for (${shown(o.address)})`);
305
+ const code = a.obj(o.code, "code");
306
+ const wasm_hash = a.digest(code.wasm_hash, "code.wasm_hash");
307
+ const interface_hash = a.digest(code.interface_hash, "code.interface_hash");
308
+ const version = a.int(o.version, "version");
309
+ if (version < 1 || version > 0xffff_ffff)
310
+ a.fail("version", `isn't a program version (${version})`);
311
+ const reported = a.digest(o.version_digest, "version_digest");
312
+ const digest = versionDigest(address, wasm_hash, interface_hash, version);
313
+ if (reported !== digest)
314
+ a.fail("version_digest", `isn't what its code, interface and version compute (${shown(reported)}, not ${digest.slice(0, 16)}…): the node is showing one thing and would have you sign another`);
315
+ const policy = o.policy;
316
+ if (policy !== "open" && policy !== "additive")
317
+ a.fail("policy", `isn't open or additive (${shown(policy)})`);
318
+ const storage = a.obj(o.storage, "storage");
319
+ const height = a.int(o.height, "height");
320
+ clock(height);
321
+ return {
322
+ address,
323
+ creator: a.label(o.creator, "creator"),
324
+ created_at: a.int(o.created_at, "created_at"),
325
+ code: { wasm_hash, size: code.size === null || code.size === undefined ? null : a.int(code.size, "code.size"), interface_hash },
326
+ version,
327
+ version_digest: digest,
328
+ authority: o.authority === null || o.authority === undefined ? null : a.label(o.authority, "authority"),
329
+ upgrade_delay_blocks: a.int(o.upgrade_delay_blocks, "upgrade_delay_blocks"),
330
+ policy: policy,
331
+ upgradability: a.str(o.upgradability, "upgradability", 600),
332
+ accepts_transfers: a.bool(o.accepts_transfers, "accepts_transfers"),
333
+ balance: a.amount(o.balance, "balance"),
334
+ storage: { items: a.int(storage.items, "storage.items"), bytes: a.int(storage.bytes, "storage.bytes"), deposit: a.amount(storage.deposit, "storage.deposit"), sponsored: a.amount(storage.sponsored, "storage.sponsored") },
335
+ record_deposit: a.amount(o.record_deposit, "record_deposit"),
336
+ record_payer: a.label(o.record_payer, "record_payer"),
337
+ methods: o.methods === null || o.methods === undefined ? null : a.arr(o.methods, "methods", 64).map((m, i) => checkMethod(a, m, `methods[${i}]`)),
338
+ proof_path: a.str(o.proof_path, "proof_path", 300),
339
+ height,
340
+ proven: false,
341
+ };
342
+ }
343
+ /** `GET /api/code/{hash}[?wasm=1]` checked: it's the code asked for, and when the bytes came they hash to it. */
344
+ export function checkCode(rpc, hash, raw, wantWasm, clock = () => { }) {
345
+ const a = new Answer(rpc, `GET /api/code/${hash}`);
346
+ const o = a.obj(raw, "answer");
347
+ if (a.digest(o.wasm_hash, "wasm_hash") !== hash)
348
+ a.fail("wasm_hash", `isn't the code that was asked for (${shown(o.wasm_hash)})`);
349
+ const size = a.int(o.size, "size");
350
+ // Exports are the `bk_<name>` functions of validated code, so each is an identifier: any other text is no export of any code.
351
+ const exports = a.arr(o.untrusted_exports, "untrusted_exports", 64).map((e, i) => {
352
+ const name = a.str(e, `untrusted_exports[${i}]`, 64);
353
+ if (!IDENT.test(name))
354
+ a.fail(`untrusted_exports[${i}]`, `isn't an identifier a method can have (${shown(name)})`);
355
+ return name;
356
+ });
357
+ const height = a.int(o.height, "height");
358
+ clock(height);
359
+ const out = {
360
+ wasm_hash: hash,
361
+ size,
362
+ untrusted_exports: exports,
363
+ uploader: o.uploader === null || o.uploader === undefined ? null : a.label(o.uploader, "uploader"),
364
+ deposit: o.deposit === null || o.deposit === undefined ? null : a.amount(o.deposit, "deposit"),
365
+ refs: o.refs === null || o.refs === undefined ? null : a.int(o.refs, "refs"),
366
+ uploaded_at: o.uploaded_at === null || o.uploaded_at === undefined ? null : a.int(o.uploaded_at, "uploaded_at"),
367
+ last_used_at: o.last_used_at === null || o.last_used_at === undefined ? null : a.int(o.last_used_at, "last_used_at"),
368
+ height,
369
+ };
370
+ if (wantWasm) {
371
+ const bytes = hexToBytes(a.hex(o.wasm, "wasm", MAX_PROGRAM_CODE_BYTES));
372
+ if (wasmHash(bytes) !== hash)
373
+ a.fail("wasm", "doesn't hash to the code that was asked for: a different program's bytes");
374
+ if (bytes.length !== size)
375
+ a.fail("wasm", `is ${bytes.length} bytes, not the ${size} the answer says`);
376
+ out.wasm = bytes;
377
+ }
378
+ return out;
379
+ }
380
+ /** `GET /api/interface/{hash}` checked: it's the interface asked for, and its bytes hash to it. */
381
+ export function checkInterface(rpc, hash, raw, clock = () => { }) {
382
+ const a = new Answer(rpc, `GET /api/interface/${hash}`);
383
+ const o = a.obj(raw, "answer");
384
+ if (a.digest(o.interface_hash, "interface_hash") !== hash)
385
+ a.fail("interface_hash", `isn't the interface that was asked for (${shown(o.interface_hash)})`);
386
+ const size = a.int(o.size, "size");
387
+ const hex = a.hex(o.hex, "hex", MAX_PROGRAM_INTERFACE_BYTES);
388
+ const bytes = hexToBytes(hex);
389
+ if (interfaceHash(bytes) !== hash)
390
+ a.fail("hex", "doesn't hash to the interface that was asked for: a different interface's bytes");
391
+ if (bytes.length !== size)
392
+ a.fail("size", `isn't the ${bytes.length} bytes the answer holds`);
393
+ const height = a.int(o.height, "height");
394
+ clock(height);
395
+ const opt = (v, f) => (v === null || v === undefined ? null : f(v));
396
+ return {
397
+ interface_hash: hash,
398
+ size,
399
+ bytes,
400
+ hex,
401
+ untrusted_interface: o.untrusted_interface ?? null,
402
+ uploader: opt(o.uploader, (x) => a.label(x, "uploader")),
403
+ deposit: opt(o.deposit, (x) => a.amount(x, "deposit")),
404
+ refs: opt(o.refs, (x) => a.int(x, "refs")),
405
+ uploaded_at: opt(o.uploaded_at, (x) => a.int(x, "uploaded_at")),
406
+ last_used_at: opt(o.last_used_at, (x) => a.int(x, "last_used_at")),
407
+ height,
408
+ };
409
+ }
410
+ /** §8.1's table: each code's number. */
411
+ const OUTCOME_NUMBERS = { trapped: 1, aborted: 2, commit: 3, value_cap: 10, exit_only: 11, storage_limit: 12, state_full: 13, invalid_code: 14, translation_refused: 15, interface_mismatch: 16, native: 20 };
412
+ function checkOutcome(a, v, field) {
413
+ const o = a.obj(v, field);
414
+ const ok = a.bool(o.ok, `${field}.ok`);
415
+ if (ok)
416
+ return { ok: true, number: 0, code: "ok" };
417
+ const code = a.str(o.code, `${field}.code`, 40);
418
+ const number = a.int(o.number, `${field}.number`);
419
+ // A code this SDK doesn't know may be a later protocol version's: shown as such. A known code must carry its own number.
420
+ // (`hasOwn`: a code named `constructor` or `__proto__` is no code of §8.1's table, and is shown as one this SDK doesn't know.)
421
+ if (Object.hasOwn(OUTCOME_NUMBERS, code) && OUTCOME_NUMBERS[code] !== number)
422
+ a.fail(`${field}.number`, `isn't ${code}'s (${number}, not ${OUTCOME_NUMBERS[code]})`);
423
+ const out = { ok: false, number, code };
424
+ const numbered = (x, f) => {
425
+ const n = a.obj(x, f);
426
+ return { number: a.int(n.number, `${f}.number`), name: a.str(n.name, `${f}.name`, 64) };
427
+ };
428
+ if (o.program !== undefined)
429
+ out.program = a.address(o.program, `${field}.program`);
430
+ if (o.trap !== undefined)
431
+ out.trap = numbered(o.trap, `${field}.trap`);
432
+ if (o.abort_code !== undefined)
433
+ out.abort_code = a.int(o.abort_code, `${field}.abort_code`);
434
+ if (o.untrusted_message !== undefined)
435
+ out.untrusted_message = a.str(o.untrusted_message, `${field}.untrusted_message`, 1024);
436
+ if (o.commit_error !== undefined)
437
+ out.commit_error = a.int(o.commit_error, `${field}.commit_error`);
438
+ if (o.cap !== undefined)
439
+ out.cap = a.amount(o.cap, `${field}.cap`);
440
+ if (o.control !== undefined)
441
+ out.control = a.str(o.control, `${field}.control`, 40);
442
+ if (o.rule !== undefined)
443
+ out.rule = numbered(o.rule, `${field}.rule`);
444
+ if (o.func !== undefined)
445
+ out.func = a.int(o.func, `${field}.func`);
446
+ if (o.offset !== undefined)
447
+ out.offset = a.int(o.offset, `${field}.offset`);
448
+ return out;
449
+ }
450
+ /** A number of an outcome, for a sentence: itself when it's a whole number (an outcome that wasn't checked may hold anything), else `?`. */
451
+ const figure = (v) => (typeof v === "number" && Number.isSafeInteger(v) ? String(v) : "?");
452
+ /** A name from an outcome (a trap's or a rule's), for a sentence: stripped of hidden characters and cut, `?` when it isn't text. */
453
+ const outcomeName = (v) => (typeof v === "string" ? [...plainText(v)].slice(0, 40).join("").replace(/_/g, " ") : "?");
454
+ /**
455
+ * A failed outcome in words, from its numbers and the chain's own names for them (docs/PROGRAMS-V0.md
456
+ * §8.1): never engine text, and **never what the program wrote**: an abort's own message stays in
457
+ * `outcome.untrusted_message`, for the caller to show as data. These words end up in exceptions that
458
+ * agent frameworks hand to models, and a stranger's text doesn't belong in them. Tolerant of an
459
+ * outcome that wasn't checked (a field of another type reads as `?`): it never throws on a node's
460
+ * odd answer. `storageLimit` is the limit the failed transaction signed, drops: a call that traps
461
+ * `storage_limit` was refused its writes' deposit by it (the default for a call is 0, which consents
462
+ * to no new storage), and says so.
463
+ */
464
+ export function outcomeWords(o, storageLimit) {
465
+ if (o === null || typeof o !== "object")
466
+ return "it failed, and the node's outcome for it can't be read";
467
+ const program = typeof o.program === "string" && isAddr(o.program) ? shortAddress(o.program) : "?";
468
+ switch (o.code) {
469
+ case "ok": return "it applied";
470
+ case "trapped": {
471
+ const hint = o.trap?.name === "storage_limit" ? `: the call's writes bond a deposit, and its storage limit (${storageLimit === undefined ? "0 unless given" : `${storageLimit} drops`}) was too small for it: pass a larger \`storageLimit\` for any method that writes` : "";
472
+ return `program ${program} trapped: ${outcomeName(o.trap?.name)} (trap ${figure(o.trap?.number)})${hint}`;
473
+ }
474
+ case "aborted": return `program ${program} aborted with code ${figure(o.abort_code)}${o.untrusted_message ? " (its message is in the receipt's untrusted_message)" : ""}`;
475
+ case "commit": return `the changes failed the chain's own commit checks (commit error ${figure(o.commit_error)})`;
476
+ case "value_cap": return `programs would hold more than the value cap (${typeof o.cap === "string" && /^\d{1,20}$/.test(o.cap) ? o.cap : "?"} drops)`;
477
+ case "exit_only": return "programs are exit-only: a call may not put more FANG under programs' control";
478
+ case "storage_limit": return "the deposits passed the transaction's storage limit";
479
+ case "state_full": return "program state is full";
480
+ case "invalid_code": return `the code was refused: validation rule ${figure(o.rule?.number)} (${outcomeName(o.rule?.name)})`;
481
+ case "translation_refused": return "the engine refused to translate the code";
482
+ case "interface_mismatch": return "the interface's methods aren't the code's exports";
483
+ case "native": return "a native check failed (funds, locks or the creation fee)";
484
+ default: return `it failed with code ${shown(o.code)} (number ${figure(o.number)}), which this SDK doesn't know`;
485
+ }
486
+ }
487
+ export function checkView(rpc, address, raw, clock = () => { }) {
488
+ const a = new Answer(rpc, `POST /api/program/${address}/view`);
489
+ const o = a.obj(raw, "answer");
490
+ const ok = a.bool(o.ok, "ok");
491
+ const outcome = checkOutcome(a, o.outcome, "outcome");
492
+ if (outcome.ok !== ok)
493
+ a.fail("outcome", "disagrees with ok");
494
+ const return_data = ok ? a.hex(o.return_data, "return_data", 4 * 1024) : null;
495
+ if (!ok && o.return_data !== null && o.return_data !== undefined)
496
+ a.fail("return_data", "is there for a view that failed");
497
+ const height = a.int(o.height, "height");
498
+ clock(height);
499
+ return { ok, outcome, return_data, fuel_used: a.int(o.fuel_used, "fuel_used"), height };
500
+ }
501
+ /** `/api/fees`' `fuel` section: the chain's, or `ProgramsOffError` when it has none (programs never switched on). */
502
+ export function checkFuel(rpc, raw) {
503
+ if (raw === undefined || raw === null)
504
+ throw new ProgramsOffError(rpc);
505
+ const a = new Answer(rpc, "GET /api/fees");
506
+ const o = a.obj(raw, "fuel");
507
+ const status = o.status;
508
+ if (status !== "on" && status !== "exit_only" && status !== "paused")
509
+ a.fail("fuel.status", `isn't on, exit_only or paused (${shown(status)})`);
510
+ // The SDK prices with schedule 0's numbers: another schedule, or other numbers under that name, aren't priced here.
511
+ const schedule = a.int(o.schedule, "fuel.schedule");
512
+ if (schedule !== SCHEDULE_V0.schedule)
513
+ a.fail("fuel.schedule", `is ${schedule}, a fuel schedule this SDK doesn't know: update @blockula/sdk`);
514
+ for (const [key, want] of [["load_base", SCHEDULE_V0.load_base], ["load_per_byte", SCHEDULE_V0.load_per_byte], ["upload_base", SCHEDULE_V0.upload_base], ["upload_per_byte", SCHEDULE_V0.upload_per_byte]]) {
515
+ if (BigInt(a.int(o[key], `fuel.${key}`)) !== want)
516
+ a.fail(`fuel.${key}`, `is ${o[key]}, not schedule 0's ${want}`);
517
+ }
518
+ const rate = BigInt(a.int(o.rate, "fuel.rate"));
519
+ const floor_rate = BigInt(a.int(o.floor_rate, "fuel.floor_rate"));
520
+ const suggested_rate = BigInt(a.int(o.suggested_rate, "fuel.suggested_rate"));
521
+ if (suggested_rate < rate || rate < floor_rate)
522
+ a.fail("fuel.suggested_rate", "isn't at least the rate, which isn't at least the floor");
523
+ return {
524
+ status: status,
525
+ rate,
526
+ floor_rate,
527
+ suggested_rate,
528
+ max_tx_fuel: BigInt(a.int(o.max_tx_fuel, "fuel.max_tx_fuel")),
529
+ max_block_fuel: BigInt(a.int(o.max_block_fuel, "fuel.max_block_fuel")),
530
+ max_tx_code_bytes: a.int(o.max_tx_code_bytes, "fuel.max_tx_code_bytes"),
531
+ state_bytes: BigInt(a.int(o.state_bytes, "fuel.state_bytes")),
532
+ state_soft_bytes: BigInt(a.int(o.state_soft_bytes, "fuel.state_soft_bytes")),
533
+ storage_price: o.storage_price === null ? null : BigInt(a.amount(o.storage_price, "fuel.storage_price")),
534
+ paused_until: o.paused_until === null || o.paused_until === undefined ? null : a.int(o.paused_until, "fuel.paused_until"),
535
+ };
536
+ }
537
+ export function checkParams(rpc, raw) {
538
+ if (raw === undefined || raw === null)
539
+ throw new ProgramsOffError(rpc);
540
+ const a = new Answer(rpc, "GET /api/params");
541
+ const params = a.obj(a.obj(raw, "programs").params, "programs.params");
542
+ const value = (key) => a.amount(a.obj(params[key], `programs.params.${key}`).value, `programs.params.${key}.value`);
543
+ const storage_price = BigInt(value("program_storage_price"));
544
+ if (storage_price === 0n)
545
+ a.fail("programs.params.program_storage_price.value", "is 0: a deposit of nothing");
546
+ const delay = BigInt(value("program_min_upgrade_delay_blocks"));
547
+ if (delay > BigInt(Number.MAX_SAFE_INTEGER))
548
+ a.fail("programs.params.program_min_upgrade_delay_blocks.value", "is past 2^53 − 1");
549
+ return { storage_price, min_upgrade_delay_blocks: Number(delay) };
550
+ }
551
+ /** The fuel to load one distinct code of `size` bytes (schedule 0: `load_base + load_per_byte × size`, §5.1). */
552
+ export const loadFuel = (size) => SCHEDULE_V0.load_base + SCHEDULE_V0.load_per_byte * BigInt(size);
553
+ /** The fuel an upload of `code` bytes of WebAssembly and `iface` bytes of interface reserves (§5.1's `upload_fuel`: its whole `exec`). */
554
+ export const uploadFuel = (code, iface) => SCHEDULE_V0.upload_base + SCHEDULE_V0.upload_per_byte * (BigInt(code) + BigInt(iface));
555
+ /** The fuel fee of `reserved` fuel at `rate` milli-drops per fuel: `rate × reserved / 1000`, rounded down (§5.1). Burned, never refunded. */
556
+ export const fuelFee = (rate, reserved) => (rate * reserved) / FUEL_RATE_SCALE;
557
+ /** `run + load`, refused (`FuelReservationError`) when no block could carry it: past `max_block_fuel`, which admission holds every transaction to. */
558
+ function checkBlockFuel(fuel, run, load) {
559
+ const reserved = run + load;
560
+ if (reserved > fuel.max_block_fuel)
561
+ throw new FuelReservationError(reserved, run, load, fuel.max_block_fuel);
562
+ return reserved;
563
+ }
564
+ /**
565
+ * Whether a block could carry `run` fuel and the load of the `declared` code (each distinct code
566
+ * hash once, with its size): refused as admission refuses it, before anything is signed. A
567
+ * reservation past `max_block_fuel` is a `FuelReservationError`; code past what one transaction may
568
+ * load, or a fuel limit past `max_tx_fuel` (`execFuelLimited`: a create's or a call's own limit, not
569
+ * an upload's), is an `Error` that says so.
570
+ */
571
+ export function reserveFuel(fuel, run, declared, execFuelLimited = true) {
572
+ if (execFuelLimited && run > fuel.max_tx_fuel)
573
+ throw new Error(`a fuel limit of ${run} is above program_max_tx_fuel (${fuel.max_tx_fuel}). Nothing was signed`);
574
+ let bytes = 0;
575
+ let load = 0n;
576
+ for (const size of declared.values()) {
577
+ bytes += size;
578
+ load += loadFuel(size);
579
+ }
580
+ if (bytes > fuel.max_tx_code_bytes)
581
+ throw new Error(`the code this transaction declares is ${bytes} bytes, past the ${fuel.max_tx_code_bytes} one transaction may load: bind fewer or smaller programs. Nothing was signed`);
582
+ return { run, load, reserved: checkBlockFuel(fuel, run, load) };
583
+ }
584
+ /**
585
+ * The fuel limit a call or an initializer is signed with when the caller gives none (docs/PROGRAMS-V0.md
586
+ * §8.4): `min(program_max_tx_fuel, program_max_block_fuel − load)`, the most one transaction may run
587
+ * that a block can still carry beside the load of the `declared` code. With the parameters at their
588
+ * defaults that's `program_max_tx_fuel`; with `program_max_tx_fuel` = `program_max_block_fuel` (both
589
+ * legal) a bare `program_max_tx_fuel` would itself trip the refusal of a reservation past the block.
590
+ * When the load alone fills the block there is no room: 1, which `reserveFuel` then refuses in words.
591
+ */
592
+ export function defaultFuelLimit(fuel, declared) {
593
+ let load = 0n;
594
+ for (const size of declared.values())
595
+ load += loadFuel(size);
596
+ const room = fuel.max_block_fuel - load;
597
+ if (room < 1n)
598
+ return 1n;
599
+ return fuel.max_tx_fuel < room ? fuel.max_tx_fuel : room;
600
+ }
601
+ /**
602
+ * The fuel fee of a reservation (drops) at the rate the next 17 blocks can reach (`suggested_rate`:
603
+ * what a transaction's `fee` is priced at, §5.5) and at the floor rate (what the tip cap is counted
604
+ * on: a block's rate is never below it), after holding it to `max_block_fuel` once more against
605
+ * these very prices. Burned, and never refunded: a transaction pays for what it reserves.
606
+ */
607
+ export function priceFuel(fuel, r) {
608
+ const reserved = checkBlockFuel(fuel, r.run, r.load);
609
+ return { suggested: fuelFee(fuel.suggested_rate, reserved), floor: fuelFee(fuel.floor_rate, reserved) };
610
+ }
611
+ /**
612
+ * Whether the chain's programs status admits this kind of transaction now: `on` admits all;
613
+ * `exit_only` only a call that sends no FANG (an exit); `paused` none. The node refuses the rest at
614
+ * submission, so this only says so before a signature (`sendsValue`: the transaction attaches FANG).
615
+ */
616
+ export function checkProgramsStatus(fuel, kind, sendsValue) {
617
+ if (fuel.status === "on")
618
+ return;
619
+ if (fuel.status === "paused")
620
+ throw new Error(`programs are paused until block ${fuel.paused_until ?? "?"}: the chain refuses every program transaction until then. Nothing was signed`);
621
+ if (kind !== "call" || sendsValue)
622
+ throw new Error(`programs are exit-only (governance switched them off): the chain refuses ${kind === "call" ? "a call that sends FANG" : `an ${kind === "upload" ? "upload" : "create"}`}. Nothing was signed`);
623
+ }
624
+ /**
625
+ * The price of a byte of program state when it holds `used` bytes (node/vm/src/storage.rs `price`):
626
+ * `base × 2^⌊8 × used / soft⌋`. `null` past the ceiling (`2 × soft`) or if the price wouldn't fit a u64.
627
+ */
628
+ export function programStoragePrice(base, soft, used) {
629
+ if (soft === 0n || used > 2n * soft)
630
+ return null;
631
+ const price = base << ((8n * used) / soft);
632
+ return price >= 1n << 64n ? null : price;
633
+ }
634
+ /**
635
+ * What an upload bonds (node/src/program_cli.rs `Bounds::upload_deposit`, §2.2 step 5): each blob that
636
+ * isn't stored yet, code first, at the price once it lands. `null` past program state's ceiling.
637
+ */
638
+ export function programUploadDeposit(fuel, params, blobs) {
639
+ let used = fuel.state_bytes;
640
+ let total = 0n;
641
+ for (const { size, isNew } of blobs) {
642
+ if (!isNew)
643
+ continue;
644
+ used += BigInt(size);
645
+ const price = programStoragePrice(params.storage_price, fuel.state_soft_bytes, used);
646
+ if (price === null)
647
+ return null;
648
+ total += price * BigInt(size);
649
+ }
650
+ return total;
651
+ }
652
+ /** What a create bonds for its record (§2.3 step 2): `PROGRAM_RECORD_BYTES` at the price once it lands. `null` past the ceiling. */
653
+ export function programRecordDeposit(fuel, params) {
654
+ const price = programStoragePrice(params.storage_price, fuel.state_soft_bytes, fuel.state_bytes + PROGRAM_RECORD_BYTES);
655
+ return price === null ? null : price * PROGRAM_RECORD_BYTES;
656
+ }
657
+ /** A deposit and ten percent more: the default `storage_limit` of an upload or a create (§8.3), room for the price to move. */
658
+ export const depositWithMargin = (deposit) => deposit + deposit / 10n;
659
+ /**
660
+ * The `program` section of a `/api/tx` answer, checked: `null` for a program transaction with no
661
+ * receipt to show (still pending, or its block pruned from this node). Every amount is a decimal
662
+ * string, every digest 64 lowercase hex, and the sums the node states are recomputed: the fee parts
663
+ * equal the transaction's `fee`, `outside_vm.total` is its parts' sum, `reserved` is `limit + load`.
664
+ * A transaction that isn't a program's carries no `program` key, and `ProgramAnswerError` says so.
665
+ */
666
+ export function checkReceipt(rpc, tx) {
667
+ const a = new Answer(rpc, "GET /api/tx/{id}");
668
+ const t = a.obj(tx, "answer");
669
+ if (!("program" in t))
670
+ a.fail("program", "is missing: that isn't a program transaction");
671
+ if (t.program === null)
672
+ return null;
673
+ const p = a.obj(t.program, "program");
674
+ const kind = p.kind;
675
+ if (kind !== "upload" && kind !== "create" && kind !== "call")
676
+ a.fail("program.kind", `isn't upload, create or call (${shown(kind)})`);
677
+ const version = a.int(p.version, "program.version");
678
+ const outcome = checkOutcome(a, p.outcome, "program.outcome");
679
+ const facts = a.obj(p.facts, "program.facts");
680
+ const big = (v, f) => BigInt(a.amount(v, f));
681
+ const fee = a.obj(facts.fee, "program.facts.fee");
682
+ const parts = { bytes: big(fee.bytes, "program.facts.fee.bytes"), fuel: big(fee.fuel, "program.facts.fee.fuel"), tip: big(fee.tip, "program.facts.fee.tip") };
683
+ if (t.fee !== undefined && BigInt(a.amount(t.fee, "fee")) !== parts.bytes + parts.fuel + parts.tip)
684
+ a.fail("fee", "isn't the sum of the receipt's fee parts");
685
+ const out = a.obj(facts.outside_vm, "program.facts.outside_vm");
686
+ const outside = { record_deposit: big(out.record_deposit, "outside_vm.record_deposit"), upload_deposit: big(out.upload_deposit, "outside_vm.upload_deposit"), account_creation_fee: big(out.account_creation_fee, "outside_vm.account_creation_fee"), total: big(out.total, "outside_vm.total") };
687
+ if (outside.total !== outside.record_deposit + outside.upload_deposit + outside.account_creation_fee)
688
+ a.fail("program.facts.outside_vm.total", "isn't the sum of its parts");
689
+ const f = a.obj(facts.fuel, "program.facts.fuel");
690
+ const fuel = { limit: BigInt(a.int(f.limit, "fuel.limit")), load: BigInt(a.int(f.load, "fuel.load")), used: BigInt(a.int(f.used, "fuel.used")), reserved: BigInt(a.int(f.reserved, "fuel.reserved")), rate: BigInt(a.int(f.rate, "fuel.rate")) };
691
+ if (fuel.reserved !== fuel.limit + fuel.load)
692
+ a.fail("program.facts.fuel.reserved", "isn't the fuel limit and the load together");
693
+ const s = a.obj(facts.storage, "program.facts.storage");
694
+ if (typeof s.bytes_delta !== "number" || !Number.isSafeInteger(s.bytes_delta))
695
+ a.fail("program.facts.storage.bytes_delta", `isn't a whole number (${shown(s.bytes_delta)})`);
696
+ const burned = big(facts.burned_by_programs, "program.facts.burned_by_programs");
697
+ const changes = a.arr(facts.balance_changes, "program.facts.balance_changes").map((c, i) => {
698
+ const o = a.obj(c, `balance_changes[${i}]`);
699
+ return { account: a.address(o.account, `balance_changes[${i}].account`), change: BigInt(a.change(o.change, `balance_changes[${i}].change`)) };
700
+ });
701
+ if (new Set(changes.map((c) => c.account)).size !== changes.length)
702
+ a.fail("program.facts.balance_changes", "lists an account twice");
703
+ let upload = null;
704
+ if (facts.upload !== null && facts.upload !== undefined) {
705
+ const u = a.obj(facts.upload, "program.facts.upload");
706
+ upload = { wasm_hash: a.digest(u.wasm_hash, "upload.wasm_hash"), interface_hash: a.digest(u.interface_hash, "upload.interface_hash"), new_code: a.bool(u.new_code, "upload.new_code"), new_interface: a.bool(u.new_interface, "upload.new_interface"), deposit: big(u.deposit, "upload.deposit") };
707
+ }
708
+ const storage = { bytes_delta: s.bytes_delta, bonded: big(s.bonded, "storage.bonded"), refunded: big(s.refunded, "storage.refunded") };
709
+ const gap = conservationGap({ balance_changes: changes, storage, outside_vm: outside, burned_by_programs: burned });
710
+ if (gap !== 0n)
711
+ a.fail("program.facts", `don't conserve FANG: the balance changes, what was bonded and refunded and what the programs burned are ${gap} drops from adding up to nothing`);
712
+ const claims = a.obj(p.claims, "program.claims");
713
+ const omitted = claims.omitted === null || claims.omitted === undefined ? null : (() => {
714
+ const o = a.obj(claims.omitted, "claims.omitted");
715
+ return { events: a.int(o.events, "omitted.events"), event_bytes: a.int(o.event_bytes, "omitted.event_bytes"), return_bytes: a.int(o.return_bytes, "omitted.return_bytes") };
716
+ })();
717
+ return {
718
+ version,
719
+ kind: kind,
720
+ outcome,
721
+ facts: {
722
+ balance_changes: changes,
723
+ storage,
724
+ outside_vm: outside,
725
+ burned_by_programs: burned,
726
+ fuel,
727
+ fee: parts,
728
+ created: facts.created === null || facts.created === undefined ? null : a.address(facts.created, "program.facts.created"),
729
+ upload,
730
+ },
731
+ claims: {
732
+ return_data: a.arr(claims.return_data, "claims.return_data", 64).map((r, i) => {
733
+ const o = a.obj(r, `return_data[${i}]`);
734
+ return { program: a.address(o.program, `return_data[${i}].program`), untrusted_method: a.str(o.untrusted_method, `return_data[${i}].untrusted_method`, 64), value: big(o.value, `return_data[${i}].value`), data: a.hex(o.data, `return_data[${i}].data`, 4 * 1024) };
735
+ }),
736
+ events: a.arr(claims.events, "claims.events", 64).map((e, i) => {
737
+ const o = a.obj(e, `events[${i}]`);
738
+ return { program: a.address(o.program, `events[${i}].program`), untrusted_name: a.str(o.untrusted_name, `events[${i}].untrusted_name`, 64), data: a.hex(o.data, `events[${i}].data`, 16 * 1024) };
739
+ }),
740
+ omitted,
741
+ },
742
+ };
743
+ }
744
+ /** What a program transaction's fee came to, drops: bytes, fuel and tip (§8.1's `Σ`). */
745
+ export const chargedFee = (r) => r.facts.fee.bytes + r.facts.fee.fuel + r.facts.fee.tip;
746
+ /** What the VM's diff did to `account`'s balance, drops (0 for an account it doesn't list). */
747
+ export const vmChange = (r, account) => r.facts.balance_changes.find((c) => c.account === account)?.change ?? 0n;
748
+ /**
749
+ * How far a receipt's facts are from conserving FANG (docs/PROGRAMS-V0.md §8.1): Σ `balance_changes` +
750
+ * (`storage.bonded` − `outside_vm.record_deposit` − `outside_vm.upload_deposit` − `storage.refunded`) +
751
+ * `burned_by_programs`. The VM's diff conserves (what its programs moved and the deposits it bonded
752
+ * or freed, plus what it burned, sum to nothing), and a create's record deposit and an upload's are
753
+ * bonded outside it: so a receipt the chain wrote is 0 here, and `checkReceipt` refuses one that
754
+ * isn't. (The account creation fee a create burns from its signer is the signer's, not a program's:
755
+ * it's in `outside_vm`, not here.)
756
+ */
757
+ export const conservationGap = (f) => f.balance_changes.reduce((sum, c) => sum + c.change, 0n) + (f.storage.bonded - f.outside_vm.record_deposit - f.outside_vm.upload_deposit - f.storage.refunded) + f.burned_by_programs;
758
+ /**
759
+ * How the signer's balance moved, exactly (docs/PROGRAMS-V0.md §8.1, S6B-R1): Δbalance(signer) =
760
+ * −(fee total + `outside_vm.total`) + `balance_changes[signer]`. The fee is burned (and the tip goes
761
+ * to the proposer), the deposits and the account creation fee leave outside the VM, and the VM's diff
762
+ * holds what the programs moved, the signer's own write bonds among it. A failed transaction's receipt
763
+ * has no balance changes and nothing outside the VM: only the fee.
764
+ */
765
+ export const signerChange = (r, signer) => vmChange(r, signer) - chargedFee(r) - r.facts.outside_vm.total;
766
+ /**
767
+ * The signer's balance before and after its program transaction (both in drops, read around it with
768
+ * nothing else touching the account), held to `signerChange`.
769
+ */
770
+ export function reconcileSigner(r, signer, before, after) {
771
+ const expected = signerChange(r, signer);
772
+ const actual = after - before;
773
+ return { expected, actual, difference: actual - expected, exact: actual === expected, conserved: conservationGap(r.facts) === 0n };
774
+ }
775
+ export function programCost(r, signer) {
776
+ const fee = chargedFee(r);
777
+ const vm = -vmChange(r, signer);
778
+ return { fee, outside_vm: r.facts.outside_vm, vm, total: fee + r.facts.outside_vm.total + vm };
779
+ }