@blockula/sdk 0.2.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,24 +1,331 @@
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, keyPopMessage, maxTip, parseFang, parseSoul, proposalDigest, seanceCommitment, seancePairsDigest, signingBytes } from "./tx.js";
6
+ import { NAMESPACES, checkProofResponse, decodeAccount, decodeName } from "./proof.js";
7
+ import { describeKind, reviewAction } from "./describe.js";
6
8
  export * from "./tx.js";
9
+ export * from "./proof.js";
10
+ export * from "./distribution.js";
11
+ export * from "./release.js";
12
+ export * from "./describe.js";
7
13
  export const DEFAULT_RPC = "http://localhost:7070";
8
- export function addressFromPublicKey(publicKey) {
9
- return bech32m.encode("boo", bech32m.toWords(blake3(publicKey)));
14
+ /** Address prefixes: `boo` on mainnet (and dev chains), `tboo` on public testnets. */
15
+ export const KNOWN_PREFIXES = ["boo", "tboo"];
16
+ export const DEFAULT_PREFIX = "boo";
17
+ /** Mainnet's chain id (node/src/chain.rs `MAINNET_CHAIN_ID`). */
18
+ export const MAINNET_CHAIN_ID = "blockula-1";
19
+ /** How long a transaction stays valid by default: 600 blocks (10 minutes at 1 s blocks). */
20
+ export const DEFAULT_VALID_FOR_BLOCKS = 600;
21
+ /** The address of a public key: bech32m(prefix, BLAKE3(public key)). */
22
+ export function addressFromPublicKey(publicKey, prefix = DEFAULT_PREFIX) {
23
+ if (!KNOWN_PREFIXES.includes(prefix))
24
+ throw new Error(`unknown address prefix ${prefix}`);
25
+ return bech32m.encode(prefix, bech32m.toWords(blake3(publicKey)));
10
26
  }
11
27
  /** A key's id: BLAKE3(public key) as hex. Same 32 bytes as inside the `boo1…` address it derives. */
12
28
  export function keyId(publicKey) {
13
29
  return bytesToHex(blake3(publicKey));
14
30
  }
15
- export function isAddress(s) {
31
+ /** The prefix of a canonical address (`boo`, `tboo`), or `undefined` if `s` isn't one. */
32
+ export function addressPrefix(s) {
16
33
  // Addresses are canonical lowercase; the node rejects any other casing.
17
34
  if (s !== s.toLowerCase())
18
- return false;
35
+ return undefined;
19
36
  try {
20
37
  const { prefix, words } = bech32m.decode(s);
21
- return prefix === "boo" && bech32m.fromWords(words).length === 32;
38
+ return KNOWN_PREFIXES.includes(prefix) && bech32m.fromWords(words).length === 32 ? prefix : undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /** Whether `s` is an address: with any known prefix, or exactly `prefix` when given. */
45
+ export function isAddress(s, prefix) {
46
+ const p = addressPrefix(s);
47
+ return p !== undefined && (prefix === undefined || p === prefix);
48
+ }
49
+ const positive = (drops, what) => {
50
+ if (drops <= 0n)
51
+ throw new Error(`${what} more than 0`);
52
+ if (drops >= 1n << 64n)
53
+ throw new Error(`${what}: amount too large`);
54
+ return drops;
55
+ };
56
+ const wholeNumber = (n, what) => {
57
+ if (!Number.isSafeInteger(n) || n < 0)
58
+ throw new Error(`${what} must be a whole number`);
59
+ return n;
60
+ };
61
+ const hexArg = (s, what) => {
62
+ const h = s.trim().toLowerCase();
63
+ if (!/^[0-9a-f]{64}$/.test(h))
64
+ throw new Error(`${what} must be 32 bytes of hex`);
65
+ return h;
66
+ };
67
+ /** A `.boo` name as the chain stores it (node/src/types.rs `normalize_name`): lowercase, without the suffix. */
68
+ export function bareName(name) {
69
+ const n = name.trim().toLowerCase().replace(/\.boo$/, "");
70
+ if (n.length < 3 || n.length > 32)
71
+ throw new Error("names must be 3–32 characters");
72
+ if (!/^[a-z0-9-]+$/.test(n))
73
+ throw new Error("names may only use a-z, 0-9 and '-'");
74
+ if (n.startsWith("-") || n.endsWith("-"))
75
+ throw new Error("names can't start or end with '-'");
76
+ return n;
77
+ }
78
+ /**
79
+ * The most `Wallet.propose` bonds by default without being told (audit gap-7-5): mainnet's genesis
80
+ * `proposal_deposit` (node/src/params.rs). A node reporting more is refused; pass `deposit`.
81
+ */
82
+ export const DEFAULT_MAX_PROPOSAL_DEPOSIT = 1000n * 1000000000n;
83
+ /** Highest vote conviction (node/src/params.rs `MAX_CONVICTION`). */
84
+ export const MAX_CONVICTION = 6;
85
+ /** Lock periods a vote at each conviction locks its amount for (params.rs `CONVICTION_PERIODS`) … */
86
+ export const CONVICTION_PERIODS = [0, 1, 2, 4, 8, 16, 32];
87
+ /** … and its weight in tenths of the amount (`CONVICTION_TENTHS`): 0.1× with no lock, then 1×–6×. */
88
+ export const CONVICTION_TENTHS = [1, 10, 20, 30, 40, 50, 60];
89
+ /** A vote's weight in drops: `amount` × the conviction multiplier (params.rs `vote_weight`). */
90
+ export function convictionWeight(amount, conviction) {
91
+ const t = CONVICTION_TENTHS[conviction];
92
+ if (t === undefined)
93
+ throw new Error(`conviction must be 0–${MAX_CONVICTION}`);
94
+ return (parseFang(amount) * BigInt(t)) / 10n;
95
+ }
96
+ /**
97
+ * Builders for `Wallet.propose` kinds (node/src/types.rs `ProposalKind`). Amounts are FANG (or
98
+ * bigint drops), Soul is in Soul ("2.5"); names in `to`/`account` are resolved when proposing.
99
+ */
100
+ export const proposal = {
101
+ /** Set a governance parameter (a key of `/api/params`, e.g. "max_validators") to a raw value (drops, blocks, bps …), within its bounds. */
102
+ paramChange(key, value) {
103
+ const v = BigInt(value);
104
+ if (!/^[a-z0-9_]{1,64}$/.test(key))
105
+ throw new Error("parameter keys are 1–64 characters of a-z, 0-9 and _");
106
+ if (v < 0n || v >= 1n << 64n)
107
+ throw new Error("parameter values are 0 to 2^64 - 1");
108
+ return { type: "param_change", key, value: v.toString() };
109
+ },
110
+ /** Pay from the treasury (spends above a share of the treasury need a supermajority). */
111
+ treasurySpend(to, amount) {
112
+ return { type: "treasury_spend", to, amount: positive(parseFang(amount), "spend").toString() };
113
+ },
114
+ /**
115
+ * Open a community distribution of `total`, claimable with Merkle proofs against `merkleRoot` until block `claimUntil`.
116
+ * `distributionId` is the id the tree was built for (`buildDistribution(id, …)`): one no distribution has yet,
117
+ * at most 1,000 past the next proposal id (`client.proposals()`'s `next_distribution_id` is free, and
118
+ * `max_distribution_id` is the largest allowed; the range only grows). Other proposals landing first don't change
119
+ * it, so a multisig can take its time approving; if a distribution with that id opens first, the node refuses this
120
+ * one (nothing is escrowed), or it fails when it runs. Claims close at most 730 days after the proposal's vote ends.
121
+ */
122
+ distribution(distributionId, merkleRoot, total, claimUntil) {
123
+ return { type: "distribution", distribution_id: wholeNumber(distributionId, "distributionId"), merkle_root: hexArg(merkleRoot, "merkleRoot"), total: positive(parseFang(total), "distribute").toString(), claim_until: wholeNumber(claimUntil, "claimUntil") };
124
+ },
125
+ /** Put a repo in the contribution pool (weight 1–100; 0 removes it). */
126
+ setRepo(repo, weight) {
127
+ if (!/^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/.test(repo))
128
+ throw new Error("repo must be owner/name");
129
+ if (!Number.isInteger(weight) || weight < 0 || weight > 100)
130
+ throw new Error("weight must be 0–100");
131
+ return { type: "set_repo", repo, weight };
132
+ },
133
+ /** Grant Soul (in Soul, "2.5"; at most 100 per grant). */
134
+ grantSoul(to, soul, reason) {
135
+ return { type: "grant_soul", to, amount: parseSoul(soul), reason };
136
+ },
137
+ flagSybil(account, reason) {
138
+ return { type: "flag_sybil", account, reason };
139
+ },
140
+ /** Lift a sybil flag: the appeal path for `flagSybil` (nothing the flag took comes back). */
141
+ unflagSybil(account, reason) {
142
+ return { type: "unflag_sybil", account, reason };
143
+ },
144
+ rejectClaim(claimId, reason) {
145
+ return { type: "reject_claim", claim_id: wholeNumber(claimId, "claimId"), reason };
146
+ },
147
+ clawback(account, reason) {
148
+ return { type: "clawback", account, reason };
149
+ },
150
+ unlinkGithub(handle) {
151
+ return { type: "unlink_github", handle };
152
+ },
153
+ /** Refund every funder of a hunt (escrow can be refunded, never redirected). */
154
+ refundHunt(huntId) {
155
+ return { type: "refund_hunt", hunt_id: wholeNumber(huntId, "huntId") };
156
+ },
157
+ /**
158
+ * Switch to protocol `version` at block `activationHeight` (needs a supermajority). The version is newer than
159
+ * every one scheduled or withdrawn (`client.upgrade()`'s `withdrawn.highest_version`) and at most 16 past the
160
+ * newest scheduled, withdrawn or superseded (`next_version` is the number to use); the activation is at most
161
+ * 365 days after the proposal's vote ends.
162
+ */
163
+ scheduleUpgrade(version, activationHeight) {
164
+ if (!Number.isInteger(version) || version < 1 || version > 0xffffffff)
165
+ throw new Error("version must be a positive 32-bit number");
166
+ return { type: "schedule_upgrade", version, activation_height: wholeNumber(activationHeight, "activationHeight") };
167
+ },
168
+ /**
169
+ * Withdraw the upgrade to protocol `version` scheduled at block `activationHeight` (see
170
+ * `client.upgrade()`), and any scheduled after it, before it activates. Needs a supermajority;
171
+ * there's no timelock (it runs the block after its vote), and it must run at least 64 blocks
172
+ * before the activation height, so propose it at least `voting_blocks + 65` blocks ahead.
173
+ */
174
+ cancelUpgrade(version, activationHeight) {
175
+ if (!Number.isInteger(version) || version < 1 || version > 0xffffffff)
176
+ throw new Error("version must be a positive 32-bit number");
177
+ if (wholeNumber(activationHeight, "activationHeight") < 1)
178
+ throw new Error("activationHeight is the block the upgrade is scheduled at");
179
+ return { type: "cancel_upgrade", version, activation_height: activationHeight };
180
+ },
181
+ /** Turn on a feature flag (e.g. "ghost"). */
182
+ activateFeature(feature) {
183
+ return { type: "activate_feature", feature };
184
+ },
185
+ deactivateFeature(feature) {
186
+ return { type: "deactivate_feature", feature };
187
+ },
188
+ /** A signal with no on-chain effect. */
189
+ text() {
190
+ return { type: "text" };
191
+ },
192
+ };
193
+ // ---- Readers for the protocol v1 endpoints: they take what the node serves (node/src/api.rs)
194
+ // and return plain shapes with amounts as decimal strings of drops. They don't throw on a
195
+ // missing field (an older node, or a page that hasn't loaded): it reads as empty or zero. ----
196
+ /** A JSON object, or an empty one. */
197
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
198
+ const arr = (v) => (Array.isArray(v) ? v : []);
199
+ /** Drops as a decimal string: from a string or number of drops, or "0". */
200
+ const drops = (v) => {
201
+ if (typeof v === "bigint")
202
+ return v.toString();
203
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
204
+ return String(v);
205
+ if (typeof v === "string" && /^\d+$/.test(v))
206
+ return v;
207
+ return "0";
208
+ };
209
+ /** An address from a label (`{address, name}`) or a plain string. */
210
+ const addressOf = (v) => (typeof v === "string" ? v : String(obj(v).address ?? ""));
211
+ const nameOf = (v) => (typeof v === "object" && v ? (obj(v).name ?? null) : null);
212
+ const num = (v) => (typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : undefined);
213
+ export function stakeSummary(raw) {
214
+ const r = obj(raw);
215
+ const delegations = arr(r.delegations).map((d0) => {
216
+ const d = obj(d0);
217
+ 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 };
218
+ });
219
+ const entries = arr(r.entries).map(obj);
220
+ 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 }));
221
+ 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 }));
222
+ return { delegations, unbonding, redelegations, bonded: drops(r.bonded), unbonding_total: drops(r.unbonding), withdrawable: drops(r.withdrawable), summary: String(r.summary ?? "") };
223
+ }
224
+ /**
225
+ * Shares worth about `value` drops in a delegation (rounded down, at most all of them), at the
226
+ * value the delegation reports. That's a node's word: to unbond an amount, pass
227
+ * `wallet.undelegate(validator, { amount })`, which the chain converts itself (audit gap-7-5).
228
+ */
229
+ export function sharesFor(d, value) {
230
+ const want = parseFang(value);
231
+ const [shares, worth] = [BigInt(d.shares), BigInt(d.value)];
232
+ if (worth === 0n || want >= worth)
233
+ return shares;
234
+ return (want * shares) / worth;
235
+ }
236
+ /** `/api/params`' governance parameters as a key → value map (values as strings: amounts can exceed 2^53). */
237
+ export function paramsOf(raw) {
238
+ const out = {};
239
+ for (const [k, v] of Object.entries(obj(obj(raw).params))) {
240
+ if ((typeof v === "number" && Number.isSafeInteger(v)) || (typeof v === "string" && /^\d+$/.test(v)))
241
+ out[k] = String(v);
242
+ }
243
+ return out;
244
+ }
245
+ /** `proposalDigest` of a proposal's content as the node served it, or why it can't be computed. */
246
+ function digestOf(kind, title, url) {
247
+ if (!kind || typeof kind !== "object")
248
+ return { digest: null, digest_error: "the node didn't say what this proposal does" };
249
+ try {
250
+ return { digest: proposalDigest(kind, String(title ?? ""), typeof url === "string" ? url : "") };
251
+ }
252
+ catch (e) {
253
+ return { digest: null, digest_error: `this client can't read the proposal exactly (${e.message})` };
254
+ }
255
+ }
256
+ export function proposalSummary(raw, rules) {
257
+ const p = obj(raw);
258
+ const weight = (side) => drops(obj(obj(side).weight).drops);
259
+ const amount = (side) => drops(obj(obj(side).amount).drops);
260
+ const tally = p.tally ? obj(p.tally) : null;
261
+ const council = arr(p.council).map(obj);
262
+ const [ayeAmount, nayAmount] = [amount(p.ayes), amount(p.nays)];
263
+ const supermajority = p.supermajority === true;
264
+ return {
265
+ id: num(p.id) ?? -1,
266
+ title: String(p.title ?? ""),
267
+ url: typeof p.url === "string" ? p.url : "",
268
+ summary: String(p.summary ?? ""),
269
+ does: describeKind(p.kind),
270
+ kind: p.kind && typeof p.kind === "object" ? p.kind : null,
271
+ ...digestOf(p.kind, p.title, p.url),
272
+ proposer: p.proposer === undefined ? null : addressOf(p.proposer),
273
+ proposer_name: nameOf(p.proposer),
274
+ status: String(p.status ?? "unknown"),
275
+ deposit: drops(p.deposit),
276
+ aye: weight(p.ayes),
277
+ nay: weight(p.nays),
278
+ aye_amount: ayeAmount,
279
+ nay_amount: nayAmount,
280
+ turnout: (BigInt(ayeAmount) + BigInt(nayAmount)).toString(),
281
+ voters: num(p.voters) ?? 0,
282
+ quorum: tally ? drops(obj(tally.quorum).drops) : undefined,
283
+ quorum_reached: tally ? tally.quorum_reached === true : undefined,
284
+ council: { ayes: council.filter((c) => c.aye === true).length, nays: council.filter((c) => c.aye === false).length, of: tally ? num(tally.council_size) : undefined },
285
+ supermajority,
286
+ voting_ends_at: num(p.voting_end) ?? 0,
287
+ executes_at: num(p.execute_at),
288
+ outcome: String(p.outcome ?? ""),
289
+ threshold_bps: supermajority ? rules?.supermajority_bps : undefined,
290
+ };
291
+ }
292
+ const GRANT_PREFIX = "bkgrant1";
293
+ /** A grant as one pasteable string: `bkgrant1.<account>.<base64url(public key ‖ pop)>`. */
294
+ export function encodeKeyGrant(g) {
295
+ const body = new Uint8Array(PUBLIC_KEY_LEN + SIGNATURE_LEN);
296
+ body.set(hexToBytes(g.public_key));
297
+ body.set(hexToBytes(g.pop), PUBLIC_KEY_LEN);
298
+ return `${GRANT_PREFIX}.${g.account}.${base64urlnopad.encode(body)}`;
299
+ }
300
+ /** Parses a grant code (`encodeKeyGrant`). Whitespace is ignored, so a wrapped paste works. */
301
+ export function decodeKeyGrant(code) {
302
+ const c = code.replace(/\s+/g, "");
303
+ if (/^[0-9a-f]{64}$/i.test(c))
304
+ 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)");
305
+ const [prefix, account, body, ...rest] = c.split(".");
306
+ if (prefix !== GRANT_PREFIX || !account || !body || rest.length)
307
+ throw new Error("that isn't a key grant code (it starts with bkgrant1.)");
308
+ if (!isAddress(account))
309
+ throw new Error("the grant code's account isn't an address");
310
+ let bytes;
311
+ try {
312
+ bytes = base64urlnopad.decode(body);
313
+ }
314
+ catch {
315
+ throw new Error("the grant code is damaged (copy all of it)");
316
+ }
317
+ if (bytes.length !== PUBLIC_KEY_LEN + SIGNATURE_LEN)
318
+ throw new Error("the grant code is damaged or cut short (copy all of it)");
319
+ const pk = bytes.slice(0, PUBLIC_KEY_LEN);
320
+ return { account, key_id: keyId(pk), public_key: bytesToHex(pk), pop: bytesToHex(bytes.slice(PUBLIC_KEY_LEN)) };
321
+ }
322
+ /** Whether `g` is a valid consent by its key to join `g.account` on the chain `(chainId, genesisHash)`. */
323
+ export function verifyKeyGrant(g, chainId, genesisHash) {
324
+ try {
325
+ const pk = hexToBytes(g.public_key);
326
+ if (pk.length !== PUBLIC_KEY_LEN || keyId(pk) !== g.key_id)
327
+ return false;
328
+ return ml_dsa65.verify(hexToBytes(g.pop), keyPopMessage(chainId, genesisHash, g.account, g.key_id), pk, { context: KEY_POP_CONTEXT });
22
329
  }
23
330
  catch {
24
331
  return false;
@@ -33,28 +340,49 @@ export class Wallet {
33
340
  seed;
34
341
  client;
35
342
  publicKey;
36
- /** This key's own address. */
37
- address;
38
- /** The account this wallet acts for (defaults to `address`). */
39
- account;
40
343
  /** WebAuthn credential id, when the wallet was unlocked by a passkey. Not secret. */
41
344
  credentialId;
345
+ /** Default tip (drops) for this wallet's transactions. */
346
+ tip = 0n;
347
+ /** Default validity (blocks from now) for this wallet's transactions. */
348
+ validForBlocks = DEFAULT_VALID_FOR_BLOCKS;
42
349
  secretKey;
350
+ acting;
351
+ /** How `actAs` found the account, when that was one node's unproven word (checked when signing on mainnet). */
352
+ actingUnproven;
43
353
  constructor(seed, client = new Client()) {
44
354
  this.seed = seed;
45
355
  this.client = client;
46
356
  const keys = ml_dsa65.keygen(seed);
47
357
  this.publicKey = keys.publicKey;
48
358
  this.secretKey = keys.secretKey;
49
- this.address = addressFromPublicKey(keys.publicKey);
50
- this.account = this.address;
359
+ }
360
+ /** This key's own address, with the chain's prefix once the client knows it (`await client.addressPrefix()`). */
361
+ get address() {
362
+ return addressFromPublicKey(this.publicKey, this.client.prefix);
363
+ }
364
+ /** The account this wallet acts for (defaults to `address`). */
365
+ get account() {
366
+ return this.acting ?? this.address;
367
+ }
368
+ set account(account) {
369
+ this.acting = account;
370
+ this.actingUnproven = undefined;
51
371
  }
52
372
  get keyId() {
53
373
  return keyId(this.publicKey);
54
374
  }
55
- /** Act for another account this key has been authorized on. Accepts a name or address. */
375
+ /**
376
+ * Act for another account this key has been authorized on. Accepts a name or address. A name
377
+ * needs an address here (every tx names the account it acts for), so it's resolved: proven with
378
+ * the client's witnesses. Without them it's one node's word, and on mainnet nothing is signed
379
+ * for an account found that way (`UnprovenNameError`): check the address and pass it instead
380
+ * (audit gap-7-1).
381
+ */
56
382
  async actAs(account) {
57
- this.account = await this.client.resolve(account);
383
+ const r = await this.addressNeeded(await this.client.resolution(account));
384
+ this.acting = r.address;
385
+ this.actingUnproven = r.proven ? undefined : r;
58
386
  return this;
59
387
  }
60
388
  static generate(client) {
@@ -80,71 +408,555 @@ export class Wallet {
80
408
  const signature = ml_dsa65.sign(signingBytes(tx), this.secretKey);
81
409
  return { ...tx, public_key: bytesToHex(this.publicKey), signature: bytesToHex(signature) };
82
410
  }
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();
411
+ /**
412
+ * This key's consent to being added to `account` (an address, or a name resolved by the
413
+ * client: proven when it has witnesses; on mainnet an unproven name is refused, pass the address)
414
+ * on the client's chain: hand `encodeKeyGrant(grant)` to the account's owner, who adds the key
415
+ * with it. Pass `chain` to sign offline.
416
+ */
417
+ async keyGrant(account, chain) {
418
+ // A resolution already made (and shown) is used as is: resolving the name again could get another answer.
419
+ const res = typeof account !== "string" ? (account.name === null ? null : account) : isAddress(account) ? null : await this.client.resolution(account);
420
+ const address = typeof account !== "string" ? account.address : (res?.address ?? account);
421
+ let c = chain;
422
+ if (!c) {
423
+ const status = await this.client.status();
424
+ if (typeof status.genesis_hash !== "string")
425
+ throw new Error(`${this.client.rpc} doesn't report a genesis_hash; it's too old for this SDK`);
426
+ c = { chainId: status.chain_id, genesisHash: status.genesis_hash };
427
+ }
428
+ if (res && !res.proven && c.chainId === MAINNET_CHAIN_ID)
429
+ throw new UnprovenNameError(res, this.client.rpc);
430
+ const pop = ml_dsa65.sign(keyPopMessage(c.chainId, c.genesisHash, address, this.keyId), this.secretKey, { context: KEY_POP_CONTEXT });
431
+ return { account: address, key_id: this.keyId, public_key: bytesToHex(this.publicKey), pop: bytesToHex(pop) };
432
+ }
433
+ /**
434
+ * The `add_key` action for a grant (an object or its code): it must be for this account and
435
+ * verify on the client's chain, so a wrong or damaged code fails here, before anything is signed.
436
+ */
437
+ async grantAction(grant, label, role) {
438
+ const g = typeof grant === "string" ? decodeKeyGrant(grant) : grant;
439
+ if (g.account !== this.account)
440
+ throw new Error(`that grant is for ${g.account}, not this account (${this.account}): the key's holder must make it for ${this.account}`);
441
+ const status = await this.client.status();
442
+ if (!verifyKeyGrant(g, status.chain_id, status.genesis_hash))
443
+ throw new Error("that grant doesn't verify: it's damaged, or it was made for another chain");
444
+ return { type: "add_key", key_id: g.key_id, label, role, public_key: g.public_key, pop: g.pop };
445
+ }
446
+ /**
447
+ * Builds and signs a tx: `fee` = the highest base fee already fixed for the next blocks
448
+ * (`/api/fees` `suggested`) + `tip`, `valid_until` = the current height + `validForBlocks`, and
449
+ * the chain's id and genesis hash from `/api/status`. Only the base fee of the block that
450
+ * includes it (+ the tip) is charged. A tip above `tip_cap_multiple` × the lowest upcoming base
451
+ * fee (`/api/fees` `lowest`, see `maxTip`) is refused here, as nodes would refuse it. To sign
452
+ * offline, build an `UnsignedTx` yourself (with the chain's `genesis_hash`) and `sign` it.
453
+ *
454
+ * `action` may be a function of the height `valid_until` is counted from, for actions that
455
+ * carry an absolute block height of their own (a session key's or a hunt's expiry): computed
456
+ * from the same status read, a node that lies about the height can stretch them by at most
457
+ * `max_tx_validity_blocks`, since validators refuse a `valid_until` further ahead than that
458
+ * (audit gap-7-3).
459
+ *
460
+ * That height is checked first (`Client.checkSigningHeight`: against the witnesses' heads and,
461
+ * when the client knows its genesis clock, the clock), since a node reporting one the chain
462
+ * hasn't reached gets a tx it can hold and release later.
463
+ *
464
+ * `opts.resolutions`: how names in fields that need an address got into `action` (their
465
+ * `Client.resolution`s). On mainnet a tx carrying one that only one node vouched for isn't
466
+ * signed (`UnprovenNameError`), checked against the very status the tx is signed for, as the
467
+ * helpers do (audit gap-7-1). Fields the chain resolves itself take `Client.recipient(x).signed`.
468
+ */
469
+ async build(action, memo = "", opts = {}) {
470
+ return this.buildChecked(action, memo, opts, opts.resolutions ?? []);
471
+ }
472
+ /**
473
+ * `build`, refusing on mainnet a tx that carries an address only one node vouched for: `unproven`
474
+ * (the helpers' resolutions of names in fields that need an address) or the account `actAs`
475
+ * found that way (audit gap-7-1). Checked against the very status the tx is signed for.
476
+ */
477
+ async buildChecked(action, memo, opts, unproven) {
478
+ await this.client.addressPrefix(); // so `this.account` uses the chain's prefix
479
+ const [status, account, fees] = await Promise.all([this.client.status(), this.client.account(this.account).catch(() => null), this.client.fees()]);
480
+ if (!Number.isSafeInteger(status.height) || status.height < 0)
481
+ throw new Error(`${this.client.rpc} doesn't report its height`);
482
+ await this.client.checkSigningHeight(status.height);
483
+ const max = status.max_tx_validity_blocks ?? Number.MAX_SAFE_INTEGER;
484
+ const validFor = opts.validForBlocks ?? Math.min(this.validForBlocks, max);
485
+ if (!Number.isSafeInteger(validFor) || validFor < 1)
486
+ throw new Error("validForBlocks must be a positive whole number of blocks");
487
+ if (validFor > max)
488
+ throw new Error(`validForBlocks can be at most ${max} (an hour of blocks)`);
489
+ const tip = opts.tip === undefined ? this.tip : parseFang(opts.tip);
490
+ if (typeof status.genesis_hash !== "string")
491
+ throw new Error(`${this.client.rpc} doesn't report a genesis_hash; it's too old for this SDK`);
492
+ const guess = [...unproven, ...(this.actingUnproven ? [this.actingUnproven] : [])].find((r) => !r.proven);
493
+ if (guess && status.chain_id === MAINNET_CHAIN_ID)
494
+ throw new UnprovenNameError(guess, this.client.rpc);
495
+ // Binds the tx to this chain instance (not just its id), so it can't replay on a relaunch.
496
+ const act = typeof action === "function" ? action(status.height) : action;
497
+ const unsigned = { chain_id: status.chain_id, genesis_hash: status.genesis_hash, from: this.account, nonce: account?.next_nonce ?? 0, fee: "0", tip: tip.toString(), valid_until: status.height + validFor, action: act, memo };
498
+ unsigned.fee = (baseFee(unsigned, fees.suggested) + tip).toString();
499
+ if (tip > 0n) {
500
+ // The cap is a multiple of the base fee of the block that includes the tx: price it at the lowest upcoming one.
501
+ const multiple = fees.tip_cap_multiple ?? DEFAULT_TIP_CAP_MULTIPLE;
502
+ const cap = maxTip(unsigned, fees.lowest ?? fees.suggested, multiple);
503
+ if (tip > cap)
504
+ throw new Error(`tip too high: at most ${multiple}× the base fee, ${cap} drops for this transaction now`);
505
+ }
87
506
  return this.sign(unsigned);
88
507
  }
89
- /** Send FANG to a `.boo` name or `boo1…` address. `amount` is in FANG ("1.5") or bigint drops. */
508
+ /**
509
+ * Send FANG to a `.boo` name or an address. `amount` is in FANG ("1.5") or bigint drops. A name
510
+ * is signed as its proven owner's address when the client has witnesses (who you checked is who
511
+ * gets paid, even if the name changes hands before the tx runs), and as the name itself when it
512
+ * doesn't: the chain then pays whoever owns it when the tx runs. A lying node can't point it at
513
+ * another account now, but it can hold the signed tx until its validity window, which counts
514
+ * from the height the node reported, and release it later, by when the name may have lapsed
515
+ * and been registered again (audit gap-7-1, review-r3-clients-2). See `Client.recipient`.
516
+ */
90
517
  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);
518
+ const recipient = await this.client.recipient(to);
519
+ const tx = await this.build({ type: "transfer", to: recipient.signed, amount: parseFang(amount).toString() }, opts.memo, opts);
95
520
  return this.client.submit(tx, opts.wait ?? true);
96
521
  }
97
522
  async registerName(name, opts = {}) {
98
- const tx = await this.build({ type: "register_name", name });
523
+ const tx = await this.build({ type: "register_name", name }, "", opts);
99
524
  return this.client.submit(tx, opts.wait ?? true);
100
525
  }
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" } }));
526
+ /**
527
+ * Authorize another device's key (e.g. a second passkey) with full control. `grant` is the
528
+ * key's consent to this account (`keyGrant` on that device, or its grant code).
529
+ */
530
+ async addOwnerKey(grant, label) {
531
+ return this.client.submit(await this.build(await this.grantAction(grant, label, { type: "owner" })));
104
532
  }
105
533
  /**
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 })`.
534
+ * Let an agent/app key spend from this account, transfers only, within a limit. `grant` is the
535
+ * key's consent to this account (the agent's grant code).
536
+ * Example: `addSessionKey(grantCode, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 })`.
108
537
  */
109
- async addSessionKey(keyIdHex, label, opts) {
538
+ async addSessionKey(grant, label, opts) {
110
539
  if (!Number.isSafeInteger(opts.periodBlocks) || opts.periodBlocks < 1)
111
540
  throw new Error("periodBlocks must be a positive whole number of blocks");
112
541
  if (!Number.isSafeInteger(opts.expiresInBlocks) || opts.expiresInBlocks < 1)
113
542
  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 }));
543
+ const limit = parseFang(opts.limit).toString();
544
+ const base = await this.grantAction(grant, label, { type: "owner" });
545
+ // The expiry counts from the height valid_until does (one status read): see `build`.
546
+ return this.client.submit(await this.build((height) => ({ ...base, role: { type: "session", limit, period_blocks: opts.periodBlocks, expires_at: height + opts.expiresInBlocks } })));
117
547
  }
