@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.
package/dist/index.js CHANGED
@@ -1,24 +1,351 @@
1
1
  import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
2
2
  import { blake3 } from "@noble/hashes/blake3.js";
3
3
  import { bytesToHex, hexToBytes, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
4
- import { bech32m } from "@scure/base";
5
- import { minFee, parseFang, signingBytes } from "./tx.js";
4
+ import { base64urlnopad, bech32m } from "@scure/base";
5
+ import { DEFAULT_TIP_CAP_MULTIPLE, KEY_POP_CONTEXT, PUBLIC_KEY_LEN, SIGNATURE_LEN, actionDigest, baseFee, formatFang, interfaceHash, keyPopMessage, maxTip, parseFang, parseSoul, programAddress, proposalDigest, seanceCommitment, seancePairsDigest, signingBytes, txBytes, txId, versionDigest, wasmHash } from "./tx.js";
6
+ import { NAMESPACES, checkProofResponse, decodeAccount, decodeName, decodeProgram } from "./proof.js";
7
+ import { ANY_PROGRAM_VERSION, MAX_PROGRAM_BINDINGS, MAX_PROGRAM_CALLS, MAX_PROGRAM_CODE_BYTES, MAX_PROGRAM_INTERFACE_BYTES, ProgramAnswerError, ProgramsOffError, defaultFuelLimit, depositWithMargin, plainText, positiveFuel, programAddressArg, programBytesArg, programHashArg, programInputArg, programMethodArg, checkCode, checkFuel, checkInterface, checkParams, checkProgram, checkProgramsStatus, checkReceipt, checkView, outcomeWords, priceFuel, programCost, programRecordDeposit, programUploadDeposit, reserveFuel, uploadFuel } from "./programs.js";
8
+ import { describeKind, reviewAction } from "./describe.js";
9
+ import { NETWORKS, isNetworkName } from "./networks.js";
6
10
  export * from "./tx.js";
11
+ export * from "./proof.js";
12
+ export * from "./distribution.js";
13
+ export * from "./release.js";
14
+ export * from "./describe.js";
15
+ export * from "./networks.js";
16
+ export * from "./payments.js";
17
+ export * from "./programs.js";
7
18
  export const DEFAULT_RPC = "http://localhost:7070";
8
- export function addressFromPublicKey(publicKey) {
9
- return bech32m.encode("boo", bech32m.toWords(blake3(publicKey)));
19
+ /** Address prefixes: `boo` on mainnet (and dev chains), `tboo` on public testnets. */
20
+ export const KNOWN_PREFIXES = ["boo", "tboo"];
21
+ export const DEFAULT_PREFIX = "boo";
22
+ /** Mainnet's chain id (node/src/chain.rs `MAINNET_CHAIN_ID`). */
23
+ export const MAINNET_CHAIN_ID = "blockula-1";
24
+ /** How long a transaction stays valid by default: 600 blocks (10 minutes at 1 s blocks). */
25
+ export const DEFAULT_VALID_FOR_BLOCKS = 600;
26
+ /** The address of a public key: bech32m(prefix, BLAKE3(public key)). */
27
+ export function addressFromPublicKey(publicKey, prefix = DEFAULT_PREFIX) {
28
+ if (!KNOWN_PREFIXES.includes(prefix))
29
+ throw new Error(`unknown address prefix ${prefix}`);
30
+ return bech32m.encode(prefix, bech32m.toWords(blake3(publicKey)));
10
31
  }
11
32
  /** A key's id: BLAKE3(public key) as hex. Same 32 bytes as inside the `boo1…` address it derives. */
12
33
  export function keyId(publicKey) {
13
34
  return bytesToHex(blake3(publicKey));
14
35
  }
15
- export function isAddress(s) {
36
+ /** The prefix of a canonical address (`boo`, `tboo`), or `undefined` if `s` isn't one. */
37
+ export function addressPrefix(s) {
16
38
  // Addresses are canonical lowercase; the node rejects any other casing.
17
39
  if (s !== s.toLowerCase())
18
- return false;
40
+ return undefined;
19
41
  try {
20
42
  const { prefix, words } = bech32m.decode(s);
21
- return prefix === "boo" && bech32m.fromWords(words).length === 32;
43
+ return KNOWN_PREFIXES.includes(prefix) && bech32m.fromWords(words).length === 32 ? prefix : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ /** Whether `s` is an address: with any known prefix, or exactly `prefix` when given. */
50
+ export function isAddress(s, prefix) {
51
+ const p = addressPrefix(s);
52
+ return p !== undefined && (prefix === undefined || p === prefix);
53
+ }
54
+ const positive = (drops, what) => {
55
+ if (drops <= 0n)
56
+ throw new Error(`${what} more than 0`);
57
+ if (drops >= 1n << 64n)
58
+ throw new Error(`${what}: amount too large`);
59
+ return drops;
60
+ };
61
+ const wholeNumber = (n, what) => {
62
+ if (!Number.isSafeInteger(n) || n < 0)
63
+ throw new Error(`${what} must be a whole number`);
64
+ return n;
65
+ };
66
+ /** `n` as drops a transaction can carry: 0 to 2^64 − 1. */
67
+ const checkedDrops = (n, what) => {
68
+ if (n < 0n || n >= 1n << 64n)
69
+ throw new Error(`${what}: amount too large`);
70
+ return n;
71
+ };
72
+ /** How long a program helper waits by default for what it needs to be usable: a minute of blocks. */
73
+ const DEFAULT_PROGRAM_WAIT_MS = 60_000;
74
+ /** The program actions that reserve fuel (tags 62–64) and the helper that prices it: `build()` and the generic paths refuse them, naming it. */
75
+ const PROGRAM_HELPERS = new Map([["upload_code", "wallet.uploadCode"], ["create_program", "wallet.createProgram"], ["call_program", "wallet.callProgram (or wallet.call)"]]);
76
+ const programWaitMs = (ms) => (ms === undefined ? DEFAULT_PROGRAM_WAIT_MS : checkTimeoutMs(ms, "waitMs"));
77
+ const hexArg = (s, what) => {
78
+ const h = s.trim().toLowerCase();
79
+ if (!/^[0-9a-f]{64}$/.test(h))
80
+ throw new Error(`${what} must be 32 bytes of hex`);
81
+ return h;
82
+ };
83
+ /** A `.boo` name as the chain stores it (node/src/types.rs `normalize_name`): lowercase, without the suffix. */
84
+ export function bareName(name) {
85
+ const n = name.trim().toLowerCase().replace(/\.boo$/, "");
86
+ if (n.length < 3 || n.length > 32)
87
+ throw new Error("names must be 3–32 characters");
88
+ if (!/^[a-z0-9-]+$/.test(n))
89
+ throw new Error("names may only use a-z, 0-9 and '-'");
90
+ if (n.startsWith("-") || n.endsWith("-"))
91
+ throw new Error("names can't start or end with '-'");
92
+ return n;
93
+ }
94
+ /**
95
+ * The most `Wallet.propose` bonds by default without being told (audit gap-7-5): mainnet's genesis
96
+ * `proposal_deposit` (node/src/params.rs). A node reporting more is refused; pass `deposit`.
97
+ */
98
+ export const DEFAULT_MAX_PROPOSAL_DEPOSIT = 1000n * 1000000000n;
99
+ /** Highest vote conviction (node/src/params.rs `MAX_CONVICTION`). */
100
+ export const MAX_CONVICTION = 6;
101
+ /** Lock periods a vote at each conviction locks its amount for (params.rs `CONVICTION_PERIODS`) … */
102
+ export const CONVICTION_PERIODS = [0, 1, 2, 4, 8, 16, 32];
103
+ /** … and its weight in tenths of the amount (`CONVICTION_TENTHS`): 0.1× with no lock, then 1×–6×. */
104
+ export const CONVICTION_TENTHS = [1, 10, 20, 30, 40, 50, 60];
105
+ /** A vote's weight in drops: `amount` × the conviction multiplier (params.rs `vote_weight`). */
106
+ export function convictionWeight(amount, conviction) {
107
+ const t = CONVICTION_TENTHS[conviction];
108
+ if (t === undefined)
109
+ throw new Error(`conviction must be 0–${MAX_CONVICTION}`);
110
+ return (parseFang(amount) * BigInt(t)) / 10n;
111
+ }
112
+ /**
113
+ * Builders for `Wallet.propose` kinds (node/src/types.rs `ProposalKind`). Amounts are FANG (or
114
+ * bigint drops), Soul is in Soul ("2.5"); names in `to`/`account` are resolved when proposing.
115
+ */
116
+ export const proposal = {
117
+ /** Set a governance parameter (a key of `/api/params`, e.g. "max_validators") to a raw value (drops, blocks, bps …), within its bounds. */
118
+ paramChange(key, value) {
119
+ const v = BigInt(value);
120
+ if (!/^[a-z0-9_]{1,64}$/.test(key))
121
+ throw new Error("parameter keys are 1–64 characters of a-z, 0-9 and _");
122
+ if (v < 0n || v >= 1n << 64n)
123
+ throw new Error("parameter values are 0 to 2^64 - 1");
124
+ return { type: "param_change", key, value: v.toString() };
125
+ },
126
+ /** Pay from the treasury (spends above a share of the treasury need a supermajority). */
127
+ treasurySpend(to, amount) {
128
+ return { type: "treasury_spend", to, amount: positive(parseFang(amount), "spend").toString() };
129
+ },
130
+ /**
131
+ * Open a community distribution of `total`, claimable with Merkle proofs against `merkleRoot` until block `claimUntil`.
132
+ * `distributionId` is the id the tree was built for (`buildDistribution(id, …)`): one no distribution has yet,
133
+ * at most 1,000 past the next proposal id (`client.proposals()`'s `next_distribution_id` is free, and
134
+ * `max_distribution_id` is the largest allowed; the range only grows). Other proposals landing first don't change
135
+ * it, so a multisig can take its time approving; if a distribution with that id opens first, the node refuses this
136
+ * one (nothing is escrowed), or it fails when it runs. Claims close at most 730 days after the proposal's vote ends.
137
+ */
138
+ distribution(distributionId, merkleRoot, total, claimUntil) {
139
+ return { type: "distribution", distribution_id: wholeNumber(distributionId, "distributionId"), merkle_root: hexArg(merkleRoot, "merkleRoot"), total: positive(parseFang(total), "distribute").toString(), claim_until: wholeNumber(claimUntil, "claimUntil") };
140
+ },
141
+ /** Put a repo in the contribution pool (weight 1–100; 0 removes it). */
142
+ setRepo(repo, weight) {
143
+ if (!/^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(repo))
144
+ throw new Error("repo must be owner/name");
145
+ if (!Number.isInteger(weight) || weight < 0 || weight > 100)
146
+ throw new Error("weight must be 0–100");
147
+ return { type: "set_repo", repo, weight };
148
+ },
149
+ /** Grant Soul (in Soul, "2.5"; at most 100 per grant). */
150
+ grantSoul(to, soul, reason) {
151
+ return { type: "grant_soul", to, amount: parseSoul(soul), reason };
152
+ },
153
+ flagSybil(account, reason) {
154
+ return { type: "flag_sybil", account, reason };
155
+ },
156
+ /** Lift a sybil flag: the appeal path for `flagSybil` (nothing the flag took comes back). */
157
+ unflagSybil(account, reason) {
158
+ return { type: "unflag_sybil", account, reason };
159
+ },
160
+ rejectClaim(claimId, reason) {
161
+ return { type: "reject_claim", claim_id: wholeNumber(claimId, "claimId"), reason };
162
+ },
163
+ clawback(account, reason) {
164
+ return { type: "clawback", account, reason };
165
+ },
166
+ unlinkGithub(handle) {
167
+ return { type: "unlink_github", handle };
168
+ },
169
+ /** Refund every funder of a hunt (escrow can be refunded, never redirected). */
170
+ refundHunt(huntId) {
171
+ return { type: "refund_hunt", hunt_id: wholeNumber(huntId, "huntId") };
172
+ },
173
+ /**
174
+ * Switch to protocol `version` at block `activationHeight` (needs a supermajority). The version is newer than
175
+ * every one scheduled or withdrawn (`client.upgrade()`'s `withdrawn.highest_version`) and at most 16 past the
176
+ * newest scheduled, withdrawn or superseded (`next_version` is the number to use); the activation is at most
177
+ * 365 days after the proposal's vote ends.
178
+ */
179
+ scheduleUpgrade(version, activationHeight) {
180
+ if (!Number.isInteger(version) || version < 1 || version > 0xffffffff)
181
+ throw new Error("version must be a positive 32-bit number");
182
+ return { type: "schedule_upgrade", version, activation_height: wholeNumber(activationHeight, "activationHeight") };
183
+ },
184
+ /**
185
+ * Withdraw the upgrade to protocol `version` scheduled at block `activationHeight` (see
186
+ * `client.upgrade()`), and any scheduled after it, before it activates. Needs a supermajority;
187
+ * there's no timelock (it runs the block after its vote), and it must run at least 64 blocks
188
+ * before the activation height, so propose it at least `voting_blocks + 65` blocks ahead.
189
+ */
190
+ cancelUpgrade(version, activationHeight) {
191
+ if (!Number.isInteger(version) || version < 1 || version > 0xffffffff)
192
+ throw new Error("version must be a positive 32-bit number");
193
+ if (wholeNumber(activationHeight, "activationHeight") < 1)
194
+ throw new Error("activationHeight is the block the upgrade is scheduled at");
195
+ return { type: "cancel_upgrade", version, activation_height: activationHeight };
196
+ },
197
+ /** Turn on a feature flag (e.g. "ghost"). */
198
+ activateFeature(feature) {
199
+ return { type: "activate_feature", feature };
200
+ },
201
+ deactivateFeature(feature) {
202
+ return { type: "deactivate_feature", feature };
203
+ },
204
+ /** A signal with no on-chain effect. */
205
+ text() {
206
+ return { type: "text" };
207
+ },
208
+ };
209
+ // ---- Readers for the protocol v1 endpoints: they take what the node serves (node/src/api.rs)
210
+ // and return plain shapes with amounts as decimal strings of drops. They don't throw on a
211
+ // missing field (an older node, or a page that hasn't loaded): it reads as empty or zero. ----
212
+ /** A JSON object, or an empty one. */
213
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
214
+ const arr = (v) => (Array.isArray(v) ? v : []);
215
+ /** Drops as a decimal string: from a string or number of drops, or "0". */
216
+ const drops = (v) => {
217
+ if (typeof v === "bigint")
218
+ return v.toString();
219
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
220
+ return String(v);
221
+ if (typeof v === "string" && /^\d+$/.test(v))
222
+ return v;
223
+ return "0";
224
+ };
225
+ /** An address from a label (`{address, name}`) or a plain string. */
226
+ const addressOf = (v) => (typeof v === "string" ? v : String(obj(v).address ?? ""));
227
+ const nameOf = (v) => (typeof v === "object" && v ? (obj(v).name ?? null) : null);
228
+ const num = (v) => (typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : undefined);
229
+ export function stakeSummary(raw) {
230
+ const r = obj(raw);
231
+ const delegations = arr(r.delegations).map((d0) => {
232
+ const d = obj(d0);
233
+ return { validator: addressOf(d.validator), validator_name: nameOf(d.validator), shares: drops(d.shares), value: drops(d.value), principal: drops(d.principal), self_bond: d.self_bond === true };
234
+ });
235
+ const entries = arr(r.entries).map(obj);
236
+ const unbonding = entries.filter((e) => e.type === "unbonding").map((e) => ({ id: num(e.id) ?? 0, validator: addressOf(e.validator), amount: drops(e.amount), started_at: num(e.started_at) ?? 0, completes_at: num(e.release_at) ?? 0, ready: e.ready === true }));
237
+ const redelegations = entries.filter((e) => e.type === "redelegation").map((e) => ({ id: num(e.id) ?? 0, from: addressOf(e.from), to: addressOf(e.to), amount: drops(e.amount), completes_at: num(e.release_at) ?? 0 }));
238
+ return { delegations, unbonding, redelegations, bonded: drops(r.bonded), unbonding_total: drops(r.unbonding), withdrawable: drops(r.withdrawable), summary: String(r.summary ?? "") };
239
+ }
240
+ /**
241
+ * Shares worth about `value` drops in a delegation (rounded down, at most all of them), at the
242
+ * value the delegation reports. That's a node's word: to unbond an amount, pass
243
+ * `wallet.undelegate(validator, { amount })`, which the chain converts itself (audit gap-7-5).
244
+ */
245
+ export function sharesFor(d, value) {
246
+ const want = parseFang(value);
247
+ const [shares, worth] = [BigInt(d.shares), BigInt(d.value)];
248
+ if (worth === 0n || want >= worth)
249
+ return shares;
250
+ return (want * shares) / worth;
251
+ }
252
+ /** `/api/params`' governance parameters as a key → value map (values as strings: amounts can exceed 2^53). */
253
+ export function paramsOf(raw) {
254
+ const out = {};
255
+ for (const [k, v] of Object.entries(obj(obj(raw).params))) {
256
+ if ((typeof v === "number" && Number.isSafeInteger(v)) || (typeof v === "string" && /^\d+$/.test(v)))
257
+ out[k] = String(v);
258
+ }
259
+ return out;
260
+ }
261
+ /** `proposalDigest` of a proposal's content as the node served it, or why it can't be computed. */
262
+ function digestOf(kind, title, url) {
263
+ if (!kind || typeof kind !== "object")
264
+ return { digest: null, digest_error: "the node didn't say what this proposal does" };
265
+ try {
266
+ return { digest: proposalDigest(kind, String(title ?? ""), typeof url === "string" ? url : "") };
267
+ }
268
+ catch (e) {
269
+ return { digest: null, digest_error: `this client can't read the proposal exactly (${e.message})` };
270
+ }
271
+ }
272
+ export function proposalSummary(raw, rules) {
273
+ const p = obj(raw);
274
+ const weight = (side) => drops(obj(obj(side).weight).drops);
275
+ const amount = (side) => drops(obj(obj(side).amount).drops);
276
+ const tally = p.tally ? obj(p.tally) : null;
277
+ const council = arr(p.council).map(obj);
278
+ const [ayeAmount, nayAmount] = [amount(p.ayes), amount(p.nays)];
279
+ const supermajority = p.supermajority === true;
280
+ return {
281
+ id: num(p.id) ?? -1,
282
+ title: String(p.title ?? ""),
283
+ url: typeof p.url === "string" ? p.url : "",
284
+ summary: String(p.summary ?? ""),
285
+ does: describeKind(p.kind),
286
+ kind: p.kind && typeof p.kind === "object" ? p.kind : null,
287
+ ...digestOf(p.kind, p.title, p.url),
288
+ proposer: p.proposer === undefined ? null : addressOf(p.proposer),
289
+ proposer_name: nameOf(p.proposer),
290
+ status: String(p.status ?? "unknown"),
291
+ deposit: drops(p.deposit),
292
+ aye: weight(p.ayes),
293
+ nay: weight(p.nays),
294
+ aye_amount: ayeAmount,
295
+ nay_amount: nayAmount,
296
+ turnout: (BigInt(ayeAmount) + BigInt(nayAmount)).toString(),
297
+ voters: num(p.voters) ?? 0,
298
+ quorum: tally ? drops(obj(tally.quorum).drops) : undefined,
299
+ quorum_reached: tally ? tally.quorum_reached === true : undefined,
300
+ council: { ayes: council.filter((c) => c.aye === true).length, nays: council.filter((c) => c.aye === false).length, of: tally ? num(tally.council_size) : undefined },
301
+ supermajority,
302
+ voting_ends_at: num(p.voting_end) ?? 0,
303
+ executes_at: num(p.execute_at),
304
+ outcome: String(p.outcome ?? ""),
305
+ threshold_bps: supermajority ? rules?.supermajority_bps : undefined,
306
+ };
307
+ }
308
+ /** The most `feeHeadroom` a tx may ask for: 100 × the base fee. */
309
+ export const MAX_FEE_HEADROOM = 100;
310
+ /** The least a payment that opens an account must carry: 0.01 FANG (node/src/types.rs `MIN_NEW_ACCOUNT`). */
311
+ export const MIN_NEW_ACCOUNT = 10000000n;
312
+ const GRANT_PREFIX = "bkgrant1";
313
+ /** A grant as one pasteable string: `bkgrant1.<account>.<base64url(public key ‖ pop)>`. */
314
+ export function encodeKeyGrant(g) {
315
+ const body = new Uint8Array(PUBLIC_KEY_LEN + SIGNATURE_LEN);
316
+ body.set(hexToBytes(g.public_key));
317
+ body.set(hexToBytes(g.pop), PUBLIC_KEY_LEN);
318
+ return `${GRANT_PREFIX}.${g.account}.${base64urlnopad.encode(body)}`;
319
+ }
320
+ /** Parses a grant code (`encodeKeyGrant`). Whitespace is ignored, so a wrapped paste works. */
321
+ export function decodeKeyGrant(code) {
322
+ const c = code.replace(/\s+/g, "");
323
+ if (/^[0-9a-f]{64}$/i.test(c))
324
+ throw new Error("that's a key id: adding a key needs its grant code now, which the key's holder makes for your account (Wallet.keyGrant, the MCP's use_account, or a new device's link screen)");
325
+ const [prefix, account, body, ...rest] = c.split(".");
326
+ if (prefix !== GRANT_PREFIX || !account || !body || rest.length)
327
+ throw new Error("that isn't a key grant code (it starts with bkgrant1.)");
328
+ if (!isAddress(account))
329
+ throw new Error("the grant code's account isn't an address");
330
+ let bytes;
331
+ try {
332
+ bytes = base64urlnopad.decode(body);
333
+ }
334
+ catch {
335
+ throw new Error("the grant code is damaged (copy all of it)");
336
+ }
337
+ if (bytes.length !== PUBLIC_KEY_LEN + SIGNATURE_LEN)
338
+ throw new Error("the grant code is damaged or cut short (copy all of it)");
339
+ const pk = bytes.slice(0, PUBLIC_KEY_LEN);
340
+ return { account, key_id: keyId(pk), public_key: bytesToHex(pk), pop: bytesToHex(bytes.slice(PUBLIC_KEY_LEN)) };
341
+ }
342
+ /** Whether `g` is a valid consent by its key to join `g.account` on the chain `(chainId, genesisHash)`. */
343
+ export function verifyKeyGrant(g, chainId, genesisHash) {
344
+ try {
345
+ const pk = hexToBytes(g.public_key);
346
+ if (pk.length !== PUBLIC_KEY_LEN || keyId(pk) !== g.key_id)
347
+ return false;
348
+ return ml_dsa65.verify(hexToBytes(g.pop), keyPopMessage(chainId, genesisHash, g.account, g.key_id), pk, { context: KEY_POP_CONTEXT });
22
349
  }
23
350
  catch {
24
351
  return false;
@@ -33,28 +360,63 @@ export class Wallet {
33
360
  seed;
34
361
  client;
35
362
  publicKey;
36
- /** This key's own address. */
37
- address;
38
- /** The account this wallet acts for (defaults to `address`). */
39
- account;
40
363
  /** WebAuthn credential id, when the wallet was unlocked by a passkey. Not secret. */
41
364
  credentialId;
365
+ /** Default tip (drops) for this wallet's transactions. */
366
+ tip = 0n;
367
+ /** Default validity (blocks from now) for this wallet's transactions. */
368
+ validForBlocks = DEFAULT_VALID_FOR_BLOCKS;
369
+ /** Default `feeHeadroom` for this wallet's transactions (1: none). See `TxOptions.feeHeadroom`. */
370
+ feeHeadroom = 1;
42
371
  secretKey;
372
+ /**
373
+ * This wallet's txs are signed and submitted one at a time, in nonce order (DEV-3): parallel
374
+ * `send`s from one wallet would otherwise read the same next nonce and all but one be refused.
375
+ */
376
+ queue = Promise.resolve();
377
+ /** The nonce after the last tx this wallet submitted, for one chain and account, and that tx. */
378
+ cursor;
379
+ acting;
380
+ /** How `actAs` found the account, when that was one node's unproven word (checked when signing on mainnet). */
381
+ actingUnproven;
43
382
  constructor(seed, client = new Client()) {
44
383
  this.seed = seed;
45
384
  this.client = client;
46
385
  const keys = ml_dsa65.keygen(seed);
47
386
  this.publicKey = keys.publicKey;
48
387
  this.secretKey = keys.secretKey;
49
- this.address = addressFromPublicKey(keys.publicKey);
50
- this.account = this.address;
388
+ }
389
+ /** This key's own address, with the chain's prefix once the client knows it (`await client.addressPrefix()`). */
390
+ get address() {
391
+ return addressFromPublicKey(this.publicKey, this.client.prefix);
392
+ }
393
+ /** The account this wallet acts for (defaults to `address`). */
394
+ get account() {
395
+ return this.acting ?? this.address;
396
+ }
397
+ set account(account) {
398
+ this.acting = account;
399
+ this.actingUnproven = undefined;
51
400
  }
52
401
  get keyId() {
53
402
  return keyId(this.publicKey);
54
403
  }
55
- /** Act for another account this key has been authorized on. Accepts a name or address. */
404
+ /**
405
+ * Act for another account this key has been authorized on. Accepts a name or address. A name
406
+ * needs an address here (every tx names the account it acts for), so it's resolved: proven with
407
+ * the client's witnesses. Without them it's one node's word, and on mainnet nothing is signed
408
+ * for an account found that way (`UnprovenNameError`): check the address and pass it instead
409
+ * (audit gap-7-1).
410
+ */
56
411
  async actAs(account) {
57
- this.account = await this.client.resolve(account);
412
+ const r = await this.addressNeeded(await this.client.resolution(account));
413
+ // An address with another chain's prefix (a relaunched testnet's `boo1…` on a `tboo1…` chain)
414
+ // isn't an account here: every tx from it, and a faucet drip to it, would be refused.
415
+ const prefix = await this.client.addressPrefix();
416
+ if (!isAddress(r.address, prefix))
417
+ throw new Error(`${r.address} is an address on another chain: this chain's addresses start with ${prefix}1`);
418
+ this.acting = r.address;
419
+ this.actingUnproven = r.proven ? undefined : r;
58
420
  return this;
59
421
  }
60
422
  static generate(client) {
@@ -80,183 +442,2107 @@ export class Wallet {
80
442
  const signature = ml_dsa65.sign(signingBytes(tx), this.secretKey);
81
443
  return { ...tx, public_key: bytesToHex(this.publicKey), signature: bytesToHex(signature) };
82
444
  }
83
- async build(action, memo = "") {
84
- const [status, account] = await Promise.all([this.client.status(), this.client.account(this.account).catch(() => null)]);
85
- const unsigned = { chain_id: status.chain_id, from: this.account, nonce: account?.next_nonce ?? 0, fee: "0", action, memo };
86
- unsigned.fee = minFee(unsigned).toString();
445
+ /**
446
+ * This key's consent to being added to `account` (an address, or a name resolved by the
447
+ * client: proven when it has witnesses; on mainnet an unproven name is refused, pass the address)
448
+ * on the client's chain: hand `encodeKeyGrant(grant)` to the account's owner, who adds the key
449
+ * with it. Pass `chain` to sign offline.
450
+ */
451
+ async keyGrant(account, chain) {
452
+ // A resolution already made (and shown) is used as is: resolving the name again could get another answer.
453
+ const res = typeof account !== "string" ? (account.name === null ? null : account) : isAddress(account) ? null : await this.client.resolution(account);
454
+ const address = typeof account !== "string" ? account.address : (res?.address ?? account);
455
+ let c = chain;
456
+ if (!c) {
457
+ const status = await this.client.status();
458
+ if (typeof status.genesis_hash !== "string")
459
+ throw new Error(`${this.client.rpc} doesn't report a genesis_hash; it's too old for this SDK`);
460
+ // A `chain` given by the caller names the chain itself; one read from the node is checked like a signing status.
461
+ this.client.checkMainnetPin(status.chain_id);
462
+ c = { chainId: status.chain_id, genesisHash: status.genesis_hash };
463
+ }
464
+ if (res && !res.proven && c.chainId === MAINNET_CHAIN_ID)
465
+ throw new UnprovenNameError(res, this.client.rpc);
466
+ const pop = ml_dsa65.sign(keyPopMessage(c.chainId, c.genesisHash, address, this.keyId), this.secretKey, { context: KEY_POP_CONTEXT });
467
+ return { account: address, key_id: this.keyId, public_key: bytesToHex(this.publicKey), pop: bytesToHex(pop) };
468
+ }
469
+ /**
470
+ * The `add_key` action for a grant (an object or its code): it must be for this account and
471
+ * verify on the client's chain, so a wrong or damaged code fails here, before anything is signed.
472
+ */
473
+ async grantAction(grant, label, role) {
474
+ const g = typeof grant === "string" ? decodeKeyGrant(grant) : grant;
475
+ if (g.account !== this.account)
476
+ throw new Error(`that grant is for ${g.account}, not this account (${this.account}): the key's holder must make it for ${this.account}`);
477
+ const status = await this.client.status();
478
+ if (!verifyKeyGrant(g, status.chain_id, status.genesis_hash))
479
+ throw new Error("that grant doesn't verify: it's damaged, or it was made for another chain");
480
+ return { type: "add_key", key_id: g.key_id, label, role, public_key: g.public_key, pop: g.pop };
481
+ }
482
+ /**
483
+ * Builds and signs a tx: `fee` = the highest base fee already fixed for the next blocks
484
+ * (`/api/fees` `suggested`) + `tip`, `valid_until` = the current height + `validForBlocks`, and
485
+ * the chain's id and genesis hash from `/api/status`. Only the base fee of the block that
486
+ * includes it (+ the tip) is charged. A tip above `tip_cap_multiple` × the lowest upcoming base
487
+ * fee (`/api/fees` `lowest`, see `maxTip`) is refused here, as nodes would refuse it. To sign
488
+ * offline, build an `UnsignedTx` yourself (with the chain's `genesis_hash`) and `sign` it.
489
+ *
490
+ * `action` may be a function of the height `valid_until` is counted from, for actions that
491
+ * carry an absolute block height of their own (a session key's or a hunt's expiry): computed
492
+ * from the same status read, a node that lies about the height can stretch them by at most
493
+ * `max_tx_validity_blocks`, since validators refuse a `valid_until` further ahead than that
494
+ * (audit gap-7-3).
495
+ *
496
+ * That height is checked first (`Client.checkSigningHeight`: against the witnesses' heads and,
497
+ * when the client knows its genesis clock, the clock), since a node reporting one the chain
498
+ * hasn't reached gets a tx it can hold and release later.
499
+ *
500
+ * `opts.resolutions`: how names in fields that need an address got into `action` (their
501
+ * `Client.resolution`s). On mainnet a tx carrying one that only one node vouched for isn't
502
+ * signed (`UnprovenNameError`), checked against the very status the tx is signed for, as the
503
+ * helpers do (audit gap-7-1). Fields the chain resolves itself take `Client.recipient(x).signed`.
504
+ */
505
+ async build(action, memo = "", opts = {}) {
506
+ return this.buildChecked(action, memo, opts, opts.resolutions ?? []);
507
+ }
508
+ /**
509
+ * `build`, refusing on mainnet a tx that carries an address only one node vouched for: `unproven`
510
+ * (the helpers' resolutions of names in fields that need an address) or the account `actAs`
511
+ * found that way (audit gap-7-1). Checked against the very status the tx is signed for.
512
+ */
513
+ async buildChecked(action, memo, opts, unproven) {
514
+ await this.client.addressPrefix(); // so `this.account` uses the chain's prefix
515
+ const [status, account, fees] = await Promise.all([this.client.status(), this.client.account(this.account).catch(() => null), this.client.fees()]);
516
+ if (!Number.isSafeInteger(status.height) || status.height < 0)
517
+ throw new Error(`${this.client.rpc} doesn't report its height`);
518
+ // blockula-1 only through a client pinned to it (DEV-1), checked against the very status the tx is signed for.
519
+ this.client.checkMainnetPin(status.chain_id);
520
+ await this.client.checkSigningHeight(status.height);
521
+ const max = status.max_tx_validity_blocks ?? Number.MAX_SAFE_INTEGER;
522
+ const validFor = opts.validForBlocks ?? Math.min(this.validForBlocks, max);
523
+ if (!Number.isSafeInteger(validFor) || validFor < 1)
524
+ throw new Error("validForBlocks must be a positive whole number of blocks");
525
+ if (validFor > max)
526
+ throw new Error(`validForBlocks can be at most ${max} (an hour of blocks)`);
527
+ const tip = opts.tip === undefined ? this.tip : parseFang(opts.tip);
528
+ if (typeof status.genesis_hash !== "string")
529
+ throw new Error(`${this.client.rpc} doesn't report a genesis_hash; it's too old for this SDK`);
530
+ const guess = [...unproven, ...(this.actingUnproven ? [this.actingUnproven] : [])].find((r) => !r.proven);
531
+ if (guess && status.chain_id === MAINNET_CHAIN_ID)
532
+ throw new UnprovenNameError(guess, this.client.rpc);
533
+ // Binds the tx to this chain instance (not just its id), so it can't replay on a relaunch.
534
+ const act = typeof action === "function" ? action(status.height) : action;
535
+ // A program action reserves fuel and pays for it (§5.1): only the helpers below price that, so this generic path refuses it.
536
+ const helper = PROGRAM_HELPERS.get(act.type);
537
+ if (helper && !opts.reserved)
538
+ throw new Error(`a ${act.type} reserves fuel, which this generic builder doesn't price (its fee would be too low and the chain would refuse it): use ${helper}. Nothing was signed`);
539
+ const nonce = await this.nonceFor(cursorKey(status.chain_id, status.genesis_hash, this.account), Number(account?.next_nonce ?? 0), status.height);
540
+ const unsigned = { chain_id: status.chain_id, genesis_hash: status.genesis_hash, from: this.account, nonce, fee: "0", tip: tip.toString(), valid_until: status.height + validFor, action: act, memo };
541
+ // A program transaction also pays for the fuel it reserves (§5.1): at the rate the next blocks can reach, from this very read
542
+ // of the node's prices, which also refuses a reservation no block could carry (programs.ts `priceFuel`).
543
+ const fuel = opts.reserved ? priceFuel(checkFuel(this.client.rpc, fees.fuel), opts.reserved) : { suggested: 0n, floor: 0n };
544
+ unsigned.fee = this.feeFor(unsigned, fees.suggested, tip, opts, fuel.suggested).toString();
545
+ if (tip > 0n) {
546
+ // The cap is a multiple of the base fee of the block that includes the tx (its bytes' and its fuel's): price it at the lowest upcoming
547
+ // one, and a program's fuel at the floor rate, which no block undercuts.
548
+ const multiple = fees.tip_cap_multiple ?? DEFAULT_TIP_CAP_MULTIPLE;
549
+ const cap = maxTip(unsigned, fees.lowest ?? fees.suggested, multiple) + BigInt(multiple) * fuel.floor;
550
+ if (tip > cap)
551
+ throw new Error(`tip too high: at most ${multiple}× the base fee, ${cap} drops for this transaction now`);
552
+ }
87
553
  return this.sign(unsigned);
88
554
  }
89
- /** Send FANG to a `.boo` name or `boo1…` address. `amount` is in FANG ("1.5") or bigint drops. */
555
+ /**
556
+ * A tx's `fee`: the base fee at `suggested` for its size (and, for a program transaction, its
557
+ * fuel fee at the suggested rate: `fuelFee`), times the headroom, plus the tip; or `maxFee`
558
+ * outright, which must cover at least that base fee plus the tip.
559
+ */
560
+ feeFor(unsigned, suggested, tip, opts, fuelFee = 0n) {
561
+ const base = baseFee(unsigned, suggested) + fuelFee;
562
+ if (opts.maxFee !== undefined) {
563
+ const fee = parseFang(opts.maxFee);
564
+ if (fee < base + tip)
565
+ throw new Error(`maxFee ${formatFang(fee)} is below what this transaction costs now: ${formatFang(base + tip)} (base fee ${formatFang(base)} + tip ${formatFang(tip)})`);
566
+ if (fee >= 1n << 64n)
567
+ throw new Error("maxFee: amount too large");
568
+ return fee;
569
+ }
570
+ const headroom = opts.feeHeadroom ?? this.feeHeadroom;
571
+ if (typeof headroom !== "number" || !Number.isFinite(headroom) || headroom < 1 || headroom > MAX_FEE_HEADROOM)
572
+ throw new Error(`feeHeadroom must be a number from 1 to ${MAX_FEE_HEADROOM} (times the base fee)`);
573
+ // In thousandths, rounded up, so 1.5 is exactly 1.5× and nothing rounds below the base fee.
574
+ const milli = BigInt(Math.ceil(headroom * 1000));
575
+ return (base * milli + 999n) / 1000n + tip;
576
+ }
577
+ /**
578
+ * The nonce for this wallet's next tx on chain/account `key`: the node's `next_nonce` (which
579
+ * counts the txs it holds), or one past the last tx this wallet submitted when that's higher
580
+ * and the tx may still run: still pending on its way through the network, or not yet counted
581
+ * by a node writing a block (DEV-3). A tx that was dropped, or can't run any more
582
+ * (`valid_until` passed), gives its nonce back: the node's word is used again. A nonce handed to
583
+ * a tx a node admitted is never handed out again while that tx may run.
584
+ */
585
+ async nonceFor(key, nodeNext, height) {
586
+ const c = this.cursor;
587
+ if (!c || c.key !== key || c.next <= nodeNext)
588
+ return nodeNext;
589
+ if (height <= c.validUntil) {
590
+ const last = await this.client.tx(c.lastId).catch(() => null);
591
+ // Unknown here (another node holds it, or it never arrived): kept, so a tx that may run isn't doubled.
592
+ if (!last || last.status === "pending" || last.status === "finalized" || (last.status === "failed" && !txDropped(last)))
593
+ return c.next;
594
+ }
595
+ if (this.cursor === c)
596
+ this.cursor = undefined;
597
+ return nodeNext;
598
+ }
599
+ /** Runs `job` after every job queued before it (its outcome doesn't stop the next one). */
600
+ enqueue(job) {
601
+ const run = this.queue.then(job, job);
602
+ this.queue = run.catch(() => undefined);
603
+ return run;
604
+ }
605
+ /**
606
+ * Builds, signs and submits one tx in this wallet's queue (`enqueue`): one at a time, so each
607
+ * is admitted before the next is signed, in nonce order (DEV-3). Waits for it after leaving the
608
+ * queue, so parallel calls are only serialized for the moment it takes to sign and submit.
609
+ *
610
+ * What a failure does to the next nonce: refused by the node (a 4xx answer: it wasn't admitted),
611
+ * the nonce is used for the next tx, which can't be paid twice since only one tx per nonce ever
612
+ * runs; on "bad nonce" the same signed bytes are tried again once (a node writing a block can
613
+ * misjudge a correct nonce for a moment), then the node's next nonce is used. Anything else
614
+ * (a timeout, a 5xx, a broken connection) may have been admitted, so its nonce isn't reused.
615
+ */
616
+ async submitQueued(action, memo, opts, unproven, retry = NO_RETRY) {
617
+ const { tx, id } = await this.enqueue(async () => {
618
+ const tx = await this.buildChecked(action, memo, opts, unproven);
619
+ // Stored before it leaves (store first, then send); a store that fails submits nothing.
620
+ if (opts.onSigned)
621
+ await opts.onSigned(tx, txId(tx));
622
+ const key = cursorKey(tx.chain_id, tx.genesis_hash, tx.from);
623
+ try {
624
+ const { id } = await postPatiently(this.client, tx, retry);
625
+ this.cursor = { key, next: tx.nonce + 1, lastId: id, validUntil: tx.valid_until };
626
+ return { tx, id };
627
+ }
628
+ catch (e) {
629
+ if (!refused(e))
630
+ this.cursor = { key, next: tx.nonce + 1, lastId: txId(tx), validUntil: tx.valid_until };
631
+ else if (/bad nonce/i.test(e.message) && this.cursor?.key === key)
632
+ this.cursor = undefined;
633
+ throw e;
634
+ }
635
+ });
636
+ if (opts.wait ?? true)
637
+ return this.client.waitForTx(id, undefined, tx.valid_until);
638
+ try {
639
+ return await this.client.tx(id);
640
+ }
641
+ catch (e) {
642
+ throw new TxPendingError(id, 0, e);
643
+ }
644
+ }
645
+ /**
646
+ * Send FANG to a `.boo` name or an address. `amount` is in FANG ("1.5") or bigint drops. A name
647
+ * is signed as its proven owner's address when the client has witnesses (who you checked is who
648
+ * gets paid, even if the name changes hands before the tx runs), and as the name itself when it
649
+ * doesn't: the chain then pays whoever owns it when the tx runs. A lying node can't point it at
650
+ * another account now, but it can hold the signed tx until its validity window, which counts
651
+ * from the height the node reported, and release it later, by when the name may have lapsed
652
+ * and been registered again (audit gap-7-1, review-r3-clients-2). See `Client.recipient`.
653
+ */
90
654
  async send(to, amount, opts = {}) {
91
- // Sign the resolved address, not the name: what you checked is exactly who gets paid,
92
- // even if the name changes hands before the block is sealed.
93
- const recipient = await this.client.resolve(to);
94
- const tx = await this.build({ type: "transfer", to: recipient, amount: parseFang(amount).toString() }, opts.memo);
95
- return this.client.submit(tx, opts.wait ?? true);
655
+ return this.pay(to, amount, opts);
656
+ }
657
+ /**
658
+ * `send`, with the batch's retry policy. An amount of 0 is refused before anything is signed
659
+ * (DEV-14): it moves nothing and only pays a fee. A payment that opens an account must carry at
660
+ * least 0.01 FANG and burns `creation_fee` (0.1 FANG) from this account on top; the result of a
661
+ * finalized payment that did says so (`opened_account`, `creation_fee`), since its `summary` and
662
+ * `fee` don't (DEV-7).
663
+ */
664
+ async pay(to, amount, opts, retry = NO_RETRY) {
665
+ const drops = positive(parseFang(amount), "send");
666
+ const recipient = await this.client.recipient(to);
667
+ const opening = await this.openingCost(recipient.address);
668
+ if (opening?.opens && drops < MIN_NEW_ACCOUNT)
669
+ throw new Error(`sending to a new account needs at least ${formatFang(MIN_NEW_ACCOUNT)}: ${recipient.input} has no account yet. Nothing was signed`);
670
+ const tx = await this.submitQueued({ type: "transfer", to: recipient.signed, amount: drops.toString() }, opts.memo ?? "", opts, [], retry);
671
+ if (opening?.opens && opening.fee > 0n && tx?.status === "finalized" && (await this.opened(recipient.address, tx))) {
672
+ return { ...tx, opened_account: true, creation_fee: opening.fee.toString(), creation_fee_display: formatFang(opening.fee) };
673
+ }
674
+ return tx;
675
+ }
676
+ /**
677
+ * Whether a payment to `address` would open its account, and what that burns from the payer
678
+ * (`/api/account`'s `exists` and `creation_fee`; on nodes without them, an account with no
679
+ * balance and no nonce counts as new). `undefined` when the node didn't say.
680
+ */
681
+ async openingCost(address) {
682
+ const a = await this.client.account(address).catch(() => null);
683
+ if (!a || typeof a !== "object")
684
+ return undefined;
685
+ const opens = typeof a.exists === "boolean" ? !a.exists : BigInt(drops(a.balance)) === 0n && !Number(a.nonce ?? 0);
686
+ if (!opens)
687
+ return { opens, fee: 0n };
688
+ const fee = /^\d+$/.test(String(a.creation_fee ?? "")) ? BigInt(a.creation_fee) : BigInt(drops((await this.client.fees().catch(() => ({}))).account_creation_fee));
689
+ return { opens, fee };
690
+ }
691
+ /**
692
+ * Whether finalized payment `tx` opened `address`'s account (it had none when the payment was
693
+ * signed): no other finalized tx naming it ran before this one. From its newest history, so a
694
+ * flood of more than 25 txs to a brand-new account within seconds can hide an earlier one.
695
+ */
696
+ async opened(address, tx) {
697
+ const a = await this.client.account(address).catch(() => null);
698
+ const height = tx.height;
699
+ if (!a || typeof height !== "number")
700
+ return true;
701
+ const before = arr(a.recent_txs).filter((t) => t?.id !== tx.id && t?.status === "finalized" && typeof t.height === "number" && t.height <= height && addressOf(t.to) === address);
702
+ if (before.some((t) => t.height < height))
703
+ return false;
704
+ if (!before.length)
705
+ return true;
706
+ // Same block: whichever ran first opened it.
707
+ const block = await this.client.block(height).catch(() => null);
708
+ const ids = arr(block?.txs).map((t) => String(t?.id));
709
+ const mine = ids.indexOf(String(tx.id));
710
+ return mine >= 0 && !before.some((t) => ids.indexOf(String(t.id)) >= 0 && ids.indexOf(String(t.id)) < mine);
711
+ }
712
+ /**
713
+ * What paying `amount` to `to` would cost this account, before anything is signed (DEV-7):
714
+ * - `fee`: the base fee for this payment's size at what `/api/fees` fixes for the next 17 blocks
715
+ * (`suggested`), plus the tip. On a chain that isn't busy, what it's charged;
716
+ * - `maxFee`: the most it can be charged (`fee`, or more with `feeHeadroom` / `maxFee`);
717
+ * - `creationFee`: burned from this account when the payment opens the recipient's account
718
+ * (0.1 FANG on chains with genesis defaults), on top of `amount`;
719
+ * - `total`: `amount + fee + creationFee`;
720
+ * - `transferableAfter`: what this account could still move afterwards, counting `maxFee`
721
+ * (negative: it can't pay this). Amounts are bigint drops; `display` has them as FANG text.
722
+ * `recipient` is how `to` gets signed (`Client.recipient`): show `recipient.how` before paying.
723
+ * Refuses (throws) a 0 amount, and less than 0.01 FANG to an account that doesn't exist yet.
724
+ */
725
+ async quote(to, amount, opts = {}) {
726
+ const value = positive(parseFang(amount), "send");
727
+ await this.client.addressPrefix();
728
+ const recipient = await this.client.recipient(to);
729
+ const [status, fees, from, opening] = await Promise.all([this.client.status(), this.client.fees(), this.client.account(this.account), this.openingCost(recipient.address)]);
730
+ if (!opening)
731
+ throw new Error(`${this.client.rpc} didn't say whether ${recipient.address} has an account, so the cost of paying it can't be quoted`);
732
+ if (opening.opens && value < MIN_NEW_ACCOUNT)
733
+ throw new Error(`sending to a new account needs at least ${formatFang(MIN_NEW_ACCOUNT)}: ${recipient.input} has no account yet`);
734
+ const tip = opts.tip === undefined ? this.tip : parseFang(opts.tip);
735
+ const unsigned = { chain_id: String(status.chain_id), genesis_hash: String(status.genesis_hash), from: this.account, nonce: Number(from?.next_nonce ?? 0), fee: "0", tip: tip.toString(), valid_until: Number(status.height) + (opts.validForBlocks ?? this.validForBlocks), action: { type: "transfer", to: recipient.signed, amount: value.toString() }, memo: opts.memo ?? "" };
736
+ const fee = baseFee(unsigned, fees.suggested) + tip;
737
+ const maxFee = this.feeFor(unsigned, fees.suggested, tip, opts);
738
+ const transferable = BigInt(drops(from?.transferable ?? from?.balance));
739
+ const total = value + fee + opening.fee;
740
+ const transferableAfter = transferable - (value + maxFee + opening.fee);
741
+ return {
742
+ amount: value,
743
+ fee,
744
+ maxFee,
745
+ creationFee: opening.fee,
746
+ total,
747
+ transferableAfter,
748
+ enough: transferableAfter >= 0n,
749
+ opensAccount: opening.opens,
750
+ recipient,
751
+ display: { amount: formatFang(value), fee: formatFang(fee), maxFee: formatFang(maxFee), creationFee: formatFang(opening.fee), total: formatFang(total), transferableAfter: transferableAfter < 0n ? `-${formatFang(-transferableAfter)}` : formatFang(transferableAfter) },
752
+ };
753
+ }
754
+ /**
755
+ * Many payments from this account (TR-5), with at most `concurrency` (default 16, at most 64,
756
+ * the chain's limit of pending txs per sender) waiting to finalize at once. They're signed in
757
+ * turn, one nonce after the other. A node that answers 429 (rate limited) or "too many pending
758
+ * transactions" gets the same signed bytes again once it says to retry (`Retry-After`), for up
759
+ * to `patienceMs` (default 2 minutes); resubmitting the same bytes can't pay twice.
760
+ *
761
+ * Every payment gets a result, in order: `{ ok: true, tx }` or `{ ok: false, error }`. A refused
762
+ * payment (the node said no: insufficient balance, a bad name) used no nonce, and the rest go on.
763
+ * A payment that failed for another reason (`TxPendingError`, `TxFailedError` with `dropped`, a
764
+ * timeout) may still run: check it (`client.tx`) before paying it again, and note that when the
765
+ * chain drops a tx it drops every later one from the account too. `onSigned` is called with
766
+ * each signed tx before it's submitted, as with `send`.
767
+ */
768
+ async sendMany(payments, opts = {}) {
769
+ const concurrency = opts.concurrency ?? 16;
770
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > MAX_PENDING_PER_SENDER)
771
+ throw new Error(`concurrency must be a whole number from 1 to ${MAX_PENDING_PER_SENDER}`);
772
+ const patienceMs = opts.patienceMs ?? 120_000;
773
+ if (!Number.isSafeInteger(patienceMs) || patienceMs < 0)
774
+ throw new Error("patienceMs must be a whole number of milliseconds");
775
+ // Every amount is checked before the first is signed.
776
+ for (const [i, p] of payments.entries())
777
+ positive(parseFang(p.amount), `payment ${i + 1}: send`);
778
+ const results = new Array(payments.length);
779
+ let next = 0;
780
+ const worker = async () => {
781
+ while (next < payments.length) {
782
+ const i = next++;
783
+ const p = payments[i];
784
+ const { to, amount, ...txOpts } = p;
785
+ try {
786
+ results[i] = { ok: true, to, amount, tx: await this.pay(to, amount, { ...opts, ...txOpts }, { untilMs: patienceMs }) };
787
+ }
788
+ catch (error) {
789
+ results[i] = { ok: false, to, amount, error };
790
+ }
791
+ }
792
+ };
793
+ await Promise.all(Array.from({ length: Math.min(concurrency, payments.length) }, worker));
794
+ return results;
96
795
  }
97
796
  async registerName(name, opts = {}) {
98
- const tx = await this.build({ type: "register_name", name });
99
- return this.client.submit(tx, opts.wait ?? true);
797
+ return this.submitQueued({ type: "register_name", name }, "", opts, []);
100
798
  }
101
- /** Authorize another device's key (e.g. a second passkey) with full control. */
102
- async addOwnerKey(keyIdHex, label) {
103
- return this.client.submit(await this.build({ type: "add_key", key_id: keyIdHex, label, role: { type: "owner" } }));
799
+ /**
800
+ * Authorize another device's key (e.g. a second passkey) with full control. `grant` is the
801
+ * key's consent to this account (`keyGrant` on that device, or its grant code).
802
+ */
803
+ async addOwnerKey(grant, label) {
804
+ return this.submitQueued(await this.grantAction(grant, label, { type: "owner" }), "", {}, []);
104
805
  }
105
806
  /**
106
- * Let an agent/app key spend from this account, transfers only, within a limit.
107
- * Example: `addSessionKey(agentKeyId, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 })`.
807
+ * Let an agent/app key spend from this account, transfers only, within a limit. `grant` is the
808
+ * key's consent to this account (the agent's grant code).
809
+ * Example: `addSessionKey(grantCode, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 })`.
108
810
  */
109
- async addSessionKey(keyIdHex, label, opts) {
811
+ async addSessionKey(grant, label, opts) {
110
812
  if (!Number.isSafeInteger(opts.periodBlocks) || opts.periodBlocks < 1)
111
813
  throw new Error("periodBlocks must be a positive whole number of blocks");
112
814
  if (!Number.isSafeInteger(opts.expiresInBlocks) || opts.expiresInBlocks < 1)
113
815
  throw new Error("expiresInBlocks must be a positive whole number of blocks");
114
- const { height } = await this.client.status();
115
- const role = { type: "session", limit: parseFang(opts.limit).toString(), period_blocks: opts.periodBlocks, expires_at: height + opts.expiresInBlocks };
116
- return this.client.submit(await this.build({ type: "add_key", key_id: keyIdHex, label, role }));
816
+ const limit = parseFang(opts.limit).toString();
817
+ const base = await this.grantAction(grant, label, { type: "owner" });
818
+ // The expiry counts from the height valid_until does (one status read): see `build`.
819
+ return this.submitQueued((height) => ({ ...base, role: { type: "session", limit, period_blocks: opts.periodBlocks, expires_at: height + opts.expiresInBlocks } }), "", {}, []);
117
820
  }
118
821
  async removeKey(keyIdHex) {
119
- return this.client.submit(await this.build({ type: "remove_key", key_id: keyIdHex }));
822
+ return this.submitQueued({ type: "remove_key", key_id: keyIdHex }, "", {}, []);
120
823
  }
121
824
  /**
122
- * Offer a validator: bonds `bond` FANG from this account. `consensusKeyHex` is the validator
123
- * node's ML-DSA-65 public key (see `blockula validator init`). Pending until an admin approves.
825
+ * Register a validator owned by this account, self-bonding `bond` FANG into its stake pool (at
826
+ * least the chain's `min_self_bond`). `consensusKey` is the validator node's ML-DSA-65 public
827
+ * key and `pop` its proof of possession (see `blockula validator init`). It's a candidate: at
828
+ * each epoch boundary the top `max_validators` pools by stake form the next active set.
124
829
  */
125
830
  async registerValidator(opts) {
126
- return this.client.submit(await this.build({ type: "register_validator", consensus_key: opts.consensusKey, p2p_addr: opts.p2pAddr, http_addr: opts.httpAddr, bond: parseFang(opts.bond).toString(), pop: opts.pop }));
831
+ return this.submitQueued({ type: "register_validator", consensus_key: opts.consensusKey, p2p_addr: opts.p2pAddr, http_addr: opts.httpAddr, bond: parseFang(opts.bond).toString(), pop: opts.pop }, "", {}, []);
127
832
  }
128
- /** Season 0 admins only. */
833
+ /** Dev and test chains with admins only: pin a validator into the set ahead of stake. */
129
834
  async approveValidator(validator) {
130
- return this.client.submit(await this.build({ type: "approve_validator", validator }));
835
+ return this.submitQueued({ type: "approve_validator", validator }, "", {}, []);
131
836
  }
132
- /** Season 0 admins only. */
837
+ /** Dev and test chains with admins only: take a validator out of the set for good. */
133
838
  async removeValidator(validator) {
134
- return this.client.submit(await this.build({ type: "remove_validator", validator }));
839
+ return this.submitQueued({ type: "remove_validator", validator }, "", {}, []);
135
840
  }
136
841
  /** Claim a GitHub handle. Verify it by creating a public gist (as that user) containing this account's address. */
137
842
  async linkGithub(handle, gistId) {
138
- return this.client.submit(await this.build({ type: "link_github", handle, gist_id: gistId }));
843
+ return this.submitQueued({ type: "link_github", handle, gist_id: gistId }, "", {}, []);
139
844
  }
140
845
  async unlinkGithub(handle) {
141
- return this.client.submit(await this.build({ type: "unlink_github", handle }));
846
+ return this.submitQueued({ type: "unlink_github", handle }, "", {}, []);
142
847
  }
143
848
  /** Put a bounty on a GitHub issue. `expiresInBlocks` from now (max ~1 year). */
144
849
  async createHunt(opts) {
145
- const { height } = await this.client.status();
146
- return this.client.submit(await this.build({ type: "create_hunt", repo: opts.repo, issue: opts.issue, amount: parseFang(opts.amount).toString(), expires_at: height + opts.expiresInBlocks, title: opts.title }));
850
+ if (!Number.isSafeInteger(opts.expiresInBlocks) || opts.expiresInBlocks < 1)
851
+ throw new Error("expiresInBlocks must be a positive whole number of blocks");
852
+ const amount = parseFang(opts.amount).toString();
853
+ // The expiry counts from the height valid_until does (one status read): see `build`.
854
+ return this.submitQueued((height) => ({ type: "create_hunt", repo: opts.repo, issue: opts.issue, amount, expires_at: height + opts.expiresInBlocks, title: opts.title }), "", {}, []);
147
855
  }
148
856
  async fundHunt(huntId, amount) {
149
- return this.client.submit(await this.build({ type: "fund_hunt", hunt_id: huntId, amount: parseFang(amount).toString() }));
857
+ return this.submitQueued({ type: "fund_hunt", hunt_id: huntId, amount: parseFang(amount).toString() }, "", {}, []);
150
858
  }
151
- /** Pay a hunt's bounty (creator or admin). `to` may be a .boo name; it's resolved and the address is signed. */
859
+ /** Pay a hunt's bounty (creator or admin). `to` may be a .boo name: signed as `Client.recipient` says (the proven address, or the name for the chain to resolve). */
152
860
  async awardHunt(huntId, to, pr, familiarKey) {
153
- const addr = await this.client.resolve(to);
154
- return this.client.submit(await this.build({ type: "award_hunt", hunt_id: huntId, to: addr, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }));
861
+ const addr = (await this.client.recipient(to)).signed;
862
+ return this.submitQueued({ type: "award_hunt", hunt_id: huntId, to: addr, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }, "", {}, []);
155
863
  }
156
864
  async cancelHunt(huntId) {
157
- return this.client.submit(await this.build({ type: "cancel_hunt", hunt_id: huntId }));
865
+ return this.submitQueued({ type: "cancel_hunt", hunt_id: huntId }, "", {}, []);
158
866
  }
159
867
  /** Mark one of this account's session keys (an agent) as a Familiar. */
160
868
  async registerFamiliar(keyIdHex, label) {
161
- return this.client.submit(await this.build({ type: "register_familiar", key_id: keyIdHex, label }));
869
+ return this.submitQueued({ type: "register_familiar", key_id: keyIdHex, label }, "", {}, []);
870
+ }
871
+ // ---- 🪦 Proof of Contribution ----
872
+ /** Claim a merged PR (authored by this account's linked GitHub handle) for the contribution pool. Bonded; validators check it on GitHub. */
873
+ async submitClaim(repo, pr, familiarKey) {
874
+ return this.submitQueued({ type: "submit_claim", repo, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }, "", {}, []);
875
+ }
876
+ /** Stake 10 FANG on a newcomer being a real, distinct person (needs 10 Soul). */
877
+ async vouch(account) {
878
+ return this.submitQueued({ type: "vouch", account: (await this.client.recipient(account)).signed }, "", {}, []);
879
+ }
880
+ /** Withdraw your vouch for `account` (after the lock), or pass your own account to decline one. */
881
+ async unvouch(account) {
882
+ return this.submitQueued({ type: "unvouch", account: (await this.client.recipient(account)).signed }, "", {}, []);
883
+ }
884
+ /** Join (or top up) the Séance juror pool. Needs 5 Soul; owner keys only. */
885
+ async joinJury(bond = "10") {
886
+ return this.submitQueued({ type: "join_jury", bond: parseFang(bond).toString() }, "", {}, []);
887
+ }
888
+ async leaveJury() {
889
+ return this.submitQueued({ type: "leave_jury" }, "", {}, []);
890
+ }
891
+ /**
892
+ * Seal a Séance judgment: one of "a", "b" or "=" per assigned pair (see `client.seance(id)`),
893
+ * plus claim ids you think are spam. Keep the returned `salt`: `revealVotes` needs it.
894
+ *
895
+ * The judgment binds the pairs it was made on (audit gap-7-6): pass `pairs`, the claims (id,
896
+ * repo, PR) you showed the juror in order, or they're read from the client (`seancePairs`). A
897
+ * node that showed other pairs or PRs makes the commitment fail instead of misdirecting it.
898
+ */
899
+ async sealVotes(seanceId, votes, spam = [], salt = bytesToHex(randomBytes(16)), opts = {}) {
900
+ await this.client.addressPrefix(); // the commitment names this account
901
+ const pairs = opts.pairs ?? (await this.client.seancePairs(seanceId, this.account));
902
+ if (votes.length !== pairs.length)
903
+ throw new Error(`you have ${pairs.length} assigned pairs: give one of a, b or = for each`);
904
+ const commitment = seanceCommitment(seanceId, this.account, votes, spam, salt);
905
+ const pairs_digest = seancePairsDigest(seanceId, this.account, pairs);
906
+ const tx = await this.submitQueued({ type: "commit_votes", seance_id: seanceId, commitment, pairs_digest }, "", {}, []);
907
+ return { tx, salt, votes, spam };
908
+ }
909
+ async revealVotes(seanceId, votes, spam, salt) {
910
+ return this.submitQueued({ type: "reveal_votes", seance_id: seanceId, votes, spam, salt }, "", {}, []);
911
+ }
912
+ async fundPool(amount) {
913
+ return this.submitQueued({ type: "fund_pool", amount: parseFang(amount).toString() }, "", {}, []);
914
+ }
915
+ /** Withdraw every contribution reward that has vested so far. */
916
+ async withdrawVested() {
917
+ return this.submitQueued({ type: "withdraw_vested" }, "", {}, []);
918
+ }
919
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). Weight 1–100; 0 removes the repo. */
920
+ async setRepo(repo, weight) {
921
+ return this.submitQueued({ type: "set_repo", repo, weight }, "", {}, []);
922
+ }
923
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). `soul` in Soul ("2.5"), up to 100 per grant. */
924
+ async grantSoul(to, soul, reason) {
925
+ return this.submitQueued({ type: "grant_soul", to: (await this.client.recipient(to)).signed, amount: parseSoul(soul), reason }, "", {}, []);
926
+ }
927
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). */
928
+ async rejectClaim(claimId, reason) {
929
+ return this.submitQueued({ type: "reject_claim", claim_id: claimId, reason }, "", {}, []);
930
+ }
931
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). */
932
+ async flagSybil(account, reason) {
933
+ return this.submitQueued({ type: "flag_sybil", account: (await this.client.recipient(account)).signed, reason }, "", {}, []);
934
+ }
935
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). */
936
+ async clawback(account, reason) {
937
+ return this.submitQueued({ type: "clawback", account: (await this.client.recipient(account)).signed, reason }, "", {}, []);
162
938
  }
163
939
  /** Reclaim the bond of a removed validator this account owns. */
164
940
  async unbond(validator) {
165
- return this.client.submit(await this.build({ type: "unbond", validator }));
941
+ return this.submitQueued({ type: "unbond", validator }, "", {}, []);
942
+ }
943
+ // ---- protocol v1 (tags 30–49). Each builds, signs and submits one tx and waits for it. ----
944
+ /** Submits one action (the shared tail of the helpers below); `unproven`: see `buildChecked`. */
945
+ async act(action, opts = {}, unproven = []) {
946
+ return this.submitQueued(action, opts.memo ?? "", opts, unproven);
947
+ }
948
+ /**
949
+ * A validator by address, or by a `.boo` name its account holds. Staking needs the address, so
950
+ * a name is resolved (proven with the client's witnesses); on mainnet a tx with an unproven one
951
+ * isn't signed (audit gap-7-1): pass the validator's address.
952
+ */
953
+ async validatorAddress(validator) {
954
+ return this.addressNeeded(await this.client.resolution(validator));
955
+ }
956
+ /** `r`, unless it's an unproven name on mainnet (`UnprovenNameError`); `buildChecked` checks again against the signing status. */
957
+ async addressNeeded(r) {
958
+ if (!r.proven && (await this.client.status()).chain_id === MAINNET_CHAIN_ID)
959
+ throw new UnprovenNameError(r, this.client.rpc);
960
+ return r;
961
+ }
962
+ /** Stake `amount` (FANG, or bigint drops) in a validator's pool. Your stake earns its share of issuance and is slashed with the pool. */
963
+ async delegate(validator, amount, opts = {}) {
964
+ const v = await this.validatorAddress(validator);
965
+ return this.act({ type: "delegate", validator: v.address, amount: positive(parseFang(amount), "delegate").toString() }, opts, [v]);
966
+ }
967
+ /**
968
+ * Start unbonding stake in a validator's pool: `{ amount }` (FANG, or bigint drops), `shares`,
969
+ * or `"all"` (every share you hold there, read from `/api/staking`). An amount is signed as an
970
+ * amount and the chain converts it at the pool's price when the tx runs, so no node's report of
971
+ * the stake decides how much unbonds (audit gap-7-5); prefer it to `sharesFor`. It stops
972
+ * earning now and can be withdrawn with `withdrawUnbonded` once the unbonding period (21 days
973
+ * on mainnet) is over.
974
+ */
975
+ async undelegate(validator, shares, opts = {}) {
976
+ const v = await this.validatorAddress(validator);
977
+ if (typeof shares === "object" && shares !== null)
978
+ return this.act({ type: "undelegate", validator: v.address, shares: "0", amount: positive(parseFang(shares.amount), "undelegate").toString() }, opts, [v]);
979
+ return this.act({ type: "undelegate", validator: v.address, shares: (await this.sharesOf(v.address, shares)).toString() }, opts, [v]);
980
+ }
981
+ /** Move `shares` (or `"all"`) from one pool to another without waiting out the unbonding period. */
982
+ async redelegate(fromValidator, toValidator, shares, opts = {}) {
983
+ const [from, to] = await Promise.all([this.validatorAddress(fromValidator), this.validatorAddress(toValidator)]);
984
+ if (from.address === to.address)
985
+ throw new Error("redelegate to a different validator");
986
+ return this.act({ type: "redelegate", from_validator: from.address, to_validator: to.address, shares: (await this.sharesOf(from.address, shares)).toString() }, opts, [from, to]);
987
+ }
988
+ /** Pay out every unbonding entry that has finished unbonding. */
989
+ async withdrawUnbonded(opts = {}) {
990
+ return this.act({ type: "withdraw_unbonded" }, opts);
991
+ }
992
+ /** A validator's owner: set the pool's commission (basis points, 0–10000; it moves a little per epoch at most) and, optionally, new addresses. */
993
+ async updateValidator(opts) {
994
+ if (!Number.isInteger(opts.commissionBps) || opts.commissionBps < 0 || opts.commissionBps > 10_000)
995
+ throw new Error("commissionBps must be a whole number from 0 to 10000");
996
+ return this.act({ type: "update_validator", commission_bps: opts.commissionBps, p2p_addr: opts.p2pAddr ?? "", http_addr: opts.httpAddr ?? "" }, opts);
997
+ }
998
+ /** Bring a jailed validator back (its owner, after the jail period). */
999
+ async unjail(validator, opts = {}) {
1000
+ const v = await this.validatorAddress(validator);
1001
+ return this.act({ type: "unjail", validator: v.address }, opts, [v]);
1002
+ }
1003
+ /** Low-level (nodes do this automatically): two conflicting signed votes, hex-encoded as the node's evidence reporter emits them. */
1004
+ async reportEquivocation(evidenceHex, opts = {}) {
1005
+ if (!/^([0-9a-f]{2})+$/.test(evidenceHex) || evidenceHex.length > 2 * 16 * 1024)
1006
+ throw new Error("evidence must be 1–16384 bytes of lowercase hex");
1007
+ return this.act({ type: "report_equivocation", evidence: evidenceHex }, opts);
1008
+ }
1009
+ /** Low-level (validator nodes do this automatically): commit to a beacon secret for `epoch`. */
1010
+ async beaconCommit(epoch, commitmentHex, opts = {}) {
1011
+ return this.act({ type: "beacon_commit", epoch: wholeNumber(epoch, "epoch"), commitment: hexArg(commitmentHex, "commitment") }, opts);
1012
+ }
1013
+ /** Low-level (validator nodes do this automatically): reveal the beacon secret committed for `epoch`. */
1014
+ async beaconReveal(epoch, secretHex, opts = {}) {
1015
+ return this.act({ type: "beacon_reveal", epoch: wholeNumber(epoch, "epoch"), secret: hexArg(secretHex, "secret") }, opts);
1016
+ }
1017
+ /**
1018
+ * A member of a set of one or two, signing with its consensus key: admit candidate `validator`
1019
+ * into the set at the end of `epoch` (default: the epoch the next block is in; the tx fails in
1020
+ * any other). The newcomer will be needed for every quorum, so this is the operator's decision,
1021
+ * once an epoch per member; it joins once a quorum of the members admitted it and its node
1022
+ * showed up (`validatorReady`).
1023
+ */
1024
+ async admitValidator(validator, opts = {}) {
1025
+ const [v, epoch] = await Promise.all([this.validatorAddress(validator), opts.epoch ?? this.client.status().then((s) => s.epoch)]);
1026
+ return this.act({ type: "admit_validator", validator: v.address, epoch: wholeNumber(epoch, "epoch") }, opts, [v]);
1027
+ }
1028
+ /** Low-level (candidate nodes do this automatically, in the epoch's last tenth, once a set of one or two admitted them): a candidate's consensus key signals its node is running in `epoch`. */
1029
+ async validatorReady(epoch, opts = {}) {
1030
+ return this.act({ type: "validator_ready", epoch: wholeNumber(epoch, "epoch") }, opts);
1031
+ }
1032
+ /**
1033
+ * Put a proposal to a vote (build `kind` with `proposal.*`). Bonds `deposit` (default: the
1034
+ * chain's `proposal_deposit`), refunded once the proposal reaches quorum and burned if it
1035
+ * doesn't. Names in the kind (`to`, `account`) are signed as `Client.recipient` says: the proven
1036
+ * address with witnesses, else the name, which the chain resolves when the proposal is made
1037
+ * (it stores the address, which voters then see and bind).
1038
+ *
1039
+ * The default deposit is what the node reports, and a node can report anything, so it's
1040
+ * bounded (audit gap-7-5): every witness of the client must report the same value, and it may
1041
+ * be at most `maxDeposit` (default `DEFAULT_MAX_PROPOSAL_DEPOSIT`, mainnet's 1,000 FANG).
1042
+ * Pass `deposit` to bond an amount you checked yourself.
1043
+ */
1044
+ async propose(kind, opts) {
1045
+ const title = opts.title.trim();
1046
+ if (!title || new TextEncoder().encode(title).length > 96 || /\p{Cc}/u.test(title))
1047
+ throw new Error("title must be 1–96 bytes with no control characters");
1048
+ const url = opts.url ?? "";
1049
+ if (url && (!url.startsWith("https://") || url.length > 200 || /\s|\p{Cc}/u.test(url)))
1050
+ throw new Error("url must be an https:// link of at most 200 bytes");
1051
+ let deposit;
1052
+ if (opts.deposit !== undefined)
1053
+ deposit = parseFang(opts.deposit);
1054
+ else {
1055
+ const reported = await Promise.all([this.client, ...this.client.witnesses].map(async (c) => paramsOf(await c.params()).proposal_deposit));
1056
+ const d = reported[0];
1057
+ if (d === undefined)
1058
+ throw new Error("this node doesn't report proposal_deposit; pass deposit");
1059
+ if (reported.some((x) => x !== d))
1060
+ throw new Error(`the node and its witnesses disagree about proposal_deposit (${reported.map((x) => (x === undefined ? "none" : formatFang(x))).join(", ")}); pass deposit`);
1061
+ deposit = BigInt(d);
1062
+ const max = opts.maxDeposit !== undefined ? parseFang(opts.maxDeposit) : DEFAULT_MAX_PROPOSAL_DEPOSIT;
1063
+ if (deposit > max)
1064
+ throw new Error(`${this.client.rpc} reports a proposal_deposit of ${formatFang(deposit)}, more than maxDeposit (${formatFang(max)}): a node can report any deposit, and all of it is burned if the vote misses quorum. Check it on another node, then pass deposit (or a higher maxDeposit)`);
1065
+ }
1066
+ return this.act({ type: "propose", kind: await this.resolveKind(kind), deposit: positive(deposit, "deposit").toString(), title, url }, opts);
1067
+ }
1068
+ /**
1069
+ * Vote on a proposal with `amount` of your unlocked holdings (liquid, staked or unbonding;
1070
+ * unvested tokens can't vote). `conviction` 0–6 locks `amount` for 0, 1, 2, 4, 8, 16 or 32 lock
1071
+ * periods after the vote ends, for 0.1×, 1×, 2× … 6× the weight (see `convictionWeight`).
1072
+ * Voting again replaces your vote. A validator's consensus key or owner voting before the
1073
+ * bootstrap council ends also casts that validator's council vote.
1074
+ *
1075
+ * The vote binds the proposal's content (audit gap-7-6): pass `proposal`, the kind, title and
1076
+ * link you showed the voter (e.g. the `client.proposal(id)` object you rendered, described with
1077
+ * `describeKind`), or it's read from the client. A node that showed another proposal's content
1078
+ * makes the vote fail. If you showed it earlier and only kept its digest
1079
+ * (`proposalSummary(raw).digest`), pass `digest`: a proposal that reads differently now is
1080
+ * refused before anything is signed.
1081
+ */
1082
+ async vote(proposalId, aye, amount, conviction = 1, opts = {}) {
1083
+ if (!Number.isInteger(conviction) || conviction < 0 || conviction > MAX_CONVICTION)
1084
+ throw new Error(`conviction must be 0–${MAX_CONVICTION}`);
1085
+ const id = wholeNumber(proposalId, "proposalId");
1086
+ const drops = positive(parseFang(amount), "vote").toString();
1087
+ const p = opts.proposal ?? (await this.client.proposal(id));
1088
+ const proposal_digest = proposalDigest(p.kind, p.title, p.url ?? "");
1089
+ shownDigest(opts.digest, proposal_digest, `proposal #${id}`);
1090
+ return this.act({ type: "vote", proposal_id: id, aye, amount: drops, conviction, proposal_digest }, opts);
1091
+ }
1092
+ /** Release every vote lock that has expired. */
1093
+ async unlock(opts = {}) {
1094
+ return this.act({ type: "unlock" }, opts);
1095
+ }
1096
+ /**
1097
+ * Make this account an M-of-N multisig of its owner keys (`ownerKeyIds`): owner-only actions
1098
+ * then need `multisigPropose` plus approvals up to `threshold`. Session keys keep working
1099
+ * within their limits.
1100
+ */
1101
+ async multisigCreate(ownerKeyIds, threshold, opts = {}) {
1102
+ const owners = ownerKeyIds.map((k) => k.trim().toLowerCase());
1103
+ if (owners.length === 0 || owners.length > 16 || owners.some((k) => !/^[0-9a-f]{64}$/.test(k)))
1104
+ throw new Error("owners must be 1–16 key ids (64 hex characters)");
1105
+ if (new Set(owners).size !== owners.length)
1106
+ throw new Error("owners must be distinct");
1107
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > owners.length)
1108
+ throw new Error(`threshold must be 1–${owners.length} (the number of owners)`);
1109
+ return this.act({ type: "multisig_create", owners, threshold }, opts);
1110
+ }
1111
+ /** Propose an action for this multisig account; it runs once enough owners approve. */
1112
+ async multisigPropose(action, opts = {}) {
1113
+ if (action.type === "multisig_propose" || action.type === "multisig_approve")
1114
+ throw new Error("a multisig proposal can't hold another multisig proposal or approval");
1115
+ return this.act({ type: "multisig_propose", action }, opts);
1116
+ }
1117
+ /**
1118
+ * Approve open multisig proposal `id`. The approval binds the action (audit gap-7-6): pass
1119
+ * `action`, the one you showed the approver (decode it and describe it with `describeAction`,
1120
+ * don't trust a summary), or it's read from `client.multisig(account)`. A node that showed
1121
+ * another action makes the approval fail. `digest`: the `actionDigest` of what you showed, if
1122
+ * that's all you kept; an action that reads differently now is refused before anything is signed.
1123
+ * A `vote` binds its proposal by digest, not by id: show it with `reviewAction(action, {
1124
+ * proposal: await client.proposal(action.proposal_id) })`, which describes the proposal only if
1125
+ * its content has that digest, and don't offer the approval unless it's `readable` (gap-7-6).
1126
+ * Pass that proposal read as `proposal` here: approving a vote is refused, before anything is
1127
+ * signed, unless the proposal shown (else one read now) has exactly the content the vote binds.
1128
+ * The approval counts on this wallet's account (`actAs`), which the digest doesn't bind: on
1129
+ * mainnet act for an address you checked, not a name one node resolved.
1130
+ */
1131
+ async multisigApprove(id, opts = {}) {
1132
+ const n = wholeNumber(id, "id");
1133
+ let action = opts.action;
1134
+ if (!action) {
1135
+ const m = await this.client.multisig(this.account);
1136
+ action = (Array.isArray(m?.pending) ? m.pending : []).find((p) => p.id === n)?.action;
1137
+ if (!action)
1138
+ throw new Error(`${this.account} has no open multisig action #${n}`);
1139
+ }
1140
+ const action_digest = actionDigest(action);
1141
+ shownDigest(opts.digest, action_digest, `multisig action #${n}`);
1142
+ if (action.type === "vote") {
1143
+ // What the ballot backs is the proposal whose content has its digest, whatever the node
1144
+ // calls #id (audit gap-7-6): a node can't make other content hash to it.
1145
+ const proposal = opts.proposal ?? (await this.client.proposal(action.proposal_id).catch(() => undefined));
1146
+ const review = reviewAction(action, { proposal, freeText: false });
1147
+ if (!review.readable)
1148
+ throw new Error(`multisig action #${n} can't be approved from here: ${review.does}. Nothing was signed`);
1149
+ }
1150
+ return this.act({ type: "multisig_approve", id: n, action_digest }, opts);
1151
+ }
1152
+ /** Extend a name you own by `years` (1–10), at the chain's renewal price per year. */
1153
+ async renewName(name, years = 1, opts = {}) {
1154
+ if (!Number.isInteger(years) || years < 1 || years > 10)
1155
+ throw new Error("renew for 1–10 years");
1156
+ return this.act({ type: "renew_name", name: bareName(name), years }, opts);
1157
+ }
1158
+ /**
1159
+ * Offer a name you own to another account (a `.boo` name or address, signed as `Client.recipient` says). It
1160
+ * stays yours until they accept it (`acceptName`); offering it again replaces the offer, and
1161
+ * `withdrawNameOffer` withdraws it. Nobody can be given a name without saying yes.
1162
+ */
1163
+ async transferName(name, to, opts = {}) {
1164
+ return this.act({ type: "transfer_name", name: bareName(name), to: (await this.client.recipient(to)).signed }, opts);
1165
+ }
1166
+ /** Accept a name offered to the account you act for (`client.name(n).offered_to`): it becomes yours. */
1167
+ async acceptName(name, opts = {}) {
1168
+ return this.act({ type: "transfer_name", name: bareName(name), to: this.account }, opts);
1169
+ }
1170
+ /** Withdraw your offer of a name you own (the same tx as `acceptName`: the chain tells them apart by who owns the name). */
1171
+ async withdrawNameOffer(name, opts = {}) {
1172
+ return this.acceptName(name, opts);
1173
+ }
1174
+ /** Give up a name you own; anyone can register it afterwards. */
1175
+ async releaseName(name, opts = {}) {
1176
+ return this.act({ type: "release_name", name: bareName(name) }, opts);
1177
+ }
1178
+ /** Claim your share of a community distribution with the Merkle proof published alongside it (hex hashes). */
1179
+ async claimDistribution(distributionId, amount, proof, opts = {}) {
1180
+ if (proof.length > 64)
1181
+ throw new Error("proof too long (max 64 hashes)");
1182
+ return this.act({ type: "claim_distribution", distribution_id: wholeNumber(distributionId, "distributionId"), amount: positive(parseFang(amount), "claim").toString(), proof: proof.map((h) => hexArg(h, "proof")) }, opts);
1183
+ }
1184
+ /** Low-level (validator nodes do this automatically): a matured claim's PR was reverted by commit `revertSha`. */
1185
+ async attestRevert(claimId, revertSha, opts = {}) {
1186
+ if (!/^[0-9a-f]{40}$/.test(revertSha))
1187
+ throw new Error("revertSha must be the 40-hex revert commit");
1188
+ return this.act({ type: "attest_revert", claim_id: wholeNumber(claimId, "claimId"), revert_sha: revertSha }, opts);
1189
+ }
1190
+ // ---- programs (protocol v2: tags 62, 63, 64 and 69; docs/PROGRAMS-V0.md §8.4). Third-party WebAssembly: every node answer is
1191
+ // checked before it's used (programs.ts), fuel is priced at the node's suggested rate and refused when no block could carry it,
1192
+ // and nothing is signed that the user wasn't shown the digest of. Upload, create and call are one step per block (§2.1): each
1193
+ // waits for what it needs to be usable instead of being refused by the mempool. ----
1194
+ /** The chain's fuel prices, with programs required to be on (`ProgramsOffError`) and to admit this kind of transaction now. */
1195
+ async programsFor(kind, sendsValue) {
1196
+ const fuel = await this.client.programFuel();
1197
+ checkProgramsStatus(fuel, kind, sendsValue);
1198
+ return fuel;
1199
+ }
1200
+ /** Waits (up to `waitMs`) for the node's head to pass `height`: what block `height` stored is usable from the next one (§2.1). */
1201
+ async waitPast(height, waitMs, what) {
1202
+ const until = Date.now() + waitMs;
1203
+ for (;;) {
1204
+ const head = (await this.client.status()).height;
1205
+ if (!Number.isSafeInteger(head) || head < 0)
1206
+ throw new Error(`${this.client.rpc} doesn't report its height`);
1207
+ this.client.checkHeightClock(head);
1208
+ if (head > height)
1209
+ return;
1210
+ if (Date.now() > until)
1211
+ throw new Error(`${what} is final in block ${height}, but ${this.client.rpc} hasn't sealed the block after it in ${waitMs} ms, so it can't be used yet: check again shortly`);
1212
+ await sleep(250);
1213
+ }
1214
+ }
1215
+ /**
1216
+ * Runs what follows a transaction that was submitted and is final, saying so if it fails: `what` names
1217
+ * the transaction (its id, and what it made), which was sent and ran, so the failure is of the receipt,
1218
+ * the wait or the read after it, and the transaction must not be sent again. The typed error stays
1219
+ * as `cause`; its reason is quoted stripped of hidden characters (it may hold a node's text). `what` may be a function
1220
+ * so the name can grow as `run` learns more (the address a create made).
1221
+ */
1222
+ async finalThen(what, run) {
1223
+ try {
1224
+ return await run();
1225
+ }
1226
+ catch (e) {
1227
+ const reason = plainText(String(e?.message ?? e)).slice(0, 600);
1228
+ throw new Error(`${typeof what === "string" ? what : what()} was sent and is final: it already ran, so don't send it again. What came after it failed: ${reason}`, { cause: e });
1229
+ }
1230
+ }
1231
+ /**
1232
+ * Waits (up to `waitMs`) until `read` finds what it names (`null`: not there yet) stored in a block
1233
+ * before the node's head, which is when it's usable (§2.1: a create, or a call, signed earlier is
1234
+ * refused by the mempool). Says what was waited for if it never is.
1235
+ */
1236
+ async waitUsable(what, waitMs, read) {
1237
+ const until = Date.now() + waitMs;
1238
+ let seen = null;
1239
+ for (;;) {
1240
+ const r = await read();
1241
+ if (r && r.stored !== null) {
1242
+ seen = r.stored;
1243
+ const head = (await this.client.status()).height;
1244
+ if (!Number.isSafeInteger(head) || head < 0)
1245
+ throw new Error(`${this.client.rpc} doesn't report its height`);
1246
+ this.client.checkHeightClock(head);
1247
+ if (r.stored < head)
1248
+ return r.found;
1249
+ }
1250
+ if (Date.now() > until)
1251
+ throw new Error(`${what} isn't usable after ${waitMs} ms: ${seen === null ? "it isn't stored (upload or create it first)" : `it was stored in block ${seen}, and is usable from the block after it`}. Nothing was signed`);
1252
+ await sleep(250);
1253
+ }
1254
+ }
1255
+ /** A program as a call needs it: usable (stored in an earlier block), its digest recomputed from its own record, and proven against the client's witnesses when it has some. */
1256
+ async programForCall(address, waitMs) {
1257
+ const info = await this.waitUsable(`the program ${address}`, waitMs, async () => {
1258
+ try {
1259
+ const p = await this.client.program(address);
1260
+ return { stored: p.created_at, found: p };
1261
+ }
1262
+ catch (e) {
1263
+ if (e instanceof ApiError && e.status === 404)
1264
+ return null;
1265
+ throw e;
1266
+ }
1267
+ });
1268
+ if (info.code.size === null)
1269
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/program/${address}`, "code.size", "is missing: the code's load can't be priced");
1270
+ if (this.client.witnesses.length) {
1271
+ const proven = await this.client.provenProgram(address, { witnesses: this.client.witnesses });
1272
+ if (!proven)
1273
+ throw new Error(`${address} isn't a program: its record is provably absent (proven against ${this.client.witnesses.length} witness${this.client.witnesses.length === 1 ? "" : "es"}), whatever ${this.client.rpc} says. Nothing was signed`);
1274
+ if (proven.version_digest !== info.version_digest || proven.wasm_hash !== info.code.wasm_hash || proven.interface_hash !== info.code.interface_hash)
1275
+ throw new Error(`${this.client.rpc} and its witnesses disagree about the code at ${address}: the proven record's digest is ${proven.version_digest.slice(0, 16)}…, the node says ${info.version_digest.slice(0, 16)}…. Nothing was signed`);
1276
+ }
1277
+ return { info, digest: info.version_digest };
1278
+ }
1279
+ /**
1280
+ * The bindings of a transaction (`ProgramBindingInput`s): each program read (usable, checked, proven
1281
+ * with witnesses), its version digest filled with the version in force now when none is given (never
1282
+ * "any version", 64 zeros, unless asked: `ANY_PROGRAM_VERSION`), and its code added to `declared`,
1283
+ * which load fuel is counted on.
1284
+ */
1285
+ async resolveBindings(bindings, declared, waitMs) {
1286
+ if (!Array.isArray(bindings) || bindings.length > MAX_PROGRAM_BINDINGS)
1287
+ throw new Error(`a transaction binds at most ${MAX_PROGRAM_BINDINGS} programs`);
1288
+ const out = [];
1289
+ for (const b of bindings) {
1290
+ const program = programAddressArg(b.program, "a binding");
1291
+ if (out.some((o) => o.program === program))
1292
+ throw new Error(`${program} is bound twice`);
1293
+ const { info, digest } = await this.programForCall(program, waitMs);
1294
+ const given = b.versionDigest === undefined ? undefined : programHashArg(b.versionDigest, "a binding's version digest");
1295
+ if (given !== undefined && given !== ANY_PROGRAM_VERSION)
1296
+ shownDigest(given, digest, `the program ${program}`);
1297
+ declared.set(info.code.wasm_hash, info.code.size);
1298
+ out.push({ program, version_digest: given ?? digest });
1299
+ }
1300
+ return out;
1301
+ }
1302
+ /**
1303
+ * Signs and submits a program transaction (reserving `reservation` fuel: its fee is priced with it),
1304
+ * waits for it, checks its receipt (programs.ts `checkReceipt`) and returns it with what it cost.
1305
+ * One that ran and failed throws `ProgramFailedError` (its fee was charged; `storageLimit` names
1306
+ * the limit it signed, for the hint a `storage_limit` trap needs).
1307
+ *
1308
+ * Once the transaction is submitted and the node reports it final, **every failure says so**
1309
+ * (`finalThen`): a receipt or answer that doesn't check isn't "nothing happened, ask another node",
1310
+ * since the transaction ran, and a payable call sent again pays twice. The typed error is the `cause`.
1311
+ */
1312
+ async sendProgram(kind, action, reservation, opts, storageLimit) {
1313
+ let signed;
1314
+ let done;
1315
+ try {
1316
+ done = await this.submitQueued(action, "", { ...opts, wait: true, reserved: reservation, onSigned: async (tx, id) => { signed = tx; await opts.onSigned?.(tx, id); } }, []);
1317
+ }
1318
+ catch (e) {
1319
+ if (e instanceof TxFailedError && !e.dropped)
1320
+ throw programFailure(this.client.rpc, e, storageLimit);
1321
+ throw e;
1322
+ }
1323
+ const tx = signed;
1324
+ const id = tx ? txId(tx) : "(its id wasn't seen here)";
1325
+ return this.finalThen(`the ${kind} (tx ${id})`, async () => {
1326
+ if (!tx)
1327
+ throw new Error("a program transaction was submitted without being signed here");
1328
+ if (done?.id !== id)
1329
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "id", `isn't the transaction that was submitted (${plainText(String(done?.id)).slice(0, 16)})`);
1330
+ const receipt = checkReceipt(this.client.rpc, done);
1331
+ if (!receipt)
1332
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "program", "is null for a transaction that is final: it should hold its receipt");
1333
+ if (receipt.kind !== kind)
1334
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "program.kind", `is ${receipt.kind}, not ${kind}`);
1335
+ if (!receipt.outcome.ok)
1336
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "program.outcome", "says it failed for a transaction that is final");
1337
+ if (!Number.isSafeInteger(done.height) || done.height < 0)
1338
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "height", "isn't a block height");
1339
+ this.client.checkHeightClock(done.height);
1340
+ return { tx: done, receipt, cost: programCost(receipt, tx.from), signed: tx };
1341
+ });
1342
+ }
1343
+ /**
1344
+ * Uploads a program's code (WebAssembly 1.0) and its interface (format 1: `blockula program interface
1345
+ * encode`, or the `.iface` files beside the guests), bonding a storage deposit for each that isn't
1346
+ * stored yet: the deposit at today's price (`programUploadDeposit`), which v0 never gives back. `storageLimit`
1347
+ * (FANG, default that deposit plus 10%) is the most the upload may bond; below the deposit it's
1348
+ * refused before signing, as the upload would fail and still be charged. Fuel is priced at the
1349
+ * node's suggested rate. Resolves once the upload is final **and the block after it has been sealed**,
1350
+ * when the code is usable (a create signed sooner is refused, §2.1), with its receipt and what it
1351
+ * cost: the fee, and the deposit, which `receipt.facts.outside_vm` holds apart from the VM's diff.
1352
+ */
1353
+ async uploadCode(wasm, iface, opts = {}) {
1354
+ const code = programBytesArg(wasm, "the code", MAX_PROGRAM_CODE_BYTES);
1355
+ const interfaceBytes = programBytesArg(iface, "the interface", MAX_PROGRAM_INTERFACE_BYTES);
1356
+ const waitMs = programWaitMs(opts.waitMs);
1357
+ const [wasm_hash, interface_hash] = [wasmHash(code), interfaceHash(interfaceBytes)];
1358
+ const fuel = await this.programsFor("upload", false);
1359
+ const params = await this.client.programParams();
1360
+ // What's stored already bonds nothing again (an upload of stored blobs only refreshes them). That's one node's word, so it only
1361
+ // says what deposit to expect: the limit defaults to the deposit as if both blobs were new, a ceiling the chain bonds only
1362
+ // what's new against, which a node that says "already stored" can't lower (it'd make the upload fail, and charge its fee).
1363
+ const stored = async (read) => read().then(() => true, (e) => (e instanceof ApiError && e.status === 404 ? false : Promise.reject(e)));
1364
+ const [codeStored, interfaceStored] = await Promise.all([stored(() => this.client.code(wasm_hash)), stored(() => this.client.interface(interface_hash))]);
1365
+ const deposit = programUploadDeposit(fuel, params, [{ size: code.length, isNew: !codeStored }, { size: interfaceBytes.length, isNew: !interfaceStored }]);
1366
+ if (deposit === null)
1367
+ throw new Error("program state is full: nothing more can be stored at any price. Nothing was signed");
1368
+ const ceiling = programUploadDeposit(fuel, params, [{ size: code.length, isNew: true }, { size: interfaceBytes.length, isNew: true }]) ?? deposit;
1369
+ const storageLimit = opts.storageLimit === undefined ? depositWithMargin(ceiling) : checkedDrops(parseFang(opts.storageLimit), "storageLimit");
1370
+ if (storageLimit < deposit)
1371
+ throw new Error(`storageLimit ${formatFang(storageLimit)} is below the deposit ${formatFang(deposit)} at today's price: the upload would fail and still be charged its fee. Nothing was signed`);
1372
+ // An upload reserves its own validation, test translation and hashing (§5.1), priced by its size.
1373
+ const reservation = reserveFuel(fuel, uploadFuel(code.length, interfaceBytes.length), new Map(), false);
1374
+ const action = { type: "upload_code", wasm: bytesToHex(code), interface: bytesToHex(interfaceBytes), storage_limit: storageLimit.toString() };
1375
+ const { tx, receipt, cost } = await this.sendProgram("upload", action, reservation, opts, storageLimit);
1376
+ return this.finalThen(`the upload (tx ${tx.id})`, async () => {
1377
+ const upload = receipt.facts.upload;
1378
+ if (!upload || upload.wasm_hash !== wasm_hash || upload.interface_hash !== interface_hash)
1379
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${tx.id}`, "program.facts.upload", "isn't the code and interface that were uploaded");
1380
+ await this.waitPast(tx.height, waitMs, "the upload");
1381
+ return { wasmHash: wasm_hash, interfaceHash: interface_hash, deposit: upload.deposit, tx, receipt, cost };
1382
+ });
166
1383
  }
167
- balance() {
168
- return this.client.account(this.account).then((a) => a.balance_display, () => "0 FANG");
1384
+ /**
1385
+ * Creates a program from stored code and interface (`uploadCode`'s hashes), at the address made from
1386
+ * this account and the transaction's nonce, which is returned **only once the create has applied**
1387
+ * and the block after it has been sealed (the program is then usable), never predicted ahead of it:
1388
+ * FANG sent to a program's address before its create is final is stranded for good if the create
1389
+ * fails (§2.3). It first waits for the code and interface to be usable (an upload a block ago).
1390
+ *
1391
+ * `init` runs the interface's `Init` method with its `input` and `value`, present exactly when the
1392
+ * interface declares one; it runs under `fuelLimit` (default `program_max_tx_fuel`, 0 without `init`).
1393
+ * `storageLimit` (FANG) is the most the record's deposit and what the initializer's writes bond may
1394
+ * be: default the record's deposit plus 10%, which a program whose initializer writes needs more than.
1395
+ * `authority` (an address, never a name) may upgrade it once a protocol version adds upgrades, with
1396
+ * `delayBlocks` of notice (default and least: `program_min_upgrade_delay_blocks`) and an upgrade
1397
+ * `policy`; without one the program is immutable. `bindings` are programs the initializer may reach.
1398
+ * The receipt's `outside_vm` holds the record deposit and the account creation fee the create burns
1399
+ * when the program's address held no account yet.
1400
+ */
1401
+ async createProgram(opts) {
1402
+ const wasm_hash = programHashArg(opts.wasmHash, "wasmHash");
1403
+ const interface_hash = programHashArg(opts.interfaceHash, "interfaceHash");
1404
+ const waitMs = programWaitMs(opts.waitMs);
1405
+ const init = opts.init;
1406
+ const initMethod = init === undefined ? undefined : programMethodArg(init.method);
1407
+ const initValue = checkedDrops(parseFang(init?.value ?? 0n), "init.value");
1408
+ if (init === undefined && (opts.fuelLimit !== undefined || opts.bindings?.length))
1409
+ throw new Error("fuelLimit and bindings are an initializer's: pass `init`, or leave them out");
1410
+ if (opts.authority === undefined && (opts.delayBlocks !== undefined || opts.policy !== undefined))
1411
+ throw new Error("delayBlocks and policy are an upgrade authority's: pass `authority` (an immutable program has neither)");
1412
+ const authority = opts.authority === undefined ? null : programAddressArg(opts.authority, "an upgrade authority");
1413
+ const fuel = await this.programsFor("create", initValue > 0n);
1414
+ const params = await this.client.programParams();
1415
+ const stored = (read) => async () => {
1416
+ try {
1417
+ const r = await read();
1418
+ return { stored: r.uploaded_at, found: r };
1419
+ }
1420
+ catch (e) {
1421
+ if (e instanceof ApiError && e.status === 404)
1422
+ return null;
1423
+ throw e;
1424
+ }
1425
+ };
1426
+ // Both blobs usable: stored in an earlier block (the upload may be a block away).
1427
+ const code = await this.waitUsable("the code", waitMs, stored(() => this.client.code(wasm_hash)));
1428
+ const iface = await this.waitUsable("the interface", waitMs, stored(() => this.client.interface(interface_hash)));
1429
+ // The interface says whether it has an initializer (the node's reading of its bytes; the chain enforces it either way).
1430
+ const methods = iface.untrusted_interface?.methods;
1431
+ if (Array.isArray(methods)) {
1432
+ const declares = methods.some((m) => m?.kind === "init");
1433
+ if (declares && init === undefined)
1434
+ throw new Error("this interface declares an initializer, so the create must run it: pass `init`. Nothing was signed");
1435
+ if (!declares && init !== undefined)
1436
+ throw new Error("this interface declares no initializer, so `init` has none to run. Nothing was signed");
1437
+ }
1438
+ const declared = new Map();
1439
+ if (init !== undefined)
1440
+ declared.set(wasm_hash, code.size);
1441
+ const bindings = await this.resolveBindings(opts.bindings ?? [], declared, waitMs);
1442
+ const fuelLimit = init === undefined ? 0n : opts.fuelLimit === undefined ? defaultFuelLimit(fuel, declared) : BigInt(positiveFuel(opts.fuelLimit));
1443
+ const reservation = reserveFuel(fuel, fuelLimit, declared);
1444
+ const delay = authority === null ? 0 : opts.delayBlocks ?? params.min_upgrade_delay_blocks;
1445
+ if (authority !== null && (!Number.isSafeInteger(delay) || delay < params.min_upgrade_delay_blocks))
1446
+ throw new Error(`delayBlocks ${delay} is below program_min_upgrade_delay_blocks (${params.min_upgrade_delay_blocks}): an upgradable program gives at least that much notice. Nothing was signed`);
1447
+ const policy = authority === null ? "open" : opts.policy ?? "open";
1448
+ if (policy !== "open" && policy !== "additive")
1449
+ throw new Error(`policy is "open" or "additive", not ${JSON.stringify(policy)}`);
1450
+ const record = programRecordDeposit(fuel, params);
1451
+ if (record === null)
1452
+ throw new Error("program state is full: nothing more can be stored at any price. Nothing was signed");
1453
+ const storageLimit = opts.storageLimit === undefined ? depositWithMargin(record) : checkedDrops(parseFang(opts.storageLimit), "storageLimit");
1454
+ if (storageLimit < record)
1455
+ throw new Error(`storageLimit ${formatFang(storageLimit)} is below the record deposit ${formatFang(record)} at today's price: the create would fail and still be charged its fee. Nothing was signed`);
1456
+ const action = {
1457
+ type: "create_program",
1458
+ wasm_hash,
1459
+ interface_hash,
1460
+ authority,
1461
+ upgrade_delay_blocks: delay,
1462
+ policy,
1463
+ accepts_transfers: opts.acceptsTransfers === true,
1464
+ init: init === undefined ? null : { method: initMethod, input: programInputArg(init.input ?? ""), value: initValue.toString() },
1465
+ fuel_limit: Number(fuelLimit),
1466
+ storage_limit: storageLimit.toString(),
1467
+ bindings,
1468
+ };
1469
+ const { tx, receipt, cost, signed } = await this.sendProgram("create", action, reservation, opts, storageLimit);
1470
+ // The address this transaction makes is its sender's and its nonce's: what the receipt names must be that one.
1471
+ let what = `the create (tx ${tx.id})`;
1472
+ return this.finalThen(() => what, async () => {
1473
+ const address = programAddress(signed.from, signed.nonce, await this.client.addressPrefix());
1474
+ what = `the create (tx ${tx.id}), which made ${address},`;
1475
+ if (receipt.facts.created !== address)
1476
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${tx.id}`, "program.facts.created", `names ${String(receipt.facts.created)}, not ${address}, the program this create makes`);
1477
+ await this.waitPast(tx.height, waitMs, "the create");
1478
+ const program = await this.client.program(address);
1479
+ if (program.code.wasm_hash !== wasm_hash || program.code.interface_hash !== interface_hash)
1480
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/program/${address}`, "code", "isn't the code and interface this create named");
1481
+ return { address, program, tx, receipt, cost };
1482
+ });
1483
+ }
1484
+ /**
1485
+ * Calls programs: 1 to 8 calls run in order, all or nothing, sharing `fuelLimit` (default
1486
+ * `program_max_tx_fuel`) and `storageLimit` (FANG): **the default is 0, which consents to no new
1487
+ * storage**, so a call whose writes bond a deposit traps `storage_limit` and still pays its fee
1488
+ * (`ProgramFailedError` says so): pass a limit for any method that writes.
1489
+ *
1490
+ * Each call binds the `versionDigest` of the program it's calling: when you pass the digest you
1491
+ * showed the user (`ProgramCallInput.versionDigest`) the call is refused if the program's code,
1492
+ * interface or version isn't the one that digest names; without it, it's recomputed from the
1493
+ * program's record (proven against the client's witnesses when it has any), never taken from the
1494
+ * node's `version_digest`. Fuel is priced at the node's suggested rate on what's reserved (fuel to
1495
+ * run, plus the load of the code the calls and `bindings` declare) and refused when no block
1496
+ * could carry it (`FuelReservationError`). Waits for each program to be usable (created a block ago).
1497
+ * The receipt's `claims` are what the programs say happened: the chain checks none of it.
1498
+ */
1499
+ async callProgram(calls, opts = {}) {
1500
+ if (!Array.isArray(calls) || calls.length < 1 || calls.length > MAX_PROGRAM_CALLS)
1501
+ throw new Error(`a call_program runs 1 to ${MAX_PROGRAM_CALLS} calls`);
1502
+ const waitMs = programWaitMs(opts.waitMs);
1503
+ const parsed = calls.map((c, i) => ({
1504
+ program: programAddressArg(c.program, `call ${i + 1}'s program`),
1505
+ method: programMethodArg(c.method),
1506
+ input: programInputArg(c.input ?? ""),
1507
+ value: checkedDrops(parseFang(c.value ?? 0n), `call ${i + 1}'s value`),
1508
+ shown: c.versionDigest === undefined ? undefined : programHashArg(c.versionDigest, `call ${i + 1}'s versionDigest`),
1509
+ }));
1510
+ const fuel = await this.programsFor("call", parsed.some((c) => c.value > 0n));
1511
+ const declared = new Map();
1512
+ const read = new Map();
1513
+ const signed = [];
1514
+ for (const c of parsed) {
1515
+ let p = read.get(c.program);
1516
+ if (!p)
1517
+ read.set(c.program, (p = await this.programForCall(c.program, waitMs)));
1518
+ if (c.shown !== undefined)
1519
+ shownDigest(c.shown, p.digest, `the program ${c.program}`);
1520
+ declared.set(p.info.code.wasm_hash, p.info.code.size);
1521
+ signed.push({ program: c.program, version_digest: p.digest, method: c.method, input: c.input, value: c.value.toString() });
1522
+ }
1523
+ const bindings = await this.resolveBindings(opts.bindings ?? [], declared, waitMs);
1524
+ const fuelLimit = opts.fuelLimit === undefined ? defaultFuelLimit(fuel, declared) : BigInt(positiveFuel(opts.fuelLimit));
1525
+ const reservation = reserveFuel(fuel, fuelLimit, declared);
1526
+ const storageLimit = opts.storageLimit === undefined ? 0n : checkedDrops(parseFang(opts.storageLimit), "storageLimit");
1527
+ const action = { type: "call_program", calls: signed, fuel_limit: Number(fuelLimit), storage_limit: storageLimit.toString(), bindings };
1528
+ const { tx, receipt, cost } = await this.sendProgram("call", action, reservation, opts, storageLimit);
1529
+ return { tx, receipt, cost };
1530
+ }
1531
+ /**
1532
+ * One call: `program`'s `method` (a `call` method of its interface; read a `view` with
1533
+ * `client.view`) with `input` (bytes, or lowercase hex) and `value` FANG attached (only to a
1534
+ * payable method). It reads the program first, so a method its interface doesn't have, a view,
1535
+ * an initializer, or FANG sent to a method that isn't payable is refused before anything is signed
1536
+ * (the chain refuses them too, and charges the fee), and signs its `version_digest`: see `callProgram`,
1537
+ * whose options it takes, including the 0 default of `storageLimit`.
1538
+ */
1539
+ async call(program, method, input = "", opts = {}) {
1540
+ const address = programAddressArg(program);
1541
+ const name = programMethodArg(method);
1542
+ const { info } = await this.programForCall(address, programWaitMs(opts.waitMs));
1543
+ const m = info.methods?.find((x) => x.untrusted_name === name);
1544
+ if (info.methods && !m)
1545
+ throw new Error(`the program's interface has no method ${JSON.stringify(name)}: client.program(${JSON.stringify(address)}) lists them. Nothing was signed`);
1546
+ if (m && m.kind === "view")
1547
+ throw new Error(`${JSON.stringify(name)} is a view: read it with client.view (a transaction can't call it). Nothing was signed`);
1548
+ if (m && m.kind === "init")
1549
+ throw new Error(`${JSON.stringify(name)} is the program's initializer, which runs only when it's created. Nothing was signed`);
1550
+ if (m && !m.payable && checkedDrops(parseFang(opts.value ?? 0n), "value") > 0n)
1551
+ throw new Error(`${JSON.stringify(name)} isn't payable, so the call can't send it FANG. Nothing was signed`);
1552
+ return this.callProgram([{ program: address, method: name, input, value: opts.value, versionDigest: opts.versionDigest }], opts);
1553
+ }
1554
+ /**
1555
+ * For a validator: signs a vote to pause programs (tag 69), from the validator's own account with
1556
+ * its key (build the wallet from `validator.key`'s seed and `actAs` nothing: it signs as itself).
1557
+ * Programs pause from the block after f + 1 of the epoch's validators have signed, for days, with
1558
+ * no undo, so check `client.params()`' `programs` section (the live `votes`, `pause_signed` and
1559
+ * `fix_pending`) first, or use `blockula program pause`, which does.
1560
+ *
1561
+ * It signs and lets the chain's admission decide: a signature the chain refuses (this validator
1562
+ * signed one recently, or has a live vote of its own, or isn't in the active set) is an `ApiError`
1563
+ * 400 and is never charged. It reserves no fuel and prices none, so its fee is a plain
1564
+ * transaction's; it only needs programs to have been switched on (`/api/fees` has a `fuel` section:
1565
+ * `ProgramsOffError` otherwise), not any fuel schedule this SDK knows, since a pause must not depend on one.
1566
+ */
1567
+ async programsPause(opts = {}) {
1568
+ const fees = await this.client.fees();
1569
+ if (fees.fuel === undefined || fees.fuel === null)
1570
+ throw new ProgramsOffError(this.client.rpc);
1571
+ return this.submitQueued({ type: "programs_pause" }, "", { ...opts, wait: true }, []);
1572
+ }
1573
+ /** This account's shares in a validator's pool, for `"all"`; anything else is parsed and checked. */
1574
+ async sharesOf(validator, shares) {
1575
+ if (shares !== "all") {
1576
+ const s = typeof shares === "bigint" ? shares : /^\d+$/.test(shares.trim()) ? BigInt(shares.trim()) : -1n;
1577
+ if (s <= 0n || s >= 1n << 64n)
1578
+ throw new Error("shares must be a positive whole number (see client.staking(account))");
1579
+ return s;
1580
+ }
1581
+ const d = stakeSummary(await this.client.staking(this.account)).delegations.find((x) => x.validator === validator);
1582
+ if (!d || BigInt(d.shares) === 0n)
1583
+ throw new Error(`${this.account} has no stake with validator ${validator}`);
1584
+ return BigInt(d.shares);
1585
+ }
1586
+ /** A proposal kind with its accounts as `Client.recipient` signs them (the chain resolves names when the proposal is made). */
1587
+ async resolveKind(kind) {
1588
+ switch (kind.type) {
1589
+ case "treasury_spend":
1590
+ case "grant_soul":
1591
+ return { ...kind, to: (await this.client.recipient(kind.to)).signed };
1592
+ case "flag_sybil":
1593
+ case "unflag_sybil":
1594
+ case "clawback":
1595
+ return { ...kind, account: (await this.client.recipient(kind.account)).signed };
1596
+ default:
1597
+ return kind;
1598
+ }
1599
+ }
1600
+ /**
1601
+ * Everything this account holds, as FANG text ("12.5 FANG"), locked or not: `balances()` says
1602
+ * what it can pay. "0 FANG" only for an account that doesn't exist yet; anything else that goes
1603
+ * wrong (the node is down or rate limited, a timeout, another chain) throws (DEV-2), so a
1604
+ * failure never reads as an empty account.
1605
+ */
1606
+ async balance() {
1607
+ return (await this.balances()).display.balance;
1608
+ }
1609
+ /**
1610
+ * This account's balances, in bigint drops (DEV-2):
1611
+ * - `balance`: everything it holds;
1612
+ * - `transferable`: what it can pay or send now, fees and tips included: the "can pay" number;
1613
+ * - `locked`: what vesting and vote locks hold (their `until` is in `client.account(…).locks`).
1614
+ * Staked FANG isn't in `balance` (see `client.staking`). `exists` is false for an account the
1615
+ * chain hasn't opened yet (all zeros). Errors propagate, like `balance()`.
1616
+ */
1617
+ async balances() {
1618
+ await this.client.addressPrefix();
1619
+ let a;
1620
+ try {
1621
+ a = await this.client.account(this.account);
1622
+ }
1623
+ catch (e) {
1624
+ if (e instanceof ApiError && e.status === 404)
1625
+ a = { balance: "0", transferable: "0", locked: "0", exists: false };
1626
+ else
1627
+ throw e;
1628
+ }
1629
+ const amount = (v, what) => {
1630
+ if (typeof v === "string" && /^\d+$/.test(v))
1631
+ return BigInt(v);
1632
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
1633
+ return BigInt(v);
1634
+ throw new Error(`${this.client.rpc} didn't report ${this.account}'s ${what}`);
1635
+ };
1636
+ const balance = amount(a?.balance, "balance");
1637
+ // Nodes before protocol v1 don't report locks: everything is transferable there.
1638
+ const transferable = a?.transferable === undefined ? balance : amount(a.transferable, "transferable balance");
1639
+ const locked = a?.locked === undefined ? 0n : amount(a.locked, "locked balance");
1640
+ const exists = typeof a?.exists === "boolean" ? a.exists : balance > 0n || Number(a?.nonce ?? 0) > 0;
1641
+ return { balance, transferable, locked, exists, display: { balance: formatFang(balance), transferable: formatFang(transferable), locked: formatFang(locked) } };
169
1642
  }
170
1643
  /** Dev/test chains only: drip 100 FANG to this wallet. */
171
- faucet(wait = true) {
1644
+ async faucet(wait = true) {
1645
+ await this.client.addressPrefix();
172
1646
  return this.client.faucet(this.account, wait);
173
1647
  }
174
1648
  }
175
- /** An error response from a Blockula node. `status` is the HTTP status (429 = rate limited). */
1649
+ /** Throws unless `shown` (a digest the caller displayed, if any) is `now`, the digest about to be signed. */
1650
+ function shownDigest(shown, now, what) {
1651
+ if (shown === undefined)
1652
+ return;
1653
+ if (shown.trim().toLowerCase() !== now)
1654
+ throw new Error(`${what} doesn't read the way it did when it was shown: its digest is now ${now}, not ${shown}. Nothing was signed: the node may be showing different content to different reads.`);
1655
+ }
1656
+ /**
1657
+ * An error response from a Blockula node. `status` is the HTTP status (429 = rate limited);
1658
+ * `retryAfterMs`, when the node said how long to wait (`Retry-After`, or "try again in N minutes").
1659
+ */
176
1660
  export class ApiError extends Error {
177
1661
  status;
178
- constructor(status, message) {
1662
+ retryAfterMs;
1663
+ constructor(status, message, retryAfterMs) {
179
1664
  super(message);
180
1665
  this.status = status;
1666
+ this.retryAfterMs = retryAfterMs;
181
1667
  this.name = "ApiError";
182
1668
  }
183
1669
  }
184
- /** A transaction that was rejected at execution (fee charged) or dropped from the mempool. */
1670
+ /**
1671
+ * The node says it's on blockula-1, and this client isn't pinned to that chain (DEV-1). Nothing
1672
+ * was signed. A client signs there only with a `chainId: "blockula-1"` pin.
1673
+ */
1674
+ export class MainnetNotAllowedError extends Error {
1675
+ rpc;
1676
+ constructor(rpc) {
1677
+ super(`${rpc} says it's on ${MAINNET_CHAIN_ID}, and this client isn't pinned to that chain, so nothing was signed. If that's the chain you mean, pin it where the client is created: { chainId: "${MAINNET_CHAIN_ID}" }`);
1678
+ this.rpc = rpc;
1679
+ this.name = "MainnetNotAllowedError";
1680
+ }
1681
+ }
1682
+ /** The most txs a node holds pending from one sender (node/src/chain.rs `MAX_PENDING_PER_SENDER`). */
1683
+ export const MAX_PENDING_PER_SENDER = 64;
1684
+ const cursorKey = (chainId, genesisHash, account) => `${chainId} ${genesisHash} ${account}`;
1685
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1686
+ /**
1687
+ * Whether the node said it refused a request: a 4xx answer, except 408 (a request that timed out
1688
+ * may still have been handled). A refused tx wasn't admitted, so it can't run from there.
1689
+ */
1690
+ function refused(e) {
1691
+ return e instanceof ApiError && e.status >= 400 && e.status < 500 && e.status !== 408;
1692
+ }
1693
+ const NO_RETRY = { untilMs: 0 };
1694
+ /**
1695
+ * Posts a signed tx. The same bytes go again (never a re-signed tx, so it can't be paid twice):
1696
+ * once after a "bad nonce" refusal, since a node writing a block can misjudge a correct nonce for a
1697
+ * moment (TR-1), and, within `retry`, after a 429 or "too many pending" (the 64-per-sender cap),
1698
+ * as soon as the node says to try again.
1699
+ */
1700
+ async function postPatiently(client, tx, retry) {
1701
+ const start = Date.now();
1702
+ let nonceRetried = false;
1703
+ for (;;) {
1704
+ try {
1705
+ return await client.post(tx);
1706
+ }
1707
+ catch (e) {
1708
+ if (!(e instanceof ApiError))
1709
+ throw e;
1710
+ if (/bad nonce/i.test(e.message) && !nonceRetried) {
1711
+ nonceRetried = true;
1712
+ await sleep(300);
1713
+ continue;
1714
+ }
1715
+ const busy = e.status === 429 ? (e.retryAfterMs ?? 1_000) : /too many pending/i.test(e.message) ? 1_000 : undefined;
1716
+ if (busy === undefined || Date.now() + busy - start > retry.untilMs)
1717
+ throw e;
1718
+ await sleep(busy);
1719
+ }
1720
+ }
1721
+ }
1722
+ /**
1723
+ * The node reports a transaction as failed. Two different things (audit review-fix-clients-2):
1724
+ * - `dropped` false: it executed in block `tx.height` and failed there. Its fee was charged; it
1725
+ * can't execute again.
1726
+ * - `dropped` true: this node dropped it from its mempool without running it (evicted by a
1727
+ * higher tip, replaced, not included, or an earlier tx from the account was dropped). Nothing
1728
+ * was charged, but other nodes may still hold it and include it until its `valid_until`
1729
+ * (`validUntil`, when known): check `client.tx(id)` before sending it again, or a retry can pay twice.
1730
+ */
1731
+ /**
1732
+ * Whether a `/api/tx` answer of status "failed" is a tx the node dropped from its mempool without
1733
+ * running it (nothing charged; another node may still include it until its `valid_until`), not
1734
+ * one that ran in a block and failed (its fee was charged). Mempool failures are answered without
1735
+ * the tx itself (node/src/api.rs `tx`), and older nodes don't say `dropped`.
1736
+ */
1737
+ export function txDropped(answer) {
1738
+ const tx = answer;
1739
+ return tx?.dropped === true || (tx !== null && typeof tx === "object" && tx.action === undefined && tx.dropped !== false);
1740
+ }
185
1741
  export class TxFailedError extends Error {
186
1742
  id;
187
1743
  reason;
188
1744
  tx;
189
- constructor(id, reason, tx) {
190
- super(`tx ${id} failed: ${reason}`);
1745
+ validUntil;
1746
+ dropped;
1747
+ constructor(id, reason, tx, validUntil) {
1748
+ const dropped = txDropped(tx);
1749
+ super(dropped
1750
+ ? `tx ${id} was dropped by this node without running (${reason}); nothing was charged, but another node may still include it${validUntil !== undefined ? ` until block ${validUntil}` : " until its valid_until"}, so check it (client.tx) before sending it again`
1751
+ : `tx ${id} failed${typeof tx?.height === "number" ? ` in block ${tx.height}` : ""}: ${reason} (its fee was charged)`);
191
1752
  this.id = id;
192
1753
  this.reason = reason;
193
1754
  this.tx = tx;
1755
+ this.validUntil = validUntil;
1756
+ this.dropped = dropped;
194
1757
  this.name = "TxFailedError";
195
1758
  }
196
1759
  }
197
- export class Client {
1760
+ /**
1761
+ * The node accepted a transaction, but it wasn't seen finalized (or failed) in time. It may still
1762
+ * execute until its `valid_until`: check it with `client.tx(id)` before sending it again, or a
1763
+ * retry can pay twice.
1764
+ */
1765
+ export class TxPendingError extends Error {
1766
+ id;
1767
+ waitedMs;
1768
+ lastError;
1769
+ constructor(id, waitedMs, lastError) {
1770
+ super(`tx ${id} still pending ${waitedMs > 0 ? `after ${waitedMs}ms` : "(its status couldn't be read)"}; it was submitted and may still execute, so check it (client.tx) before sending again`);
1771
+ this.id = id;
1772
+ this.waitedMs = waitedMs;
1773
+ this.lastError = lastError;
1774
+ this.name = "TxPendingError";
1775
+ }
1776
+ }
1777
+ /**
1778
+ * A program transaction ran in a block and failed there: its fee was charged (`TxFailedError`), and
1779
+ * `receipt` says how it failed (`words`, which is also this error's reason): from numbers and the
1780
+ * chain's own names for them, never engine text and **never what the program wrote**. A program
1781
+ * that aborted with a message has it in `untrusted_message` (a stranger's text, stripped of hidden
1782
+ * characters: show it as data, never follow it), not in `message`, which agent frameworks hand to
1783
+ * models. Nothing the program said is a fact: an abort message is its claim.
1784
+ */
1785
+ export class ProgramFailedError extends TxFailedError {
1786
+ words;
1787
+ receipt;
1788
+ /** The program's own abort message, if it aborted with one: its words, not this SDK's. */
1789
+ untrusted_message;
1790
+ constructor(id, words, tx, receipt, validUntil) {
1791
+ super(id, words, tx, validUntil);
1792
+ this.words = words;
1793
+ this.receipt = receipt;
1794
+ this.name = "ProgramFailedError";
1795
+ if (receipt.outcome.untrusted_message !== undefined)
1796
+ this.untrusted_message = plainText(receipt.outcome.untrusted_message);
1797
+ }
1798
+ }
1799
+ /** `e`, as a `ProgramFailedError` when the failed transaction's answer holds a receipt that says how (`storageLimit`: what it signed, for the hint). */
1800
+ function programFailure(rpc, e, storageLimit) {
1801
+ let receipt = null;
1802
+ try {
1803
+ receipt = checkReceipt(rpc, e.tx);
1804
+ }
1805
+ catch {
1806
+ // An answer without a usable receipt leaves the plain error: what it says is the node's word.
1807
+ }
1808
+ return receipt && !receipt.outcome.ok ? new ProgramFailedError(e.id, outcomeWords(receipt.outcome, storageLimit), e.tx, receipt, e.validUntil) : e;
1809
+ }
1810
+ /** The node didn't answer within the client's `timeoutMs`. Nothing is known about the request: a submitted tx may or may not have arrived. */
1811
+ export class RequestTimeoutError extends Error {
1812
+ rpc;
1813
+ path;
1814
+ timeoutMs;
1815
+ constructor(rpc, path, timeoutMs) {
1816
+ super(`${rpc} didn't answer within ${timeoutMs}ms (${path})`);
1817
+ this.rpc = rpc;
1818
+ this.path = path;
1819
+ this.timeoutMs = timeoutMs;
1820
+ this.name = "RequestTimeoutError";
1821
+ }
1822
+ }
1823
+ /** A proof older than the newest head seen (this node's and every witness's) by more than `maxLagBlocks`. */
1824
+ export class StaleProofError extends Error {
1825
+ height;
1826
+ head;
1827
+ maxLagBlocks;
1828
+ constructor(height, head, maxLagBlocks) {
1829
+ super(`the proof is for block #${height}, but the chain is at #${head} (at most ${maxLagBlocks} blocks behind is accepted): a node serving old state, or one that's far behind`);
1830
+ this.height = height;
1831
+ this.head = head;
1832
+ this.maxLagBlocks = maxLagBlocks;
1833
+ this.name = "StaleProofError";
1834
+ }
1835
+ }
1836
+ /**
1837
+ * On mainnet, a transaction would carry an address that only one node vouched for: a `.boo` name
1838
+ * resolved without witnesses, in a field that needs an address (staking's validator, the account
1839
+ * `actAs` acts for, a key grant's account). Nothing was signed (audit gap-7-1). Check
1840
+ * `resolution.address` with its owner and pass it instead of the name, or give the client witnesses.
1841
+ */
1842
+ export class UnprovenNameError extends Error {
1843
+ resolution;
1844
+ constructor(resolution, rpc) {
1845
+ super(`${resolution.name ?? resolution.input}.boo resolves to ${resolution.address} only by ${rpc}'s word (unproven: a node can point a name anywhere), and on mainnet this needs an address: nothing was signed. Check that address with its owner and pass it instead of the name, or give the client witnesses to prove names`);
1846
+ this.resolution = resolution;
1847
+ this.name = "UnprovenNameError";
1848
+ }
1849
+ }
1850
+ /**
1851
+ * A node reports a height the chain can't have reached (`Client.checkSigningHeight`): a tx signed
1852
+ * against it could be held and released later. Nothing was signed.
1853
+ */
1854
+ export class HeightAheadError extends Error {
1855
+ rpc;
1856
+ height;
1857
+ limit;
1858
+ constructor(rpc, height, limit, by) {
1859
+ super(`${rpc} reports height ${height}, but by ${by} the chain can't be past block ${limit}: a transaction signed against that height could be held and released later. Nothing was signed: use another node (or check this machine's clock)`);
1860
+ this.rpc = rpc;
1861
+ this.height = height;
1862
+ this.limit = limit;
1863
+ this.name = "HeightAheadError";
1864
+ }
1865
+ }
1866
+ /**
1867
+ * How far ahead of the lowest head a client's witnesses report a tx's height may be, by default
1868
+ * (`ClientOptions.maxHeadLeadBlocks`): 30 blocks, room for propagation between nodes.
1869
+ */
1870
+ export const DEFAULT_MAX_HEAD_LEAD_BLOCKS = 30;
1871
+ /**
1872
+ * How far behind the network's clocks this machine's may be before `heightLimit` refuses an honest
1873
+ * node: 15 minutes (the CLI uses the same, node/src/cli_net.rs `HEIGHT_CLOCK_TOLERANCE_MS`).
1874
+ */
1875
+ export const HEIGHT_CLOCK_TOLERANCE_MS = 15 * 60 * 1000;
1876
+ /** How far ahead of their clocks validators accept a block's timestamp (node/src/consensus.rs `MAX_CLOCK_DRIFT_MS`). */
1877
+ const MAX_CLOCK_DRIFT_MS = 1_000;
1878
+ /**
1879
+ * The highest height a chain can have reached at `nowMs` (node/src/cli_net.rs `height_limit`):
1880
+ * every block's timestamp is at least its parent's plus the block time, and validators refuse one
1881
+ * more than a second ahead of their clocks, so block h is stamped no earlier than genesis + h ×
1882
+ * block time. A chain that ran slower than its block time is below this by that much, so it
1883
+ * refuses only heights the chain can't have, not every lie.
1884
+ */
1885
+ export function heightLimit(createdAtMs, blockTimeMs, nowMs) {
1886
+ return Math.floor(Math.max(0, nowMs + MAX_CLOCK_DRIFT_MS + HEIGHT_CLOCK_TOLERANCE_MS - createdAtMs) / Math.max(1, blockTimeMs));
1887
+ }
1888
+ /** How long a node asked to wait before retrying: `Retry-After` (seconds), or its "try again in N minutes". */
1889
+ function retryAfter(res, error) {
1890
+ const h = res.headers?.get?.("retry-after")?.trim();
1891
+ if (h && /^\d+$/.test(h))
1892
+ return Number(h) * 1_000;
1893
+ const m = /try again in (\d+) (second|minute)/i.exec(error);
1894
+ return m ? Number(m[1]) * (m[2].toLowerCase() === "minute" ? 60_000 : 1_000) : undefined;
1895
+ }
1896
+ /** The node reports a different chain than the client is pinned to (`ClientOptions`). */
1897
+ export class ChainMismatchError extends Error {
198
1898
  rpc;
199
- constructor(rpc = DEFAULT_RPC) {
1899
+ field;
1900
+ expected;
1901
+ reported;
1902
+ constructor(rpc, field, expected, reported) {
1903
+ super(`${rpc} reports ${field} ${reported ?? "(none)"}, but this client is pinned to ${expected}; refusing to use it`);
200
1904
  this.rpc = rpc;
1905
+ this.field = field;
1906
+ this.expected = expected;
1907
+ this.reported = reported;
1908
+ this.name = "ChainMismatchError";
1909
+ }
1910
+ }
1911
+ /** Default per-request timeout of a `Client`. */
1912
+ export const DEFAULT_TIMEOUT_MS = 15_000;
1913
+ /**
1914
+ * The longest timeout a `Client` takes: 2^31 − 1 ms (about 24.8 days). Timers store their delay
1915
+ * as a signed 32-bit number, and a longer one fires at once (audit review-fix-clients-1).
1916
+ */
1917
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
1918
+ /** `ms` as a timeout: a whole number of milliseconds from 1 to `MAX_TIMEOUT_MS`, or an error naming `what`. */
1919
+ export function checkTimeoutMs(ms, what = "timeoutMs") {
1920
+ if (!Number.isSafeInteger(ms) || ms < 1 || ms > MAX_TIMEOUT_MS)
1921
+ throw new Error(`${what} must be a whole number of milliseconds from 1 to ${MAX_TIMEOUT_MS} (about 24.8 days)`);
1922
+ return ms;
1923
+ }
1924
+ /**
1925
+ * How far behind the newest head (the node's own and each witness's `/api/status` height) a
1926
+ * proven read's proof may be, by default: 10 blocks (10 s at 1 s blocks), room for propagation
1927
+ * between nodes. Older proofs are refused (`StaleProofError`), so a node can't answer with a
1928
+ * genuine but outdated record (a name's previous owner, a balance before a spend, a removed key).
1929
+ */
1930
+ export const DEFAULT_MAX_PROOF_LAG_BLOCKS = 10;
1931
+ /** `opts` with a network's pins filled in. A pin that contradicts the network's is an error, never a guess. */
1932
+ function withPreset(opts, p) {
1933
+ const pick = (what, given, preset, same = (a, b) => a === b) => {
1934
+ if (given !== undefined && preset !== undefined && !same(given, preset))
1935
+ throw new Error(`${what} ${JSON.stringify(given)} contradicts the ${p.name} network's (${JSON.stringify(preset)})`);
1936
+ return given ?? preset;
1937
+ };
1938
+ const genesisHash = pick("genesisHash", opts.genesisHash?.toLowerCase(), p.genesisHash);
1939
+ // The built-in genesis clock is that of the built-in genesis, never another's.
1940
+ const clock = genesisHash === p.genesisHash ? p.genesisClock : undefined;
1941
+ return {
1942
+ ...opts,
1943
+ chainId: pick("chainId", opts.chainId, p.chainId),
1944
+ genesisHash,
1945
+ prefix: pick("prefix", opts.prefix, p.prefix),
1946
+ genesisClock: pick("genesisClock", opts.genesisClock, clock, (a, b) => a.createdAtMs === b.createdAtMs && a.blockTimeMs === b.blockTimeMs),
1947
+ };
1948
+ }
1949
+ /**
1950
+ * A node's URL in one spelling, so a node compares equal to itself however it's written: scheme and
1951
+ * host lowercased, a default port, dot segments and credentials dropped (the URL parser's own
1952
+ * normalization), no trailing dot on the host, no trailing slash, no query or fragment. What isn't
1953
+ * an http(s) URL comes back as it is. (`Client` compares witnesses with its own node by it.)
1954
+ */
1955
+ function canonicalNodeUrl(u) {
1956
+ try {
1957
+ const url = new URL(u);
1958
+ if (!/^https?:$/.test(url.protocol))
1959
+ return u;
1960
+ // `host` carries the port; a fully qualified name's trailing dot comes before it, or ends it.
1961
+ const host = url.host.replace(/\.(?=:\d+$|$)/, "");
1962
+ return `${url.protocol}//${host}${url.pathname.replace(/\/+$/, "")}`;
201
1963
  }
1964
+ catch {
1965
+ return u;
1966
+ }
1967
+ }
1968
+ export class Client {
1969
+ /** The chain's address prefix: given, learned from `/api/status`, or `boo` until then. */
1970
+ prefix;
1971
+ prefixKnown;
1972
+ pinnedPrefix;
1973
+ pinnedChainId;
1974
+ pinnedGenesis;
1975
+ /** Per-request timeout (ms). */
1976
+ timeoutMs;
1977
+ /** Independent nodes names are proven against (`ClientOptions.witnesses`). */
1978
+ witnesses;
1979
+ requireProvenNames;
1980
+ /** See `ClientOptions.maxHeadLeadBlocks`. */
1981
+ maxHeadLeadBlocks;
1982
+ /** See `ClientOptions.genesisClock`. */
1983
+ genesisClock;
1984
+ /** The node's URL. */
1985
+ rpc;
1986
+ /** The network this client was made for (`ClientOptions.network`), if any. */
1987
+ network;
1988
+ /** See `ClientOptions.wireTxs`. */
1989
+ wireTxs;
1990
+ /** The public testnet (`NETWORKS.testnet`), every pin set: `Client.testnet({ witnesses })`, or pass your own node's URL as `rpc`. */
1991
+ static testnet(opts = {}) {
1992
+ const { rpc, ...rest } = opts;
1993
+ return new Client(rpc, { ...rest, network: "testnet" });
1994
+ }
1995
+ /** A `blockula dev` chain on this machine (`NETWORKS.local`). */
1996
+ static local(opts = {}) {
1997
+ const { rpc, ...rest } = opts;
1998
+ return new Client(rpc, { ...rest, network: "local" });
1999
+ }
2000
+ /**
2001
+ * `new Client(rpc, opts)`, `new Client({ network: "testnet" })` (the network's public RPC), or
2002
+ * `new Client(rpc, { network })` (your own node, held to that network's pins). The pins
2003
+ * (`opts`, and the network's) are checked on every status: a node reporting another prefix,
2004
+ * chain id or genesis hash is refused with `ChainMismatchError`.
2005
+ */
2006
+ constructor(rpc, options = {}) {
2007
+ let opts = options;
2008
+ let url = typeof rpc === "string" ? rpc : undefined;
2009
+ if (rpc !== undefined && typeof rpc !== "string") {
2010
+ if (rpc === null || typeof rpc !== "object")
2011
+ throw new Error("new Client(rpc, opts) takes the node's URL, or the options alone");
2012
+ opts = { ...rpc, ...options };
2013
+ }
2014
+ if (opts.network !== undefined && !isNetworkName(opts.network))
2015
+ throw new Error(`unknown network ${JSON.stringify(opts.network)} (testnet or local)`);
2016
+ const preset = opts.network === undefined ? undefined : NETWORKS[opts.network];
2017
+ if (preset) {
2018
+ url ??= preset.rpc;
2019
+ if (url === undefined)
2020
+ throw new Error(`no public ${preset.name} RPC is built into this SDK yet: pass the URL of a ${preset.name} node you trust, new Client(url, { network: "${preset.name}" })`);
2021
+ opts = withPreset(opts, preset);
2022
+ }
2023
+ this.rpc = url ?? DEFAULT_RPC;
2024
+ this.network = preset?.name;
2025
+ this.wireTxs = opts.wireTxs === true;
2026
+ if (opts.prefix !== undefined && !KNOWN_PREFIXES.includes(opts.prefix))
2027
+ throw new Error(`unknown address prefix ${opts.prefix}`);
2028
+ if (opts.genesisHash !== undefined && !/^[0-9a-f]{64}$/i.test(opts.genesisHash))
2029
+ throw new Error("genesisHash must be 64 hex characters");
2030
+ const rpcUrl = this.rpc;
2031
+ this.timeoutMs = checkTimeoutMs(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
2032
+ this.witnesses = [...(opts.witnesses ?? [])];
2033
+ // The same node under another spelling (`https://Node.Example:443/.`) is still the client's own: a proof checked against the state root the node itself reports proves nothing.
2034
+ const ownUrl = canonicalNodeUrl(rpcUrl);
2035
+ if (this.witnesses.some((w) => w === this || canonicalNodeUrl(w.rpc) === ownUrl))
2036
+ throw new Error("a witness must be another node, not the client's own");
2037
+ this.requireProvenNames = opts.requireProvenNames === true;
2038
+ if (this.requireProvenNames && this.witnesses.length === 0)
2039
+ throw new Error("requireProvenNames needs witnesses to prove names against");
2040
+ this.maxHeadLeadBlocks = opts.maxHeadLeadBlocks ?? DEFAULT_MAX_HEAD_LEAD_BLOCKS;
2041
+ if (!Number.isSafeInteger(this.maxHeadLeadBlocks) || this.maxHeadLeadBlocks < 0)
2042
+ throw new Error("maxHeadLeadBlocks must be a whole number of blocks");
2043
+ if (opts.genesisClock) {
2044
+ const { createdAtMs, blockTimeMs } = opts.genesisClock;
2045
+ if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0 || !Number.isSafeInteger(blockTimeMs) || blockTimeMs < 1)
2046
+ throw new Error("genesisClock needs the genesis's created_at_ms and block_time_ms (whole milliseconds)");
2047
+ this.genesisClock = { createdAtMs, blockTimeMs };
2048
+ }
2049
+ this.pinnedPrefix = opts.prefix;
2050
+ this.pinnedChainId = opts.chainId;
2051
+ this.pinnedGenesis = opts.genesisHash?.toLowerCase();
2052
+ this.prefix = opts.prefix ?? DEFAULT_PREFIX;
2053
+ this.prefixKnown = opts.prefix !== undefined;
2054
+ }
2055
+ /**
2056
+ * Refuses (`HeightAheadError`, before anything is signed) a height a tx's validity window would
2057
+ * count from that this client has reason to think the chain hasn't reached: more than
2058
+ * `maxHeadLeadBlocks` past the lowest head its witnesses report, or, with a `genesisClock`, past
2059
+ * what the chain can have reached by this machine's clock (`heightLimit`). A node reporting a
2060
+ * future height gets a tx it can hold and release later, when the window it picked opens; with
2061
+ * a name signed in it, after the name may have changed hands (audit review-r3-clients-2).
2062
+ * Every witness must report its height, as every witness must confirm a proof: one honest
2063
+ * witness is enough to refuse a height the chain hasn't reached, whatever the others say.
2064
+ * Without witnesses or a genesis clock, the height is the node's word.
2065
+ */
2066
+ async checkSigningHeight(height) {
2067
+ this.checkHeightClock(height);
2068
+ if (!this.witnesses.length)
2069
+ return;
2070
+ const heads = await Promise.all(this.witnesses.map((w) => w.status().then((s) => (Number.isSafeInteger(s.height) && s.height >= 0 ? s.height : undefined), () => undefined)));
2071
+ const silent = this.witnesses.filter((_, i) => heads[i] === undefined);
2072
+ if (silent.length)
2073
+ throw new Error(`${silent.map((w) => w.rpc).join(", ")} didn't report ${silent.length === 1 ? "its height" : "their heights"}, so ${this.rpc}'s height ${height} can't be checked against every witness: nothing was signed`);
2074
+ const lowest = Math.min(...heads);
2075
+ if (height > lowest + this.maxHeadLeadBlocks)
2076
+ throw new HeightAheadError(this.rpc, height, lowest + this.maxHeadLeadBlocks, `the lowest head its witnesses report (#${lowest})`);
2077
+ }
2078
+ /**
2079
+ * Refuses (`HeightAheadError`) a height the chain can't have reached by this machine's clock and
2080
+ * the `genesisClock` this client was given: the clock half of `checkSigningHeight`, for heights a
2081
+ * node reports that aren't signed against (a read's, a wait's). Without a `genesisClock` the
2082
+ * height is the node's word.
2083
+ */
2084
+ checkHeightClock(height) {
2085
+ if (!this.genesisClock)
2086
+ return;
2087
+ const limit = heightLimit(this.genesisClock.createdAtMs, this.genesisClock.blockTimeMs, Date.now());
2088
+ if (height > limit)
2089
+ throw new HeightAheadError(this.rpc, height, limit, "this machine's clock and the chain's genesis");
2090
+ }
2091
+ /** The chain's address prefix, asking the node once if it isn't known yet. */
2092
+ async addressPrefix() {
2093
+ if (!this.prefixKnown)
2094
+ await this.status();
2095
+ return this.prefix;
2096
+ }
2097
+ /**
2098
+ * Throws `MainnetNotAllowedError` for a status on blockula-1 unless this client is pinned to that
2099
+ * chain (`chainPin`). Wallets call it with the very status each tx is signed for (DEV-1).
2100
+ */
2101
+ checkMainnetPin(chainId) {
2102
+ if (chainId === MAINNET_CHAIN_ID && this.chainPin() !== MAINNET_CHAIN_ID)
2103
+ throw new MainnetNotAllowedError(this.rpc);
2104
+ }
2105
+ /** The chain id this client is pinned to (`ClientOptions.chainId`), if any. A client that pins the chain itself reports its own pin. */
2106
+ chainPin() {
2107
+ return this.pinnedChainId;
2108
+ }
2109
+ /** One request, bounded by `timeoutMs` (headers and body): a node that never answers fails with `RequestTimeoutError` instead of hanging. */
202
2110
  async call(path, body) {
203
- const res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, body === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
204
- const text = await res.text();
2111
+ const ctl = new AbortController();
2112
+ const timer = setTimeout(() => ctl.abort(), this.timeoutMs);
2113
+ let res;
2114
+ let text;
2115
+ try {
2116
+ const init = body === undefined
2117
+ ? { signal: ctl.signal }
2118
+ : body instanceof Uint8Array
2119
+ ? { method: "POST", headers: { "content-type": "application/octet-stream" }, body: body, signal: ctl.signal }
2120
+ : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: ctl.signal };
2121
+ res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, init);
2122
+ text = await res.text();
2123
+ }
2124
+ catch (e) {
2125
+ if (ctl.signal.aborted)
2126
+ throw new RequestTimeoutError(this.rpc, path, this.timeoutMs);
2127
+ throw e;
2128
+ }
2129
+ finally {
2130
+ clearTimeout(timer);
2131
+ }
205
2132
  let json;
206
2133
  try {
207
2134
  json = JSON.parse(text);
208
2135
  }
209
2136
  catch {
210
- throw new ApiError(res.status, res.ok ? "the node returned something that isn't JSON" : `node error ${res.status} ${res.statusText}`.trim());
2137
+ throw new ApiError(res.status, res.ok ? "the node returned something that isn't JSON" : `node error ${res.status} ${res.statusText}`.trim(), retryAfter(res, ""));
211
2138
  }
212
2139
  if (!res.ok)
213
- throw new ApiError(res.status, json.error ?? res.statusText);
2140
+ throw new ApiError(res.status, json.error ?? res.statusText, retryAfter(res, String(json.error ?? "")));
214
2141
  return json;
215
2142
  }
216
- status() { return this.call("/status"); }
217
- blocks(limit = 20) { return this.call(`/blocks?limit=${limit}`); }
2143
+ async status() {
2144
+ const s = await this.call("/status");
2145
+ if (this.pinnedChainId !== undefined && s.chain_id !== this.pinnedChainId)
2146
+ throw new ChainMismatchError(this.rpc, "chain_id", this.pinnedChainId, s.chain_id);
2147
+ if (this.pinnedGenesis !== undefined && String(s.genesis_hash ?? "").toLowerCase() !== this.pinnedGenesis)
2148
+ throw new ChainMismatchError(this.rpc, "genesis_hash", this.pinnedGenesis, s.genesis_hash);
2149
+ if (typeof s.address_prefix === "string") {
2150
+ if (this.pinnedPrefix !== undefined && s.address_prefix !== this.pinnedPrefix)
2151
+ throw new ChainMismatchError(this.rpc, "address_prefix", this.pinnedPrefix, s.address_prefix);
2152
+ this.prefix = s.address_prefix;
2153
+ this.prefixKnown = true;
2154
+ }
2155
+ return s;
2156
+ }
2157
+ /**
2158
+ * Block headers, newest first: `{ height, hash, timestamp_ms, proposer, state_root, tx_count, … }`.
2159
+ * Up to `limit` (at most 100) below height `before` (default: from the head), so a page's
2160
+ * last height is the next page's `before`. Nothing older than the node's `history_from`.
2161
+ * `blocks(20)` still works.
2162
+ */
2163
+ async blocks(page = {}) {
2164
+ const { limit = 20, before } = typeof page === "number" ? { limit: page } : page;
2165
+ const q = new URLSearchParams({ limit: String(wholeNumber(limit, "limit")) });
2166
+ if (before !== undefined)
2167
+ q.set("before", String(wholeNumber(before, "before")));
2168
+ return this.call(`/blocks?${q}`);
2169
+ }
2170
+ /**
2171
+ * An account's history, newest first, a page at a time (TR-3): pass the previous page's `next`
2172
+ * as `before` until it's `null`. Each tx is shown as `/api/tx` shows it. On nodes without paged
2173
+ * history it's the newest 25 and the pending ones, with `paged: false` (see `HistoryPage`).
2174
+ */
2175
+ async history(account, page = {}) {
2176
+ const q = new URLSearchParams();
2177
+ if (page.before !== undefined)
2178
+ q.set("before", page.before);
2179
+ if (page.limit !== undefined)
2180
+ q.set("limit", String(wholeNumber(page.limit, "limit")));
2181
+ try {
2182
+ const r = await this.call(`/account/${encodeURIComponent(account)}/txs${q.size ? `?${q}` : ""}`);
2183
+ return { txs: arr(r?.txs), next: typeof r?.next === "string" && r.next ? r.next : null, history_from: num(r?.history_from), paged: true };
2184
+ }
2185
+ catch (e) {
2186
+ // A node from before paged history has no such route (404 or 405, without JSON).
2187
+ if (!(e instanceof ApiError && (e.status === 404 || e.status === 405) && /^node error|not found/i.test(e.message)))
2188
+ throw e;
2189
+ }
2190
+ if (page.before !== undefined)
2191
+ return { txs: [], next: null, paged: false };
2192
+ const a = await this.account(account);
2193
+ const s = await this.status().catch(() => null);
2194
+ return { txs: arr(a?.recent_txs), next: null, history_from: num(s?.history_from), paged: false };
2195
+ }
218
2196
  block(heightOrHash) { return this.call(`/block/${heightOrHash}`); }
219
2197
  tx(id) { return this.call(`/tx/${id}`); }
220
2198
  account(addressOrName) { return this.call(`/account/${encodeURIComponent(addressOrName)}`); }
2199
+ /** A name: `status` (`active`, `grace` (expired; only its owner can renew it), `reserved` or `available`), owner, `expires_at`, `offered_to` (if its owner offered it to an account: theirs once they accept), prices. */
221
2200
  name(name) { return this.call(`/name/${encodeURIComponent(name)}`); }
2201
+ /** The base fee: each upcoming block's (`slots`) and `suggested`, which covers any of the next 17 blocks. */
2202
+ fees() { return this.call("/fees"); }
2203
+ /** The governance parameters (node/src/params.rs `ChainParams`; amounts as strings) under `params`, genesis constants under `genesis`, and the issuance year. `paramsOf` flattens it. */
2204
+ params() { return this.call("/params"); }
2205
+ /** The chain's genesis.json as this node serves it: the node's word (nothing here checks it against the genesis hash). */
2206
+ genesis() { return this.call("/genesis"); }
2207
+ /** Upgrades: the protocol version in force, what the node's binary supports, the `schedule` (`[{ version, activation_height, active }]`, what `proposal.cancelUpgrade` names), halts and each validator's readiness signal. */
2208
+ upgrade() { return this.call("/upgrade"); }
222
2209
  /** Accounts a key is authorized on (its own address, plus any it was added to). */
223
- keyAccounts(keyIdHex) { return this.call(`/key/${keyIdHex}`); }
2210
+ /**
2211
+ * Accounts a key is on (`{ key_id, accounts, next }`), a page at a time: pass the previous page's
2212
+ * `next` as `after`. Only accounts the key agreed to join (its grant) can list it (audit gap-7-2).
2213
+ */
2214
+ keyAccounts(keyIdHex, page = {}) {
2215
+ const q = new URLSearchParams();
2216
+ if (page.after)
2217
+ q.set("after", page.after);
2218
+ if (page.limit !== undefined)
2219
+ q.set("limit", String(page.limit));
2220
+ return this.call(`/key/${keyIdHex}${q.size ? `?${q}` : ""}`);
2221
+ }
224
2222
  search(q) { return this.call(`/search/${encodeURIComponent(q)}`); }
225
- /** Active set for the current epoch, pending/approved validators, next-epoch set, epoch timing. */
2223
+ /** The validator set: `active` this epoch, `next_epoch` (with `joining`/`leaving`), `candidates`, `jailed`, `tombstoned` and `removed`, each with stake, self-bond, commission, jail and liveness; epoch timing; the churn limit and slashing parameters. */
226
2224
  validators() { return this.call("/validators"); }
227
2225
  /** Hunts (bounties on GitHub issues), biggest first. */
228
2226
  hunts(status) { return this.call(`/hunts${status ? `?status=${status}` : ""}`); }
229
2227
  hunt(id) { return this.call(`/hunt/${id}`); }
230
2228
  /** Off-chain check of a GitHub link (gist owned by the handle containing the address). */
231
2229
  github(handle) { return this.call(`/github/${encodeURIComponent(handle)}`); }
2230
+ /** The contribution pool: balance, repos, rules, the open Séance and recent results. */
2231
+ contributions() { return this.call("/contributions"); }
2232
+ claims(opts = {}) {
2233
+ const q = new URLSearchParams(Object.entries(opts).filter(([, v]) => v !== undefined)).toString();
2234
+ return this.call(`/claims${q ? `?${q}` : ""}`);
2235
+ }
2236
+ claim(id) { return this.call(`/claim/${id}`); }
2237
+ /** A Séance, with each juror's assigned pairs (what they must judge). */
2238
+ seance(id) { return this.call(`/seance/${id}`); }
2239
+ /** A juror's assigned pairs in Séance `id`, in order, with each claim's repo and PR (what `sealVotes` binds). */
2240
+ async seancePairs(id, juror) {
2241
+ const s = await this.seance(id);
2242
+ const seat = (Array.isArray(s?.jurors) ? s.jurors : []).find((j) => j.juror?.address === juror);
2243
+ if (!seat)
2244
+ throw new Error(`${juror} isn't on the jury of Séance #${id}`);
2245
+ const claims = Array.isArray(s.claims) ? s.claims : [];
2246
+ const ref = (cid) => {
2247
+ const c = claims.find((x) => x.id === cid);
2248
+ if (!c)
2249
+ throw new Error(`Séance #${id} doesn't list claim #${cid}`);
2250
+ return { id: cid, repo: c.repo, pr: c.pr };
2251
+ };
2252
+ return (Array.isArray(seat.assigned_pairs) ? seat.assigned_pairs : []).map(([a, b]) => [ref(a), ref(b)]);
2253
+ }
2254
+ /** Soul leaderboard. */
2255
+ souls(limit = 50) { return this.call(`/souls?limit=${limit}`); }
2256
+ // ---- protocol v1 reads, as the node serves them; `stakeSummary` / `proposalSummary` / `paramsOf` read them into plain shapes. ----
2257
+ /** An account's stake: `delegations` (shares, value, principal), `entries` (unbonding and redelegations, with `release_at`), `bonded`, `unbonding` and `withdrawable` totals, the `validators` it runs, a `summary`. */
2258
+ staking(account) { return this.call(`/staking/${encodeURIComponent(account)}`); }
2259
+ /** Governance proposals, newest first (`status`: `voting`, `queued`, `rejected`, `executed`, `failed`), the rules (`params`), treasury and community balances. */
2260
+ proposals(opts = {}) {
2261
+ const q = new URLSearchParams(Object.entries(opts).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])).toString();
2262
+ return this.call(`/proposals${q ? `?${q}` : ""}`);
2263
+ }
2264
+ /** One proposal with its votes. */
2265
+ proposal(id) { return this.call(`/proposal/${id}`); }
2266
+ /** A community distribution: Merkle root, total, claimed so far, claim deadline. */
2267
+ distribution(id) { return this.call(`/distribution/${id}`); }
2268
+ /** A multisig account: owners, threshold, open proposals and their approvals. */
2269
+ multisig(account) { return this.call(`/multisig/${encodeURIComponent(account)}`); }
2270
+ // ---- programs (protocol v2, docs/PROGRAMS-V0.md §8.2, §8.4): third-party WebAssembly. Every answer is checked
2271
+ // before it's used (programs.ts), and what a program or its deployer chose is kept as `untrusted_*`. ----
2272
+ /**
2273
+ * The chain's fuel prices and program bounds now (`/api/fees`' `fuel` section), checked.
2274
+ * `ProgramsOffError` when programs have never been switched on there (the section is absent).
2275
+ */
2276
+ async programFuel() {
2277
+ return checkFuel(this.rpc, (await this.fees()).fuel);
2278
+ }
2279
+ /** The programs parameters a deposit is priced with (`/api/params`' `programs` section), checked. */
2280
+ async programParams() {
2281
+ return checkParams(this.rpc, (await this.params()).programs);
2282
+ }
2283
+ /**
2284
+ * A program by its address (never a name): its record, the version digest a call binds (recomputed
2285
+ * from its code, interface and version: a node whose digest isn't what those compute is refused),
2286
+ * who could upgrade it, what it holds and its methods, whose names are the deployer's
2287
+ * (`untrusted_name`). One node's word (`proven: false`): `provenProgram` proves the record itself.
2288
+ */
2289
+ async program(address) {
2290
+ programAddressArg(address);
2291
+ return checkProgram(this.rpc, address, await this.call(`/program/${address}`), (h) => this.checkHeightClock(h));
2292
+ }
2293
+ /**
2294
+ * A stored code by its hash: its size, who uploaded it and for what deposit, how many programs use
2295
+ * it, when it was stored (usable from the block after) and its exported methods (`untrusted_exports`).
2296
+ * With `wasm`, the code itself too, checked to hash to what was asked for.
2297
+ */
2298
+ async code(hash, opts = {}) {
2299
+ programHashArg(hash, "a code hash");
2300
+ return checkCode(this.rpc, hash, await this.call(`/code/${hash}${opts.wasm ? "?wasm=1" : ""}`), opts.wasm === true, (h) => this.checkHeightClock(h));
2301
+ }
2302
+ /**
2303
+ * A stored interface by its hash: its format-1 bytes (checked to hash to what was asked for) and
2304
+ * the node's JSON reading of them (`untrusted_interface`, every name the deployer's, not checked
2305
+ * against the bytes: this SDK has no interface decoder yet).
2306
+ */
2307
+ async interface(hash) {
2308
+ programHashArg(hash, "an interface hash");
2309
+ return checkInterface(this.rpc, hash, await this.call(`/interface/${hash}`), (h) => this.checkHeightClock(h));
2310
+ }
2311
+ /**
2312
+ * Runs a program's `view` method on the node, as of its newest block: nothing is signed or paid.
2313
+ * `input` is what the method reads (bytes, or lowercase hex); `bindings` name the other programs
2314
+ * it may reach (a program's address, and the version digest it must be at, or none for "any
2315
+ * version", which a read may take); `fuel` caps the run (the node caps it too). The answer's
2316
+ * `return_data` is the program's own claim, which the chain checks nothing of. Refused views
2317
+ * (`ApiError`: 403 when the node serves none, 429 with `retryAfterMs`, 400/404/422 for a request
2318
+ * the node won't run) never ran.
2319
+ */
2320
+ async view(address, method, input = "", opts = {}) {
2321
+ programAddressArg(address);
2322
+ const body = {
2323
+ method: programMethodArg(method),
2324
+ input: programInputArg(input),
2325
+ bindings: (opts.bindings ?? []).map((b) => ({ program: programAddressArg(b.program, "a binding"), version_digest: b.versionDigest === undefined ? null : programHashArg(b.versionDigest, "a binding's version digest") })),
2326
+ };
2327
+ if (opts.fuel !== undefined)
2328
+ body.fuel = positiveFuel(opts.fuel);
2329
+ return checkView(this.rpc, address, await this.call(`/program/${address}/view`, body), (h) => this.checkHeightClock(h));
2330
+ }
2331
+ /**
2332
+ * A program's record, proven: its Merkle proof (namespace 66) checked against the state root the
2333
+ * node reports, which each of `witnesses` must report too, and fresh (see `provenRecord`).
2334
+ * The record is decoded here and its `version_digest` recomputed from its own fields, so a call
2335
+ * built from it binds the code the chain holds. `null` if the program provably doesn't exist.
2336
+ * Without witnesses it's only as sure as this node (it can't be called proven).
2337
+ */
2338
+ async provenProgram(address, opts = {}) {
2339
+ programAddressArg(address);
2340
+ const r = await this.provenRecord(NAMESPACES.PROGRAMS, address, opts);
2341
+ if (r.value === null)
2342
+ return null;
2343
+ const rec = decodeProgram(r.value);
2344
+ return { address, ...rec, version_digest: versionDigest(address, rec.wasm_hash, rec.interface_hash, rec.version), witnesses: opts.witnesses?.length ?? 0, height: r.height, root: r.root, head: r.head };
2345
+ }
2346
+ /** `/api/proof/{namespace}/{key}`, unchecked. Prefer `provenRecord`. */
2347
+ proof(namespace, key) {
2348
+ const k = typeof key === "string" ? utf8ToBytes(key) : key;
2349
+ return this.call(`/proof/${namespace}/${bytesToHex(k)}`);
2350
+ }
2351
+ /**
2352
+ * A record with its Merkle proof checked against the state root the node reports. On its own
2353
+ * that only shows the node is consistent; `witnesses` (clients for independent nodes) must
2354
+ * each report the same state root for that block, so a single lying node can't fake balances,
2355
+ * names or keys. A witness that hasn't reached the block yet is retried for `waitMs`.
2356
+ *
2357
+ * The proof must also be fresh: its block may be at most `maxLagBlocks` behind the newest head
2358
+ * this node and the witnesses report (`StaleProofError` otherwise). Witnesses confirm any
2359
+ * genuine root, old ones included, so without this a lying node could serve a real but outdated
2360
+ * record (a name's previous owner, a balance before a spend, a key since removed). `head` is
2361
+ * the newest head seen. Without witnesses only this node's own head is compared, which a lying
2362
+ * node controls: pass witnesses for anything that matters.
2363
+ */
2364
+ async provenRecord(namespace, key, opts = {}) {
2365
+ const maxLag = opts.maxLagBlocks ?? DEFAULT_MAX_PROOF_LAG_BLOCKS;
2366
+ if (!Number.isSafeInteger(maxLag) || maxLag < 0)
2367
+ throw new Error("maxLagBlocks must be a whole number of blocks");
2368
+ const witnesses = opts.witnesses ?? [];
2369
+ // Heads first, so the honest ones can only be behind the proof, never far ahead of it.
2370
+ const heads = await Promise.all([this, ...witnesses].map(async (c) => {
2371
+ const h = (await c.status()).height;
2372
+ if (!Number.isSafeInteger(h) || h < 0)
2373
+ throw new Error(`${c.rpc} doesn't report its height`);
2374
+ return h;
2375
+ }));
2376
+ const r = checkProofResponse(await this.proof(namespace, key), namespace, key);
2377
+ const head = Math.max(...heads);
2378
+ if (r.height + maxLag < head)
2379
+ throw new StaleProofError(r.height, head, maxLag);
2380
+ await Promise.all(witnesses.map((w) => w.confirmStateRoot(r.height, r.root, opts.waitMs ?? 5_000)));
2381
+ return { ...r, head };
2382
+ }
2383
+ /** Throws unless this node's block `height` has state root `root` (waiting up to `waitMs` for it to get there). */
2384
+ async confirmStateRoot(height, root, waitMs = 5_000) {
2385
+ const until = Date.now() + waitMs;
2386
+ for (;;) {
2387
+ try {
2388
+ const b = await this.block(height);
2389
+ if (b.state_root !== root)
2390
+ throw new Error(`${this.rpc} disagrees about the state at block #${height}: root ${String(b.state_root).slice(0, 16)}…, not ${root.slice(0, 16)}…`);
2391
+ return;
2392
+ }
2393
+ catch (e) {
2394
+ if (!(e instanceof ApiError && e.status === 404) || Date.now() > until)
2395
+ throw e;
2396
+ await new Promise((r) => setTimeout(r, 250));
2397
+ }
2398
+ }
2399
+ }
2400
+ /** An address's balance, nonce and keys, proven and fresh (see `provenRecord`). `null` if the account provably doesn't exist. */
2401
+ async provenAccount(address, opts = {}) {
2402
+ if (!isAddress(address))
2403
+ throw new Error(`${address} isn't an address`);
2404
+ const r = await this.provenRecord(NAMESPACES.ACCOUNTS, address, opts);
2405
+ return r.value === null ? null : { address, ...decodeAccount(r.value), height: r.height, root: r.root, head: r.head };
2406
+ }
2407
+ /**
2408
+ * Who a `.boo` name resolves to, proven and fresh (see `provenRecord`). `null` if it provably
2409
+ * doesn't resolve: never registered, or expired (`expires_at` passed; in its grace period only
2410
+ * the owner can renew it, and after that anyone can register it). `includeExpired` returns an
2411
+ * expired record too, with `active: false`: never pay its `owner`.
2412
+ */
2413
+ async provenName(name, opts = {}) {
2414
+ const r = await this.provenRecord(NAMESPACES.NAMES, bareName(name), opts);
2415
+ if (r.value === null)
2416
+ return null;
2417
+ const rec = decodeName(r.value);
2418
+ // node/src/econ.rs `name_active`: the next block (height + 1) must be at most `expires_at`.
2419
+ const active = r.height + 1 <= rec.expires_at;
2420
+ if (!active && !opts.includeExpired)
2421
+ return null;
2422
+ return { ...rec, active, height: r.height, root: r.root, head: r.head };
2423
+ }
2424
+ /**
2425
+ * The address a name or address resolves to, for fields that need an address (see
2426
+ * `resolution`): proven with witnesses, else one node's word. Where the chain resolves names
2427
+ * itself (payments, vouches, name offers…), use `recipient` instead, which signs the name.
2428
+ */
232
2429
  async resolve(nameOrAddress) {
233
- if (isAddress(nameOrAddress))
234
- return nameOrAddress;
235
- if (/^boo1/i.test(nameOrAddress.trim()))
236
- throw new Error(`${nameOrAddress} isn't a valid address (check for typos; addresses are lowercase)`);
237
- const r = await this.name(nameOrAddress);
238
- if (r.available)
2430
+ return (await this.resolution(nameOrAddress)).address;
2431
+ }
2432
+ /**
2433
+ * How to sign a recipient in a field the chain resolves itself (`transfer.to`, `award_hunt.to`,
2434
+ * `vouch`/`unvouch`/`flag_sybil`/`clawback.account`, `grant_soul.to`, `transfer_name.to`, and
2435
+ * proposal targets): an address as given; with witnesses a name's proven owner; without them
2436
+ * the name itself, which the chain resolves to its owner when the tx runs, so a lying node
2437
+ * can't point it at another account now (audit gap-7-1). It can still hold the signed tx and
2438
+ * release it later within its validity window, which counts from the height that node reported
2439
+ * (bounded by `checkSigningHeight` only with witnesses or a `genesisClock`): whoever owns the
2440
+ * name then is paid, so a name that lapses in between pays its next owner (review-r3-clients-2).
2441
+ * Show `how` before signing.
2442
+ */
2443
+ async recipient(nameOrAddress) {
2444
+ const r = await this.resolution(nameOrAddress);
2445
+ if (r.name === null || r.proven)
2446
+ return { ...r, signed: r.address, byChain: false };
2447
+ return {
2448
+ ...r,
2449
+ signed: `${r.name}.boo`,
2450
+ byChain: true,
2451
+ how: `${r.name}.boo is signed as the name: the chain pays whoever owns it when the transaction runs, so ${this.rpc} can't point it at another account now, though a node holding the signed transaction could release it later in its validity window, after the name changed hands (${this.rpc} says the owner is ${r.address} now, unproven)`,
2452
+ };
2453
+ }
2454
+ /**
2455
+ * Where a recipient goes and how sure that is: show `address` and `how` before signing. With the
2456
+ * client's `witnesses` a name is a proven, fresh read (`provenName`), so a lying node can't
2457
+ * point it elsewhere; without them it's this node's answer alone (`proven: false`), which a
2458
+ * lying node controls (audit gap-7-1).
2459
+ */
2460
+ async resolution(nameOrAddress) {
2461
+ const input = nameOrAddress;
2462
+ if (isAddress(input))
2463
+ return { input, address: input, name: null, proven: true, how: "an address, signed as given" };
2464
+ if (/^t?boo1/i.test(input.trim()))
2465
+ throw new Error(`${input} isn't a valid address (check for typos; addresses are lowercase)`);
2466
+ const name = bareName(input);
2467
+ if (this.witnesses.length) {
2468
+ const rec = await this.provenName(name, { witnesses: this.witnesses });
2469
+ if (!rec)
2470
+ throw new Error(`${name}.boo doesn't resolve: it isn't registered or has expired (proven against ${this.witnesses.length} witness${this.witnesses.length === 1 ? "" : "es"})`);
2471
+ if (!isAddress(rec.owner))
2472
+ throw new Error(`${name}.boo's record names ${rec.owner}, which isn't an address`);
2473
+ return { input, address: rec.owner, name, proven: true, how: `${name}.boo proven at block #${rec.height} against ${this.witnesses.length} independent node${this.witnesses.length === 1 ? "" : "s"}`, height: rec.height };
2474
+ }
2475
+ if (this.requireProvenNames)
2476
+ throw new Error(`${name}.boo can't be proven: this client has no witnesses`);
2477
+ const r = await this.name(name);
2478
+ if (r.status === "grace")
2479
+ throw new Error(`${r.name} has expired (at block ${r.expires_at})`);
2480
+ if (r.available || !r.owner)
239
2481
  throw new Error(`${r.name} isn't registered`);
240
- return r.owner.address;
2482
+ const address = String(r.owner.address ?? "");
2483
+ if (!isAddress(address))
2484
+ throw new Error(`${this.rpc} resolved ${name}.boo to ${JSON.stringify(address)}, which isn't an address`);
2485
+ return { input, address, name, proven: false, how: `${name}.boo as ${this.rpc} reports it, unproven (a node can point a name anywhere; give the client witnesses to prove it)` };
241
2486
  }
2487
+ /**
2488
+ * Submits a signed tx, then (with `wait`) waits for it. What a failure means:
2489
+ * - `ApiError`: the node refused the tx; it wasn't admitted.
2490
+ * - `RequestTimeoutError` or a network error: unknown; the tx may have arrived.
2491
+ * - `TxFailedError`: it executed and failed (`dropped` false: its fee was charged), or this node
2492
+ * dropped it unrun (`dropped` true: nothing charged, but it may still execute elsewhere until
2493
+ * its `valid_until`).
2494
+ * - `TxPendingError`: it was admitted and may still execute. Don't send it again blindly.
2495
+ */
242
2496
  async submit(tx, wait = true) {
243
- const { id } = await this.call("/tx", tx);
244
- return wait ? this.waitForTx(id) : this.tx(id);
2497
+ const { id } = await this.post(tx);
2498
+ if (wait)
2499
+ return this.waitForTx(id, undefined, tx.valid_until);
2500
+ try {
2501
+ return await this.tx(id);
2502
+ }
2503
+ catch (e) {
2504
+ throw new TxPendingError(id, 0, e);
2505
+ }
2506
+ }
2507
+ /**
2508
+ * Submits a signed tx and returns its id, without reading it back or waiting (`submit` does
2509
+ * both). The same bytes can be posted again safely: a node answers with the same id, and the tx
2510
+ * runs at most once. As JSON, or in its wire form with `wireTxs`. Errors as `submit`'s.
2511
+ */
2512
+ async post(tx) {
2513
+ const r = await this.call("/tx", this.wireTxs ? txBytes(tx) : tx);
2514
+ if (typeof r?.id !== "string")
2515
+ throw new Error(`${this.rpc} answered the submission without the transaction's id: it may or may not have been admitted`);
2516
+ return { id: r.id };
245
2517
  }
246
2518
  async faucet(to, wait = true) {
247
2519
  const { id } = await this.call("/faucet", { to });
248
2520
  return wait ? this.waitForTx(id) : this.tx(id);
249
2521
  }
250
- async waitForTx(id, timeoutMs = 15_000) {
2522
+ /**
2523
+ * Polls a tx until it's finalized (returns it) or failed (`TxFailedError`, which says whether it
2524
+ * executed or was dropped unrun). A poll that fails (rate limited, a node error, a timeout, not
2525
+ * visible yet) is retried; after `timeoutMs` it throws `TxPendingError`, since the tx may still
2526
+ * execute. `validUntil` (the tx's, when the caller has it) goes into a dropped tx's error.
2527
+ */
2528
+ async waitForTx(id, timeoutMs = 15_000, validUntil) {
251
2529
  const start = Date.now();
2530
+ let lastError;
252
2531
  for (;;) {
253
- const tx = await this.tx(id);
254
- if (tx.status === "finalized")
255
- return tx;
256
- if (tx.status === "failed")
257
- throw new TxFailedError(id, tx.error ?? "unknown reason", tx);
2532
+ try {
2533
+ const tx = await this.tx(id);
2534
+ if (tx.status === "finalized")
2535
+ return tx;
2536
+ if (tx.status === "failed")
2537
+ throw new TxFailedError(id, tx.error ?? "unknown reason", tx, validUntil);
2538
+ }
2539
+ catch (e) {
2540
+ if (e instanceof TxFailedError || (e instanceof ApiError && e.status === 400))
2541
+ throw e;
2542
+ lastError = e;
2543
+ }
258
2544
  if (Date.now() - start > timeoutMs)
259
- throw new Error(`tx ${id} still pending after ${timeoutMs}ms`);
2545
+ throw new TxPendingError(id, timeoutMs, lastError);
260
2546
  await new Promise((r) => setTimeout(r, 250));
261
2547
  }
262
2548
  }