@blockula/sdk 0.5.1 → 1.0.2

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
@@ -2,14 +2,19 @@ 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
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";
5
+ import { DEFAULT_TIP_CAP_MULTIPLE, KEY_POP_CONTEXT, PUBLIC_KEY_LEN, SIGNATURE_LEN, actionDigest, baseFee, formatFang, interfaceHash, keyPopMessage, maxTip, parseFang, parseSoul, programAddress, proposalDigest, seanceCommitment, seancePairsDigest, signingBytes, txBytes, txId, versionDigest, wasmHash } from "./tx.js";
6
+ import { NAMESPACES, checkProofResponse, decodeAccount, decodeName, decodeProgram } from "./proof.js";
7
+ import { ANY_PROGRAM_VERSION, MAX_PROGRAM_BINDINGS, MAX_PROGRAM_CALLS, MAX_PROGRAM_CODE_BYTES, MAX_PROGRAM_INTERFACE_BYTES, ProgramAnswerError, ProgramsOffError, defaultFuelLimit, depositWithMargin, plainText, positiveFuel, programAddressArg, programBytesArg, programHashArg, programInputArg, programMethodArg, checkCode, checkFuel, checkInterface, checkParams, checkProgram, checkProgramsStatus, checkReceipt, checkView, outcomeWords, priceFuel, programCost, programRecordDeposit, programUploadDeposit, reserveFuel, uploadFuel } from "./programs.js";
7
8
  import { describeKind, reviewAction } from "./describe.js";
9
+ import { NETWORKS, TESTNET_RETIRED, isNetworkName } from "./networks.js";
8
10
  export * from "./tx.js";
9
11
  export * from "./proof.js";
10
12
  export * from "./distribution.js";
11
13
  export * from "./release.js";
12
14
  export * from "./describe.js";
15
+ export * from "./networks.js";
16
+ export * from "./payments.js";
17
+ export * from "./programs.js";
13
18
  export const DEFAULT_RPC = "http://localhost:7070";
14
19
  /** Address prefixes: `boo` on mainnet (and dev chains), `tboo` on public testnets. */
15
20
  export const KNOWN_PREFIXES = ["boo", "tboo"];
@@ -58,6 +63,17 @@ const wholeNumber = (n, what) => {
58
63
  throw new Error(`${what} must be a whole number`);
59
64
  return n;
60
65
  };
66
+ /** `n` as drops a transaction can carry: 0 to 2^64 − 1. */
67
+ const checkedDrops = (n, what) => {
68
+ if (n < 0n || n >= 1n << 64n)
69
+ throw new Error(`${what}: amount too large`);
70
+ return n;
71
+ };
72
+ /** How long a program helper waits by default for what it needs to be usable: a minute of blocks. */
73
+ const DEFAULT_PROGRAM_WAIT_MS = 60_000;
74
+ /** The program actions that reserve fuel (tags 62–64) and the helper that prices it: `build()` and the generic paths refuse them, naming it. */
75
+ const PROGRAM_HELPERS = new Map([["upload_code", "wallet.uploadCode"], ["create_program", "wallet.createProgram"], ["call_program", "wallet.callProgram (or wallet.call)"]]);
76
+ const programWaitMs = (ms) => (ms === undefined ? DEFAULT_PROGRAM_WAIT_MS : checkTimeoutMs(ms, "waitMs"));
61
77
  const hexArg = (s, what) => {
62
78
  const h = s.trim().toLowerCase();
63
79
  if (!/^[0-9a-f]{64}$/.test(h))
@@ -289,6 +305,10 @@ export function proposalSummary(raw, rules) {
289
305
  threshold_bps: supermajority ? rules?.supermajority_bps : undefined,
290
306
  };
291
307
  }
308
+ /** The most `feeHeadroom` a tx may ask for: 100 × the base fee. */
309
+ export const MAX_FEE_HEADROOM = 100;
310
+ /** The least a payment that opens an account must carry: 0.01 FANG (node/src/types.rs `MIN_NEW_ACCOUNT`). */
311
+ export const MIN_NEW_ACCOUNT = 10000000n;
292
312
  const GRANT_PREFIX = "bkgrant1";
293
313
  /** A grant as one pasteable string: `bkgrant1.<account>.<base64url(public key ‖ pop)>`. */