118
548
  async removeKey(keyIdHex) {
119
549
  return this.client.submit(await this.build({ type: "remove_key", key_id: keyIdHex }));
120
550
  }
121
551
  /**
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.
552
+ * Register a validator owned by this account, self-bonding `bond` FANG into its stake pool (at
553
+ * least the chain's `min_self_bond`). `consensusKey` is the validator node's ML-DSA-65 public
554
+ * key and `pop` its proof of possession (see `blockula validator init`). It's a candidate: at
555
+ * each epoch boundary the top `max_validators` pools by stake form the next active set.
124
556
  */
125
557
  async registerValidator(opts) {
126
558
  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 }));
127
559
  }
128
- /** Season 0 admins only. */
560
+ /** Dev and test chains with admins only: pin a validator into the set ahead of stake. */
129
561
  async approveValidator(validator) {
130
562
  return this.client.submit(await this.build({ type: "approve_validator", validator }));
131
563
  }
132
- /** Season 0 admins only. */
564
+ /** Dev and test chains with admins only: take a validator out of the set for good. */
133
565
  async removeValidator(validator) {
134
566
  return this.client.submit(await this.build({ type: "remove_validator", validator }));
135
567
  }
568
+ /** Claim a GitHub handle. Verify it by creating a public gist (as that user) containing this account's address. */
569
+ async linkGithub(handle, gistId) {
570
+ return this.client.submit(await this.build({ type: "link_github", handle, gist_id: gistId }));
571
+ }
572
+ async unlinkGithub(handle) {
573
+ return this.client.submit(await this.build({ type: "unlink_github", handle }));
574
+ }
575
+ /** Put a bounty on a GitHub issue. `expiresInBlocks` from now (max ~1 year). */
576
+ async createHunt(opts) {
577
+ if (!Number.isSafeInteger(opts.expiresInBlocks) || opts.expiresInBlocks < 1)
578
+ throw new Error("expiresInBlocks must be a positive whole number of blocks");
579
+ const amount = parseFang(opts.amount).toString();
580
+ // The expiry counts from the height valid_until does (one status read): see `build`.
581
+ return this.client.submit(await this.build((height) => ({ type: "create_hunt", repo: opts.repo, issue: opts.issue, amount, expires_at: height + opts.expiresInBlocks, title: opts.title })));
582
+ }
583
+ async fundHunt(huntId, amount) {
584
+ return this.client.submit(await this.build({ type: "fund_hunt", hunt_id: huntId, amount: parseFang(amount).toString() }));
585
+ }
586
+ /** 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). */
587
+ async awardHunt(huntId, to, pr, familiarKey) {
588
+ const addr = (await this.client.recipient(to)).signed;
589
+ return this.client.submit(await this.build({ type: "award_hunt", hunt_id: huntId, to: addr, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }));
590
+ }
591
+ async cancelHunt(huntId) {
592
+ return this.client.submit(await this.build({ type: "cancel_hunt", hunt_id: huntId }));
593
+ }
594
+ /** Mark one of this account's session keys (an agent) as a Familiar. */
595
+ async registerFamiliar(keyIdHex, label) {
596
+ return this.client.submit(await this.build({ type: "register_familiar", key_id: keyIdHex, label }));
597
+ }
598
+ // ---- 🪦 Proof of Contribution ----
599
+ /** Claim a merged PR (authored by this account's linked GitHub handle) for the contribution pool. Bonded; validators check it on GitHub. */
600
+ async submitClaim(repo, pr, familiarKey) {
601
+ return this.client.submit(await this.build({ type: "submit_claim", repo, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }));
602
+ }
603
+ /** Stake 10 FANG on a newcomer being a real, distinct person (needs 10 Soul). */
604
+ async vouch(account) {
605
+ return this.client.submit(await this.build({ type: "vouch", account: (await this.client.recipient(account)).signed }));
606
+ }
607
+ /** Withdraw your vouch for `account` (after the lock), or pass your own account to decline one. */
608
+ async unvouch(account) {
609
+ return this.client.submit(await this.build({ type: "unvouch", account: (await this.client.recipient(account)).signed }));
610
+ }
611
+ /** Join (or top up) the Séance juror pool. Needs 5 Soul; owner keys only. */
612
+ async joinJury(bond = "10") {
613
+ return this.client.submit(await this.build({ type: "join_jury", bond: parseFang(bond).toString() }));
614
+ }
615
+ async leaveJury() {
616
+ return this.client.submit(await this.build({ type: "leave_jury" }));
617
+ }
618
+ /**
619
+ * Seal a Séance judgment: one of "a", "b" or "=" per assigned pair (see `client.seance(id)`),
620
+ * plus claim ids you think are spam. Keep the returned `salt`: `revealVotes` needs it.
621
+ *
622
+ * The judgment binds the pairs it was made on (audit gap-7-6): pass `pairs`, the claims (id,
623
+ * repo, PR) you showed the juror in order, or they're read from the client (`seancePairs`). A
624
+ * node that showed other pairs or PRs makes the commitment fail instead of misdirecting it.
625
+ */
626
+ async sealVotes(seanceId, votes, spam = [], salt = bytesToHex(randomBytes(16)), opts = {}) {
627
+ await this.client.addressPrefix(); // the commitment names this account
628
+ const pairs = opts.pairs ?? (await this.client.seancePairs(seanceId, this.account));
629
+ if (votes.length !== pairs.length)
630
+ throw new Error(`you have ${pairs.length} assigned pairs: give one of a, b or = for each`);
631
+ const commitment = seanceCommitment(seanceId, this.account, votes, spam, salt);
632
+ const pairs_digest = seancePairsDigest(seanceId, this.account, pairs);
633
+ const tx = await this.client.submit(await this.build({ type: "commit_votes", seance_id: seanceId, commitment, pairs_digest }));
634
+ return { tx, salt, votes, spam };
635
+ }
636
+ async revealVotes(seanceId, votes, spam, salt) {
637
+ return this.client.submit(await this.build({ type: "reveal_votes", seance_id: seanceId, votes, spam, salt }));
638
+ }
639
+ async fundPool(amount) {
640
+ return this.client.submit(await this.build({ type: "fund_pool", amount: parseFang(amount).toString() }));
641
+ }
642
+ /** Withdraw every contribution reward that has vested so far. */
643
+ async withdrawVested() {
644
+ return this.client.submit(await this.build({ type: "withdraw_vested" }));
645
+ }
646
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). Weight 1–100; 0 removes the repo. */
647
+ async setRepo(repo, weight) {
648
+ return this.client.submit(await this.build({ type: "set_repo", repo, weight }));
649
+ }
650
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). `soul` in Soul ("2.5"), up to 100 per grant. */
651
+ async grantSoul(to, soul, reason) {
652
+ return this.client.submit(await this.build({ type: "grant_soul", to: (await this.client.recipient(to)).signed, amount: parseSoul(soul), reason }));
653
+ }
654
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). */
655
+ async rejectClaim(claimId, reason) {
656
+ return this.client.submit(await this.build({ type: "reject_claim", claim_id: claimId, reason }));
657
+ }
658
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). */
659
+ async flagSybil(account, reason) {
660
+ return this.client.submit(await this.build({ type: "flag_sybil", account: (await this.client.recipient(account)).signed, reason }));
661
+ }
662
+ /** Admins of dev and test chains only (elsewhere: a governance proposal). */
663
+ async clawback(account, reason) {
664
+ return this.client.submit(await this.build({ type: "clawback", account: (await this.client.recipient(account)).signed, reason }));
665
+ }
136
666
  /** Reclaim the bond of a removed validator this account owns. */
