@blockula/sdk 0.3.0 β 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -11
- package/dist/describe.d.ts +52 -0
- package/dist/describe.js +441 -0
- package/dist/distribution.d.ts +26 -0
- package/dist/distribution.js +110 -0
- package/dist/index.d.ts +1469 -32
- package/dist/index.js +2375 -89
- package/dist/networks.d.ts +25 -0
- package/dist/networks.js +27 -0
- package/dist/payments.d.ts +83 -0
- package/dist/payments.js +240 -0
- package/dist/programs.d.ts +471 -0
- package/dist/programs.js +779 -0
- package/dist/proof.d.ts +170 -0
- package/dist/proof.js +317 -0
- package/dist/release.d.ts +15 -0
- package/dist/release.js +41 -0
- package/dist/tx.d.ts +593 -3
- package/dist/tx.js +866 -26
- package/package.json +10 -7
package/README.md
CHANGED
|
@@ -1,23 +1,129 @@
|
|
|
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
|
|
7
9
|
```
|
|
8
10
|
|
|
9
11
|
```ts
|
|
10
|
-
import { Client, Wallet } from "@blockula/sdk";
|
|
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
|
|
|
17
|
-
// Let an agent spend up to 10 FANG/day from this account (enforced on-chain)
|
|
18
|
-
|
|
19
|
+
// Let an agent spend up to 10 FANG/day from this account (enforced on-chain). The agent's key
|
|
20
|
+
// consents to joining this account first: its grant code (the MCP's use_account answers with one).
|
|
21
|
+
const agentGrantCode = encodeKeyGrant(await agent.keyGrant(wallet.account));
|
|
22
|
+
await wallet.addSessionKey(agentGrantCode, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 });
|
|
23
|
+
```
|
|
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
|
+
});
|
|
19
112
|
```
|
|
20
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
|
+
|
|
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.
|
|
122
|
+
|
|
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.
|
|
124
|
+
|
|
125
|
+
Show the voter or co-signer what the digest covers, rendered by the client, never the node's `summary` text (no digest covers it, so a node can make it say anything): `describeKind(kind)` and `describeAction(action)` say what a proposal or a multisig action does in plain English, every amount spelled out (`"Pay 10,000,000 FANG from the treasury to boo1β¦"`) and every recipient in full, and `proposalSummary(raw)` adds `does` (that description) and `digest` (what a vote binds; `null`, with `digest_error`, when the client can't read the proposal exactly, e.g. a number past 2^53: don't vote on it from JS). They never throw on content they can't read; they say so. Pass `{ freeText: false }` to leave reasons and labels out (to show them apart, marked as the proposer's words). If you show a digest and vote later, compare it with the one you sign. A multisig's pending `vote` binds its proposal by digest, not by id: `reviewAction(action, { proposal: await client.proposal(action.proposal_id) })` describes the proposal only when its content has the digest the vote binds, and says otherwise (`readable: false`, as for anything it describes as unreadable): don't offer to approve such an action. `multisigApprove(id, { action, proposal })` checks the same before signing: a vote is approved only if the proposal shown (else one read then) has exactly the content it binds.
|
|
126
|
+
|
|
21
127
|
In the browser, a passkey unlocks the wallet. There's no seed phrase, and the passkey's (non-quantum-safe) signature is never used on-chain. Its PRF output derives the ML-DSA key:
|
|
22
128
|
|
|
23
129
|
```ts
|
|
@@ -25,12 +131,136 @@ import { createPasskeyWallet, unlockPasskeyWallet } from "@blockula/sdk/passkey"
|
|
|
25
131
|
const wallet = await createPasskeyWallet({ username: "vlad" });
|
|
26
132
|
```
|
|
27
133
|
|
|
28
|
-
|
|
134
|
+
Contribution pool (Proof of Contribution):
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
await wallet.submitClaim("jackdishman/blockula", 34); // a merged PR by your linked GitHub handle
|
|
138
|
+
const s = await client.seance((await client.contributions()).open_seance.id); // jurors: your pairs
|
|
139
|
+
const sealed = await juror.sealVotes(s.id, "ab=", [17]); // a/b/= per pair, spam flags; keep sealed.salt
|
|
140
|
+
await juror.revealVotes(s.id, sealed.votes, sealed.spam, sealed.salt); // after the sealing phase
|
|
141
|
+
await wallet.withdrawVested();
|
|
142
|
+
```
|
|
143
|
+
|
|
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`.
|
|
145
|
+
|
|
146
|
+
Fees, tips and expiry (protocol v1):
|
|
29
147
|
|
|
30
|
-
|
|
31
|
-
|
|
148
|
+
```ts
|
|
149
|
+
await wallet.send("alice.boo", "1.5", { tip: "0.001", validForBlocks: 120 });
|
|
150
|
+
wallet.tip = 0n; // defaults for every tx this wallet builds
|
|
151
|
+
wallet.validForBlocks = 600;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- A transaction is charged its block's base fee (`baseFee(tx, slot)`: priced per byte, burned; it rises while blocks are full and is fixed 17 blocks ahead) plus its `tip` (paid to the block's proposer; blocks take the highest tip per byte first). The wallet sets `fee` to the base fee at `client.fees()`'s `suggested` plus the tip; only the actual charge (`txCharge(tx, slot)`) is debited. On an idle chain the base fee is `minFee(tx)`.
|
|
155
|
+
- A tip is at most `tip_cap_multiple` (20) Γ the base fee of the block that includes it (`maxTip(tx, slot, multiple)`); `wallet.build` refuses a bigger one, priced at `client.fees()`'s `lowest`. It's paid only from FANG the account could transfer (vesting and vote locks hold it), and a multisig's owner keys and a validator's consensus key acting for its owner can't tip at all.
|
|
156
|
+
- Every transaction carries `valid_until`, the last block it may execute in. The wallet sets it to the current height plus `validForBlocks` (default 600 blocks, at most the chain's `max_tx_validity_blocks`, an hour of blocks). A transaction past `valid_until` can never execute, so it's safe to report it as expired.
|
|
157
|
+
- `txBytes(tx)` is the binary wire form nodes gossip and put in blocks, and `txId(tx)` its id, known before you submit.
|
|
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`.
|
|
159
|
+
|
|
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.
|
|
161
|
+
|
|
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").
|
|
32
165
|
- Pass `rpId` to `createPasskeyWallet`/`unlockPasskeyWallet` to pin wallets to your production domain. Passkeys are bound to that hostname for good.
|
|
33
166
|
|
|
34
|
-
|
|
167
|
+
### Staking, governance and names (protocol v1)
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { Client, Wallet, proposal, stakeSummary } from "@blockula/sdk";
|
|
171
|
+
|
|
172
|
+
await wallet.delegate(validatorAddress, "100"); // stake in a validator's pool
|
|
173
|
+
const stake = stakeSummary(await client.staking(wallet.account), (await client.status()).height);
|
|
174
|
+
await wallet.undelegate(validatorAddress, { amount: "10" }); // the chain converts it to shares; or "all", or a share count
|
|
175
|
+
await wallet.withdrawUnbonded(); // once the unbonding period is over
|
|
176
|
+
|
|
177
|
+
await wallet.propose(proposal.paramChange("max_validators", 40), { title: "More validators", url: "https://forum.example/p/1" });
|
|
178
|
+
await wallet.vote(7, true, "10", 2); // aye, 10 FANG locked at 2Γ conviction
|
|
179
|
+
await wallet.unlock(); // release vote locks that ended
|
|
180
|
+
|
|
181
|
+
await wallet.renewName("vlad", 2); // names are registered a year at a time
|
|
182
|
+
await wallet.transferName("vlad", "alice.boo"); // an offer: alice.boo accepts with acceptName("vlad")
|
|
183
|
+
await wallet.multisigCreate([phoneKeyId, laptopKeyId, kitKeyId], 2);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- Every tag 30β49 has a helper: `delegate`, `undelegate`, `redelegate`, `withdrawUnbonded`, `updateValidator`, `unjail`, `propose`, `vote`, `unlock`, `multisigCreate` / `multisigPropose` / `multisigApprove`, `renewName`, `transferName` (an offer the recipient takes with `acceptName`; `withdrawNameOffer`), `releaseName`, `claimDistribution`, and the low-level validator ones (`reportEquivocation`, `beaconCommit`, `beaconReveal`, `attestRevert`); tags 60β61 are a set of one or two's `admitValidator` (a member's consensus key admits a candidate) and `validatorReady` (candidate nodes send it). They check their arguments before signing, resolve `.boo` names to the addresses they sign, and wait for the block.
|
|
187
|
+
- `proposal.*` builds every `ProposalKind`: `paramChange`, `treasurySpend`, `distribution`, `setRepo`, `grantSoul`, `flagSybil`, `unflagSybil` (its appeal), `rejectClaim`, `clawback`, `unlinkGithub`, `refundHunt`, `scheduleUpgrade`, `cancelUpgrade`, `activateFeature`, `deactivateFeature`, `text`. `propose` bonds the chain's `proposal_deposit` unless you pass `deposit`.
|
|
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`).
|
|
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.
|
|
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
|
+
|
|
214
|
+
### Which chain? Pinning and proofs
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
const client = new Client("https://rpc.example", { chainId: "mychain", genesisHash: "β¦", prefix: "boo" });
|
|
218
|
+
```
|
|
219
|
+
|
|
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.
|
|
221
|
+
|
|
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).
|
|
223
|
+
|
|
224
|
+
Proven reads check the node's Merkle proof (`/api/proof`) against its state root, ask independent nodes (`witnesses`) for that block's root, and require the proof to be fresh:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const others = [new Client("https://rpc-2.example"), new Client("https://rpc-3.example")];
|
|
228
|
+
const acct = await client.provenAccount("boo1β¦", { witnesses: others }); // { balance, nonce, keys, height, root, head } or null
|
|
229
|
+
const name = await client.provenName("vlad.boo", { witnesses: others }); // { owner, registered_at, expires_at, active, β¦ } or null
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- **Fresh, not just genuine.** Witnesses confirm any root the chain ever had, so a lying node could answer with a real but old record: a name's previous owner, a balance before a spend, a key since removed. The proof's block may be at most `maxLagBlocks` (default 10) behind the newest head the node and its witnesses report, or it throws `StaleProofError`. Without witnesses only the node's own head is compared, which a lying node controls.
|
|
233
|
+
- **Expired names don't resolve.** `provenName` returns `null` once a name's `expires_at` has passed (its grace period included), like `client.resolve`. `{ includeExpired: true }` returns the record with `active: false`; never pay its `owner`.
|
|
234
|
+
|
|
235
|
+
`verifyProof(root, namespace, key, value, proof)` is the bare check (a port of `node/src/state/tree.rs`, tested against the node's own vectors), and `provenRecord(namespace, key)` works for any record (check its `height` against `head` if you call it with your own options).
|
|
236
|
+
|
|
237
|
+
### Timeouts and pending transactions
|
|
238
|
+
|
|
239
|
+
Every request has a timeout (`new Client(rpc, { timeoutMs })`, default 15 s, at most `MAX_TIMEOUT_MS` = 2^31 β 1 ms, since a longer timer fires at once): a node that never answers fails with `RequestTimeoutError` instead of hanging. `submit` (and every helper that sends) tells you what a failure means:
|
|
240
|
+
|
|
241
|
+
- `ApiError`: the node refused the tx. It wasn't admitted, so nothing was spent.
|
|
242
|
+
- `TxFailedError` with `dropped` false: it executed in a block and failed there (its fee was charged). It can't execute again.
|
|
243
|
+
- `TxFailedError` with `dropped` true: this node dropped it from its mempool without running it (evicted by a higher tip, replaced, not included, or an earlier tx from the account was dropped). Nothing was charged, but another node may still hold it and include it until its `valid_until`: check `client.tx(id)` before sending again, or the payment can go out twice.
|
|
244
|
+
- `TxPendingError`: it was admitted but isn't final yet, and it can still execute until its `valid_until`. Check `client.tx(err.id)` before sending again, or the payment can go out twice.
|
|
245
|
+
- `RequestTimeoutError` or a network error while submitting: unknown, the tx may have arrived. Same advice.
|
|
246
|
+
|
|
247
|
+
### Encoding
|
|
248
|
+
|
|
249
|
+
`signingBytes` and `txBytes` encode a tx exactly as the node does (`node/src/types.rs`, checked offline against the node's vectors in `test/tx-vectors.json`). They take integers as the node's JSON parser does, as JSON numbers or strings of digits within the field's width, and refuse anything else (a negative or oversized amount, `"0x10"`, `" 1"`, a fraction) instead of wrapping it into a different tx.
|
|
250
|
+
|
|
251
|
+
`verifyReleaseSignature(sig, checksumsFile, trustedKeyId)` checks a release's offline ML-DSA-65 signature (docs/OPERATIONS.md "Releases").
|
|
252
|
+
|
|
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.
|
|
263
|
+
|
|
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`.
|
|
35
265
|
|
|
36
|
-
|
|
266
|
+
Blockula is experimental software, provided as is under the MIT license, without warranty of any kind.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Options of `describeKind` / `describeAction`. */
|
|
2
|
+
export interface DescribeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Quote the text a stranger wrote (reasons, labels, titles, memos) inside the description.
|
|
5
|
+
* Default true. Pass false where it must be shown apart, marked untrusted (an agent's tools do).
|
|
6
|
+
*/
|
|
7
|
+
freeText?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* For a `vote` action (a multisig's ballot): the node's read of the proposal it names
|
|
10
|
+
* (`client.proposal(id)`). A vote binds that proposal's content by digest, not by id (audit
|
|
11
|
+
* gap-7-6), so the proposal is described only if its kind, title and link hash to the digest
|
|
12
|
+
* the vote binds; otherwise the description says the node's proposal isn't what it backs.
|
|
13
|
+
* `reviewAction` says whether that makes the action fit to approve.
|
|
14
|
+
*/
|
|
15
|
+
proposal?: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** An amount of drops, spelled out in FANG with every digit: "10,000,000 FANG", "0.5 FANG". */
|
|
18
|
+
export declare function fangText(drops: unknown): string;
|
|
19
|
+
/**
|
|
20
|
+
* What a governance proposal's `kind` does, in plain English, from the kind itself (as stored on
|
|
21
|
+
* chain: accounts are addresses there). Every amount is spelled out in FANG and every recipient
|
|
22
|
+
* in full. Show this to a voter, not the node's `summary`: `proposalDigest(kind, title, url)` is
|
|
23
|
+
* what the vote binds, and this is rendered from the same `kind`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function describeKind(kind: unknown, opts?: DescribeOptions): string;
|
|
26
|
+
/**
|
|
27
|
+
* What a transaction action does (a multisig's pending action, before an owner approves it), in
|
|
28
|
+
* plain English, from the action itself. Show this, not the node's summary:
|
|
29
|
+
* `actionDigest(action)` is what the approval binds, and this is rendered from the same action.
|
|
30
|
+
*/
|
|
31
|
+
export declare function describeAction(action: unknown, opts?: DescribeOptions): string;
|
|
32
|
+
/** Whether an approver can act on a description (`reviewAction`). */
|
|
33
|
+
export interface ActionReview {
|
|
34
|
+
/** `describeAction(action, opts)`. */
|
|
35
|
+
does: string;
|
|
36
|
+
/**
|
|
37
|
+
* Whether this client could read all of it: false for anything it described as unreadable
|
|
38
|
+
* (an unknown type, a malformed field, a number JSON rounded, a key id the chain couldn't store)
|
|
39
|
+
* and for a `vote` whose proposal (`opts.proposal`) wasn't given or doesn't hash to the digest
|
|
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`.
|
|
43
|
+
*/
|
|
44
|
+
readable: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `describeAction`, and whether the description is complete enough to approve on (a multisig
|
|
48
|
+
* co-signer's check). For a `vote`, pass the node's read of the proposal it names as
|
|
49
|
+
* `opts.proposal` (`client.proposal(action.proposal_id)`): it's described, and approvable, only
|
|
50
|
+
* when its content hashes to the digest the vote binds (audit gap-7-6).
|
|
51
|
+
*/
|
|
52
|
+
export declare function reviewAction(action: unknown, opts?: DescribeOptions): ActionReview;
|