294
314
  export function encodeKeyGrant(g) {
@@ -346,7 +366,16 @@ export class Wallet {
346
366
  tip = 0n;
347
367
  /** Default validity (blocks from now) for this wallet's transactions. */
348
368
  validForBlocks = DEFAULT_VALID_FOR_BLOCKS;
369
+ /** Default `feeHeadroom` for this wallet's transactions (1: none). See `TxOptions.feeHeadroom`. */
370
+ feeHeadroom = 1;
349
371
  secretKey;
372
+ /**
373
+ * This wallet's txs are signed and submitted one at a time, in nonce order (DEV-3): parallel
374
+ * `send`s from one wallet would otherwise read the same next nonce and all but one be refused.
375
+ */
376
+ queue = Promise.resolve();
377
+ /** The nonce after the last tx this wallet submitted, for one chain and account, and that tx. */
378
+ cursor;
350
379
  acting;
351
380
  /** How `actAs` found the account, when that was one node's unproven word (checked when signing on mainnet). */
352
381
  actingUnproven;
@@ -381,6 +410,11 @@ export class Wallet {
381
410
  */
382
411
  async actAs(account) {
383
412
  const r = await this.addressNeeded(await this.client.resolution(account));
413
+ // An address with another chain's prefix (a relaunched testnet's `boo1…` on a `tboo1…` chain)
414
+ // isn't an account here: every tx from it, and a faucet drip to it, would be refused.
415
+ const prefix = await this.client.addressPrefix();
416
+ if (!isAddress(r.address, prefix))
417
+ throw new Error(`${r.address} is an address on another chain: this chain's addresses start with ${prefix}1`);
384
418
  this.acting = r.address;
385
419
  this.actingUnproven = r.proven ? undefined : r;
386
420
  return this;
@@ -423,6 +457,8 @@ export class Wallet {
423
457
  const status = await this.client.status();
424
458
  if (typeof status.genesis_hash !== "string")
425
459
  throw new Error(`${this.client.rpc} doesn't report a genesis_hash; it's too old for this SDK`);
460
+ // A `chain` given by the caller names the chain itself; one read from the node is checked like a signing status.
461
+ this.client.checkMainnetPin(status.chain_id);
426
462
  c = { chainId: status.chain_id, genesisHash: status.genesis_hash };
427
463
  }
428
464
  if (res && !res.proven && c.chainId === MAINNET_CHAIN_ID)
@@ -479,6 +515,8 @@ export class Wallet {
479
515
  const [status, account, fees] = await Promise.all([this.client.status(), this.client.account(this.account).catch(() => null), this.client.fees()]);
480
516
  if (!Number.isSafeInteger(status.height) || status.height < 0)
481
517
  throw new Error(`${this.client.rpc} doesn't report its height`);
518
+ // blockula-1 only through a client pinned to it (DEV-1), checked against the very status the tx is signed for.
519
+ this.client.checkMainnetPin(status.chain_id);
482
520
  await this.client.checkSigningHeight(status.height);
483
521
  const max = status.max_tx_validity_blocks ?? Number.MAX_SAFE_INTEGER;
484
522
  const validFor = opts.validForBlocks ?? Math.min(this.validForBlocks, max);
@@ -494,17 +532,116 @@ export class Wallet {
494
532
  throw new UnprovenNameError(guess, this.client.rpc);
495
533
  // Binds the tx to this chain instance (not just its id), so it can't replay on a relaunch.
496
534
  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();
535
+ // A program action reserves fuel and pays for it (§5.1): only the helpers below price that, so this generic path refuses it.
536
+ const helper = PROGRAM_HELPERS.get(act.type);
537
+ if (helper && !opts.reserved)
538
+ throw new Error(`a ${act.type} reserves fuel, which this generic builder doesn't price (its fee would be too low and the chain would refuse it): use ${helper}. Nothing was signed`);
539
+ const nonce = await this.nonceFor(cursorKey(status.chain_id, status.genesis_hash, this.account), Number(account?.next_nonce ?? 0), status.height);
540
+ const unsigned = { chain_id: status.chain_id, genesis_hash: status.genesis_hash, from: this.account, nonce, fee: "0", tip: tip.toString(), valid_until: status.height + validFor, action: act, memo };
541
+ // A program transaction also pays for the fuel it reserves (§5.1): at the rate the next blocks can reach, from this very read
542
+ // of the node's prices, which also refuses a reservation no block could carry (programs.ts `priceFuel`).
543
+ const fuel = opts.reserved ? priceFuel(checkFuel(this.client.rpc, fees.fuel), opts.reserved) : { suggested: 0n, floor: 0n };
544
+ unsigned.fee = this.feeFor(unsigned, fees.suggested, tip, opts, fuel.suggested).toString();
499
545
  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.
546
+ // The cap is a multiple of the base fee of the block that includes the tx (its bytes' and its fuel's): price it at the lowest upcoming
547
+ // one, and a program's fuel at the floor rate, which no block undercuts.
501
548
  const multiple = fees.tip_cap_multiple ?? DEFAULT_TIP_CAP_MULTIPLE;
502
- const cap = maxTip(unsigned, fees.lowest ?? fees.suggested, multiple);
549
+ const cap = maxTip(unsigned, fees.lowest ?? fees.suggested, multiple) + BigInt(multiple) * fuel.floor;
503
550
  if (tip > cap)
504
551
  throw new Error(`tip too high: at most ${multiple}× the base fee, ${cap} drops for this transaction now`);
505
552
  }
506
553
  return this.sign(unsigned);
507
554
  }
555
+ /**
556
+ * A tx's `fee`: the base fee at `suggested` for its size (and, for a program transaction, its
557
+ * fuel fee at the suggested rate: `fuelFee`), times the headroom, plus the tip; or `maxFee`
558
+ * outright, which must cover at least that base fee plus the tip.
559
+ */
560
+ feeFor(unsigned, suggested, tip, opts, fuelFee = 0n) {
561
+ const base = baseFee(unsigned, suggested) + fuelFee;
562
+ if (opts.maxFee !== undefined) {
563
+ const fee = parseFang(opts.maxFee);
564
+ if (fee < base + tip)
565
+ throw new Error(`maxFee ${formatFang(fee)} is below what this transaction costs now: ${formatFang(base + tip)} (base fee ${formatFang(base)} + tip ${formatFang(tip)})`);
566
+ if (fee >= 1n << 64n)
567
+ throw new Error("maxFee: amount too large");
568
+ return fee;
569
+ }
570
+ const headroom = opts.feeHeadroom ?? this.feeHeadroom;
571
+ if (typeof headroom !== "number" || !Number.isFinite(headroom) || headroom < 1 || headroom > MAX_FEE_HEADROOM)
572
+ throw new Error(`feeHeadroom must be a number from 1 to ${MAX_FEE_HEADROOM} (times the base fee)`);
573
+ // In thousandths, rounded up, so 1.5 is exactly 1.5× and nothing rounds below the base fee.
574
+ const milli = BigInt(Math.ceil(headroom * 1000));
575
+ return (base * milli + 999n) / 1000n + tip;
576
+ }
577
+ /**
578
+ * The nonce for this wallet's next tx on chain/account `key`: the node's `next_nonce` (which
579
+ * counts the txs it holds), or one past the last tx this wallet submitted when that's higher
580
+ * and the tx may still run: still pending on its way through the network, or not yet counted
581
+ * by a node writing a block (DEV-3). A tx that was dropped, or can't run any more
582
+ * (`valid_until` passed), gives its nonce back: the node's word is used again. A nonce handed to
583
+ * a tx a node admitted is never handed out again while that tx may run.
584
+ */
585
+ async nonceFor(key, nodeNext, height) {
586
+ const c = this.cursor;
587
+ if (!c || c.key !== key || c.next <= nodeNext)
588
+ return nodeNext;
589
+ if (height <= c.validUntil) {
590
+ const last = await this.client.tx(c.lastId).catch(() => null);
591
+ // Unknown here (another node holds it, or it never arrived): kept, so a tx that may run isn't doubled.
592
+ if (!last || last.status === "pending" || last.status === "finalized" || (last.status === "failed" && !txDropped(last)))
593
+ return c.next;
594
+ }
595
+ if (this.cursor === c)
596
+ this.cursor = undefined;
597
+ return nodeNext;
598
+ }
599
+ /** Runs `job` after every job queued before it (its outcome doesn't stop the next one). */
600
+ enqueue(job) {
601
+ const run = this.queue.then(job, job);
602
+ this.queue = run.catch(() => undefined);
603
+ return run;
604
+ }
605
+ /**
606
+ * Builds, signs and submits one tx in this wallet's queue (`enqueue`): one at a time, so each
607
+ * is admitted before the next is signed, in nonce order (DEV-3). Waits for it after leaving the
608
+ * queue, so parallel calls are only serialized for the moment it takes to sign and submit.
609
+ *
610
+ * What a failure does to the next nonce: refused by the node (a 4xx answer: it wasn't admitted),
611
+ * the nonce is used for the next tx, which can't be paid twice since only one tx per nonce ever
612
+ * runs; on "bad nonce" the same signed bytes are tried again once (a node writing a block can
613
+ * misjudge a correct nonce for a moment), then the node's next nonce is used. Anything else
614
+ * (a timeout, a 5xx, a broken connection) may have been admitted, so its nonce isn't reused.
615
+ */
616
+ async submitQueued(action, memo, opts, unproven, retry = NO_RETRY) {
617
+ const { tx, id } = await this.enqueue(async () => {
618
+ const tx = await this.buildChecked(action, memo, opts, unproven);
619
+ // Stored before it leaves (store first, then send); a store that fails submits nothing.
620
+ if (opts.onSigned)
621
+ await opts.onSigned(tx, txId(tx));
622
+ const key = cursorKey(tx.chain_id, tx.genesis_hash, tx.from);
623
+ try {
624
+ const { id } = await postPatiently(this.client, tx, retry);
625
+ this.cursor = { key, next: tx.nonce + 1, lastId: id, validUntil: tx.valid_until };
626
+ return { tx, id };
627
+ }
628
+ catch (e) {
629
+ if (!refused(e))
630
+ this.cursor = { key, next: tx.nonce + 1, lastId: txId(tx), validUntil: tx.valid_until };
631
+ else if (/bad nonce/i.test(e.message) && this.cursor?.key === key)
632
+ this.cursor = undefined;
633
+ throw e;
634
+ }
635
+ });
636
+ if (opts.wait ?? true)
637
+ return this.client.waitForTx(id, undefined, tx.valid_until);
638
+ try {
639
+ return await this.client.tx(id);
640
+ }
641
+ catch (e) {
642
+ throw new TxPendingError(id, 0, e);
643
+ }
644
+ }
508
645
  /**
509
646
  * Send FANG to a `.boo` name or an address. `amount` is in FANG ("1.5") or bigint drops. A name
510
647
  * is signed as its proven owner's address when the client has witnesses (who you checked is who
@@ -515,20 +652,156 @@ export class Wallet {
515
652
  * and been registered again (audit gap-7-1, review-r3-clients-2). See `Client.recipient`.
516
653
  */
517
654
  async send(to, amount, opts = {}) {
655
+ return this.pay(to, amount, opts);
656
+ }
657
+ /**
658
+ * `send`, with the batch's retry policy. An amount of 0 is refused before anything is signed
659
+ * (DEV-14): it moves nothing and only pays a fee. A payment that opens an account must carry at
660
+ * least 0.01 FANG and burns `creation_fee` (0.1 FANG) from this account on top; the result of a
661
+ * finalized payment that did says so (`opened_account`, `creation_fee`), since its `summary` and
662
+ * `fee` don't (DEV-7).
663
+ */
664
+ async pay(to, amount, opts, retry = NO_RETRY) {
665
+ const drops = positive(parseFang(amount), "send");
666
+ const recipient = await this.client.recipient(to);
667
+ const opening = await this.openingCost(recipient.address);
668
+ if (opening?.opens && drops < MIN_NEW_ACCOUNT)
669
+ throw new Error(`sending to a new account needs at least ${formatFang(MIN_NEW_ACCOUNT)}: ${recipient.input} has no account yet. Nothing was signed`);
670
+ const tx = await this.submitQueued({ type: "transfer", to: recipient.signed, amount: drops.toString() }, opts.memo ?? "", opts, [], retry);
671
+ if (opening?.opens && opening.fee > 0n && tx?.status === "finalized" && (await this.opened(recipient.address, tx))) {
672
+ return { ...tx, opened_account: true, creation_fee: opening.fee.toString(), creation_fee_display: formatFang(opening.fee) };
673
+ }
674
+ return tx;
675
+ }
676
+ /**
677
+ * Whether a payment to `address` would open its account, and what that burns from the payer
678
+ * (`/api/account`'s `exists` and `creation_fee`; on nodes without them, an account with no
679
+ * balance and no nonce counts as new). `undefined` when the node didn't say.
680
+ */
681
+ async openingCost(address) {
682
+ const a = await this.client.account(address).catch(() => null);
683
+ if (!a || typeof a !== "object")
684
+ return undefined;
685
+ const opens = typeof a.exists === "boolean" ? !a.exists : BigInt(drops(a.balance)) === 0n && !Number(a.nonce ?? 0);
686
+ if (!opens)
687
+ return { opens, fee: 0n };
688
+ const fee = /^\d+$/.test(String(a.creation_fee ?? "")) ? BigInt(a.creation_fee) : BigInt(drops((await this.client.fees().catch(() => ({}))).account_creation_fee));
689
+ return { opens, fee };
690
+ }
691
+ /**
692
+ * Whether finalized payment `tx` opened `address`'s account (it had none when the payment was
693
+ * signed): no other finalized tx naming it ran before this one. From its newest history, so a
694
+ * flood of more than 25 txs to a brand-new account within seconds can hide an earlier one.
695
+ */
696
+ async opened(address, tx) {
697
+ const a = await this.client.account(address).catch(() => null);
698
+ const height = tx.height;
699
+ if (!a || typeof height !== "number")
700
+ return true;
701
+ const before = arr(a.recent_txs).filter((t) => t?.id !== tx.id && t?.status === "finalized" && typeof t.height === "number" && t.height <= height && addressOf(t.to) === address);
702
+ if (before.some((t) => t.height < height))
703
+ return false;
704
+ if (!before.length)
705
+ return true;
706
+ // Same block: whichever ran first opened it.
707
+ const block = await this.client.block(height).catch(() => null);
708
+ const ids = arr(block?.txs).map((t) => String(t?.id));
709
+ const mine = ids.indexOf(String(tx.id));
710
+ return mine >= 0 && !before.some((t) => ids.indexOf(String(t.id)) >= 0 && ids.indexOf(String(t.id)) < mine);
711
+ }
712
+ /**
713
+ * What paying `amount` to `to` would cost this account, before anything is signed (DEV-7):
714
+ * - `fee`: the base fee for this payment's size at what `/api/fees` fixes for the next 17 blocks
715
+ * (`suggested`), plus the tip. On a chain that isn't busy, what it's charged;
716
+ * - `maxFee`: the most it can be charged (`fee`, or more with `feeHeadroom` / `maxFee`);
717
+ * - `creationFee`: burned from this account when the payment opens the recipient's account
718
+ * (0.1 FANG on chains with genesis defaults), on top of `amount`;
719
+ * - `total`: `amount + fee + creationFee`;
720
+ * - `transferableAfter`: what this account could still move afterwards, counting `maxFee`
721
+ * (negative: it can't pay this). Amounts are bigint drops; `display` has them as FANG text.
722
+ * `recipient` is how `to` gets signed (`Client.recipient`): show `recipient.how` before paying.
723
+ * Refuses (throws) a 0 amount, and less than 0.01 FANG to an account that doesn't exist yet.
724
+ */
725
+ async quote(to, amount, opts = {}) {
726
+ const value = positive(parseFang(amount), "send");
727
+ await this.client.addressPrefix();
518
728
  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);
520
- return this.client.submit(tx, opts.wait ?? true);
729
+ const [status, fees, from, opening] = await Promise.all([this.client.status(), this.client.fees(), this.client.account(this.account), this.openingCost(recipient.address)]);
730
+ if (!opening)
731
+ throw new Error(`${this.client.rpc} didn't say whether ${recipient.address} has an account, so the cost of paying it can't be quoted`);
732
+ if (opening.opens && value < MIN_NEW_ACCOUNT)
733
+ throw new Error(`sending to a new account needs at least ${formatFang(MIN_NEW_ACCOUNT)}: ${recipient.input} has no account yet`);
734
+ const tip = opts.tip === undefined ? this.tip : parseFang(opts.tip);
735
+ const unsigned = { chain_id: String(status.chain_id), genesis_hash: String(status.genesis_hash), from: this.account, nonce: Number(from?.next_nonce ?? 0), fee: "0", tip: tip.toString(), valid_until: Number(status.height) + (opts.validForBlocks ?? this.validForBlocks), action: { type: "transfer", to: recipient.signed, amount: value.toString() }, memo: opts.memo ?? "" };
736
+ const fee = baseFee(unsigned, fees.suggested) + tip;
737
+ const maxFee = this.feeFor(unsigned, fees.suggested, tip, opts);
738
+ const transferable = BigInt(drops(from?.transferable ?? from?.balance));
739
+ const total = value + fee + opening.fee;
740
+ const transferableAfter = transferable - (value + maxFee + opening.fee);
741
+ return {
742
+ amount: value,
743
+ fee,
744
+ maxFee,
745
+ creationFee: opening.fee,
746
+ total,
747
+ transferableAfter,
748
+ enough: transferableAfter >= 0n,
749
+ opensAccount: opening.opens,
750
+ recipient,
751
+ display: { amount: formatFang(value), fee: formatFang(fee), maxFee: formatFang(maxFee), creationFee: formatFang(opening.fee), total: formatFang(total), transferableAfter: transferableAfter < 0n ? `-${formatFang(-transferableAfter)}` : formatFang(transferableAfter) },
752
+ };
753
+ }
754
+ /**
755
+ * Many payments from this account (TR-5), with at most `concurrency` (default 16, at most 64,
756
+ * the chain's limit of pending txs per sender) waiting to finalize at once. They're signed in
757
+ * turn, one nonce after the other. A node that answers 429 (rate limited) or "too many pending
758
+ * transactions" gets the same signed bytes again once it says to retry (`Retry-After`), for up
759
+ * to `patienceMs` (default 2 minutes); resubmitting the same bytes can't pay twice.
760
+ *
761
+ * Every payment gets a result, in order: `{ ok: true, tx }` or `{ ok: false, error }`. A refused
762
+ * payment (the node said no: insufficient balance, a bad name) used no nonce, and the rest go on.
763
+ * A payment that failed for another reason (`TxPendingError`, `TxFailedError` with `dropped`, a
764
+ * timeout) may still run: check it (`client.tx`) before paying it again, and note that when the
765
+ * chain drops a tx it drops every later one from the account too. `onSigned` is called with
766
+ * each signed tx before it's submitted, as with `send`.
767
+ */
768
+ async sendMany(payments, opts = {}) {
769
+ const concurrency = opts.concurrency ?? 16;
770
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > MAX_PENDING_PER_SENDER)
771
+ throw new Error(`concurrency must be a whole number from 1 to ${MAX_PENDING_PER_SENDER}`);
772
+ const patienceMs = opts.patienceMs ?? 120_000;
773
+ if (!Number.isSafeInteger(patienceMs) || patienceMs < 0)
774
+ throw new Error("patienceMs must be a whole number of milliseconds");
775
+ // Every amount is checked before the first is signed.
776
+ for (const [i, p] of payments.entries())
777
+ positive(parseFang(p.amount), `payment ${i + 1}: send`);
778
+ const results = new Array(payments.length);
779
+ let next = 0;
780
+ const worker = async () => {
781
+ while (next < payments.length) {
782
+ const i = next++;
783
+ const p = payments[i];
784
+ const { to, amount, ...txOpts } = p;
785
+ try {
786
+ results[i] = { ok: true, to, amount, tx: await this.pay(to, amount, { ...opts, ...txOpts }, { untilMs: patienceMs }) };
787
+ }
788
+ catch (error) {
789
+ results[i] = { ok: false, to, amount, error };
790
+ }
791
+ }
792
+ };
793
+ await Promise.all(Array.from({ length: Math.min(concurrency, payments.length) }, worker));
794
+ return results;
521
795
  }
522
796
  async registerName(name, opts = {}) {
523
- const tx = await this.build({ type: "register_name", name }, "", opts);
524
- return this.client.submit(tx, opts.wait ?? true);
797
+ return this.submitQueued({ type: "register_name", name }, "", opts, []);
525
798
  }
526
799
  /**
527
800
  * Authorize another device's key (e.g. a second passkey) with full control. `grant` is the
528
801
  * key's consent to this account (`keyGrant` on that device, or its grant code).
529
802
  */
530
803
  async addOwnerKey(grant, label) {
531
- return this.client.submit(await this.build(await this.grantAction(grant, label, { type: "owner" })));
804
+ return this.submitQueued(await this.grantAction(grant, label, { type: "owner" }), "", {}, []);
532
805
  }
533
806
  /**
534
807
  * Let an agent/app key spend from this account, transfers only, within a limit. `grant` is the
@@ -543,10 +816,10 @@ export class Wallet {
543
816
  const limit = parseFang(opts.limit).toString();
544
817
  const base = await this.grantAction(grant, label, { type: "owner" });
545
818
  // 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 } })));
819
+ return this.submitQueued((height) => ({ ...base, role: { type: "session", limit, period_blocks: opts.periodBlocks, expires_at: height + opts.expiresInBlocks } }), "", {}, []);
547
820
  }
548
821
  async removeKey(keyIdHex) {
549
- return this.client.submit(await this.build({ type: "remove_key", key_id: keyIdHex }));
822
+ return this.submitQueued({ type: "remove_key", key_id: keyIdHex }, "", {}, []);
550
823
  }
551
824
  /**
552
825
  * Register a validator owned by this account, self-bonding `bond` FANG into its stake pool (at
@@ -555,22 +828,22 @@ export class Wallet {
555
828
  * each epoch boundary the top `max_validators` pools by stake form the next active set.
556
829
  */
557
830
  async registerValidator(opts) {
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 }));
831
+ return this.submitQueued({ type: "register_validator", consensus_key: opts.consensusKey, p2p_addr: opts.p2pAddr, http_addr: opts.httpAddr, bond: parseFang(opts.bond).toString(), pop: opts.pop }, "", {}, []);
559
832
  }