137
667
  async unbond(validator) {
138
668
  return this.client.submit(await this.build({ type: "unbond", validator }));
139
669
  }
670
+ // ---- protocol v1 (tags 30–49). Each builds, signs and submits one tx and waits for it. ----
671
+ /** Submits one action (the shared tail of the helpers below); `unproven`: see `buildChecked`. */
672
+ async act(action, opts = {}, unproven = []) {
673
+ return this.client.submit(await this.buildChecked(action, opts.memo ?? "", opts, unproven), opts.wait ?? true);
674
+ }
675
+ /**
676
+ * A validator by address, or by a `.boo` name its account holds. Staking needs the address, so
677
+ * a name is resolved (proven with the client's witnesses); on mainnet a tx with an unproven one
678
+ * isn't signed (audit gap-7-1): pass the validator's address.
679
+ */
680
+ async validatorAddress(validator) {
681
+ return this.addressNeeded(await this.client.resolution(validator));
682
+ }
683
+ /** `r`, unless it's an unproven name on mainnet (`UnprovenNameError`); `buildChecked` checks again against the signing status. */
684
+ async addressNeeded(r) {
685
+ if (!r.proven && (await this.client.status()).chain_id === MAINNET_CHAIN_ID)
686
+ throw new UnprovenNameError(r, this.client.rpc);
687
+ return r;
688
+ }
689
+ /** Stake `amount` (FANG, or bigint drops) in a validator's pool. Your stake earns its share of issuance and is slashed with the pool. */
690
+ async delegate(validator, amount, opts = {}) {
691
+ const v = await this.validatorAddress(validator);
692
+ return this.act({ type: "delegate", validator: v.address, amount: positive(parseFang(amount), "delegate").toString() }, opts, [v]);
693
+ }
694
+ /**
695
+ * Start unbonding stake in a validator's pool: `{ amount }` (FANG, or bigint drops), `shares`,
696
+ * or `"all"` (every share you hold there, read from `/api/staking`). An amount is signed as an
697
+ * amount and the chain converts it at the pool's price when the tx runs, so no node's report of
698
+ * the stake decides how much unbonds (audit gap-7-5); prefer it to `sharesFor`. It stops
699
+ * earning now and can be withdrawn with `withdrawUnbonded` once the unbonding period (21 days
700
+ * on mainnet) is over.
701
+ */
702
+ async undelegate(validator, shares, opts = {}) {
703
+ const v = await this.validatorAddress(validator);
704
+ if (typeof shares === "object" && shares !== null)
705
+ return this.act({ type: "undelegate", validator: v.address, shares: "0", amount: positive(parseFang(shares.amount), "undelegate").toString() }, opts, [v]);
706
+ return this.act({ type: "undelegate", validator: v.address, shares: (await this.sharesOf(v.address, shares)).toString() }, opts, [v]);
707
+ }
708
+ /** Move `shares` (or `"all"`) from one pool to another without waiting out the unbonding period. */
709
+ async redelegate(fromValidator, toValidator, shares, opts = {}) {
710
+ const [from, to] = await Promise.all([this.validatorAddress(fromValidator), this.validatorAddress(toValidator)]);
711
+ if (from.address === to.address)
712
+ throw new Error("redelegate to a different validator");
713
+ return this.act({ type: "redelegate", from_validator: from.address, to_validator: to.address, shares: (await this.sharesOf(from.address, shares)).toString() }, opts, [from, to]);
714
+ }
715
+ /** Pay out every unbonding entry that has finished unbonding. */
716
+ async withdrawUnbonded(opts = {}) {
717
+ return this.act({ type: "withdraw_unbonded" }, opts);
718
+ }
719
+ /** 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. */
720
+ async updateValidator(opts) {
721
+ if (!Number.isInteger(opts.commissionBps) || opts.commissionBps < 0 || opts.commissionBps > 10_000)
722
+ throw new Error("commissionBps must be a whole number from 0 to 10000");
723
+ return this.act({ type: "update_validator", commission_bps: opts.commissionBps, p2p_addr: opts.p2pAddr ?? "", http_addr: opts.httpAddr ?? "" }, opts);
724
+ }
725
+ /** Bring a jailed validator back (its owner, after the jail period). */
726
+ async unjail(validator, opts = {}) {
727
+ const v = await this.validatorAddress(validator);
728
+ return this.act({ type: "unjail", validator: v.address }, opts, [v]);
729
+ }
730
+ /** Low-level (nodes do this automatically): two conflicting signed votes, hex-encoded as the node's evidence reporter emits them. */
731
+ async reportEquivocation(evidenceHex, opts = {}) {
732
+ if (!/^([0-9a-f]{2})+$/.test(evidenceHex) || evidenceHex.length > 2 * 16 * 1024)
733
+ throw new Error("evidence must be 1–16384 bytes of lowercase hex");
734
+ return this.act({ type: "report_equivocation", evidence: evidenceHex }, opts);
735
+ }
736
+ /** Low-level (validator nodes do this automatically): commit to a beacon secret for `epoch`. */
737
+ async beaconCommit(epoch, commitmentHex, opts = {}) {
738
+ return this.act({ type: "beacon_commit", epoch: wholeNumber(epoch, "epoch"), commitment: hexArg(commitmentHex, "commitment") }, opts);
739
+ }
740
+ /** Low-level (validator nodes do this automatically): reveal the beacon secret committed for `epoch`. */
741
+ async beaconReveal(epoch, secretHex, opts = {}) {
742
+ return this.act({ type: "beacon_reveal", epoch: wholeNumber(epoch, "epoch"), secret: hexArg(secretHex, "secret") }, opts);
743
+ }
744
+ /**
745
+ * A member of a set of one or two, signing with its consensus key: admit candidate `validator`
746
+ * into the set at the end of `epoch` (default: the epoch the next block is in; the tx fails in
747
+ * any other). The newcomer will be needed for every quorum, so this is the operator's decision,
748
+ * once an epoch per member; it joins once a quorum of the members admitted it and its node
749
+ * showed up (`validatorReady`).
750
+ */
751
+ async admitValidator(validator, opts = {}) {
752
+ const [v, epoch] = await Promise.all([this.validatorAddress(validator), opts.epoch ?? this.client.status().then((s) => s.epoch)]);
753
+ return this.act({ type: "admit_validator", validator: v.address, epoch: wholeNumber(epoch, "epoch") }, opts, [v]);
754
+ }
755
+ /** 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`. */
756
+ async validatorReady(epoch, opts = {}) {
757
+ return this.act({ type: "validator_ready", epoch: wholeNumber(epoch, "epoch") }, opts);
758
+ }
759
+ /**
760
+ * Put a proposal to a vote (build `kind` with `proposal.*`). Bonds `deposit` (default: the
761
+ * chain's `proposal_deposit`), refunded once the proposal reaches quorum and burned if it
762
+ * doesn't. Names in the kind (`to`, `account`) are signed as `Client.recipient` says: the proven
763
+ * address with witnesses, else the name, which the chain resolves when the proposal is made
764
+ * (it stores the address, which voters then see and bind).
765
+ *
766
+ * The default deposit is what the node reports, and a node can report anything, so it's
767
+ * bounded (audit gap-7-5): every witness of the client must report the same value, and it may
768
+ * be at most `maxDeposit` (default `DEFAULT_MAX_PROPOSAL_DEPOSIT`, mainnet's 1,000 FANG).
769
+ * Pass `deposit` to bond an amount you checked yourself.
770
+ */
771
+ async propose(kind, opts) {
772
+ const title = opts.title.trim();
773
+ if (!title || new TextEncoder().encode(title).length > 96 || /\p{Cc}/u.test(title))
774
+ throw new Error("title must be 1–96 bytes with no control characters");
775
+ const url = opts.url ?? "";
776
+ if (url && (!url.startsWith("https://") || url.length > 200 || /\s|\p{Cc}/u.test(url)))
777
+ throw new Error("url must be an https:// link of at most 200 bytes");
778
+ let deposit;
779
+ if (opts.deposit !== undefined)
780
+ deposit = parseFang(opts.deposit);
781
+ else {
782
+ const reported = await Promise.all([this.client, ...this.client.witnesses].map(async (c) => paramsOf(await c.params()).proposal_deposit));
783
+ const d = reported[0];
784
+ if (d === undefined)
785
+ throw new Error("this node doesn't report proposal_deposit; pass deposit");
786
+ if (reported.some((x) => x !== d))
787
+ throw new Error(`the node and its witnesses disagree about proposal_deposit (${reported.map((x) => (x === undefined ? "none" : formatFang(x))).join(", ")}); pass deposit`);
788
+ deposit = BigInt(d);
789
+ const max = opts.maxDeposit !== undefined ? parseFang(opts.maxDeposit) : DEFAULT_MAX_PROPOSAL_DEPOSIT;
790
+ if (deposit > max)
791
+ 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)`);
792
+ }
793
+ return this.act({ type: "propose", kind: await this.resolveKind(kind), deposit: positive(deposit, "deposit").toString(), title, url }, opts);
794
+ }
795
+ /**
796
+ * Vote on a proposal with `amount` of your unlocked holdings (liquid, staked or unbonding;
797
+ * unvested tokens can't vote). `conviction` 0–6 locks `amount` for 0, 1, 2, 4, 8, 16 or 32 lock
798
+ * periods after the vote ends, for 0.1×, 1×, 2× … 6× the weight (see `convictionWeight`).
799
+ * Voting again replaces your vote. A validator's consensus key or owner voting before the
800
+ * bootstrap council ends also casts that validator's council vote.
801
+ *
802
+ * The vote binds the proposal's content (audit gap-7-6): pass `proposal`, the kind, title and
803
+ * link you showed the voter (e.g. the `client.proposal(id)` object you rendered, described with
804
+ * `describeKind`), or it's read from the client. A node that showed another proposal's content
805
+ * makes the vote fail. If you showed it earlier and only kept its digest
806
+ * (`proposalSummary(raw).digest`), pass `digest`: a proposal that reads differently now is
807
+ * refused before anything is signed.
808
+ */
809
+ async vote(proposalId, aye, amount, conviction = 1, opts = {}) {
810
+ if (!Number.isInteger(conviction) || conviction < 0 || conviction > MAX_CONVICTION)
811
+ throw new Error(`conviction must be 0–${MAX_CONVICTION}`);
812
+ const id = wholeNumber(proposalId, "proposalId");
813
+ const drops = positive(parseFang(amount), "vote").toString();
814
+ const p = opts.proposal ?? (await this.client.proposal(id));
815
+ const proposal_digest = proposalDigest(p.kind, p.title, p.url ?? "");
816
+ shownDigest(opts.digest, proposal_digest, `proposal #${id}`);
817
+ return this.act({ type: "vote", proposal_id: id, aye, amount: drops, conviction, proposal_digest }, opts);
818
+ }
819
+ /** Release every vote lock that has expired. */
820
+ async unlock(opts = {}) {
821
+ return this.act({ type: "unlock" }, opts);
822
+ }
823
+ /**
824
+ * Make this account an M-of-N multisig of its owner keys (`ownerKeyIds`): owner-only actions
825
+ * then need `multisigPropose` plus approvals up to `threshold`. Session keys keep working
826
+ * within their limits.
827
+ */
828
+ async multisigCreate(ownerKeyIds, threshold, opts = {}) {
829
+ const owners = ownerKeyIds.map((k) => k.trim().toLowerCase());
830
+ if (owners.length === 0 || owners.length > 16 || owners.some((k) => !/^[0-9a-f]{64}$/.test(k)))
831
+ throw new Error("owners must be 1–16 key ids (64 hex characters)");
832
+ if (new Set(owners).size !== owners.length)
833
+ throw new Error("owners must be distinct");
834
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > owners.length)
835
+ throw new Error(`threshold must be 1–${owners.length} (the number of owners)`);
836
+ return this.act({ type: "multisig_create", owners, threshold }, opts);
837
+ }
838
+ /** Propose an action for this multisig account; it runs once enough owners approve. */
839
+ async multisigPropose(action, opts = {}) {
840
+ if (action.type === "multisig_propose" || action.type === "multisig_approve")
841
+ throw new Error("a multisig proposal can't hold another multisig proposal or approval");
842
+ return this.act({ type: "multisig_propose", action }, opts);
843
+ }
844
+ /**
845
+ * Approve open multisig proposal `id`. The approval binds the action (audit gap-7-6): pass
846
+ * `action`, the one you showed the approver (decode it and describe it with `describeAction`,
847
+ * don't trust a summary), or it's read from `client.multisig(account)`. A node that showed
848
+ * another action makes the approval fail. `digest`: the `actionDigest` of what you showed, if
849
+ * that's all you kept; an action that reads differently now is refused before anything is signed.
850
+ * A `vote` binds its proposal by digest, not by id: show it with `reviewAction(action, {
851
+ * proposal: await client.proposal(action.proposal_id) })`, which describes the proposal only if
852
+ * its content has that digest, and don't offer the approval unless it's `readable` (gap-7-6).
853
+ * Pass that proposal read as `proposal` here: approving a vote is refused, before anything is
854
+ * signed, unless the proposal shown (else one read now) has exactly the content the vote binds.
855
+ * The approval counts on this wallet's account (`actAs`), which the digest doesn't bind: on
856
+ * mainnet act for an address you checked, not a name one node resolved.
857
+ */
858
+ async multisigApprove(id, opts = {}) {
859
+ const n = wholeNumber(id, "id");
860
+ let action = opts.action;
861
+ if (!action) {
862
+ const m = await this.client.multisig(this.account);
863
+ action = (Array.isArray(m?.pending) ? m.pending : []).find((p) => p.id === n)?.action;
864
+ if (!action)
865
+ throw new Error(`${this.account} has no open multisig action #${n}`);
866
+ }
867
+ const action_digest = actionDigest(action);
868
+ shownDigest(opts.digest, action_digest, `multisig action #${n}`);
869
+ if (action.type === "vote") {
870
+ // What the ballot backs is the proposal whose content has its digest, whatever the node
871
+ // calls #id (audit gap-7-6): a node can't make other content hash to it.
872
+ const proposal = opts.proposal ?? (await this.client.proposal(action.proposal_id).catch(() => undefined));
873
+ const review = reviewAction(action, { proposal, freeText: false });
874
+ if (!review.readable)
875
+ throw new Error(`multisig action #${n} can't be approved from here: ${review.does}. Nothing was signed`);
876
+ }
877
+ return this.act({ type: "multisig_approve", id: n, action_digest }, opts);
878
+ }
879
+ /** Extend a name you own by `years` (1–10), at the chain's renewal price per year. */
880
+ async renewName(name, years = 1, opts = {}) {
881
+ if (!Number.isInteger(years) || years < 1 || years > 10)
882
+ throw new Error("renew for 1–10 years");
883
+ return this.act({ type: "renew_name", name: bareName(name), years }, opts);
884
+ }
885
+ /**
886
+ * Offer a name you own to another account (a `.boo` name or address, signed as `Client.recipient` says). It
887
+ * stays yours until they accept it (`acceptName`); offering it again replaces the offer, and
888
+ * `withdrawNameOffer` withdraws it. Nobody can be given a name without saying yes.
889
+ */
890
+ async transferName(name, to, opts = {}) {
891
+ return this.act({ type: "transfer_name", name: bareName(name), to: (await this.client.recipient(to)).signed }, opts);
892
+ }
893
+ /** Accept a name offered to the account you act for (`client.name(n).offered_to`): it becomes yours. */
894
+ async acceptName(name, opts = {}) {
895
+ return this.act({ type: "transfer_name", name: bareName(name), to: this.account }, opts);
896
+ }
897
+ /** Withdraw your offer of a name you own (the same tx as `acceptName`: the chain tells them apart by who owns the name). */
898
+ async withdrawNameOffer(name, opts = {}) {
899
+ return this.acceptName(name, opts);
900
+ }
901
+ /** Give up a name you own; anyone can register it afterwards. */
902
+ async releaseName(name, opts = {}) {
903
+ return this.act({ type: "release_name", name: bareName(name) }, opts);
904
+ }
905
+ /** Claim your share of a community distribution with the Merkle proof published alongside it (hex hashes). */
906
+ async claimDistribution(distributionId, amount, proof, opts = {}) {
907
+ if (proof.length > 64)
908
+ throw new Error("proof too long (max 64 hashes)");
909
+ 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);
910
+ }
911
+ /** Low-level (validator nodes do this automatically): a matured claim's PR was reverted by commit `revertSha`. */
912
+ async attestRevert(claimId, revertSha, opts = {}) {
913
+ if (!/^[0-9a-f]{40}$/.test(revertSha))
914
+ throw new Error("revertSha must be the 40-hex revert commit");
915
+ return this.act({ type: "attest_revert", claim_id: wholeNumber(claimId, "claimId"), revert_sha: revertSha }, opts);
916
+ }
917
+ /** This account's shares in a validator's pool, for `"all"`; anything else is parsed and checked. */
918
+ async sharesOf(validator, shares) {
919
+ if (shares !== "all") {
920
+ const s = typeof shares === "bigint" ? shares : /^\d+$/.test(shares.trim()) ? BigInt(shares.trim()) : -1n;
921
+ if (s <= 0n || s >= 1n << 64n)
922
+ throw new Error("shares must be a positive whole number (see client.staking(account))");
923
+ return s;
924
+ }
925
+ const d = stakeSummary(await this.client.staking(this.account)).delegations.find((x) => x.validator === validator);
926
+ if (!d || BigInt(d.shares) === 0n)
927
+ throw new Error(`${this.account} has no stake with validator ${validator}`);
928
+ return BigInt(d.shares);
929
+ }
930
+ /** A proposal kind with its accounts as `Client.recipient` signs them (the chain resolves names when the proposal is made). */
931
+ async resolveKind(kind) {
932
+ switch (kind.type) {
933
+ case "treasury_spend":
934
+ case "grant_soul":
935
+ return { ...kind, to: (await this.client.recipient(kind.to)).signed };
936
+ case "flag_sybil":
937
+ case "unflag_sybil":
938
+ case "clawback":
939
+ return { ...kind, account: (await this.client.recipient(kind.account)).signed };
940
+ default:
941
+ return kind;
942
+ }
943
+ }
140
944
  balance() {
141
- return this.client.account(this.account).then((a) => a.balance_display, () => "0 FANG");
945
+ return this.client.addressPrefix().then(() => this.client.account(this.account)).then((a) => a.balance_display, () => "0 FANG");
142
946
  }
