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