560
833
  /** Dev and test chains with admins only: pin a validator into the set ahead of stake. */
561
834
  async approveValidator(validator) {
562
- return this.client.submit(await this.build({ type: "approve_validator", validator }));
835
+ return this.submitQueued({ type: "approve_validator", validator }, "", {}, []);
563
836
  }
564
837
  /** Dev and test chains with admins only: take a validator out of the set for good. */
565
838
  async removeValidator(validator) {
566
- return this.client.submit(await this.build({ type: "remove_validator", validator }));
839
+ return this.submitQueued({ type: "remove_validator", validator }, "", {}, []);
567
840
  }
568
841
  /** Claim a GitHub handle. Verify it by creating a public gist (as that user) containing this account's address. */
569
842
  async linkGithub(handle, gistId) {
570
- return this.client.submit(await this.build({ type: "link_github", handle, gist_id: gistId }));
843
+ return this.submitQueued({ type: "link_github", handle, gist_id: gistId }, "", {}, []);
571
844
  }
572
845
  async unlinkGithub(handle) {
573
- return this.client.submit(await this.build({ type: "unlink_github", handle }));
846
+ return this.submitQueued({ type: "unlink_github", handle }, "", {}, []);
574
847
  }
575
848
  /** Put a bounty on a GitHub issue. `expiresInBlocks` from now (max ~1 year). */
576
849
  async createHunt(opts) {
@@ -578,42 +851,42 @@ export class Wallet {
578
851
  throw new Error("expiresInBlocks must be a positive whole number of blocks");
579
852
  const amount = parseFang(opts.amount).toString();
580
853
  // 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 })));
854
+ return this.submitQueued((height) => ({ type: "create_hunt", repo: opts.repo, issue: opts.issue, amount, expires_at: height + opts.expiresInBlocks, title: opts.title }), "", {}, []);
582
855
  }
583
856
  async fundHunt(huntId, amount) {
584
- return this.client.submit(await this.build({ type: "fund_hunt", hunt_id: huntId, amount: parseFang(amount).toString() }));
857
+ return this.submitQueued({ type: "fund_hunt", hunt_id: huntId, amount: parseFang(amount).toString() }, "", {}, []);
585
858
  }
586
859
  /** Pay a hunt's bounty (creator or admin). `to` may be a .boo name: signed as `Client.recipient` says (the proven address, or the name for the chain to resolve). */
587
860
  async awardHunt(huntId, to, pr, familiarKey) {
588
861
  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 } : {}) }));
862
+ return this.submitQueued({ type: "award_hunt", hunt_id: huntId, to: addr, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }, "", {}, []);
590
863
  }
591
864
  async cancelHunt(huntId) {
592
- return this.client.submit(await this.build({ type: "cancel_hunt", hunt_id: huntId }));
865
+ return this.submitQueued({ type: "cancel_hunt", hunt_id: huntId }, "", {}, []);
593
866
  }
594
867
  /** Mark one of this account's session keys (an agent) as a Familiar. */
595
868
  async registerFamiliar(keyIdHex, label) {
596
- return this.client.submit(await this.build({ type: "register_familiar", key_id: keyIdHex, label }));
869
+ return this.submitQueued({ type: "register_familiar", key_id: keyIdHex, label }, "", {}, []);
597
870
  }
598
871
  // ---- 🪦 Proof of Contribution ----
599
872
  /** Claim a merged PR (authored by this account's linked GitHub handle) for the contribution pool. Bonded; validators check it on GitHub. */
600
873
  async submitClaim(repo, pr, familiarKey) {
601
- return this.client.submit(await this.build({ type: "submit_claim", repo, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }));
874
+ return this.submitQueued({ type: "submit_claim", repo, pr, ...(familiarKey ? { familiar_key: familiarKey } : {}) }, "", {}, []);
602
875
  }
603
876
  /** Stake 10 FANG on a newcomer being a real, distinct person (needs 10 Soul). */
604
877
  async vouch(account) {
605
- return this.client.submit(await this.build({ type: "vouch", account: (await this.client.recipient(account)).signed }));
878
+ return this.submitQueued({ type: "vouch", account: (await this.client.recipient(account)).signed }, "", {}, []);
606
879
  }
607
880
  /** Withdraw your vouch for `account` (after the lock), or pass your own account to decline one. */
608
881
  async unvouch(account) {
609
- return this.client.submit(await this.build({ type: "unvouch", account: (await this.client.recipient(account)).signed }));
882
+ return this.submitQueued({ type: "unvouch", account: (await this.client.recipient(account)).signed }, "", {}, []);
610
883
  }
611
884
  /** Join (or top up) the Séance juror pool. Needs 5 Soul; owner keys only. */
612
885
  async joinJury(bond = "10") {
613
- return this.client.submit(await this.build({ type: "join_jury", bond: parseFang(bond).toString() }));
886
+ return this.submitQueued({ type: "join_jury", bond: parseFang(bond).toString() }, "", {}, []);
614
887
  }
615
888
  async leaveJury() {
616
- return this.client.submit(await this.build({ type: "leave_jury" }));
889
+ return this.submitQueued({ type: "leave_jury" }, "", {}, []);
617
890
  }
618
891
  /**
619
892
  * Seal a Séance judgment: one of "a", "b" or "=" per assigned pair (see `client.seance(id)`),
@@ -630,47 +903,47 @@ export class Wallet {
630
903
  throw new Error(`you have ${pairs.length} assigned pairs: give one of a, b or = for each`);
631
904
  const commitment = seanceCommitment(seanceId, this.account, votes, spam, salt);
632
905
  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 }));
906
+ const tx = await this.submitQueued({ type: "commit_votes", seance_id: seanceId, commitment, pairs_digest }, "", {}, []);
634
907
  return { tx, salt, votes, spam };
635
908
  }
636
909
  async revealVotes(seanceId, votes, spam, salt) {
637
- return this.client.submit(await this.build({ type: "reveal_votes", seance_id: seanceId, votes, spam, salt }));
910
+ return this.submitQueued({ type: "reveal_votes", seance_id: seanceId, votes, spam, salt }, "", {}, []);
638
911
  }
639
912
  async fundPool(amount) {
640
- return this.client.submit(await this.build({ type: "fund_pool", amount: parseFang(amount).toString() }));
913
+ return this.submitQueued({ type: "fund_pool", amount: parseFang(amount).toString() }, "", {}, []);
641
914
  }
642
915
  /** Withdraw every contribution reward that has vested so far. */
643
916
  async withdrawVested() {
644
- return this.client.submit(await this.build({ type: "withdraw_vested" }));
917
+ return this.submitQueued({ type: "withdraw_vested" }, "", {}, []);
645
918
  }
646
919
  /** Admins of dev and test chains only (elsewhere: a governance proposal). Weight 1–100; 0 removes the repo. */
647
920
  async setRepo(repo, weight) {
648
- return this.client.submit(await this.build({ type: "set_repo", repo, weight }));
921
+ return this.submitQueued({ type: "set_repo", repo, weight }, "", {}, []);
649
922
  }
650
923
  /** Admins of dev and test chains only (elsewhere: a governance proposal). `soul` in Soul ("2.5"), up to 100 per grant. */
651
924
  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 }));
925
+ return this.submitQueued({ type: "grant_soul", to: (await this.client.recipient(to)).signed, amount: parseSoul(soul), reason }, "", {}, []);
653
926
  }
654
927
  /** Admins of dev and test chains only (elsewhere: a governance proposal). */
655
928
  async rejectClaim(claimId, reason) {
656
- return this.client.submit(await this.build({ type: "reject_claim", claim_id: claimId, reason }));
929
+ return this.submitQueued({ type: "reject_claim", claim_id: claimId, reason }, "", {}, []);
657
930
  }
658
931
  /** Admins of dev and test chains only (elsewhere: a governance proposal). */
659
932
  async flagSybil(account, reason) {
660
- return this.client.submit(await this.build({ type: "flag_sybil", account: (await this.client.recipient(account)).signed, reason }));
933
+ return this.submitQueued({ type: "flag_sybil", account: (await this.client.recipient(account)).signed, reason }, "", {}, []);
661
934
  }
662
935
  /** Admins of dev and test chains only (elsewhere: a governance proposal). */
663
936
  async clawback(account, reason) {
664
- return this.client.submit(await this.build({ type: "clawback", account: (await this.client.recipient(account)).signed, reason }));
937
+ return this.submitQueued({ type: "clawback", account: (await this.client.recipient(account)).signed, reason }, "", {}, []);
665
938
  }
666
939
  /** Reclaim the bond of a removed validator this account owns. */
667
940
  async unbond(validator) {
668
- return this.client.submit(await this.build({ type: "unbond", validator }));
941
+ return this.submitQueued({ type: "unbond", validator }, "", {}, []);
669
942
  }
670
943
  // ---- protocol v1 (tags 30–49). Each builds, signs and submits one tx and waits for it. ----