143
947
  /** Dev/test chains only: drip 100 FANG to this wallet. */
144
- faucet(wait = true) {
948
+ async faucet(wait = true) {
949
+ await this.client.addressPrefix();
145
950
  return this.client.faucet(this.account, wait);
146
951
  }
147
952
  }
953
+ /** Throws unless `shown` (a digest the caller displayed, if any) is `now`, the digest about to be signed. */
954
+ function shownDigest(shown, now, what) {
955
+ if (shown === undefined)
956
+ return;
957
+ if (shown.trim().toLowerCase() !== now)
958
+ 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.`);
959
+ }
148
960
  /** An error response from a Blockula node. `status` is the HTTP status (429 = rate limited). */
149
961
  export class ApiError extends Error {
150
962
  status;
@@ -154,27 +966,272 @@ export class ApiError extends Error {
154
966
  this.name = "ApiError";
155
967
  }
156
968
  }
157
- /** A transaction that was rejected at execution (fee charged) or dropped from the mempool. */
969
+ /**
970
+ * The node reports a transaction as failed. Two different things (audit review-fix-clients-2):
971
+ * - `dropped` false: it executed in block `tx.height` and failed there. Its fee was charged; it
972
+ * can't execute again.
973
+ * - `dropped` true: this node dropped it from its mempool without running it (evicted by a
974
+ * higher tip, replaced, not included, or an earlier tx from the account was dropped). Nothing
975
+ * was charged, but other nodes may still hold it and include it until its `valid_until`
976
+ * (`validUntil`, when known): check `client.tx(id)` before sending it again, or a retry can pay twice.
977
+ */
978
+ /**
979
+ * Whether a `/api/tx` answer of status "failed" is a tx the node dropped from its mempool without
980
+ * running it (nothing charged; another node may still include it until its `valid_until`), not
981
+ * one that ran in a block and failed (its fee was charged). Mempool failures are answered without
982
+ * the tx itself (node/src/api.rs `tx`), and older nodes don't say `dropped`.
983
+ */
984
+ export function txDropped(answer) {
985
+ const tx = answer;
986
+ return tx?.dropped === true || (tx !== null && typeof tx === "object" && tx.action === undefined && tx.dropped !== false);
987
+ }
158
988
  export class TxFailedError extends Error {
159
989
  id;
160
990
  reason;
161
991
  tx;
162
- constructor(id, reason, tx) {
163
- super(`tx ${id} failed: ${reason}`);
992
+ validUntil;
993
+ dropped;
994
+ constructor(id, reason, tx, validUntil) {
995
+ const dropped = txDropped(tx);
996
+ super(dropped
997
+ ? `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`
998
+ : `tx ${id} failed${typeof tx?.height === "number" ? ` in block ${tx.height}` : ""}: ${reason} (its fee was charged)`);
164
999
  this.id = id;
165
1000
  this.reason = reason;
166
1001
  this.tx = tx;
1002
+ this.validUntil = validUntil;
1003
+ this.dropped = dropped;
167
1004
  this.name = "TxFailedError";
168
1005
  }
169
1006
  }
1007
+ /**
1008
+ * The node accepted a transaction, but it wasn't seen finalized (or failed) in time. It may still
1009
+ * execute until its `valid_until`: check it with `client.tx(id)` before sending it again, or a
1010
+ * retry can pay twice.
1011
+ */
1012
+ export class TxPendingError extends Error {
1013
+ id;
1014
+ waitedMs;
1015
+ lastError;
1016
+ constructor(id, waitedMs, lastError) {
1017
+ 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`);
1018
+ this.id = id;
1019
+ this.waitedMs = waitedMs;
1020
+ this.lastError = lastError;
1021
+ this.name = "TxPendingError";
1022
+ }
1023
+ }
1024
+ /** 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. */
1025
+ export class RequestTimeoutError extends Error {
1026
+ rpc;
1027
+ path;
1028
+ timeoutMs;
1029
+ constructor(rpc, path, timeoutMs) {
1030
+ super(`${rpc} didn't answer within ${timeoutMs}ms (${path})`);
1031
+ this.rpc = rpc;
1032
+ this.path = path;
1033
+ this.timeoutMs = timeoutMs;
1034
+ this.name = "RequestTimeoutError";
1035
+ }
1036
+ }
1037
+ /** A proof older than the newest head seen (this node's and every witness's) by more than `maxLagBlocks`. */
1038
+ export class StaleProofError extends Error {
1039
+ height;
1040
+ head;
1041
+ maxLagBlocks;
1042
+ constructor(height, head, maxLagBlocks) {
1043
+ 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`);
1044
+ this.height = height;
1045
+ this.head = head;
1046
+ this.maxLagBlocks = maxLagBlocks;
1047
+ this.name = "StaleProofError";
1048
+ }
1049
+ }
1050
+ /**
1051
+ * On mainnet, a transaction would carry an address that only one node vouched for: a `.boo` name
1052
+ * resolved without witnesses, in a field that needs an address (staking's validator, the account
1053
+ * `actAs` acts for, a key grant's account). Nothing was signed (audit gap-7-1). Check
1054
+ * `resolution.address` with its owner and pass it instead of the name, or give the client witnesses.
1055
+ */
1056
+ export class UnprovenNameError extends Error {
1057
+ resolution;
1058
+ constructor(resolution, rpc) {
1059
+ 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`);
1060
+ this.resolution = resolution;
1061
+ this.name = "UnprovenNameError";
1062
+ }
1063
+ }
1064
+ /**
1065
+ * A node reports a height the chain can't have reached (`Client.checkSigningHeight`): a tx signed
1066
+ * against it could be held and released later. Nothing was signed.
1067
+ */
1068
+ export class HeightAheadError extends Error {
1069
+ rpc;
1070
+ height;
1071
+ limit;
1072
+ constructor(rpc, height, limit, by) {
1073
+ 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)`);
1074
+ this.rpc = rpc;
1075
+ this.height = height;
1076
+ this.limit = limit;
1077
+ this.name = "HeightAheadError";
1078
+ }
1079
+ }
1080
+ /**
1081
+ * How far ahead of the lowest head a client's witnesses report a tx's height may be, by default
1082
+ * (`ClientOptions.maxHeadLeadBlocks`): 30 blocks, room for propagation between nodes.
1083
+ */
1084
+ export const DEFAULT_MAX_HEAD_LEAD_BLOCKS = 30;
1085
+ /**
1086
+ * How far behind the network's clocks this machine's may be before `heightLimit` refuses an honest
1087
+ * node: 15 minutes (the CLI uses the same, node/src/cli_net.rs `HEIGHT_CLOCK_TOLERANCE_MS`).
1088
+ */
1089
+ export const HEIGHT_CLOCK_TOLERANCE_MS = 15 * 60 * 1000;
1090
+ /** How far ahead of their clocks validators accept a block's timestamp (node/src/consensus.rs `MAX_CLOCK_DRIFT_MS`). */
1091
+ const MAX_CLOCK_DRIFT_MS = 1_000;
1092
+ /**
1093
+ * The highest height a chain can have reached at `nowMs` (node/src/cli_net.rs `height_limit`):
1094
+ * every block's timestamp is at least its parent's plus the block time, and validators refuse one
1095
+ * more than a second ahead of their clocks, so block h is stamped no earlier than genesis + h ×
1096
+ * block time. A chain that ran slower than its block time is below this by that much, so it
1097
+ * refuses only heights the chain can't have, not every lie.
1098
+ */
1099
+ export function heightLimit(createdAtMs, blockTimeMs, nowMs) {
1100
+ return Math.floor(Math.max(0, nowMs + MAX_CLOCK_DRIFT_MS + HEIGHT_CLOCK_TOLERANCE_MS - createdAtMs) / Math.max(1, blockTimeMs));
1101
+ }
1102
+ /** The node reports a different chain than the client is pinned to (`ClientOptions`). */
1103
+ export class ChainMismatchError extends Error {
1104
+ rpc;
1105
+ field;
1106
+ expected;
1107
+ reported;
1108
+ constructor(rpc, field, expected, reported) {
1109
+ super(`${rpc} reports ${field} ${reported ?? "(none)"}, but this client is pinned to ${expected}; refusing to use it`);
1110
+ this.rpc = rpc;
1111
+ this.field = field;
1112
+ this.expected = expected;
1113
+ this.reported = reported;
1114
+ this.name = "ChainMismatchError";
1115
+ }
1116
+ }
1117
+ /** Default per-request timeout of a `Client`. */
1118
+ export const DEFAULT_TIMEOUT_MS = 15_000;
1119
+ /**
1120
+ * The longest timeout a `Client` takes: 2^31 − 1 ms (about 24.8 days). Timers store their delay
1121
+ * as a signed 32-bit number, and a longer one fires at once (audit review-fix-clients-1).
1122
+ */
1123
+ export const MAX_TIMEOUT_MS = 2_147_483_647;
1124
+ /** `ms` as a timeout: a whole number of milliseconds from 1 to `MAX_TIMEOUT_MS`, or an error naming `what`. */
1125
+ export function checkTimeoutMs(ms, what = "timeoutMs") {
1126
+ if (!Number.isSafeInteger(ms) || ms < 1 || ms > MAX_TIMEOUT_MS)
1127
+ throw new Error(`${what} must be a whole number of milliseconds from 1 to ${MAX_TIMEOUT_MS} (about 24.8 days)`);
1128
+ return ms;
1129
+ }
1130
+ /**
1131
+ * How far behind the newest head (the node's own and each witness's `/api/status` height) a
1132
+ * proven read's proof may be, by default: 10 blocks (10 s at 1 s blocks), room for propagation
1133
+ * between nodes. Older proofs are refused (`StaleProofError`), so a node can't answer with a
1134
+ * genuine but outdated record (a name's previous owner, a balance before a spend, a removed key).
1135
+ */
1136
+ export const DEFAULT_MAX_PROOF_LAG_BLOCKS = 10;
170
1137
  export class Client {
171
1138
  rpc;
172
- constructor(rpc = DEFAULT_RPC) {
1139
+ /** The chain's address prefix: given, learned from `/api/status`, or `boo` until then. */
1140
+ prefix;
1141
+ prefixKnown;
1142
+ pinnedPrefix;
1143
+ pinnedChainId;
1144
+ pinnedGenesis;
1145
+ /** Per-request timeout (ms). */
1146
+ timeoutMs;
1147
+ /** Independent nodes names are proven against (`ClientOptions.witnesses`). */
1148
+ witnesses;
1149
+ requireProvenNames;
1150
+ /** See `ClientOptions.maxHeadLeadBlocks`. */
1151
+ maxHeadLeadBlocks;
1152
+ /** See `ClientOptions.genesisClock`. */
1153
+ genesisClock;
1154
+ /** `opts` pins the chain: a node reporting another prefix, chain id or genesis hash is refused with `ChainMismatchError`. */
1155
+ constructor(rpc = DEFAULT_RPC, opts = {}) {
173
1156
  this.rpc = rpc;
1157
+ if (opts.prefix !== undefined && !KNOWN_PREFIXES.includes(opts.prefix))
1158
+ throw new Error(`unknown address prefix ${opts.prefix}`);
1159
+ if (opts.genesisHash !== undefined && !/^[0-9a-f]{64}$/i.test(opts.genesisHash))
1160
+ throw new Error("genesisHash must be 64 hex characters");
1161
+ this.timeoutMs = checkTimeoutMs(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
1162
+ this.witnesses = [...(opts.witnesses ?? [])];
1163
+ if (this.witnesses.some((w) => w === this || w.rpc.replace(/\/$/, "") === rpc.replace(/\/$/, "")))
1164
+ throw new Error("a witness must be another node, not the client's own");
1165
+ this.requireProvenNames = opts.requireProvenNames === true;
1166
+ if (this.requireProvenNames && this.witnesses.length === 0)
1167
+ throw new Error("requireProvenNames needs witnesses to prove names against");
1168
+ this.maxHeadLeadBlocks = opts.maxHeadLeadBlocks ?? DEFAULT_MAX_HEAD_LEAD_BLOCKS;
1169
+ if (!Number.isSafeInteger(this.maxHeadLeadBlocks) || this.maxHeadLeadBlocks < 0)
1170
+ throw new Error("maxHeadLeadBlocks must be a whole number of blocks");
1171
+ if (opts.genesisClock) {
1172
+ const { createdAtMs, blockTimeMs } = opts.genesisClock;
1173
+ if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0 || !Number.isSafeInteger(blockTimeMs) || blockTimeMs < 1)
1174
+ throw new Error("genesisClock needs the genesis's created_at_ms and block_time_ms (whole milliseconds)");
1175
+ this.genesisClock = { createdAtMs, blockTimeMs };
1176
+ }
1177
+ this.pinnedPrefix = opts.prefix;
1178
+ this.pinnedChainId = opts.chainId;
1179
+ this.pinnedGenesis = opts.genesisHash?.toLowerCase();
1180
+ this.prefix = opts.prefix ?? DEFAULT_PREFIX;
1181
+ this.prefixKnown = opts.prefix !== undefined;
1182
+ }
1183
+ /**
1184
+ * Refuses (`HeightAheadError`, before anything is signed) a height a tx's validity window would
1185
+ * count from that this client has reason to think the chain hasn't reached: more than
1186
+ * `maxHeadLeadBlocks` past the lowest head its witnesses report, or, with a `genesisClock`, past
1187
+ * what the chain can have reached by this machine's clock (`heightLimit`). A node reporting a
1188
+ * future height gets a tx it can hold and release later, when the window it picked opens; with
1189
+ * a name signed in it, after the name may have changed hands (audit review-r3-clients-2).
1190
+ * Every witness must report its height, as every witness must confirm a proof: one honest
1191
+ * witness is enough to refuse a height the chain hasn't reached, whatever the others say.
1192
+ * Without witnesses or a genesis clock, the height is the node's word.
1193
+ */
1194
+ async checkSigningHeight(height) {
1195
+ if (this.genesisClock) {
1196
+ const limit = heightLimit(this.genesisClock.createdAtMs, this.genesisClock.blockTimeMs, Date.now());
1197
+ if (height > limit)
1198
+ throw new HeightAheadError(this.rpc, height, limit, "this machine's clock and the chain's genesis");
1199
+ }
1200
+ if (!this.witnesses.length)
1201
+ return;
1202
+ const heads = await Promise.all(this.witnesses.map((w) => w.status().then((s) => (Number.isSafeInteger(s.height) && s.height >= 0 ? s.height : undefined), () => undefined)));
1203
+ const silent = this.witnesses.filter((_, i) => heads[i] === undefined);
1204
+ if (silent.length)
1205
+ 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`);
1206
+ const lowest = Math.min(...heads);
1207
+ if (height > lowest + this.maxHeadLeadBlocks)
1208
+ throw new HeightAheadError(this.rpc, height, lowest + this.maxHeadLeadBlocks, `the lowest head its witnesses report (#${lowest})`);
1209
+ }
1210
+ /** The chain's address prefix, asking the node once if it isn't known yet. */
1211
+ async addressPrefix() {
1212
+ if (!this.prefixKnown)
1213
+ await this.status();
1214
+ return this.prefix;
174
1215
  }
