@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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # @blockula/sdk
2
2
 
3
- ๐Ÿง› TypeScript SDK for **Blockula**, a post-quantum blockchain. It covers ML-DSA-65 (FIPS 204) wallets, `.boo` names, transfers, multi-key accounts, agent session keys, and passkey-unlocked wallets.
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(new Client("http://localhost:7070"));
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โ€ฆ` on mainnet, `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.
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 mainnet 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.
65
- - `wallet.address` uses the chain's prefix once the client knows it. Before the first request it shows the `boo` form, so `await client.addressPrefix()` first if you display it on a testnet.
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: "blockula-1", genesisHash: "โ€ฆ", prefix: "boo" });
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-wallet.vercel.app.
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
- 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), `npm run test:kat` (ML-DSA known answers), `npm test` (end to end against `blockula dev`).
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
- โš ๏ธ Early and unaudited: test chains only.
266
+ Blockula is experimental software, provided as is under the MIT license, without warranty of any kind.
@@ -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. Don't offer to approve an action that isn't `readable`.
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
- return { does, readable: !unreadable && vote };
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
  }