671
944
  /** Submits one action (the shared tail of the helpers below); `unproven`: see `buildChecked`. */
672
945
  async act(action, opts = {}, unproven = []) {
673
- return this.client.submit(await this.buildChecked(action, opts.memo ?? "", opts, unproven), opts.wait ?? true);
946
+ return this.submitQueued(action, opts.memo ?? "", opts, unproven);
674
947
  }
675
948
  /**
676
949
  * A validator by address, or by a `.boo` name its account holds. Staking needs the address, so
@@ -914,6 +1187,389 @@ export class Wallet {
914
1187
  throw new Error("revertSha must be the 40-hex revert commit");
915
1188
  return this.act({ type: "attest_revert", claim_id: wholeNumber(claimId, "claimId"), revert_sha: revertSha }, opts);
916
1189
  }
1190
+ // ---- programs (protocol v2: tags 62, 63, 64 and 69; docs/PROGRAMS-V0.md §8.4). Third-party WebAssembly: every node answer is
1191
+ // checked before it's used (programs.ts), fuel is priced at the node's suggested rate and refused when no block could carry it,
1192
+ // and nothing is signed that the user wasn't shown the digest of. Upload, create and call are one step per block (§2.1): each
1193
+ // waits for what it needs to be usable instead of being refused by the mempool. ----
1194
+ /** The chain's fuel prices, with programs required to be on (`ProgramsOffError`) and to admit this kind of transaction now. */
1195
+ async programsFor(kind, sendsValue) {
1196
+ const fuel = await this.client.programFuel();
1197
+ checkProgramsStatus(fuel, kind, sendsValue);
1198
+ return fuel;
1199
+ }
1200
+ /** Waits (up to `waitMs`) for the node's head to pass `height`: what block `height` stored is usable from the next one (§2.1). */
1201
+ async waitPast(height, waitMs, what) {
1202
+ const until = Date.now() + waitMs;
1203
+ for (;;) {
1204
+ const head = (await this.client.status()).height;
1205
+ if (!Number.isSafeInteger(head) || head < 0)
1206
+ throw new Error(`${this.client.rpc} doesn't report its height`);
1207
+ this.client.checkHeightClock(head);
1208
+ if (head > height)
1209
+ return;
1210
+ if (Date.now() > until)
1211
+ throw new Error(`${what} is final in block ${height}, but ${this.client.rpc} hasn't sealed the block after it in ${waitMs} ms, so it can't be used yet: check again shortly`);
1212
+ await sleep(250);
1213
+ }
1214
+ }
1215
+ /**
1216
+ * Runs what follows a transaction that was submitted and is final, saying so if it fails: `what` names
1217
+ * the transaction (its id, and what it made), which was sent and ran, so the failure is of the receipt,
1218
+ * the wait or the read after it, and the transaction must not be sent again. The typed error stays
1219
+ * as `cause`; its reason is quoted stripped of hidden characters (it may hold a node's text). `what` may be a function
1220
+ * so the name can grow as `run` learns more (the address a create made).
1221
+ */
1222
+ async finalThen(what, run) {
1223
+ try {
1224
+ return await run();
1225
+ }
1226
+ catch (e) {
1227
+ const reason = plainText(String(e?.message ?? e)).slice(0, 600);
1228
+ throw new Error(`${typeof what === "string" ? what : what()} was sent and is final: it already ran, so don't send it again. What came after it failed: ${reason}`, { cause: e });
1229
+ }
1230
+ }
1231
+ /**
1232
+ * Waits (up to `waitMs`) until `read` finds what it names (`null`: not there yet) stored in a block
1233
+ * before the node's head, which is when it's usable (§2.1: a create, or a call, signed earlier is
1234
+ * refused by the mempool). Says what was waited for if it never is.
1235
+ */
1236
+ async waitUsable(what, waitMs, read) {
1237
+ const until = Date.now() + waitMs;
1238
+ let seen = null;
1239
+ for (;;) {
1240
+ const r = await read();
1241
+ if (r && r.stored !== null) {
1242
+ seen = r.stored;
1243
+ const head = (await this.client.status()).height;
1244
+ if (!Number.isSafeInteger(head) || head < 0)
1245
+ throw new Error(`${this.client.rpc} doesn't report its height`);
1246
+ this.client.checkHeightClock(head);
1247
+ if (r.stored < head)
1248
+ return r.found;
1249
+ }
1250
+ if (Date.now() > until)
1251
+ throw new Error(`${what} isn't usable after ${waitMs} ms: ${seen === null ? "it isn't stored (upload or create it first)" : `it was stored in block ${seen}, and is usable from the block after it`}. Nothing was signed`);
1252
+ await sleep(250);
1253
+ }
1254
+ }
1255
+ /** A program as a call needs it: usable (stored in an earlier block), its digest recomputed from its own record, and proven against the client's witnesses when it has some. */
1256
+ async programForCall(address, waitMs) {
1257
+ const info = await this.waitUsable(`the program ${address}`, waitMs, async () => {
1258
+ try {
1259
+ const p = await this.client.program(address);
1260
+ return { stored: p.created_at, found: p };
1261
+ }
1262
+ catch (e) {
1263
+ if (e instanceof ApiError && e.status === 404)
1264
+ return null;
1265
+ throw e;
1266
+ }
1267
+ });
1268
+ if (info.code.size === null)
1269
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/program/${address}`, "code.size", "is missing: the code's load can't be priced");
1270
+ if (this.client.witnesses.length) {
1271
+ const proven = await this.client.provenProgram(address, { witnesses: this.client.witnesses });
1272
+ if (!proven)
1273
+ throw new Error(`${address} isn't a program: its record is provably absent (proven against ${this.client.witnesses.length} witness${this.client.witnesses.length === 1 ? "" : "es"}), whatever ${this.client.rpc} says. Nothing was signed`);
1274
+ if (proven.version_digest !== info.version_digest || proven.wasm_hash !== info.code.wasm_hash || proven.interface_hash !== info.code.interface_hash)
1275
+ throw new Error(`${this.client.rpc} and its witnesses disagree about the code at ${address}: the proven record's digest is ${proven.version_digest.slice(0, 16)}…, the node says ${info.version_digest.slice(0, 16)}…. Nothing was signed`);
1276
+ }
1277
+ return { info, digest: info.version_digest };
1278
+ }
1279
+ /**
1280
+ * The bindings of a transaction (`ProgramBindingInput`s): each program read (usable, checked, proven
1281
+ * with witnesses), its version digest filled with the version in force now when none is given (never
1282
+ * "any version", 64 zeros, unless asked: `ANY_PROGRAM_VERSION`), and its code added to `declared`,
1283
+ * which load fuel is counted on.
1284
+ */
1285
+ async resolveBindings(bindings, declared, waitMs) {
1286
+ if (!Array.isArray(bindings) || bindings.length > MAX_PROGRAM_BINDINGS)
1287
+ throw new Error(`a transaction binds at most ${MAX_PROGRAM_BINDINGS} programs`);
1288
+ const out = [];
1289
+ for (const b of bindings) {
1290
+ const program = programAddressArg(b.program, "a binding");
1291
+ if (out.some((o) => o.program === program))
1292
+ throw new Error(`${program} is bound twice`);
1293
+ const { info, digest } = await this.programForCall(program, waitMs);
1294
+ const given = b.versionDigest === undefined ? undefined : programHashArg(b.versionDigest, "a binding's version digest");
1295
+ if (given !== undefined && given !== ANY_PROGRAM_VERSION)
1296
+ shownDigest(given, digest, `the program ${program}`);
1297
+ declared.set(info.code.wasm_hash, info.code.size);
1298
+ out.push({ program, version_digest: given ?? digest });
1299
+ }
1300
+ return out;
1301
+ }
1302
+ /**
1303
+ * Signs and submits a program transaction (reserving `reservation` fuel: its fee is priced with it),
1304
+ * waits for it, checks its receipt (programs.ts `checkReceipt`) and returns it with what it cost.
1305
+ * One that ran and failed throws `ProgramFailedError` (its fee was charged; `storageLimit` names
1306
+ * the limit it signed, for the hint a `storage_limit` trap needs).
1307
+ *
1308
+ * Once the transaction is submitted and the node reports it final, **every failure says so**
1309
+ * (`finalThen`): a receipt or answer that doesn't check isn't "nothing happened, ask another node",
1310
+ * since the transaction ran, and a payable call sent again pays twice. The typed error is the `cause`.
1311
+ */
1312
+ async sendProgram(kind, action, reservation, opts, storageLimit) {
1313
+ let signed;
1314
+ let done;
1315
+ try {
1316
+ done = await this.submitQueued(action, "", { ...opts, wait: true, reserved: reservation, onSigned: async (tx, id) => { signed = tx; await opts.onSigned?.(tx, id); } }, []);
1317
+ }
1318
+ catch (e) {
1319
+ if (e instanceof TxFailedError && !e.dropped)
1320
+ throw programFailure(this.client.rpc, e, storageLimit);
1321
+ throw e;
1322
+ }
1323
+ const tx = signed;
1324
+ const id = tx ? txId(tx) : "(its id wasn't seen here)";
1325
+ return this.finalThen(`the ${kind} (tx ${id})`, async () => {
1326
+ if (!tx)
1327
+ throw new Error("a program transaction was submitted without being signed here");
1328
+ if (done?.id !== id)
1329
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "id", `isn't the transaction that was submitted (${plainText(String(done?.id)).slice(0, 16)})`);
1330
+ const receipt = checkReceipt(this.client.rpc, done);
1331
+ if (!receipt)
1332
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "program", "is null for a transaction that is final: it should hold its receipt");
1333
+ if (receipt.kind !== kind)
1334
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "program.kind", `is ${receipt.kind}, not ${kind}`);
1335
+ if (!receipt.outcome.ok)
1336
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "program.outcome", "says it failed for a transaction that is final");
1337
+ if (!Number.isSafeInteger(done.height) || done.height < 0)
1338
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${id}`, "height", "isn't a block height");
1339
+ this.client.checkHeightClock(done.height);
1340
+ return { tx: done, receipt, cost: programCost(receipt, tx.from), signed: tx };
1341
+ });
1342
+ }
1343
+ /**
1344
+ * Uploads a program's code (WebAssembly 1.0) and its interface (format 1: `blockula program interface
1345
+ * encode`, or the `.iface` files beside the guests), bonding a storage deposit for each that isn't
1346
+ * stored yet: the deposit at today's price (`programUploadDeposit`), which v0 never gives back. `storageLimit`
1347
+ * (FANG, default that deposit plus 10%) is the most the upload may bond; below the deposit it's
1348
+ * refused before signing, as the upload would fail and still be charged. Fuel is priced at the
1349
+ * node's suggested rate. Resolves once the upload is final **and the block after it has been sealed**,
1350
+ * when the code is usable (a create signed sooner is refused, §2.1), with its receipt and what it
1351
+ * cost: the fee, and the deposit, which `receipt.facts.outside_vm` holds apart from the VM's diff.
1352
+ */
1353
+ async uploadCode(wasm, iface, opts = {}) {
1354
+ const code = programBytesArg(wasm, "the code", MAX_PROGRAM_CODE_BYTES);
1355
+ const interfaceBytes = programBytesArg(iface, "the interface", MAX_PROGRAM_INTERFACE_BYTES);
1356
+ const waitMs = programWaitMs(opts.waitMs);
1357
+ const [wasm_hash, interface_hash] = [wasmHash(code), interfaceHash(interfaceBytes)];
1358
+ const fuel = await this.programsFor("upload", false);
1359
+ const params = await this.client.programParams();
1360
+ // What's stored already bonds nothing again (an upload of stored blobs only refreshes them). That's one node's word, so it only
1361
+ // says what deposit to expect: the limit defaults to the deposit as if both blobs were new, a ceiling the chain bonds only
1362
+ // what's new against, which a node that says "already stored" can't lower (it'd make the upload fail, and charge its fee).
1363
+ const stored = async (read) => read().then(() => true, (e) => (e instanceof ApiError && e.status === 404 ? false : Promise.reject(e)));
1364
+ const [codeStored, interfaceStored] = await Promise.all([stored(() => this.client.code(wasm_hash)), stored(() => this.client.interface(interface_hash))]);
1365
+ const deposit = programUploadDeposit(fuel, params, [{ size: code.length, isNew: !codeStored }, { size: interfaceBytes.length, isNew: !interfaceStored }]);
1366
+ if (deposit === null)
1367
+ throw new Error("program state is full: nothing more can be stored at any price. Nothing was signed");
1368
+ const ceiling = programUploadDeposit(fuel, params, [{ size: code.length, isNew: true }, { size: interfaceBytes.length, isNew: true }]) ?? deposit;
1369
+ const storageLimit = opts.storageLimit === undefined ? depositWithMargin(ceiling) : checkedDrops(parseFang(opts.storageLimit), "storageLimit");
1370
+ if (storageLimit < deposit)
1371
+ throw new Error(`storageLimit ${formatFang(storageLimit)} is below the deposit ${formatFang(deposit)} at today's price: the upload would fail and still be charged its fee. Nothing was signed`);
1372
+ // An upload reserves its own validation, test translation and hashing (§5.1), priced by its size.
1373
+ const reservation = reserveFuel(fuel, uploadFuel(code.length, interfaceBytes.length), new Map(), false);
1374
+ const action = { type: "upload_code", wasm: bytesToHex(code), interface: bytesToHex(interfaceBytes), storage_limit: storageLimit.toString() };
1375
+ const { tx, receipt, cost } = await this.sendProgram("upload", action, reservation, opts, storageLimit);
1376
+ return this.finalThen(`the upload (tx ${tx.id})`, async () => {
1377
+ const upload = receipt.facts.upload;
1378
+ if (!upload || upload.wasm_hash !== wasm_hash || upload.interface_hash !== interface_hash)
1379
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${tx.id}`, "program.facts.upload", "isn't the code and interface that were uploaded");
1380
+ await this.waitPast(tx.height, waitMs, "the upload");
1381
+ return { wasmHash: wasm_hash, interfaceHash: interface_hash, deposit: upload.deposit, tx, receipt, cost };
1382
+ });
1383
+ }
1384
+ /**
1385
+ * Creates a program from stored code and interface (`uploadCode`'s hashes), at the address made from
1386
+ * this account and the transaction's nonce, which is returned **only once the create has applied**
1387
+ * and the block after it has been sealed (the program is then usable), never predicted ahead of it:
1388
+ * FANG sent to a program's address before its create is final is stranded for good if the create
1389
+ * fails (§2.3). It first waits for the code and interface to be usable (an upload a block ago).
1390
+ *
1391
+ * `init` runs the interface's `Init` method with its `input` and `value`, present exactly when the
1392
+ * interface declares one; it runs under `fuelLimit` (default `program_max_tx_fuel`, 0 without `init`).
1393
+ * `storageLimit` (FANG) is the most the record's deposit and what the initializer's writes bond may
1394
+ * be: default the record's deposit plus 10%, which a program whose initializer writes needs more than.
1395
+ * `authority` (an address, never a name) may upgrade it once a protocol version adds upgrades, with
1396
+ * `delayBlocks` of notice (default and least: `program_min_upgrade_delay_blocks`) and an upgrade
1397
+ * `policy`; without one the program is immutable. `bindings` are programs the initializer may reach.
1398
+ * The receipt's `outside_vm` holds the record deposit and the account creation fee the create burns
1399
+ * when the program's address held no account yet.
1400
+ */
1401
+ async createProgram(opts) {
1402
+ const wasm_hash = programHashArg(opts.wasmHash, "wasmHash");
1403
+ const interface_hash = programHashArg(opts.interfaceHash, "interfaceHash");
1404
+ const waitMs = programWaitMs(opts.waitMs);
1405
+ const init = opts.init;
1406
+ const initMethod = init === undefined ? undefined : programMethodArg(init.method);
1407
+ const initValue = checkedDrops(parseFang(init?.value ?? 0n), "init.value");
1408
+ if (init === undefined && (opts.fuelLimit !== undefined || opts.bindings?.length))
1409
+ throw new Error("fuelLimit and bindings are an initializer's: pass `init`, or leave them out");
1410
+ if (opts.authority === undefined && (opts.delayBlocks !== undefined || opts.policy !== undefined))
1411
+ throw new Error("delayBlocks and policy are an upgrade authority's: pass `authority` (an immutable program has neither)");
1412
+ const authority = opts.authority === undefined ? null : programAddressArg(opts.authority, "an upgrade authority");
1413
+ const fuel = await this.programsFor("create", initValue > 0n);
1414
+ const params = await this.client.programParams();
1415
+ const stored = (read) => async () => {
1416
+ try {
1417
+ const r = await read();
1418
+ return { stored: r.uploaded_at, found: r };
1419
+ }
1420
+ catch (e) {
1421
+ if (e instanceof ApiError && e.status === 404)
1422
+ return null;
1423
+ throw e;
1424
+ }
1425
+ };
1426
+ // Both blobs usable: stored in an earlier block (the upload may be a block away).
1427
+ const code = await this.waitUsable("the code", waitMs, stored(() => this.client.code(wasm_hash)));
1428
+ const iface = await this.waitUsable("the interface", waitMs, stored(() => this.client.interface(interface_hash)));
1429
+ // The interface says whether it has an initializer (the node's reading of its bytes; the chain enforces it either way).
1430
+ const methods = iface.untrusted_interface?.methods;
1431
+ if (Array.isArray(methods)) {
1432
+ const declares = methods.some((m) => m?.kind === "init");
1433
+ if (declares && init === undefined)
1434
+ throw new Error("this interface declares an initializer, so the create must run it: pass `init`. Nothing was signed");
1435
+ if (!declares && init !== undefined)
1436
+ throw new Error("this interface declares no initializer, so `init` has none to run. Nothing was signed");
1437
+ }
1438
+ const declared = new Map();
1439
+ if (init !== undefined)
1440
+ declared.set(wasm_hash, code.size);
1441
+ const bindings = await this.resolveBindings(opts.bindings ?? [], declared, waitMs);
1442
+ const fuelLimit = init === undefined ? 0n : opts.fuelLimit === undefined ? defaultFuelLimit(fuel, declared) : BigInt(positiveFuel(opts.fuelLimit));
1443
+ const reservation = reserveFuel(fuel, fuelLimit, declared);
1444
+ const delay = authority === null ? 0 : opts.delayBlocks ?? params.min_upgrade_delay_blocks;
1445
+ if (authority !== null && (!Number.isSafeInteger(delay) || delay < params.min_upgrade_delay_blocks))
1446
+ throw new Error(`delayBlocks ${delay} is below program_min_upgrade_delay_blocks (${params.min_upgrade_delay_blocks}): an upgradable program gives at least that much notice. Nothing was signed`);
1447
+ const policy = authority === null ? "open" : opts.policy ?? "open";
1448
+ if (policy !== "open" && policy !== "additive")
1449
+ throw new Error(`policy is "open" or "additive", not ${JSON.stringify(policy)}`);
1450
+ const record = programRecordDeposit(fuel, params);
1451
+ if (record === null)
1452
+ throw new Error("program state is full: nothing more can be stored at any price. Nothing was signed");
1453
+ const storageLimit = opts.storageLimit === undefined ? depositWithMargin(record) : checkedDrops(parseFang(opts.storageLimit), "storageLimit");
1454
+ if (storageLimit < record)
1455
+ throw new Error(`storageLimit ${formatFang(storageLimit)} is below the record deposit ${formatFang(record)} at today's price: the create would fail and still be charged its fee. Nothing was signed`);
1456
+ const action = {
1457
+ type: "create_program",
1458
+ wasm_hash,
1459
+ interface_hash,
1460
+ authority,
1461
+ upgrade_delay_blocks: delay,
1462
+ policy,
1463
+ accepts_transfers: opts.acceptsTransfers === true,
1464
+ init: init === undefined ? null : { method: initMethod, input: programInputArg(init.input ?? ""), value: initValue.toString() },
1465
+ fuel_limit: Number(fuelLimit),
1466
+ storage_limit: storageLimit.toString(),
1467
+ bindings,
1468
+ };
1469
+ const { tx, receipt, cost, signed } = await this.sendProgram("create", action, reservation, opts, storageLimit);
1470
+ // The address this transaction makes is its sender's and its nonce's: what the receipt names must be that one.
1471
+ let what = `the create (tx ${tx.id})`;
1472
+ return this.finalThen(() => what, async () => {
1473
+ const address = programAddress(signed.from, signed.nonce, await this.client.addressPrefix());
1474
+ what = `the create (tx ${tx.id}), which made ${address},`;
1475
+ if (receipt.facts.created !== address)
1476
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/tx/${tx.id}`, "program.facts.created", `names ${String(receipt.facts.created)}, not ${address}, the program this create makes`);
1477
+ await this.waitPast(tx.height, waitMs, "the create");
1478
+ const program = await this.client.program(address);
1479
+ if (program.code.wasm_hash !== wasm_hash || program.code.interface_hash !== interface_hash)
1480
+ throw new ProgramAnswerError(this.client.rpc, `GET /api/program/${address}`, "code", "isn't the code and interface this create named");
1481
+ return { address, program, tx, receipt, cost };
1482
+ });
1483
+ }
1484
+ /**
1485
+ * Calls programs: 1 to 8 calls run in order, all or nothing, sharing `fuelLimit` (default
1486
+ * `program_max_tx_fuel`) and `storageLimit` (FANG): **the default is 0, which consents to no new
1487
+ * storage**, so a call whose writes bond a deposit traps `storage_limit` and still pays its fee
1488
+ * (`ProgramFailedError` says so): pass a limit for any method that writes.
1489
+ *
1490
+ * Each call binds the `versionDigest` of the program it's calling: when you pass the digest you
1491
+ * showed the user (`ProgramCallInput.versionDigest`) the call is refused if the program's code,
1492
+ * interface or version isn't the one that digest names; without it, it's recomputed from the
1493
+ * program's record (proven against the client's witnesses when it has any), never taken from the
1494
+ * node's `version_digest`. Fuel is priced at the node's suggested rate on what's reserved (fuel to
1495
+ * run, plus the load of the code the calls and `bindings` declare) and refused when no block
1496
+ * could carry it (`FuelReservationError`). Waits for each program to be usable (created a block ago).
1497
+ * The receipt's `claims` are what the programs say happened: the chain checks none of it.
1498
+ */
1499
+ async callProgram(calls, opts = {}) {
1500
+ if (!Array.isArray(calls) || calls.length < 1 || calls.length > MAX_PROGRAM_CALLS)
1501
+ throw new Error(`a call_program runs 1 to ${MAX_PROGRAM_CALLS} calls`);
1502
+ const waitMs = programWaitMs(opts.waitMs);
1503
+ const parsed = calls.map((c, i) => ({
1504
+ program: programAddressArg(c.program, `call ${i + 1}'s program`),
1505
+ method: programMethodArg(c.method),
1506
+ input: programInputArg(c.input ?? ""),
1507
+ value: checkedDrops(parseFang(c.value ?? 0n), `call ${i + 1}'s value`),
1508
+ shown: c.versionDigest === undefined ? undefined : programHashArg(c.versionDigest, `call ${i + 1}'s versionDigest`),
1509
+ }));
1510
+ const fuel = await this.programsFor("call", parsed.some((c) => c.value > 0n));
1511
+ const declared = new Map();
1512
+ const read = new Map();
1513
+ const signed = [];
1514
+ for (const c of parsed) {
1515
+ let p = read.get(c.program);
1516
+ if (!p)
1517
+ read.set(c.program, (p = await this.programForCall(c.program, waitMs)));
1518
+ if (c.shown !== undefined)
1519
+ shownDigest(c.shown, p.digest, `the program ${c.program}`);
1520
+ declared.set(p.info.code.wasm_hash, p.info.code.size);
1521
+ signed.push({ program: c.program, version_digest: p.digest, method: c.method, input: c.input, value: c.value.toString() });
1522
+ }
1523
+ const bindings = await this.resolveBindings(opts.bindings ?? [], declared, waitMs);
1524
+ const fuelLimit = opts.fuelLimit === undefined ? defaultFuelLimit(fuel, declared) : BigInt(positiveFuel(opts.fuelLimit));
1525
+ const reservation = reserveFuel(fuel, fuelLimit, declared);
1526
+ const storageLimit = opts.storageLimit === undefined ? 0n : checkedDrops(parseFang(opts.storageLimit), "storageLimit");
1527
+ const action = { type: "call_program", calls: signed, fuel_limit: Number(fuelLimit), storage_limit: storageLimit.toString(), bindings };
1528
+ const { tx, receipt, cost } = await this.sendProgram("call", action, reservation, opts, storageLimit);
1529
+ return { tx, receipt, cost };
1530
+ }
1531
+ /**
1532
+ * One call: `program`'s `method` (a `call` method of its interface; read a `view` with
1533
+ * `client.view`) with `input` (bytes, or lowercase hex) and `value` FANG attached (only to a
1534
+ * payable method). It reads the program first, so a method its interface doesn't have, a view,
1535
+ * an initializer, or FANG sent to a method that isn't payable is refused before anything is signed
1536
+ * (the chain refuses them too, and charges the fee), and signs its `version_digest`: see `callProgram`,
1537
+ * whose options it takes, including the 0 default of `storageLimit`.
1538
+ */
1539
+ async call(program, method, input = "", opts = {}) {
1540
+ const address = programAddressArg(program);
1541
+ const name = programMethodArg(method);
1542
+ const { info } = await this.programForCall(address, programWaitMs(opts.waitMs));
1543
+ const m = info.methods?.find((x) => x.untrusted_name === name);
1544
+ if (info.methods && !m)
1545
+ throw new Error(`the program's interface has no method ${JSON.stringify(name)}: client.program(${JSON.stringify(address)}) lists them. Nothing was signed`);
1546
+ if (m && m.kind === "view")
1547
+ throw new Error(`${JSON.stringify(name)} is a view: read it with client.view (a transaction can't call it). Nothing was signed`);
1548
+ if (m && m.kind === "init")
1549
+ throw new Error(`${JSON.stringify(name)} is the program's initializer, which runs only when it's created. Nothing was signed`);
1550
+ if (m && !m.payable && checkedDrops(parseFang(opts.value ?? 0n), "value") > 0n)
1551
+ throw new Error(`${JSON.stringify(name)} isn't payable, so the call can't send it FANG. Nothing was signed`);
1552
+ return this.callProgram([{ program: address, method: name, input, value: opts.value, versionDigest: opts.versionDigest }], opts);
1553
+ }
1554
+ /**
1555
+ * For a validator: signs a vote to pause programs (tag 69), from the validator's own account with
1556
+ * its key (build the wallet from `validator.key`'s seed and `actAs` nothing: it signs as itself).
1557
+ * Programs pause from the block after f + 1 of the epoch's validators have signed, for days, with
1558
+ * no undo, so check `client.params()`' `programs` section (the live `votes`, `pause_signed` and
1559
+ * `fix_pending`) first, or use `blockula program pause`, which does.
1560
+ *
1561
+ * It signs and lets the chain's admission decide: a signature the chain refuses (this validator
1562
+ * signed one recently, or has a live vote of its own, or isn't in the active set) is an `ApiError`
1563
+ * 400 and is never charged. It reserves no fuel and prices none, so its fee is a plain
1564
+ * transaction's; it only needs programs to have been switched on (`/api/fees` has a `fuel` section:
1565
+ * `ProgramsOffError` otherwise), not any fuel schedule this SDK knows, since a pause must not depend on one.
1566
+ */
1567
+ async programsPause(opts = {}) {
1568
+ const fees = await this.client.fees();
1569
+ if (fees.fuel === undefined || fees.fuel === null)
1570
+ throw new ProgramsOffError(this.client.rpc);
1571
+ return this.submitQueued({ type: "programs_pause" }, "", { ...opts, wait: true }, []);
1572
+ }
917
1573
  /** This account's shares in a validator's pool, for `"all"`; anything else is parsed and checked. */