1216
+ /** One request, bounded by `timeoutMs` (headers and body): a node that never answers fails with `RequestTimeoutError` instead of hanging. */
175
1217
  async call(path, body) {
176
- const res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, body === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
177
- const text = await res.text();
1218
+ const ctl = new AbortController();
1219
+ const timer = setTimeout(() => ctl.abort(), this.timeoutMs);
1220
+ let res;
1221
+ let text;
1222
+ try {
1223
+ const init = body === undefined ? { signal: ctl.signal } : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: ctl.signal };
1224
+ res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, init);
1225
+ text = await res.text();
1226
+ }
1227
+ catch (e) {
1228
+ if (ctl.signal.aborted)
1229
+ throw new RequestTimeoutError(this.rpc, path, this.timeoutMs);
1230
+ throw e;
1231
+ }
1232
+ finally {
1233
+ clearTimeout(timer);
1234
+ }
178
1235
  let json;
179
1236
  try {
180
1237
  json = JSON.parse(text);
@@ -186,45 +1243,284 @@ export class Client {
186
1243
  throw new ApiError(res.status, json.error ?? res.statusText);
187
1244
  return json;
188
1245
  }
189
- status() { return this.call("/status"); }
1246
+ async status() {
1247
+ const s = await this.call("/status");
1248
+ if (this.pinnedChainId !== undefined && s.chain_id !== this.pinnedChainId)
1249
+ throw new ChainMismatchError(this.rpc, "chain_id", this.pinnedChainId, s.chain_id);
1250
+ if (this.pinnedGenesis !== undefined && String(s.genesis_hash ?? "").toLowerCase() !== this.pinnedGenesis)
1251
+ throw new ChainMismatchError(this.rpc, "genesis_hash", this.pinnedGenesis, s.genesis_hash);
1252
+ if (typeof s.address_prefix === "string") {
1253
+ if (this.pinnedPrefix !== undefined && s.address_prefix !== this.pinnedPrefix)
1254
+ throw new ChainMismatchError(this.rpc, "address_prefix", this.pinnedPrefix, s.address_prefix);
1255
+ this.prefix = s.address_prefix;
1256
+ this.prefixKnown = true;
1257
+ }
1258
+ return s;
1259
+ }
190
1260
  blocks(limit = 20) { return this.call(`/blocks?limit=${limit}`); }
191
1261
  block(heightOrHash) { return this.call(`/block/${heightOrHash}`); }
192
1262
  tx(id) { return this.call(`/tx/${id}`); }
193
1263
  account(addressOrName) { return this.call(`/account/${encodeURIComponent(addressOrName)}`); }
1264
+ /** 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. */
194
1265
  name(name) { return this.call(`/name/${encodeURIComponent(name)}`); }
1266
+ /** The base fee: each upcoming block's (`slots`) and `suggested`, which covers any of the next 17 blocks. */
1267
+ fees() { return this.call("/fees"); }
1268
+ /** The governance parameters (node/src/params.rs `ChainParams`; amounts as strings) under `params`, genesis constants under `genesis`, and the issuance year. `paramsOf` flattens it. */
1269
+ params() { return this.call("/params"); }
1270
+ /** The chain's genesis.json as this node serves it: the node's word (nothing here checks it against the genesis hash). */
1271
+ genesis() { return this.call("/genesis"); }
1272
+ /** 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. */
1273
+ upgrade() { return this.call("/upgrade"); }
195
1274
  /** Accounts a key is authorized on (its own address, plus any it was added to). */
196
- keyAccounts(keyIdHex) { return this.call(`/key/${keyIdHex}`); }
1275
+ /**
1276
+ * Accounts a key is on (`{ key_id, accounts, next }`), a page at a time: pass the previous page's
1277
+ * `next` as `after`. Only accounts the key agreed to join (its grant) can list it (audit gap-7-2).
1278
+ */
1279
+ keyAccounts(keyIdHex, page = {}) {
1280
+ const q = new URLSearchParams();
1281
+ if (page.after)
1282
+ q.set("after", page.after);
1283
+ if (page.limit !== undefined)
1284
+ q.set("limit", String(page.limit));
1285
+ return this.call(`/key/${keyIdHex}${q.size ? `?${q}` : ""}`);
1286
+ }
197
1287
  search(q) { return this.call(`/search/${encodeURIComponent(q)}`); }
198
- /** Active set for the current epoch, pending/approved validators, next-epoch set, epoch timing. */
1288
+ /** 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. */
199
1289
  validators() { return this.call("/validators"); }
1290
+ /** Hunts (bounties on GitHub issues), biggest first. */
1291
+ hunts(status) { return this.call(`/hunts${status ? `?status=${status}` : ""}`); }
1292
+ hunt(id) { return this.call(`/hunt/${id}`); }
1293
+ /** Off-chain check of a GitHub link (gist owned by the handle containing the address). */
1294
+ github(handle) { return this.call(`/github/${encodeURIComponent(handle)}`); }
1295
+ /** The contribution pool: balance, repos, rules, the open Séance and recent results. */
1296
+ contributions() { return this.call("/contributions"); }
1297
+ claims(opts = {}) {
1298
+ const q = new URLSearchParams(Object.entries(opts).filter(([, v]) => v !== undefined)).toString();
1299
+ return this.call(`/claims${q ? `?${q}` : ""}`);
1300
+ }
1301
+ claim(id) { return this.call(`/claim/${id}`); }
1302
+ /** A Séance, with each juror's assigned pairs (what they must judge). */
1303
+ seance(id) { return this.call(`/seance/${id}`); }
1304
+ /** A juror's assigned pairs in Séance `id`, in order, with each claim's repo and PR (what `sealVotes` binds). */
1305
+ async seancePairs(id, juror) {
1306
+ const s = await this.seance(id);
1307
+ const seat = (Array.isArray(s?.jurors) ? s.jurors : []).find((j) => j.juror?.address === juror);
1308
+ if (!seat)
1309
+ throw new Error(`${juror} isn't on the jury of Séance #${id}`);
1310
+ const claims = Array.isArray(s.claims) ? s.claims : [];
1311
+ const ref = (cid) => {
1312
+ const c = claims.find((x) => x.id === cid);
1313
+ if (!c)
1314
+ throw new Error(`Séance #${id} doesn't list claim #${cid}`);
1315
+ return { id: cid, repo: c.repo, pr: c.pr };
1316
+ };
1317
+ return (Array.isArray(seat.assigned_pairs) ? seat.assigned_pairs : []).map(([a, b]) => [ref(a), ref(b)]);
1318
+ }
1319
+ /** Soul leaderboard. */
1320
+ souls(limit = 50) { return this.call(`/souls?limit=${limit}`); }
1321
+ // ---- protocol v1 reads, as the node serves them; `stakeSummary` / `proposalSummary` / `paramsOf` read them into plain shapes. ----
1322
+ /** 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`. */
1323
+ staking(account) { return this.call(`/staking/${encodeURIComponent(account)}`); }
1324
+ /** Governance proposals, newest first (`status`: `voting`, `queued`, `rejected`, `executed`, `failed`), the rules (`params`), treasury and community balances. */
1325
+ proposals(opts = {}) {
1326
+ const q = new URLSearchParams(Object.entries(opts).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)])).toString();
1327
+ return this.call(`/proposals${q ? `?${q}` : ""}`);
1328
+ }
1329
+ /** One proposal with its votes. */
1330
+ proposal(id) { return this.call(`/proposal/${id}`); }
1331
+ /** A community distribution: Merkle root, total, claimed so far, claim deadline. */
1332
+ distribution(id) { return this.call(`/distribution/${id}`); }
1333
+ /** A multisig account: owners, threshold, open proposals and their approvals. */
1334
+ multisig(account) { return this.call(`/multisig/${encodeURIComponent(account)}`); }
1335
+ /** `/api/proof/{namespace}/{key}`, unchecked. Prefer `provenRecord`. */
1336
+ proof(namespace, key) {
1337
+ const k = typeof key === "string" ? utf8ToBytes(key) : key;
1338
+ return this.call(`/proof/${namespace}/${bytesToHex(k)}`);
1339
+ }
1340
+ /**
1341
+ * A record with its Merkle proof checked against the state root the node reports. On its own
1342
+ * that only shows the node is consistent; `witnesses` (clients for independent nodes) must
1343
+ * each report the same state root for that block, so a single lying node can't fake balances,
1344
+ * names or keys. A witness that hasn't reached the block yet is retried for `waitMs`.
1345
+ *
1346
+ * The proof must also be fresh: its block may be at most `maxLagBlocks` behind the newest head
1347
+ * this node and the witnesses report (`StaleProofError` otherwise). Witnesses confirm any
1348
+ * genuine root, old ones included, so without this a lying node could serve a real but outdated
1349
+ * record (a name's previous owner, a balance before a spend, a key since removed). `head` is
1350
+ * the newest head seen. Without witnesses only this node's own head is compared, which a lying
1351
+ * node controls: pass witnesses for anything that matters.
1352
+ */
1353
+ async provenRecord(namespace, key, opts = {}) {
1354
+ const maxLag = opts.maxLagBlocks ?? DEFAULT_MAX_PROOF_LAG_BLOCKS;
1355
+ if (!Number.isSafeInteger(maxLag) || maxLag < 0)
1356
+ throw new Error("maxLagBlocks must be a whole number of blocks");
1357
+ const witnesses = opts.witnesses ?? [];
1358
+ // Heads first, so the honest ones can only be behind the proof, never far ahead of it.
1359
+ const heads = await Promise.all([this, ...witnesses].map(async (c) => {
1360
+ const h = (await c.status()).height;
1361
+ if (!Number.isSafeInteger(h) || h < 0)
1362
+ throw new Error(`${c.rpc} doesn't report its height`);
1363
+ return h;
1364
+ }));
1365
+ const r = checkProofResponse(await this.proof(namespace, key), namespace, key);
1366
+ const head = Math.max(...heads);
1367
+ if (r.height + maxLag < head)
1368
+ throw new StaleProofError(r.height, head, maxLag);
1369
+ await Promise.all(witnesses.map((w) => w.confirmStateRoot(r.height, r.root, opts.waitMs ?? 5_000)));
1370
+ return { ...r, head };
1371
+ }
1372
+ /** Throws unless this node's block `height` has state root `root` (waiting up to `waitMs` for it to get there). */
1373
+ async confirmStateRoot(height, root, waitMs = 5_000) {
1374
+ const until = Date.now() + waitMs;
1375
+ for (;;) {
1376
+ try {
1377
+ const b = await this.block(height);
1378
+ if (b.state_root !== root)
1379
+ 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)}…`);
1380
+ return;
1381
+ }
1382
+ catch (e) {
1383
+ if (!(e instanceof ApiError && e.status === 404) || Date.now() > until)
1384
+ throw e;
1385
+ await new Promise((r) => setTimeout(r, 250));
1386
+ }
1387
+ }
1388
+ }
1389
+ /** An address's balance, nonce and keys, proven and fresh (see `provenRecord`). `null` if the account provably doesn't exist. */
1390
+ async provenAccount(address, opts = {}) {
1391
+ if (!isAddress(address))
1392
+ throw new Error(`${address} isn't an address`);
1393
+ const r = await this.provenRecord(NAMESPACES.ACCOUNTS, address, opts);
1394
+ return r.value === null ? null : { address, ...decodeAccount(r.value), height: r.height, root: r.root, head: r.head };
1395
+ }
1396
+ /**
1397
+ * Who a `.boo` name resolves to, proven and fresh (see `provenRecord`). `null` if it provably
1398
+ * doesn't resolve: never registered, or expired (`expires_at` passed; in its grace period only
1399
+ * the owner can renew it, and after that anyone can register it). `includeExpired` returns an
1400
+ * expired record too, with `active: false`: never pay its `owner`.
1401
+ */
1402
+ async provenName(name, opts = {}) {
1403
+ const r = await this.provenRecord(NAMESPACES.NAMES, bareName(name), opts);
1404
+ if (r.value === null)
1405
+ return null;
1406
+ const rec = decodeName(r.value);
1407
+ // node/src/econ.rs `name_active`: the next block (height + 1) must be at most `expires_at`.
1408
+ const active = r.height + 1 <= rec.expires_at;
1409
+ if (!active && !opts.includeExpired)
1410
+ return null;
1411
+ return { ...rec, active, height: r.height, root: r.root, head: r.head };
1412
+ }
1413
+ /**
1414
+ * The address a name or address resolves to, for fields that need an address (see
1415
+ * `resolution`): proven with witnesses, else one node's word. Where the chain resolves names
1416
+ * itself (payments, vouches, name offers…), use `recipient` instead, which signs the name.
1417
+ */
200
1418
  async resolve(nameOrAddress) {
201
- if (isAddress(nameOrAddress))
202
- return nameOrAddress;
203
- if (/^boo1/i.test(nameOrAddress.trim()))
204
- throw new Error(`${nameOrAddress} isn't a valid address (check for typos; addresses are lowercase)`);
205
- const r = await this.name(nameOrAddress);
206
- if (r.available)
1419
+ return (await this.resolution(nameOrAddress)).address;
1420
+ }
1421
+ /**
1422
+ * How to sign a recipient in a field the chain resolves itself (`transfer.to`, `award_hunt.to`,
1423
+ * `vouch`/`unvouch`/`flag_sybil`/`clawback.account`, `grant_soul.to`, `transfer_name.to`, and
1424
+ * proposal targets): an address as given; with witnesses a name's proven owner; without them
1425
+ * the name itself, which the chain resolves to its owner when the tx runs, so a lying node
1426
+ * can't point it at another account now (audit gap-7-1). It can still hold the signed tx and
1427
+ * release it later within its validity window, which counts from the height that node reported
1428
+ * (bounded by `checkSigningHeight` only with witnesses or a `genesisClock`): whoever owns the
1429
+ * name then is paid, so a name that lapses in between pays its next owner (review-r3-clients-2).
1430
+ * Show `how` before signing.
1431
+ */
1432
+ async recipient(nameOrAddress) {
1433
+ const r = await this.resolution(nameOrAddress);
1434
+ if (r.name === null || r.proven)
1435
+ return { ...r, signed: r.address, byChain: false };
1436
+ return {
1437
+ ...r,
1438
+ signed: `${r.name}.boo`,
1439
+ byChain: true,
1440
+ 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)`,
1441
+ };
1442
+ }
1443
+ /**
1444
+ * Where a recipient goes and how sure that is: show `address` and `how` before signing. With the
1445
+ * client's `witnesses` a name is a proven, fresh read (`provenName`), so a lying node can't
1446
+ * point it elsewhere; without them it's this node's answer alone (`proven: false`), which a
1447
+ * lying node controls (audit gap-7-1).
1448
+ */
1449
+ async resolution(nameOrAddress) {
1450
+ const input = nameOrAddress;
1451
+ if (isAddress(input))
1452
+ return { input, address: input, name: null, proven: true, how: "an address, signed as given" };
1453
+ if (/^t?boo1/i.test(input.trim()))
1454
+ throw new Error(`${input} isn't a valid address (check for typos; addresses are lowercase)`);
1455
+ const name = bareName(input);
1456
+ if (this.witnesses.length) {
1457
+ const rec = await this.provenName(name, { witnesses: this.witnesses });
1458
+ if (!rec)
1459
+ 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"})`);
1460
+ if (!isAddress(rec.owner))
1461
+ throw new Error(`${name}.boo's record names ${rec.owner}, which isn't an address`);
1462
+ 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 };
1463
+ }
1464
+ if (this.requireProvenNames)
1465
+ throw new Error(`${name}.boo can't be proven: this client has no witnesses`);
1466
+ const r = await this.name(name);
1467
+ if (r.status === "grace")
1468
+ throw new Error(`${r.name} has expired (at block ${r.expires_at})`);
1469
+ if (r.available || !r.owner)
207
1470
  throw new Error(`${r.name} isn't registered`);
