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