918
1574
  async sharesOf(validator, shares) {
919
1575
  if (shares !== "all") {
@@ -941,8 +1597,48 @@ export class Wallet {
941
1597
  return kind;
942
1598
  }
943
1599
  }
944
- balance() {
945
- return this.client.addressPrefix().then(() => this.client.account(this.account)).then((a) => a.balance_display, () => "0 FANG");
1600
+ /**
1601
+ * Everything this account holds, as FANG text ("12.5 FANG"), locked or not: `balances()` says
1602
+ * what it can pay. "0 FANG" only for an account that doesn't exist yet; anything else that goes
1603
+ * wrong (the node is down or rate limited, a timeout, another chain) throws (DEV-2), so a
1604
+ * failure never reads as an empty account.
1605
+ */
1606
+ async balance() {
1607
+ return (await this.balances()).display.balance;
1608
+ }
1609
+ /**
1610
+ * This account's balances, in bigint drops (DEV-2):
1611
+ * - `balance`: everything it holds;
1612
+ * - `transferable`: what it can pay or send now, fees and tips included: the "can pay" number;
1613
+ * - `locked`: what vesting and vote locks hold (their `until` is in `client.account(…).locks`).
1614
+ * Staked FANG isn't in `balance` (see `client.staking`). `exists` is false for an account the
1615
+ * chain hasn't opened yet (all zeros). Errors propagate, like `balance()`.
1616
+ */
1617
+ async balances() {
1618
+ await this.client.addressPrefix();
1619
+ let a;
1620
+ try {
1621
+ a = await this.client.account(this.account);
1622
+ }
1623
+ catch (e) {
1624
+ if (e instanceof ApiError && e.status === 404)
1625
+ a = { balance: "0", transferable: "0", locked: "0", exists: false };
1626
+ else
1627
+ throw e;
1628
+ }
1629
+ const amount = (v, what) => {
1630
+ if (typeof v === "string" && /^\d+$/.test(v))
1631
+ return BigInt(v);
1632
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
1633
+ return BigInt(v);
1634
+ throw new Error(`${this.client.rpc} didn't report ${this.account}'s ${what}`);
1635
+ };
1636
+ const balance = amount(a?.balance, "balance");
1637
+ // Nodes before protocol v1 don't report locks: everything is transferable there.
1638
+ const transferable = a?.transferable === undefined ? balance : amount(a.transferable, "transferable balance");
1639
+ const locked = a?.locked === undefined ? 0n : amount(a.locked, "locked balance");
1640
+ const exists = typeof a?.exists === "boolean" ? a.exists : balance > 0n || Number(a?.nonce ?? 0) > 0;
1641
+ return { balance, transferable, locked, exists, display: { balance: formatFang(balance), transferable: formatFang(transferable), locked: formatFang(locked) } };
946
1642
  }
947
1643
  /** Dev/test chains only: drip 100 FANG to this wallet. */
948
1644
  async faucet(wait = true) {
@@ -957,15 +1653,72 @@ function shownDigest(shown, now, what) {
957
1653
  if (shown.trim().toLowerCase() !== now)
958
1654
  throw new Error(`${what} doesn't read the way it did when it was shown: its digest is now ${now}, not ${shown}. Nothing was signed: the node may be showing different content to different reads.`);
959
1655
  }
960
- /** An error response from a Blockula node. `status` is the HTTP status (429 = rate limited). */
1656
+ /**
1657
+ * An error response from a Blockula node. `status` is the HTTP status (429 = rate limited);
1658
+ * `retryAfterMs`, when the node said how long to wait (`Retry-After`, or "try again in N minutes").
1659
+ */
961
1660
  export class ApiError extends Error {
962
1661
  status;
963
- constructor(status, message) {
1662
+ retryAfterMs;
1663
+ constructor(status, message, retryAfterMs) {
964
1664
  super(message);
965
1665
  this.status = status;
1666
+ this.retryAfterMs = retryAfterMs;
966
1667
  this.name = "ApiError";
967
1668
  }
968
1669
  }
1670
+ /**
1671
+ * The node says it's on blockula-1, and this client isn't pinned to that chain (DEV-1). Nothing
1672
+ * was signed. A client signs there only with a `chainId: "blockula-1"` pin.
1673
+ */
1674
+ export class MainnetNotAllowedError extends Error {
1675
+ rpc;
1676
+ constructor(rpc) {
1677
+ super(`${rpc} says it's on ${MAINNET_CHAIN_ID}, and this client isn't pinned to that chain, so nothing was signed. If that's the chain you mean, pin it where the client is created: { chainId: "${MAINNET_CHAIN_ID}" }`);
1678
+ this.rpc = rpc;
1679
+ this.name = "MainnetNotAllowedError";
1680
+ }
1681
+ }
1682
+ /** The most txs a node holds pending from one sender (node/src/chain.rs `MAX_PENDING_PER_SENDER`). */
1683
+ export const MAX_PENDING_PER_SENDER = 64;
1684
+ const cursorKey = (chainId, genesisHash, account) => `${chainId} ${genesisHash} ${account}`;
1685
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1686
+ /**
1687
+ * Whether the node said it refused a request: a 4xx answer, except 408 (a request that timed out
1688
+ * may still have been handled). A refused tx wasn't admitted, so it can't run from there.
1689
+ */
1690
+ function refused(e) {
1691
+ return e instanceof ApiError && e.status >= 400 && e.status < 500 && e.status !== 408;
1692
+ }
1693
+ const NO_RETRY = { untilMs: 0 };
1694
+ /**
1695
+ * Posts a signed tx. The same bytes go again (never a re-signed tx, so it can't be paid twice):
1696
+ * once after a "bad nonce" refusal, since a node writing a block can misjudge a correct nonce for a
1697
+ * moment (TR-1), and, within `retry`, after a 429 or "too many pending" (the 64-per-sender cap),
1698
+ * as soon as the node says to try again.
1699
+ */
1700
+ async function postPatiently(client, tx, retry) {
1701
+ const start = Date.now();
1702
+ let nonceRetried = false;
1703
+ for (;;) {
1704
+ try {
1705
+ return await client.post(tx);
1706
+ }
1707
+ catch (e) {
1708
+ if (!(e instanceof ApiError))
1709
+ throw e;
1710
+ if (/bad nonce/i.test(e.message) && !nonceRetried) {
1711
+ nonceRetried = true;
1712
+ await sleep(300);
1713
+ continue;
1714
+ }
1715
+ const busy = e.status === 429 ? (e.retryAfterMs ?? 1_000) : /too many pending/i.test(e.message) ? 1_000 : undefined;
1716
+ if (busy === undefined || Date.now() + busy - start > retry.untilMs)
1717
+ throw e;
1718
+ await sleep(busy);
1719
+ }
1720
+ }
1721
+ }
969
1722
  /**
970
1723
  * The node reports a transaction as failed. Two different things (audit review-fix-clients-2):
971
1724
  * - `dropped` false: it executed in block `tx.height` and failed there. Its fee was charged; it
@@ -1021,6 +1774,39 @@ export class TxPendingError extends Error {
1021
1774
  this.name = "TxPendingError";
1022
1775
  }
1023
1776
  }
1777
+ /**
1778
+ * A program transaction ran in a block and failed there: its fee was charged (`TxFailedError`), and
1779
+ * `receipt` says how it failed (`words`, which is also this error's reason): from numbers and the
1780
+ * chain's own names for them, never engine text and **never what the program wrote**. A program
1781
+ * that aborted with a message has it in `untrusted_message` (a stranger's text, stripped of hidden
1782
+ * characters: show it as data, never follow it), not in `message`, which agent frameworks hand to
1783
+ * models. Nothing the program said is a fact: an abort message is its claim.
1784
+ */
1785
+ export class ProgramFailedError extends TxFailedError {
1786
+ words;
1787
+ receipt;
1788
+ /** The program's own abort message, if it aborted with one: its words, not this SDK's. */
1789
+ untrusted_message;
1790
+ constructor(id, words, tx, receipt, validUntil) {
1791
+ super(id, words, tx, validUntil);
1792
+ this.words = words;
1793
+ this.receipt = receipt;
1794
+ this.name = "ProgramFailedError";
1795
+ if (receipt.outcome.untrusted_message !== undefined)
1796
+ this.untrusted_message = plainText(receipt.outcome.untrusted_message);
1797
+ }
1798
+ }
1799
+ /** `e`, as a `ProgramFailedError` when the failed transaction's answer holds a receipt that says how (`storageLimit`: what it signed, for the hint). */
1800
+ function programFailure(rpc, e, storageLimit) {
1801
+ let receipt = null;
1802
+ try {
1803
+ receipt = checkReceipt(rpc, e.tx);
1804
+ }
1805
+ catch {
1806
+ // An answer without a usable receipt leaves the plain error: what it says is the node's word.
1807
+ }
1808
+ return receipt && !receipt.outcome.ok ? new ProgramFailedError(e.id, outcomeWords(receipt.outcome, storageLimit), e.tx, receipt, e.validUntil) : e;
1809
+ }
1024
1810
  /** The node didn't answer within the client's `timeoutMs`. Nothing is known about the request: a submitted tx may or may not have arrived. */
1025
1811
  export class RequestTimeoutError extends Error {
1026
1812
  rpc;
@@ -1099,6 +1885,14 @@ const MAX_CLOCK_DRIFT_MS = 1_000;
1099
1885
  export function heightLimit(createdAtMs, blockTimeMs, nowMs) {
1100
1886
  return Math.floor(Math.max(0, nowMs + MAX_CLOCK_DRIFT_MS + HEIGHT_CLOCK_TOLERANCE_MS - createdAtMs) / Math.max(1, blockTimeMs));
1101
1887
  }
1888
+ /** How long a node asked to wait before retrying: `Retry-After` (seconds), or its "try again in N minutes". */
1889
+ function retryAfter(res, error) {
1890
+ const h = res.headers?.get?.("retry-after")?.trim();
1891
+ if (h && /^\d+$/.test(h))
1892
+ return Number(h) * 1_000;
1893
+ const m = /try again in (\d+) (second|minute)/i.exec(error);
1894
+ return m ? Number(m[1]) * (m[2].toLowerCase() === "minute" ? 60_000 : 1_000) : undefined;
1895
+ }
1102
1896
  /** The node reports a different chain than the client is pinned to (`ClientOptions`). */
1103
1897
  export class ChainMismatchError extends Error {
1104
1898
  rpc;
@@ -1134,8 +1928,44 @@ export function checkTimeoutMs(ms, what = "timeoutMs") {
1134
1928
  * genuine but outdated record (a name's previous owner, a balance before a spend, a removed key).
1135
1929
  */
1136
1930
  export const DEFAULT_MAX_PROOF_LAG_BLOCKS = 10;
1931
+ /** `opts` with a network's pins filled in. A pin that contradicts the network's is an error, never a guess. */
1932
+ function withPreset(opts, p) {
1933
+ const pick = (what, given, preset, same = (a, b) => a === b) => {
1934
+ if (given !== undefined && preset !== undefined && !same(given, preset))
1935
+ throw new Error(`${what} ${JSON.stringify(given)} contradicts the ${p.name} network's (${JSON.stringify(preset)})`);
1936
+ return given ?? preset;
1937
+ };
1938
+ const genesisHash = pick("genesisHash", opts.genesisHash?.toLowerCase(), p.genesisHash);
1939
+ // The built-in genesis clock is that of the built-in genesis, never another's.
1940
+ const clock = genesisHash === p.genesisHash ? p.genesisClock : undefined;
1941
+ return {
1942
+ ...opts,
1943
+ chainId: pick("chainId", opts.chainId, p.chainId),
1944
+ genesisHash,
1945
+ prefix: pick("prefix", opts.prefix, p.prefix),
1946
+ genesisClock: pick("genesisClock", opts.genesisClock, clock, (a, b) => a.createdAtMs === b.createdAtMs && a.blockTimeMs === b.blockTimeMs),
1947
+ };
1948
+ }
1949
+ /**
1950
+ * A node's URL in one spelling, so a node compares equal to itself however it's written: scheme and
1951
+ * host lowercased, a default port, dot segments and credentials dropped (the URL parser's own
1952
+ * normalization), no trailing dot on the host, no trailing slash, no query or fragment. What isn't
1953
+ * an http(s) URL comes back as it is. (`Client` compares witnesses with its own node by it.)
1954
+ */
1955
+ function canonicalNodeUrl(u) {
1956
+ try {
1957
+ const url = new URL(u);
1958
+ if (!/^https?:$/.test(url.protocol))
1959
+ return u;
1960
+ // `host` carries the port; a fully qualified name's trailing dot comes before it, or ends it.
1961
+ const host = url.host.replace(/\.(?=:\d+$|$)/, "");
1962
+ return `${url.protocol}//${host}${url.pathname.replace(/\/+$/, "")}`;
1963
+ }
1964
+ catch {
1965
+ return u;
1966
+ }
1967
+ }
1137
1968
  export class Client {
1138
- rpc;
1139
1969
  /** The chain's address prefix: given, learned from `/api/status`, or `boo` until then. */
1140
1970
  prefix;
1141
1971
  prefixKnown;
@@ -1151,16 +1981,58 @@ export class Client {
1151
1981
  maxHeadLeadBlocks;
1152
1982
  /** See `ClientOptions.genesisClock`. */
1153
1983
  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 = {}) {
1156
- this.rpc = rpc;
1984
+ /** The node's URL. */
1985
+ rpc;
1986
+ /** The network this client was made for (`ClientOptions.network`), if any. */
1987
+ network;
1988
+ /** See `ClientOptions.wireTxs`. */
1989
+ wireTxs;
1990
+ /** A node of blockula-testnet-2 (`NETWORKS.testnet`), every pin set: `Client.testnet({ rpc })`. The public testnet was retired and has no public node, so `rpc` is required. */
1991
+ static testnet(opts = {}) {
1992
+ const { rpc, ...rest } = opts;
1993
+ return new Client(rpc, { ...rest, network: "testnet" });
1994
+ }
1995
+ /** A `blockula dev` chain on this machine (`NETWORKS.local`). */
1996
+ static local(opts = {}) {
1997
+ const { rpc, ...rest } = opts;
1998
+ return new Client(rpc, { ...rest, network: "local" });
1999
+ }
2000
+ /**
2001
+ * `new Client(rpc, opts)`, `new Client({ network: "local" })` (the network's built-in RPC), or
2002
+ * `new Client(rpc, { network })` (your own node, held to that network's pins). The pins
2003
+ * (`opts`, and the network's) are checked on every status: a node reporting another prefix,
2004
+ * chain id or genesis hash is refused with `ChainMismatchError`.
2005
+ */
2006
+ constructor(rpc, options = {}) {
2007
+ let opts = options;
2008
+ let url = typeof rpc === "string" ? rpc : undefined;
2009
+ if (rpc !== undefined && typeof rpc !== "string") {
2010
+ if (rpc === null || typeof rpc !== "object")
2011
+ throw new Error("new Client(rpc, opts) takes the node's URL, or the options alone");
2012
+ opts = { ...rpc, ...options };
2013
+ }
2014
+ if (opts.network !== undefined && !isNetworkName(opts.network))
2015
+ throw new Error(`unknown network ${JSON.stringify(opts.network)} (testnet or local)`);
2016
+ const preset = opts.network === undefined ? undefined : NETWORKS[opts.network];
2017
+ if (preset) {
2018
+ url ??= preset.rpc;
2019
+ if (url === undefined)
2020
+ throw new Error(preset.name === "testnet" ? TESTNET_RETIRED : `no public ${preset.name} RPC is built into this SDK: pass the URL of a ${preset.name} node you trust, new Client(url, { network: "${preset.name}" })`);
2021
+ opts = withPreset(opts, preset);
2022
+ }
2023
+ this.rpc = url ?? DEFAULT_RPC;
2024
+ this.network = preset?.name;
2025
+ this.wireTxs = opts.wireTxs === true;
1157
2026
  if (opts.prefix !== undefined && !KNOWN_PREFIXES.includes(opts.prefix))
1158
2027
  throw new Error(`unknown address prefix ${opts.prefix}`);
1159
2028
  if (opts.genesisHash !== undefined && !/^[0-9a-f]{64}$/i.test(opts.genesisHash))
1160
2029
  throw new Error("genesisHash must be 64 hex characters");
2030
+ const rpcUrl = this.rpc;
1161
2031
  this.timeoutMs = checkTimeoutMs(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
1162
2032
  this.witnesses = [...(opts.witnesses ?? [])];
1163
- if (this.witnesses.some((w) => w === this || w.rpc.replace(/\/$/, "") === rpc.replace(/\/$/, "")))
2033
+ // The same node under another spelling (`https://Node.Example:443/.`) is still the client's own: a proof checked against the state root the node itself reports proves nothing.
2034
+ const ownUrl = canonicalNodeUrl(rpcUrl);
2035
+ if (this.witnesses.some((w) => w === this || canonicalNodeUrl(w.rpc) === ownUrl))
1164
2036
  throw new Error("a witness must be another node, not the client's own");
1165
2037
  this.requireProvenNames = opts.requireProvenNames === true;
1166
2038
  if (this.requireProvenNames && this.witnesses.length === 0)
@@ -1192,11 +2064,7 @@ export class Client {
1192
2064
  * Without witnesses or a genesis clock, the height is the node's word.
1193
2065
  */
1194
2066
  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
- }
2067
+ this.checkHeightClock(height);
1200
2068
  if (!this.witnesses.length)
1201
2069
  return;
1202
2070
  const heads = await Promise.all(this.witnesses.map((w) => w.status().then((s) => (Number.isSafeInteger(s.height) && s.height >= 0 ? s.height : undefined), () => undefined)));
@@ -1207,12 +2075,37 @@ export class Client {
1207
2075
  if (height > lowest + this.maxHeadLeadBlocks)
1208
2076
  throw new HeightAheadError(this.rpc, height, lowest + this.maxHeadLeadBlocks, `the lowest head its witnesses report (#${lowest})`);
1209
2077
  }
2078
+ /**
2079
+ * Refuses (`HeightAheadError`) a height the chain can't have reached by this machine's clock and
2080
+ * the `genesisClock` this client was given: the clock half of `checkSigningHeight`, for heights a
2081
+ * node reports that aren't signed against (a read's, a wait's). Without a `genesisClock` the
2082
+ * height is the node's word.
2083
+ */
2084
+ checkHeightClock(height) {
2085
+ if (!this.genesisClock)
2086
+ return;
2087
+ const limit = heightLimit(this.genesisClock.createdAtMs, this.genesisClock.blockTimeMs, Date.now());
2088
+ if (height > limit)
2089
+ throw new HeightAheadError(this.rpc, height, limit, "this machine's clock and the chain's genesis");
2090
+ }
1210
2091
  /** The chain's address prefix, asking the node once if it isn't known yet. */
1211
2092
  async addressPrefix() {
1212
2093
  if (!this.prefixKnown)
1213
2094
  await this.status();
1214
2095
  return this.prefix;
1215
2096
  }
2097
+ /**
2098
+ * Throws `MainnetNotAllowedError` for a status on blockula-1 unless this client is pinned to that
2099
+ * chain (`chainPin`). Wallets call it with the very status each tx is signed for (DEV-1).
2100
+ */
2101
+ checkMainnetPin(chainId) {
2102
+ if (chainId === MAINNET_CHAIN_ID && this.chainPin() !== MAINNET_CHAIN_ID)
2103
+ throw new MainnetNotAllowedError(this.rpc);
2104
+ }
2105
+ /** The chain id this client is pinned to (`ClientOptions.chainId`), if any. A client that pins the chain itself reports its own pin. */
2106
+ chainPin() {
2107
+ return this.pinnedChainId;
2108
+ }
1216
2109
  /** One request, bounded by `timeoutMs` (headers and body): a node that never answers fails with `RequestTimeoutError` instead of hanging. */
1217
2110
  async call(path, body) {
1218
2111
  const ctl = new AbortController();
@@ -1220,7 +2113,11 @@ export class Client {
1220
2113
  let res;
1221
2114
  let text;
1222
2115
  try {
1223
- const init = body === undefined ? { signal: ctl.signal } : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: ctl.signal };
2116
+ const init = body === undefined
2117
+ ? { signal: ctl.signal }
2118
+ : body instanceof Uint8Array
2119
+ ? { method: "POST", headers: { "content-type": "application/octet-stream" }, body: body, signal: ctl.signal }
2120
+ : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: ctl.signal };
1224
2121
  res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, init);
1225
2122
  text = await res.text();
1226
2123
  }
@@ -1237,10 +2134,10 @@ export class Client {
1237
2134
  json = JSON.parse(text);
1238
2135
  }
1239
2136
  catch {
1240
- throw new ApiError(res.status, res.ok ? "the node returned something that isn't JSON" : `node error ${res.status} ${res.statusText}`.trim());
2137
+ throw new ApiError(res.status, res.ok ? "the node returned something that isn't JSON" : `node error ${res.status} ${res.statusText}`.trim(), retryAfter(res, ""));
1241
2138
  }
1242
2139
  if (!res.ok)
1243
- throw new ApiError(res.status, json.error ?? res.statusText);
2140
+ throw new ApiError(res.status, json.error ?? res.statusText, retryAfter(res, String(json.error ?? "")));
1244
2141
  return json;
1245
2142
  }
1246
2143
  async status() {
@@ -1257,7 +2154,45 @@ export class Client {
1257
2154
  }
1258
2155
  return s;
1259
2156
  }
1260
- blocks(limit = 20) { return this.call(`/blocks?limit=${limit}`); }
2157
+ /**
2158
+ * Block headers, newest first: `{ height, hash, timestamp_ms, proposer, state_root, tx_count, … }`.
2159
+ * Up to `limit` (at most 100) below height `before` (default: from the head), so a page's
2160
+ * last height is the next page's `before`. Nothing older than the node's `history_from`.
2161
+ * `blocks(20)` still works.
2162
+ */
2163
+ async blocks(page = {}) {
2164
+ const { limit = 20, before } = typeof page === "number" ? { limit: page } : page;
2165
+ const q = new URLSearchParams({ limit: String(wholeNumber(limit, "limit")) });
2166
+ if (before !== undefined)
2167
+ q.set("before", String(wholeNumber(before, "before")));
2168
+ return this.call(`/blocks?${q}`);
2169
+ }
2170
+ /**
2171
+ * An account's history, newest first, a page at a time (TR-3): pass the previous page's `next`
2172
+ * as `before` until it's `null`. Each tx is shown as `/api/tx` shows it. On nodes without paged
2173
+ * history it's the newest 25 and the pending ones, with `paged: false` (see `HistoryPage`).
2174
+ */
2175
+ async history(account, page = {}) {
2176
+ const q = new URLSearchParams();
2177
+ if (page.before !== undefined)
2178
+ q.set("before", page.before);
2179
+ if (page.limit !== undefined)
2180
+ q.set("limit", String(wholeNumber(page.limit, "limit")));
2181
+ try {
2182
+ const r = await this.call(`/account/${encodeURIComponent(account)}/txs${q.size ? `?${q}` : ""}`);
2183
+ return { txs: arr(r?.txs), next: typeof r?.next === "string" && r.next ? r.next : null, history_from: num(r?.history_from), paged: true };
2184
+ }
2185
+ catch (e) {
2186
+ // A node from before paged history has no such route (404 or 405, without JSON).
2187
+ if (!(e instanceof ApiError && (e.status === 404 || e.status === 405) && /^node error|not found/i.test(e.message)))
2188
+ throw e;
2189
+ }
2190
+ if (page.before !== undefined)
2191
+ return { txs: [], next: null, paged: false };
2192
+ const a = await this.account(account);
2193
+ const s = await this.status().catch(() => null);
2194
+ return { txs: arr(a?.recent_txs), next: null, history_from: num(s?.history_from), paged: false };
2195
+ }
1261
2196
  block(heightOrHash) { return this.call(`/block/${heightOrHash}`); }
1262
2197
  tx(id) { return this.call(`/tx/${id}`); }
1263
2198
  account(addressOrName) { return this.call(`/account/${encodeURIComponent(addressOrName)}`); }
@@ -1332,6 +2267,82 @@ export class Client {
1332
2267
  distribution(id) { return this.call(`/distribution/${id}`); }
1333
2268
  /** A multisig account: owners, threshold, open proposals and their approvals. */
1334
2269
  multisig(account) { return this.call(`/multisig/${encodeURIComponent(account)}`); }
2270
+ // ---- programs (protocol v2, docs/PROGRAMS-V0.md §8.2, §8.4): third-party WebAssembly. Every answer is checked
2271
+ // before it's used (programs.ts), and what a program or its deployer chose is kept as `untrusted_*`. ----
2272
+ /**
2273
+ * The chain's fuel prices and program bounds now (`/api/fees`' `fuel` section), checked.
2274
+ * `ProgramsOffError` when programs have never been switched on there (the section is absent).
2275
+ */
2276
+ async programFuel() {
2277
+ return checkFuel(this.rpc, (await this.fees()).fuel);
2278
+ }
2279
+ /** The programs parameters a deposit is priced with (`/api/params`' `programs` section), checked. */
2280
+ async programParams() {
2281
+ return checkParams(this.rpc, (await this.params()).programs);
2282
+ }
2283
+ /**
2284
+ * A program by its address (never a name): its record, the version digest a call binds (recomputed
2285
+ * from its code, interface and version: a node whose digest isn't what those compute is refused),
2286
+ * who could upgrade it, what it holds and its methods, whose names are the deployer's
2287
+ * (`untrusted_name`). One node's word (`proven: false`): `provenProgram` proves the record itself.
2288
+ */
2289
+ async program(address) {
2290
+ programAddressArg(address);
2291
+ return checkProgram(this.rpc, address, await this.call(`/program/${address}`), (h) => this.checkHeightClock(h));
2292
+ }
2293
+ /**
2294
+ * A stored code by its hash: its size, who uploaded it and for what deposit, how many programs use
2295
+ * it, when it was stored (usable from the block after) and its exported methods (`untrusted_exports`).
2296
+ * With `wasm`, the code itself too, checked to hash to what was asked for.
2297
+ */
2298
+ async code(hash, opts = {}) {
2299
+ programHashArg(hash, "a code hash");
2300
+ return checkCode(this.rpc, hash, await this.call(`/code/${hash}${opts.wasm ? "?wasm=1" : ""}`), opts.wasm === true, (h) => this.checkHeightClock(h));
2301
+ }
2302
+ /**
2303
+ * A stored interface by its hash: its format-1 bytes (checked to hash to what was asked for) and
2304
+ * the node's JSON reading of them (`untrusted_interface`, every name the deployer's, not checked
2305
+ * against the bytes: this SDK has no interface decoder yet).
2306
+ */
2307
+ async interface(hash) {
2308
+ programHashArg(hash, "an interface hash");
2309
+ return checkInterface(this.rpc, hash, await this.call(`/interface/${hash}`), (h) => this.checkHeightClock(h));
2310
+ }
2311
+ /**
2312
+ * Runs a program's `view` method on the node, as of its newest block: nothing is signed or paid.
2313
+ * `input` is what the method reads (bytes, or lowercase hex); `bindings` name the other programs
2314
+ * it may reach (a program's address, and the version digest it must be at, or none for "any
2315
+ * version", which a read may take); `fuel` caps the run (the node caps it too). The answer's
2316
+ * `return_data` is the program's own claim, which the chain checks nothing of. Refused views
2317
+ * (`ApiError`: 403 when the node serves none, 429 with `retryAfterMs`, 400/404/422 for a request
2318
+ * the node won't run) never ran.
2319
+ */
2320
+ async view(address, method, input = "", opts = {}) {
2321
+ programAddressArg(address);
2322
+ const body = {
2323
+ method: programMethodArg(method),
2324
+ input: programInputArg(input),
2325
+ bindings: (opts.bindings ?? []).map((b) => ({ program: programAddressArg(b.program, "a binding"), version_digest: b.versionDigest === undefined ? null : programHashArg(b.versionDigest, "a binding's version digest") })),
2326
+ };
2327
+ if (opts.fuel !== undefined)
2328
+ body.fuel = positiveFuel(opts.fuel);
2329
+ return checkView(this.rpc, address, await this.call(`/program/${address}/view`, body), (h) => this.checkHeightClock(h));
2330
+ }
2331
+ /**
2332
+ * A program's record, proven: its Merkle proof (namespace 66) checked against the state root the
2333
+ * node reports, which each of `witnesses` must report too, and fresh (see `provenRecord`).
2334
+ * The record is decoded here and its `version_digest` recomputed from its own fields, so a call
2335
+ * built from it binds the code the chain holds. `null` if the program provably doesn't exist.
2336
+ * Without witnesses it's only as sure as this node (it can't be called proven).
2337
+ */
2338
+ async provenProgram(address, opts = {}) {
2339
+ programAddressArg(address);
2340
+ const r = await this.provenRecord(NAMESPACES.PROGRAMS, address, opts);
2341
+ if (r.value === null)
2342
+ return null;
2343
+ const rec = decodeProgram(r.value);
2344
+ return { address, ...rec, version_digest: versionDigest(address, rec.wasm_hash, rec.interface_hash, rec.version), witnesses: opts.witnesses?.length ?? 0, height: r.height, root: r.root, head: r.head };
2345
+ }
1335
2346
  /** `/api/proof/{namespace}/{key}`, unchecked. Prefer `provenRecord`. */
1336
2347
  proof(namespace, key) {
1337
2348
  const k = typeof key === "string" ? utf8ToBytes(key) : key;
@@ -1483,7 +2494,7 @@ export class Client {
1483
2494
  * - `TxPendingError`: it was admitted and may still execute. Don't send it again blindly.
1484
2495
  */
1485
2496
  async submit(tx, wait = true) {
1486
- const { id } = await this.call("/tx", tx);
2497
+ const { id } = await this.post(tx);
1487
2498
  if (wait)
1488
2499
  return this.waitForTx(id, undefined, tx.valid_until);
1489
2500
  try {
@@ -1493,6 +2504,17 @@ export class Client {
1493
2504
  throw new TxPendingError(id, 0, e);
1494
2505
  }
1495
2506
  }
2507
+ /**
2508
+ * Submits a signed tx and returns its id, without reading it back or waiting (`submit` does
2509
+ * both). The same bytes can be posted again safely: a node answers with the same id, and the tx
2510
+ * runs at most once. As JSON, or in its wire form with `wireTxs`. Errors as `submit`'s.
2511
+ */
2512
+ async post(tx) {
2513
+ const r = await this.call("/tx", this.wireTxs ? txBytes(tx) : tx);
2514
+ if (typeof r?.id !== "string")
2515
+ throw new Error(`${this.rpc} answered the submission without the transaction's id: it may or may not have been admitted`);
2516
+ return { id: r.id };
2517
+ }
1496
2518
  async faucet(to, wait = true) {
1497
2519
  const { id } = await this.call("/faucet", { to });
1498
2520
  return wait ? this.waitForTx(id) : this.tx(id);