@blockula/sdk 0.5.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -13
- package/dist/describe.d.ts +3 -1
- package/dist/describe.js +93 -2
- package/dist/index.d.ts +602 -11
- package/dist/index.js +1080 -58
- package/dist/networks.d.ts +25 -0
- package/dist/networks.js +27 -0
- package/dist/payments.d.ts +83 -0
- package/dist/payments.js +240 -0
- package/dist/programs.d.ts +471 -0
- package/dist/programs.js +779 -0
- package/dist/proof.d.ts +44 -0
- package/dist/proof.js +62 -0
- package/dist/tx.d.ts +135 -0
- package/dist/tx.js +213 -0
- package/package.json +6 -5
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, 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
|
-
|
|
498
|
-
|
|
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
|
|
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
|
|
520
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
843
|
+
return this.submitQueued({ type: "link_github", handle, gist_id: gistId }, "", {}, []);
|
|
571
844
|
}
|
|
572
845
|
async unlinkGithub(handle) {
|
|
573
|
-
return this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
886
|
+
return this.submitQueued({ type: "join_jury", bond: parseFang(bond).toString() }, "", {}, []);
|
|
614
887
|
}
|
|
615
888
|
async leaveJury() {
|
|
616
|
-
return this.
|
|
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.
|
|
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.
|
|
910
|
+
return this.submitQueued({ type: "reveal_votes", seance_id: seanceId, votes, spam, salt }, "", {}, []);
|
|
638
911
|
}
|
|
639
912
|
async fundPool(amount) {
|
|
640
|
-
return this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
945
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
1155
|
-
|
|
1156
|
-
|
|
1984
|
+
/** The node's URL. */
|
|
1985
|
+
rpc;
|
|
1986
|
+
/** The network this client was made for (`ClientOptions.network`), if any. */
|
|
1987
|
+
network;
|
|
1988
|
+
/** See `ClientOptions.wireTxs`. */
|
|
1989
|
+
wireTxs;
|
|
1990
|
+
/** The public testnet (`NETWORKS.testnet`), every pin set: `Client.testnet({ witnesses })`, or pass your own node's URL as `rpc`. */
|
|
1991
|
+
static testnet(opts = {}) {
|
|
1992
|
+
const { rpc, ...rest } = opts;
|
|
1993
|
+
return new Client(rpc, { ...rest, network: "testnet" });
|
|
1994
|
+
}
|
|
1995
|
+
/** A `blockula dev` chain on this machine (`NETWORKS.local`). */
|
|
1996
|
+
static local(opts = {}) {
|
|
1997
|
+
const { rpc, ...rest } = opts;
|
|
1998
|
+
return new Client(rpc, { ...rest, network: "local" });
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* `new Client(rpc, opts)`, `new Client({ network: "testnet" })` (the network's public RPC), or
|
|
2002
|
+
* `new Client(rpc, { network })` (your own node, held to that network's pins). The pins
|
|
2003
|
+
* (`opts`, and the network's) are checked on every status: a node reporting another prefix,
|
|
2004
|
+
* chain id or genesis hash is refused with `ChainMismatchError`.
|
|
2005
|
+
*/
|
|
2006
|
+
constructor(rpc, options = {}) {
|
|
2007
|
+
let opts = options;
|
|
2008
|
+
let url = typeof rpc === "string" ? rpc : undefined;
|
|
2009
|
+
if (rpc !== undefined && typeof rpc !== "string") {
|
|
2010
|
+
if (rpc === null || typeof rpc !== "object")
|
|
2011
|
+
throw new Error("new Client(rpc, opts) takes the node's URL, or the options alone");
|
|
2012
|
+
opts = { ...rpc, ...options };
|
|
2013
|
+
}
|
|
2014
|
+
if (opts.network !== undefined && !isNetworkName(opts.network))
|
|
2015
|
+
throw new Error(`unknown network ${JSON.stringify(opts.network)} (testnet or local)`);
|
|
2016
|
+
const preset = opts.network === undefined ? undefined : NETWORKS[opts.network];
|
|
2017
|
+
if (preset) {
|
|
2018
|
+
url ??= preset.rpc;
|
|
2019
|
+
if (url === undefined)
|
|
2020
|
+
throw new Error(`no public ${preset.name} RPC is built into this SDK yet: pass the URL of a ${preset.name} node you trust, new Client(url, { network: "${preset.name}" })`);
|
|
2021
|
+
opts = withPreset(opts, preset);
|
|
2022
|
+
}
|
|
2023
|
+
this.rpc = url ?? DEFAULT_RPC;
|
|
2024
|
+
this.network = preset?.name;
|
|
2025
|
+
this.wireTxs = opts.wireTxs === true;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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);
|