208
- return r.owner.address;
1471
+ const address = String(r.owner.address ?? "");
1472
+ if (!isAddress(address))
1473
+ throw new Error(`${this.rpc} resolved ${name}.boo to ${JSON.stringify(address)}, which isn't an address`);
1474
+ 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)` };
209
1475
  }
1476
+ /**
1477
+ * Submits a signed tx, then (with `wait`) waits for it. What a failure means:
1478
+ * - `ApiError`: the node refused the tx; it wasn't admitted.
1479
+ * - `RequestTimeoutError` or a network error: unknown; the tx may have arrived.
1480
+ * - `TxFailedError`: it executed and failed (`dropped` false: its fee was charged), or this node
1481
+ * dropped it unrun (`dropped` true: nothing charged, but it may still execute elsewhere until
1482
+ * its `valid_until`).
1483
+ * - `TxPendingError`: it was admitted and may still execute. Don't send it again blindly.
1484
+ */
210
1485
  async submit(tx, wait = true) {
211
1486
  const { id } = await this.call("/tx", tx);
212
- return wait ? this.waitForTx(id) : this.tx(id);
1487
+ if (wait)
1488
+ return this.waitForTx(id, undefined, tx.valid_until);
1489
+ try {
1490
+ return await this.tx(id);
1491
+ }
1492
+ catch (e) {
1493
+ throw new TxPendingError(id, 0, e);
1494
+ }
213
1495
  }
214
1496
  async faucet(to, wait = true) {
215
1497
  const { id } = await this.call("/faucet", { to });
216
1498
  return wait ? this.waitForTx(id) : this.tx(id);
217
1499
  }
218
- async waitForTx(id, timeoutMs = 15_000) {
1500
+ /**
1501
+ * Polls a tx until it's finalized (returns it) or failed (`TxFailedError`, which says whether it
1502
+ * executed or was dropped unrun). A poll that fails (rate limited, a node error, a timeout, not
1503
+ * visible yet) is retried; after `timeoutMs` it throws `TxPendingError`, since the tx may still
1504
+ * execute. `validUntil` (the tx's, when the caller has it) goes into a dropped tx's error.
1505
+ */
1506
+ async waitForTx(id, timeoutMs = 15_000, validUntil) {
219
1507
  const start = Date.now();
1508
+ let lastError;
220
1509
  for (;;) {
221
- const tx = await this.tx(id);
222
- if (tx.status === "finalized")
223
- return tx;
224
- if (tx.status === "failed")
225
- throw new TxFailedError(id, tx.error ?? "unknown reason", tx);
1510
+ try {
1511
+ const tx = await this.tx(id);
1512
+ if (tx.status === "finalized")
1513
+ return tx;
1514
+ if (tx.status === "failed")
1515
+ throw new TxFailedError(id, tx.error ?? "unknown reason", tx, validUntil);
1516
+ }
1517
+ catch (e) {
1518
+ if (e instanceof TxFailedError || (e instanceof ApiError && e.status === 400))
1519
+ throw e;
1520
+ lastError = e;
1521
+ }
226
1522
  if (Date.now() - start > timeoutMs)
227
- throw new Error(`tx ${id} still pending after ${timeoutMs}ms`);
1523
+ throw new TxPendingError(id, timeoutMs, lastError);
228
1524
  await new Promise((r) => setTimeout(r, 250));
229
1525
  }
230
1526
  }