@blockula/sdk 0.5.1 โ 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -13
- package/dist/describe.d.ts +3 -1
- package/dist/describe.js +93 -2
- package/dist/index.d.ts +602 -11
- package/dist/index.js +1080 -58
- package/dist/networks.d.ts +25 -0
- package/dist/networks.js +27 -0
- package/dist/payments.d.ts +83 -0
- package/dist/payments.js +240 -0
- package/dist/programs.d.ts +471 -0
- package/dist/programs.js +779 -0
- package/dist/proof.d.ts +44 -0
- package/dist/proof.js +62 -0
- package/dist/tx.d.ts +135 -0
- package/dist/tx.js +213 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @blockula/sdk
|
|
2
2
|
|
|
3
|
-
๐ง TypeScript SDK for **Blockula**, a post-quantum
|
|
3
|
+
๐ง TypeScript SDK for **Blockula**, a blockchain with post-quantum signatures (ML-DSA-65, FIPS 204). It covers wallets, `.boo` names, transfers, multi-key accounts, agent session keys, and passkey-unlocked wallets.
|
|
4
|
+
|
|
5
|
+
> Blockula is experimental software, provided as is under the MIT license. Each chain's FANG has no value: its faucet hands it out, and it pays fees. Apps, services and bots: [docs/INTEGRATION.md](https://github.com/jackdishman/blockula/blob/main/docs/INTEGRATION.md).
|
|
4
6
|
|
|
5
7
|
```bash
|
|
6
8
|
npm install @blockula/sdk
|
|
@@ -9,8 +11,8 @@ npm install @blockula/sdk
|
|
|
9
11
|
```ts
|
|
10
12
|
import { Client, Wallet, encodeKeyGrant } from "@blockula/sdk";
|
|
11
13
|
|
|
12
|
-
const wallet = Wallet.generate(
|
|
13
|
-
await wallet.faucet(); // test chains: 100 FANG
|
|
14
|
+
const wallet = Wallet.generate(Client.testnet()); // the public testnet, every pin set: tboo1โฆ addresses
|
|
15
|
+
await wallet.faucet(); // test chains: 100 test FANG
|
|
14
16
|
await wallet.registerName("vlad"); // vlad.boo
|
|
15
17
|
await wallet.send("alice.boo", "1.5", { memo: "trick or treat" });
|
|
16
18
|
|
|
@@ -20,6 +22,102 @@ const agentGrantCode = encodeKeyGrant(await agent.keyGrant(wallet.account));
|
|
|
20
22
|
await wallet.addSessionKey(agentGrantCode, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 });
|
|
21
23
|
```
|
|
22
24
|
|
|
25
|
+
On a `blockula dev` chain, use `Client.local()` (or `new Client("http://localhost:7070")`).
|
|
26
|
+
|
|
27
|
+
## Which network?
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
Client.testnet(); // the public testnet
|
|
31
|
+
new Client({ network: "testnet" }); // the same
|
|
32
|
+
new Client("http://localhost:7171", { network: "testnet" }); // your own node, held to the testnet's pins
|
|
33
|
+
Client.local(); // blockula dev on this machine
|
|
34
|
+
new Client("http://<your node>:7070", { chainId: "mychain", genesisHash: "โฆ" }); // a chain of your own
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`NETWORKS` lists what each preset pins: the chain id, the genesis hash, the address prefix and the genesis clock. A client made for a network checks them on every status it reads, including the one each signature is made against, and refuses a node on another chain (`ChainMismatchError`). The prefix is known up front, so `wallet.address` is `tboo1โฆ` on the testnet before the first request. Pins you pass as well must agree with the network's.
|
|
38
|
+
|
|
39
|
+
A wallet signs nothing for a node that says it's on `blockula-1` unless its client pins that chain (`chainId: "blockula-1"`): it throws `MainnetNotAllowedError` instead.
|
|
40
|
+
|
|
41
|
+
## Balances
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const b = await wallet.balances(); // { balance, transferable, locked, exists, display }
|
|
45
|
+
b.transferable; // bigint drops: what it can pay now
|
|
46
|
+
await wallet.balance(); // "12.5 FANG": everything it holds
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `transferable` is the "can pay" number: what the account can send now, fees included. Vesting and vote locks hold the rest (`locked`). Staked FANG isn't in the balance (`client.staking(account)`).
|
|
50
|
+
- `balance()` and `balances()` throw when the node can't answer (down, rate limited, a timeout, another chain). They return 0 only for an account that doesn't exist yet. Never show a failed read as an empty account.
|
|
51
|
+
|
|
52
|
+
## Costs
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const q = await wallet.quote("alice.boo", "0.02");
|
|
56
|
+
q.total; // amount + fee + creationFee, in drops
|
|
57
|
+
q.creationFee; // 0.1 FANG if this payment opens alice's account, else 0
|
|
58
|
+
q.enough; // whether the account can pay it
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Fees.** Every tx pays its block's base fee (priced per byte and burned) plus its tip. A transfer is about 5.5 KB, about 0.000065 FANG at the floor. `/api/fees` fixes the base fee 17 blocks ahead, and it rises while blocks are full.
|
|
62
|
+
- **Opening an account.** A payment to an address the chain hasn't seen burns `creation_fee` (0.1 FANG with the default parameters) from the payer, on top of the amount. That payment must carry at least 0.01 FANG. `quote()` counts both. The result of a finalized `send` that opened an account says so (`opened_account`, `creation_fee`), since its `summary` and `fee` don't: show it next to the summary.
|
|
63
|
+
- **Zero.** `send` refuses an amount of 0 before signing: it would move nothing and pay a fee.
|
|
64
|
+
- **Names.** Registering a name costs what `client.name(n).price` says. With the default parameters: 100 FANG for 3 characters, 25 for 4, 5 for 5 or more. A year's renewal (`renewal_price`) is a fifth of that.
|
|
65
|
+
- **Headroom.** `send(to, amount, { feeHeadroom: 3 })` lets the fee rise to 3ร today's base fee before the tx is included, and `{ maxFee }` sets it outright. Only the including block's base fee and the tip are charged, so headroom costs nothing. Without it, a tx stuck behind full blocks for more than 17 blocks can stop covering the base fee and be dropped (with every later tx from the account). `wallet.feeHeadroom = 2` sets a default.
|
|
66
|
+
|
|
67
|
+
## Receiving payments
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { watchPayments } from "@blockula/sdk";
|
|
71
|
+
|
|
72
|
+
await watchPayments(client, shopAddress, {
|
|
73
|
+
fromHeight: await db.get("next"), // resume where you stopped
|
|
74
|
+
onPayment: async (p) => { // in chain order
|
|
75
|
+
await db.creditOrderOnce(p.id, p.memo, p.amount);
|
|
76
|
+
},
|
|
77
|
+
onProgress: async (next) => db.set("next", next),
|
|
78
|
+
signal, // abort to stop
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **Finalized is final.** Blocks run only once they're finalized, so there are no reorgs and no confirmations to wait for. `watchPayments` reads only finalized blocks.
|
|
83
|
+
- **Match a unique memo.** Give each order its own memo (an order id you made up) and match it. Memos are public, and anyone can write anything in one: never act on what a memo says.
|
|
84
|
+
- **Credit by `id`.** If your `onPayment` throws, the watch stops; resuming from the last stored height delivers that block again. Credit each payment once, by its tx id.
|
|
85
|
+
- **Watch an address,** not a name: names expire and change hands. `p.to_name` says which name the payer used.
|
|
86
|
+
- **Only transfers.** Other ways FANG arrives (a hunt's award, staking) aren't payments here. `client.history(account, { before, limit })` pages through everything an account did, newest first.
|
|
87
|
+
- **Pruned history.** Nodes keep a limited window of blocks (`/api/status` `history_from`). If you resume from before it, `watchPayments` throws `HistoryPrunedError` rather than skip payments: ask a node that keeps every block, or pass `skipPruned`.
|
|
88
|
+
- `paymentsIn(block, account)` reads one `/api/block/{height}` answer, for your own scanner. `client.blocks({ limit, before })` pages block headers backwards; fetch only blocks with `tx_count > 0`.
|
|
89
|
+
|
|
90
|
+
## Sending a lot
|
|
91
|
+
|
|
92
|
+
- **One wallet, many sends.** A wallet signs and submits its txs one at a time, in nonce order, so `Promise.all` of many `send`s works: each gets the next nonce. Waiting for them happens in parallel.
|
|
93
|
+
- **`sendMany`** pays a list, with at most `concurrency` (default 16) waiting to finalize at once. A node allows 64 pending txs per sender. On a 429 (rate limited) or "too many pending", it resubmits the same signed bytes once the node says to retry. Each payment gets a result, `{ ok, tx }` or `{ ok: false, error }`.
|
|
94
|
+
- **Rate limits.** A public node takes about 120 txs a minute and 50 reads a second per IP, and answers `429` with `Retry-After` (`ApiError.retryAfterMs`). Your own node doesn't limit you (loopback isn't limited), but the validator it forwards to does.
|
|
95
|
+
- **Never re-sign a payment you're unsure of.** A tx that comes back pending, dropped or timed out may still run until its `valid_until`. Resubmitting the same bytes (`client.post(tx)`) is safe: it runs at most once. A new signature is a new payment. See [Timeouts and pending transactions](#timeouts-and-pending-transactions).
|
|
96
|
+
- **Store first, then send.** `send(to, amount, { onSigned: (tx, id) => outbox.save(id, tx) })` (and `sendMany(payments, { onSigned })`) hands you each signed tx and its id before it's submitted, in the wallet's queue, so parallel sends still get one nonce each. If `onSigned` throws, nothing is submitted. `wallet.build()` followed by your own `client.post(tx)` takes no nonce from the queue: two builds in a row get the same nonce, so build and post one tx at a time per key that way.
|
|
97
|
+
- **The wire form** (`new Client(rpc, { wireTxs: true })`) posts txs as their binary wire form, about half the bytes of JSON.
|
|
98
|
+
|
|
99
|
+
## Your own RPC node
|
|
100
|
+
|
|
101
|
+
Any node serves the same API, and one on your machine isn't rate limited. To run one against the testnet:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
blockula join --genesis https://blockula-testnet.fly.dev --home ~/blockula-node --port 7171
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const client = new Client("http://localhost:7171", {
|
|
109
|
+
network: "testnet", // its pins: a node on another chain is refused
|
|
110
|
+
witnesses: [new Client("https://blockula-testnet.fly.dev")], // names proven against a second node
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- It fast-syncs from a certified snapshot in seconds, so it holds history only from there (`history_from`). Pass `--full` to replay from genesis.
|
|
115
|
+
- Txs you send through it are forwarded to a validator. That needs a node release that relays through another full node; an older node answers "forwarding loop" when it can only reach the public node.
|
|
116
|
+
- A second, independent node as a witness means no single node's word decides who a name pays (see "Which chain? Pinning and proofs" below).
|
|
117
|
+
- [docs/INTEGRATION.md](https://github.com/jackdishman/blockula/blob/main/docs/INTEGRATION.md) covers pruning, checkpoints and the weak-subjectivity window.
|
|
118
|
+
|
|
119
|
+
## Accounts, keys and fees
|
|
120
|
+
|
|
23
121
|
Adding a key needs the key's consent (audit gap-7-2): `AddKey` carries the key's public key and its signature over the chain, the account and its key id, so nobody can put your key on their account (and pad `client.keyAccounts(keyId)`, which pages with `{ after }`). The key's holder makes it with `keyGrant(account)`; `encodeKeyGrant`/`decodeKeyGrant` turn it into a pasteable `bkgrant1.โฆ` code, `verifyKeyGrant` checks one, and `addOwnerKey`/`addSessionKey` take either and refuse a grant made for another account or chain.
|
|
24
122
|
|
|
25
123
|
Votes, multisig approvals and sealed Sรฉance judgments bind what they approve (audit gap-7-6): `vote(id, aye, amount, conviction, { proposal })` signs `proposalDigest(kind, title, url)` of the proposal you showed, `multisigApprove(id, { action })` signs `actionDigest(action)`, and `sealVotes(โฆ, { pairs })` signs `seancePairsDigest` of the pairs and PRs the juror compared (each read from the client when not given). The chain refuses a digest that doesn't match what's stored, so a node that misreports what's being approved makes it fail instead of landing elsewhere.
|
|
@@ -43,7 +141,7 @@ await juror.revealVotes(s.id, sealed.votes, sealed.spam, sealed.salt); // after
|
|
|
43
141
|
await wallet.withdrawVested();
|
|
44
142
|
```
|
|
45
143
|
|
|
46
|
-
Amounts are FANG strings (`"1.5"`) or bigint drops (1 FANG = 10โน drops). Every transaction result includes a plain-English `summary`.
|
|
144
|
+
Amounts are FANG strings (`"1.5"`) or bigint drops (1 FANG = 10โน drops). A JS number still works but is deprecated: floats can't hold most decimal amounts exactly. Every transaction result includes a plain-English `summary`.
|
|
47
145
|
|
|
48
146
|
Fees, tips and expiry (protocol v1):
|
|
49
147
|
|
|
@@ -59,11 +157,11 @@ wallet.validForBlocks = 600;
|
|
|
59
157
|
- `txBytes(tx)` is the binary wire form nodes gossip and put in blocks, and `txId(tx)` its id, known before you submit.
|
|
60
158
|
- Every transaction names the chain it's for by `chain_id` and `genesis_hash` (the chain instance, from `/api/status`), so it can't replay on a relaunched chain that reuses the id. `wallet.build` fills both in; to sign offline, put the chain's `genesis_hash` in the `UnsignedTx` you pass to `wallet.sign`.
|
|
61
159
|
|
|
62
|
-
Addresses are bech32m with the chain's prefix: `boo1โฆ`
|
|
160
|
+
Addresses are bech32m with the chain's prefix: `boo1โฆ` by default, `tboo1โฆ` on public testnets. `isAddress(s)` accepts any known prefix and `isAddress(s, "tboo")` exactly one; `addressFromPublicKey(pk, prefix)` renders one. A `Client` learns its chain's prefix from `/api/status` (`await client.addressPrefix()`), and wallets using that client sign with it (`wallet.address`, `wallet.account`). Pin it with `new Client(rpc, { prefix: "boo" })` to refuse a node on another network (see "Which chain?" below). Nodes reject addresses of another network's prefix.
|
|
63
161
|
|
|
64
|
-
- Names in a signature (audit gap-7-1). Where the chain resolves a name itself (`send`, `awardHunt`, `vouch`/`unvouch`, `grantSoul`, `flagSybil`, `clawback`, `transferName`, proposal targets), a client with `witnesses` (below) signs the name's proven owner, and one without signs the **name itself** (`alice.boo`): the chain pays whoever owns it when the tx runs, so a lying node can't point it at another account now. It could still hold the signed tx and release it later in its validity window, which counts from the height that node reported, by when the name may have lapsed and been registered again: pay a name that's about to expire by address. `Wallet.build` refuses a height more than `maxHeadLeadBlocks` (30) ahead of the lowest head the witnesses report (every witness must answer, so one honest witness is enough), or past what a `genesisClock: { createdAtMs, blockTimeMs }` pin allows by this machine's clock (`HeightAheadError`, nothing signed); without either the height is the node's word. `client.recipient(name)` says which (`signed`, `byChain`, and `how` to show before signing); the address it reports for an unproven name is the node's word, for display. Where an address is needed (`delegate`, `undelegate`, `redelegate`, `unjail`, `actAs`, `keyGrant`), a name is resolved (`client.resolution`: `proven` and `how`), and on
|
|
65
|
-
- `wallet.address` uses the chain's prefix once the client knows it.
|
|
66
|
-
- Node errors throw `ApiError` (`.status`: 429 means rate limited). A transaction the node reports as failed throws `TxFailedError` from `waitForTx` (`dropped` says whether it ran: see "Timeouts and pending transactions").
|
|
162
|
+
- Names in a signature (audit gap-7-1). Where the chain resolves a name itself (`send`, `awardHunt`, `vouch`/`unvouch`, `grantSoul`, `flagSybil`, `clawback`, `transferName`, proposal targets), a client with `witnesses` (below) signs the name's proven owner, and one without signs the **name itself** (`alice.boo`): the chain pays whoever owns it when the tx runs, so a lying node can't point it at another account now. It could still hold the signed tx and release it later in its validity window, which counts from the height that node reported, by when the name may have lapsed and been registered again: pay a name that's about to expire by address. `Wallet.build` refuses a height more than `maxHeadLeadBlocks` (30) ahead of the lowest head the witnesses report (every witness must answer, so one honest witness is enough), or past what a `genesisClock: { createdAtMs, blockTimeMs }` pin allows by this machine's clock (`HeightAheadError`, nothing signed); without either the height is the node's word. `client.recipient(name)` says which (`signed`, `byChain`, and `how` to show before signing); the address it reports for an unproven name is the node's word, for display. Where an address is needed (`delegate`, `undelegate`, `redelegate`, `unjail`, `actAs`, `keyGrant`), a name is resolved (`client.resolution`: `proven` and `how`), and on `blockula-1` nothing is signed with one only a single node vouched for: it throws `UnprovenNameError` (checked against the very status each tx is signed for). Pass the address once its owner has confirmed it, or give the client witnesses.
|
|
163
|
+
- `wallet.address` uses the chain's prefix once the client knows it. A client made for a network (`Client.testnet()`) or pinned with `prefix` knows it up front. Otherwise it shows the `boo` form until the first request, so `await client.addressPrefix()` first if you display it.
|
|
164
|
+
- Node errors throw `ApiError` (`.status`: 429 means rate limited, and `.retryAfterMs` says how long the node asked you to wait). A transaction the node reports as failed throws `TxFailedError` from `waitForTx` (`dropped` says whether it ran: see "Timeouts and pending transactions").
|
|
67
165
|
- Pass `rpId` to `createPasskeyWallet`/`unlockPasskeyWallet` to pin wallets to your production domain. Passkeys are bound to that hostname for good.
|
|
68
166
|
|
|
69
167
|
### Staking, governance and names (protocol v1)
|
|
@@ -90,13 +188,36 @@ await wallet.multisigCreate([phoneKeyId, laptopKeyId, kitKeyId], 2);
|
|
|
90
188
|
- `convictionWeight(amount, conviction)` is a vote's weight: 0.1ร with no lock, then 1รโ6ร for 1, 2, 4, 8, 16 or 32 lock periods (`CONVICTION_PERIODS`).
|
|
91
189
|
- Reads: `client.staking(account)`, `client.proposals()`, `client.proposal(id)`, `client.params()`, `client.fees()`, `client.upgrade()`, `client.distribution(id)`, `client.multisig(account)`. `stakeSummary`, `proposalSummary` and `paramsOf` turn them into stable shapes, tolerating renamed or missing fields.
|
|
92
190
|
|
|
191
|
+
### Programs (protocol v2)
|
|
192
|
+
|
|
193
|
+
Programs are third-party WebAssembly, on a chain that has switched the `programs` feature on (`blockula dev --programs` does). Everything a program or its deployer chooses (method, argument and event names, an abort message, what a method returns) is a stranger's claim: the SDK keeps those as the node serves them, under `untrusted_*`. A failure's words (`ProgramFailedError.message`) hold only numbers and the chain's own names, never what the program wrote: an abort message is on the error as `untrusted_message`. Any other text of a node or a program that an error quotes is stripped of control, format (bidi, zero-width), private-use and separator characters. Every node answer is checked before it's used: digests are 64 lowercase hex, heights whole numbers the clock allows, amounts decimal strings, and a program's `version_digest` is the one its own code, interface and version compute (`ProgramAnswerError` otherwise).
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { Client, Wallet, reconcileSigner } from "@blockula/sdk";
|
|
197
|
+
import { readFileSync } from "node:fs";
|
|
198
|
+
|
|
199
|
+
const wasm = readFileSync("counter.wasm"), iface = readFileSync("counter.iface"); // format-1 interface bytes: `blockula program interface encode`
|
|
200
|
+
const up = await wallet.uploadCode(wasm, iface); // bonds a deposit (v0 never gives it back); resolves once the block after it is sealed
|
|
201
|
+
const { address, receipt } = await wallet.createProgram({ wasmHash: up.wasmHash, interfaceHash: up.interfaceHash }); // the address, only once the create has applied
|
|
202
|
+
await wallet.call(address, "increment", "", { storageLimit: "1" }); // a call's storage limit is 0 unless given: a method that writes needs one
|
|
203
|
+
(await client.view(address, "get")).return_data; // a read, as the node's newest block has it: the program's claim, as hex
|
|
204
|
+
const proven = await client.provenProgram(address, { witnesses }); // its record, Merkle-proven; the digest recomputed from it
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- **Fees include fuel.** A program tx reserves fuel (`fuelLimit`, default `min(program_max_tx_fuel, program_max_block_fuel โ load)`, plus the load of the code it declares) and pays for all of it, never refunded; its `fee` is the bytes' base fee plus `suggested_rate ร reserved / 1000`, and `feeHeadroom`, `maxFee` and the tip cap count it. A reservation no block could carry (`program_max_block_fuel`) is refused before signing (`FuelReservationError`).
|
|
208
|
+
- **Sign what was shown.** A call binds the program's `versionDigest`. Pass the one you showed the user (`call(..., { versionDigest })`) and the call is refused if the program isn't the one it names; without it the SDK recomputes it from the record (proven against the client's `witnesses` when it has some), never signing a digest because a node reported it. A binding without a digest gets the version in force, never "any version" (`ANY_PROGRAM_VERSION` is explicit).
|
|
209
|
+
- **Waiting.** Upload and create are one step per block: each resolves after its block is final and the next one sealed, and a create or call first waits for the code or program to be usable (`waitMs`), instead of being refused by the mempool.
|
|
210
|
+
- **Receipts.** Each result has `receipt`: facts the chain applied (`facts`: outcome, balance changes, fuel, fees, `outside_vm` deposits and the creation fee, `burned_by_programs`: the creation fees of accounts a program's own sends opened) apart from claims (`claims`: return data, events). A signer's balance moves by exactly โ(fee + `outside_vm.total`) + its entry in `facts.balance_changes`: `reconcileSigner(receipt, signer, before, after)` checks it (and that the receipt conserves FANG: ฮฃ balance changes + (bonded โ record deposit โ upload deposit โ refunded) + `burned_by_programs` = 0, which `checkReceipt` holds every receipt to), and `cost` says what it debited. A failed call throws `ProgramFailedError` (its fee was charged; `.receipt`, `.words`; a `storage_limit` trap says to pass a limit).
|
|
211
|
+
- Reads: `client.program(address)`, `client.code(hash, { wasm })`, `client.interface(hash)`, `client.view(address, method, input, { bindings, fuel })`, `client.provenProgram(address, { witnesses })`, `client.programFuel()`, `client.programParams()`. Programs are named by address, never by a `.boo` name. `wallet.programsPause()` is for a validator's own key.
|
|
212
|
+
- There's no interface JSON codec in the SDK yet: `uploadCode` takes the format-1 bytes (`blockula program interface encode` prints them).
|
|
213
|
+
|
|
93
214
|
### Which chain? Pinning and proofs
|
|
94
215
|
|
|
95
216
|
```ts
|
|
96
|
-
const client = new Client("https://rpc.example", { chainId: "
|
|
217
|
+
const client = new Client("https://rpc.example", { chainId: "mychain", genesisHash: "โฆ", prefix: "boo" });
|
|
97
218
|
```
|
|
98
219
|
|
|
99
|
-
A pinned client checks every `/api/status` it reads, including the one each signature is made against, and throws `ChainMismatchError` instead of signing for another chain.
|
|
220
|
+
A pinned client checks every `/api/status` it reads, including the one each signature is made against, and throws `ChainMismatchError` instead of signing for another chain. `network` (see "Which network?") sets these pins for you. Wallets sign for a node on `blockula-1` only through a client with that pin.
|
|
100
221
|
|
|
101
222
|
A node can still lie about the state of the right chain. Give a client witnesses and the names it resolves for signatures are proven (without them, see "Names in a signature" above) (`new Client(rpc, { โฆpins, witnesses: [new Client(rpc2, pins), โฆ], requireProvenNames: true })`; `requireProvenNames` refuses any name it can't prove). `Wallet.propose`'s default deposit must be the same on the node and every witness, and at most `maxDeposit` (default `DEFAULT_MAX_PROPOSAL_DEPOSIT`, 1,000 FANG): a node can report any deposit, and all of it burns if the vote misses quorum (audit gap-7-5). Session-key and hunt expiries count from the same status read as `valid_until`, so a node lying about the height can stretch them by at most `max_tx_validity_blocks` (audit gap-7-3).
|
|
102
223
|
|
|
@@ -129,8 +250,17 @@ Every request has a timeout (`new Client(rpc, { timeoutMs })`, default 15 s, at
|
|
|
129
250
|
|
|
130
251
|
`verifyReleaseSignature(sig, checksumsFile, trustedKeyId)` checks a release's offline ML-DSA-65 signature (docs/OPERATIONS.md "Releases").
|
|
131
252
|
|
|
132
|
-
The public testnet is `https://blockula-testnet.fly.dev`, and the wallet is https://blockula
|
|
253
|
+
The public testnet is `https://blockula-testnet.fly.dev`, and the wallet is https://testnet.blockula.app.
|
|
254
|
+
|
|
255
|
+
Tests: `npm run test:vectors` (offline: passkey derivation, state-proof and tx-encoding vectors from the node, every protocol v1 helper against a fake node, pinning, proof freshness, timeouts, release signatures, and `test/integrators.ts`: network presets, the `blockula-1` pin, balances, the nonce queue, `sendMany`, `quote`, history, `watchPayments` and `onSigned`; `test/programs.ts` (programs: every answer check, the fuel and deposit arithmetic, receipts and balance reconciliation, `provenProgram`'s refusals one by one, and the Wallet's helpers against a fake node serving answers recorded from a dev chain), and `test/examples.ts`, which runs `examples/pay-from-a-script.mjs` against a fake node, so build first), `npm run test:kat` (ML-DSA known answers), `npm test` (end to end against `blockula dev`: `test/e2e.ts` and `test/e2e-integrators.ts`, on a chain with programs or without), `npm run test:programs` (`test/e2e-programs.ts`, which needs `blockula dev --programs`: on a chain without programs it says so and passes, and `BLOCKULA_E2E_PROGRAMS=1` makes that a failure, as CI sets it). The `examples/` directory at the repository's root has two runnable scripts: receiving payments, and paying from a script.
|
|
256
|
+
|
|
257
|
+
## Changes in 0.6
|
|
258
|
+
|
|
259
|
+
Two changes need a look when you upgrade:
|
|
260
|
+
|
|
261
|
+
- **`balance()` throws** when the node can't answer, instead of returning `"0 FANG"`. It's `"0 FANG"` only for an account that doesn't exist yet. `balances()` is new.
|
|
262
|
+
- **`blockula-1` needs a pin.** A wallet whose client isn't pinned to `blockula-1` (`chainId: "blockula-1"`) throws `MainnetNotAllowedError` instead of signing for a node on it.
|
|
133
263
|
|
|
134
|
-
|
|
264
|
+
Also new: network presets (`NETWORKS`, `Client.testnet()`), a nonce queue in each wallet, `sendMany`, `quote`, `feeHeadroom` and `maxFee`, zero amounts refused, `opened_account` and `creation_fee` on receipts, `client.blocks({ limit, before })`, `client.history`, `watchPayments`, `client.post`, `wireTxs`, `onSigned`, and `ApiError.retryAfterMs`.
|
|
135
265
|
|
|
136
|
-
|
|
266
|
+
Blockula is experimental software, provided as is under the MIT license, without warranty of any kind.
|
package/dist/describe.d.ts
CHANGED
|
@@ -37,7 +37,9 @@ export interface ActionReview {
|
|
|
37
37
|
* Whether this client could read all of it: false for anything it described as unreadable
|
|
38
38
|
* (an unknown type, a malformed field, a number JSON rounded, a key id the chain couldn't store)
|
|
39
39
|
* and for a `vote` whose proposal (`opts.proposal`) wasn't given or doesn't hash to the digest
|
|
40
|
-
* it binds
|
|
40
|
+
* it binds; and for a program action, which no multisig holds (the node never decodes one inside
|
|
41
|
+
* a multisig proposal), so a node that shows one as pending isn't showing what the chain stores.
|
|
42
|
+
* Don't offer to approve an action that isn't `readable`.
|
|
41
43
|
*/
|
|
42
44
|
readable: boolean;
|
|
43
45
|
}
|
package/dist/describe.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { blake3 } from "@noble/hashes/blake3.js";
|
|
9
9
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
|
|
10
10
|
import { bech32m } from "@scure/base";
|
|
11
|
-
import { DROPS_PER_FANG, PUBLIC_KEY_LEN, proposalDigest } from "./tx.js";
|
|
11
|
+
import { DROPS_PER_FANG, PUBLIC_KEY_LEN, interfaceHash, isProgramAction, proposalDigest, wasmHash } from "./tx.js";
|
|
12
12
|
const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
|
|
13
13
|
/** Whether `s` is a canonical address (any known prefix). Kept local so this module has no import cycle. */
|
|
14
14
|
function address(s) {
|
|
@@ -111,6 +111,73 @@ function consensusKey(v) {
|
|
|
111
111
|
return "an unreadable consensus key";
|
|
112
112
|
return `consensus key ${hex(bytesToHex(blake3(hexToBytes(v))), 32, "key id")}`;
|
|
113
113
|
}
|
|
114
|
+
// ---- Program actions (tags 62โ64 and 69, docs/PROGRAMS-V0.md ยง2), described from their structure
|
|
115
|
+
// only, as node/src/programs/describe.rs does: programs and code by address and hash, the FANG
|
|
116
|
+
// that goes in, what the program may become. A method's name, an interface's identifiers and a
|
|
117
|
+
// call's input are the deployer's or the caller's words, and these never quote them (PRG 9.7). ----
|
|
118
|
+
/** An address a program action names (a program, an authority): in full, or unreadable. Never a name: programs are named by address. */
|
|
119
|
+
function addressOnly(v, what) {
|
|
120
|
+
return address(v) ? v : `an unreadable ${what}`;
|
|
121
|
+
}
|
|
122
|
+
/** Uploaded bytes (lowercase hex, as `upload_code` carries them) by the hash a create names them with, and their size. */
|
|
123
|
+
function blob(v, hash, what) {
|
|
124
|
+
if (typeof v !== "string" || !/^(?:[0-9a-f]{2})*$/.test(v))
|
|
125
|
+
return `an unreadable ${what}`;
|
|
126
|
+
return `${what} ${hex(hash(v), 32, "hash")} (${grouped(String(v.length / 2))} bytes)`;
|
|
127
|
+
}
|
|
128
|
+
/** A binding's digest that admits any version of its program (node/vm `exec::ANY_VERSION`). */
|
|
129
|
+
const ANY_VERSION = "0".repeat(64);
|
|
130
|
+
/** The other programs a program action's code may reach (its bindings), or nothing when there are none. */
|
|
131
|
+
function bindings(v) {
|
|
132
|
+
if (!Array.isArray(v))
|
|
133
|
+
return "; its code may also reach an unreadable list of programs";
|
|
134
|
+
if (v.length === 0)
|
|
135
|
+
return "";
|
|
136
|
+
const each = v.map((b) => {
|
|
137
|
+
const x = obj(b);
|
|
138
|
+
const version = x.version_digest === ANY_VERSION ? " (any version)" : typeof x.version_digest === "string" && /^[0-9a-f]{64}$/.test(x.version_digest) ? "" : " (an unreadable version)";
|
|
139
|
+
return `${addressOnly(x.program, "program")}${version}`;
|
|
140
|
+
});
|
|
141
|
+
return `; its code may also reach ${each.join(", ")}`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* What a create's authority may later do to the program, once program upgrades exist (inert in
|
|
145
|
+
* v0): the sentence's subject ("an immutable program" or "a program") and what follows the code.
|
|
146
|
+
*/
|
|
147
|
+
function upgrades(a) {
|
|
148
|
+
if (a.authority === undefined || a.authority === null) {
|
|
149
|
+
// The one encoding of an immutable program; the chain refuses any other.
|
|
150
|
+
if (whole(a.upgrade_delay_blocks) === 0n && a.policy === "open")
|
|
151
|
+
return ["an immutable program", ""];
|
|
152
|
+
return ["a program", ", with an unreadable upgrade setting (no authority, but an upgrade delay or policy)"];
|
|
153
|
+
}
|
|
154
|
+
const policy = a.policy === "open" ? "any code" : a.policy === "additive" ? "code that keeps every method" : "an unreadable policy";
|
|
155
|
+
return ["a program", `, upgradable by ${addressOnly(a.authority, "account")} to ${policy} with ${n(a.upgrade_delay_blocks)} blocks' notice once program upgrades exist`];
|
|
156
|
+
}
|
|
157
|
+
/** A create's initializer (the interface's `Init` method, never named here) and the FANG it's sent, or nothing when there's none. */
|
|
158
|
+
function initializer(v) {
|
|
159
|
+
if (v === undefined || v === null)
|
|
160
|
+
return "";
|
|
161
|
+
if (typeof v !== "object" || Array.isArray(v))
|
|
162
|
+
return ", running an unreadable initializer";
|
|
163
|
+
const value = obj(v).value;
|
|
164
|
+
return whole(value) === 0n ? ", running its initializer" : `, running its initializer with ${fangText(value)} attached`;
|
|
165
|
+
}
|
|
166
|
+
/** The sum of the calls' values (drops), or `undefined` if one can't be read exactly. */
|
|
167
|
+
function callValue(calls) {
|
|
168
|
+
let sum = 0n;
|
|
169
|
+
for (const c of calls) {
|
|
170
|
+
const v = whole(c.value);
|
|
171
|
+
if (v === undefined)
|
|
172
|
+
return undefined;
|
|
173
|
+
sum += v;
|
|
174
|
+
}
|
|
175
|
+
return sum;
|
|
176
|
+
}
|
|
177
|
+
/** The storage deposits a program action may bond, at most (`storage_limit`, and what for), or nothing when it's 0. */
|
|
178
|
+
function storage(v, what = "") {
|
|
179
|
+
return whole(v) === 0n ? "" : `, bonding at most ${fangText(v)} in storage deposits${what}`;
|
|
180
|
+
}
|
|
114
181
|
/**
|
|
115
182
|
* What a `vote` action backs, given the node's read of the proposal it names (`opts.proposal`):
|
|
116
183
|
* `ok` only when that proposal's content hashes to the digest the vote binds (no node can make
|
|
@@ -329,6 +396,28 @@ export function describeAction(action, opts = {}) {
|
|
|
329
396
|
return `Signal that the account's candidate validator node is running, to join a set of one or two at the end of epoch ${n(a.epoch)} (validators)`;
|
|
330
397
|
case "admit_validator":
|
|
331
398
|
return `Admit candidate validator ${account(a.validator)} into a set of one or two at the end of epoch ${n(a.epoch)} (validators)`;
|
|
399
|
+
case "upload_code":
|
|
400
|
+
return `Upload ${blob(a.wasm, wasmHash, "program code")} with ${blob(a.interface, interfaceHash, "interface")}${storage(a.storage_limit, " for whichever isn't stored yet")}`;
|
|
401
|
+
case "create_program": {
|
|
402
|
+
const [program, upgradable] = upgrades(a);
|
|
403
|
+
const transfers = a.accepts_transfers === true ? ", accepting transfers" : a.accepts_transfers === false ? "" : ", with an unreadable transfer setting";
|
|
404
|
+
return `Create ${program} from code ${hex(a.wasm_hash, 32, "code hash")} and interface ${hex(a.interface_hash, 32, "interface hash")}${upgradable}${transfers}${initializer(a.init)}${storage(a.storage_limit)}${bindings(a.bindings)}`;
|
|
405
|
+
}
|
|
406
|
+
case "call_program": {
|
|
407
|
+
if (!Array.isArray(a.calls) || a.calls.length === 0)
|
|
408
|
+
return `Call programs: an unreadable list of calls${bindings(a.bindings)}`;
|
|
409
|
+
const calls = a.calls.map(obj);
|
|
410
|
+
// The programs called, each once, in the order the calls first name them.
|
|
411
|
+
const programs = [...new Set(calls.map((c) => addressOnly(c.program, "program")))];
|
|
412
|
+
const what = calls.length === 1 ? `Call program ${programs[0]}`
|
|
413
|
+
: programs.length === 1 ? `Make ${calls.length} calls to program ${programs[0]} (all or nothing)`
|
|
414
|
+
: `Call ${programs.length} programs (${programs.join(", ")}) in ${calls.length} calls (all or nothing)`;
|
|
415
|
+
const value = callValue(calls);
|
|
416
|
+
const attached = value === undefined ? " with an unreadable amount of FANG attached" : value > 0n ? ` with ${fangText(value)} attached` : "";
|
|
417
|
+
return `${what}${attached}${storage(a.storage_limit)}${bindings(a.bindings)}`;
|
|
418
|
+
}
|
|
419
|
+
case "programs_pause":
|
|
420
|
+
return "Vote to pause programs (validators: they pause once f + 1 of the epoch's validators have)";
|
|
332
421
|
default:
|
|
333
422
|
return `Something this client can't read (an action of type ${typeof a.type === "string" ? token(a.type, /^[a-z_]{1,40}$/, "type") : "unknown"}): don't approve it from here`;
|
|
334
423
|
}
|
|
@@ -346,5 +435,7 @@ export function reviewAction(action, opts = {}) {
|
|
|
346
435
|
const ours = does.replace(/ \((?:reason|label|title), as written: โ[^โ]*โ\)/g, "");
|
|
347
436
|
const unreadable = /\b(unreadable|can't read|too large for this client)\b/.test(ours);
|
|
348
437
|
const vote = a.type !== "vote" || voteTarget(a, opts).ok;
|
|
349
|
-
|
|
438
|
+
// A multisig holds no program action: the node never decodes one inside a multisig proposal.
|
|
439
|
+
const multisigCanHold = !isProgramAction(a);
|
|
440
|
+
return { does, readable: !unreadable && vote && multisigCanHold };
|
|
350
441
|
}
|