@blockula/sdk 0.1.0 → 0.2.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 +6 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +67 -3
- package/dist/passkey.d.ts +2 -0
- package/dist/passkey.js +10 -5
- package/dist/tx.d.ts +27 -0
- package/dist/tx.js +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,4 +27,10 @@ const wallet = await createPasskeyWallet({ username: "vlad" });
|
|
|
27
27
|
|
|
28
28
|
Amounts are FANG strings (`"1.5"`) or bigint drops (1 FANG = 10⁹ drops). Every transaction result includes a plain-English `summary`.
|
|
29
29
|
|
|
30
|
+
- `send("alice.boo", …)` resolves the name first and signs the **address**, so whoever you checked is exactly who gets paid.
|
|
31
|
+
- Node errors throw `ApiError` (`.status`: 429 means rate limited). A transaction that fails at execution throws `TxFailedError` from `waitForTx`.
|
|
32
|
+
- Pass `rpId` to `createPasskeyWallet`/`unlockPasskeyWallet` to pin wallets to your production domain. Passkeys are bound to that hostname for good.
|
|
33
|
+
|
|
34
|
+
The public testnet is `https://blockula-testnet.fly.dev`, and the wallet is https://blockula-wallet.vercel.app.
|
|
35
|
+
|
|
30
36
|
⚠️ Early and unaudited: test chains only.
|
package/dist/index.d.ts
CHANGED
|
@@ -55,10 +55,39 @@ export declare class Wallet {
|
|
|
55
55
|
expiresInBlocks: number;
|
|
56
56
|
}): Promise<any>;
|
|
57
57
|
removeKey(keyIdHex: string): Promise<any>;
|
|
58
|
+
/**
|
|
59
|
+
* Offer a validator: bonds `bond` FANG from this account. `consensusKeyHex` is the validator
|
|
60
|
+
* node's ML-DSA-65 public key (see `blockula validator init`). Pending until an admin approves.
|
|
61
|
+
*/
|
|
62
|
+
registerValidator(opts: {
|
|
63
|
+
consensusKey: string;
|
|
64
|
+
p2pAddr: string;
|
|
65
|
+
httpAddr: string;
|
|
66
|
+
bond: string | number | bigint;
|
|
67
|
+
pop: string;
|
|
68
|
+
}): Promise<any>;
|
|
69
|
+
/** Season 0 admins only. */
|
|
70
|
+
approveValidator(validator: string): Promise<any>;
|
|
71
|
+
/** Season 0 admins only. */
|
|
72
|
+
removeValidator(validator: string): Promise<any>;
|
|
73
|
+
/** Reclaim the bond of a removed validator this account owns. */
|
|
74
|
+
unbond(validator: string): Promise<any>;
|
|
58
75
|
balance(): Promise<string>;
|
|
59
76
|
/** Dev/test chains only: drip 100 FANG to this wallet. */
|
|
60
77
|
faucet(wait?: boolean): Promise<any>;
|
|
61
78
|
}
|
|
79
|
+
/** An error response from a Blockula node. `status` is the HTTP status (429 = rate limited). */
|
|
80
|
+
export declare class ApiError extends Error {
|
|
81
|
+
readonly status: number;
|
|
82
|
+
constructor(status: number, message: string);
|
|
83
|
+
}
|
|
84
|
+
/** A transaction that was rejected at execution (fee charged) or dropped from the mempool. */
|
|
85
|
+
export declare class TxFailedError extends Error {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly reason: string;
|
|
88
|
+
readonly tx: any;
|
|
89
|
+
constructor(id: string, reason: string, tx: any);
|
|
90
|
+
}
|
|
62
91
|
export declare class Client {
|
|
63
92
|
readonly rpc: string;
|
|
64
93
|
constructor(rpc?: string);
|
|
@@ -72,6 +101,8 @@ export declare class Client {
|
|
|
72
101
|
/** Accounts a key is authorized on (its own address, plus any it was added to). */
|
|
73
102
|
keyAccounts(keyIdHex: string): Promise<any>;
|
|
74
103
|
search(q: string): Promise<any>;
|
|
104
|
+
/** Active set for the current epoch, pending/approved validators, next-epoch set, epoch timing. */
|
|
105
|
+
validators(): Promise<any>;
|
|
75
106
|
resolve(nameOrAddress: string): Promise<string>;
|
|
76
107
|
submit(tx: Tx, wait?: boolean): Promise<any>;
|
|
77
108
|
faucet(to: string, wait?: boolean): Promise<any>;
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,9 @@ export function keyId(publicKey) {
|
|
|
13
13
|
return bytesToHex(blake3(publicKey));
|
|
14
14
|
}
|
|
15
15
|
export function isAddress(s) {
|
|
16
|
+
// Addresses are canonical lowercase; the node rejects any other casing.
|
|
17
|
+
if (s !== s.toLowerCase())
|
|
18
|
+
return false;
|
|
16
19
|
try {
|
|
17
20
|
const { prefix, words } = bech32m.decode(s);
|
|
18
21
|
return prefix === "boo" && bech32m.fromWords(words).length === 32;
|
|
@@ -85,7 +88,10 @@ export class Wallet {
|
|
|
85
88
|
}
|
|
86
89
|
/** Send FANG to a `.boo` name or `boo1…` address. `amount` is in FANG ("1.5") or bigint drops. */
|
|
87
90
|
async send(to, amount, opts = {}) {
|
|
88
|
-
|
|
91
|
+
// Sign the resolved address, not the name: what you checked is exactly who gets paid,
|
|
92
|
+
// even if the name changes hands before the block is sealed.
|
|
93
|
+
const recipient = await this.client.resolve(to);
|
|
94
|
+
const tx = await this.build({ type: "transfer", to: recipient, amount: parseFang(amount).toString() }, opts.memo);
|
|
89
95
|
return this.client.submit(tx, opts.wait ?? true);
|
|
90
96
|
}
|
|
91
97
|
async registerName(name, opts = {}) {
|
|
@@ -101,6 +107,10 @@ export class Wallet {
|
|
|
101
107
|
* Example: `addSessionKey(agentKeyId, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 })`.
|
|
102
108
|
*/
|
|
103
109
|
async addSessionKey(keyIdHex, label, opts) {
|
|
110
|
+
if (!Number.isSafeInteger(opts.periodBlocks) || opts.periodBlocks < 1)
|
|
111
|
+
throw new Error("periodBlocks must be a positive whole number of blocks");
|
|
112
|
+
if (!Number.isSafeInteger(opts.expiresInBlocks) || opts.expiresInBlocks < 1)
|
|
113
|
+
throw new Error("expiresInBlocks must be a positive whole number of blocks");
|
|
104
114
|
const { height } = await this.client.status();
|
|
105
115
|
const role = { type: "session", limit: parseFang(opts.limit).toString(), period_blocks: opts.periodBlocks, expires_at: height + opts.expiresInBlocks };
|
|
106
116
|
return this.client.submit(await this.build({ type: "add_key", key_id: keyIdHex, label, role }));
|
|
@@ -108,6 +118,25 @@ export class Wallet {
|
|
|
108
118
|
async removeKey(keyIdHex) {
|
|
109
119
|
return this.client.submit(await this.build({ type: "remove_key", key_id: keyIdHex }));
|
|
110
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Offer a validator: bonds `bond` FANG from this account. `consensusKeyHex` is the validator
|
|
123
|
+
* node's ML-DSA-65 public key (see `blockula validator init`). Pending until an admin approves.
|
|
124
|
+
*/
|
|
125
|
+
async registerValidator(opts) {
|
|
126
|
+
return this.client.submit(await this.build({ type: "register_validator", consensus_key: opts.consensusKey, p2p_addr: opts.p2pAddr, http_addr: opts.httpAddr, bond: parseFang(opts.bond).toString(), pop: opts.pop }));
|
|
127
|
+
}
|
|
128
|
+
/** Season 0 admins only. */
|
|
129
|
+
async approveValidator(validator) {
|
|
130
|
+
return this.client.submit(await this.build({ type: "approve_validator", validator }));
|
|
131
|
+
}
|
|
132
|
+
/** Season 0 admins only. */
|
|
133
|
+
async removeValidator(validator) {
|
|
134
|
+
return this.client.submit(await this.build({ type: "remove_validator", validator }));
|
|
135
|
+
}
|
|
136
|
+
/** Reclaim the bond of a removed validator this account owns. */
|
|
137
|
+
async unbond(validator) {
|
|
138
|
+
return this.client.submit(await this.build({ type: "unbond", validator }));
|
|
139
|
+
}
|
|
111
140
|
balance() {
|
|
112
141
|
return this.client.account(this.account).then((a) => a.balance_display, () => "0 FANG");
|
|
113
142
|
}
|
|
@@ -116,6 +145,28 @@ export class Wallet {
|
|
|
116
145
|
return this.client.faucet(this.account, wait);
|
|
117
146
|
}
|
|
118
147
|
}
|
|
148
|
+
/** An error response from a Blockula node. `status` is the HTTP status (429 = rate limited). */
|
|
149
|
+
export class ApiError extends Error {
|
|
150
|
+
status;
|
|
151
|
+
constructor(status, message) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.status = status;
|
|
154
|
+
this.name = "ApiError";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A transaction that was rejected at execution (fee charged) or dropped from the mempool. */
|
|
158
|
+
export class TxFailedError extends Error {
|
|
159
|
+
id;
|
|
160
|
+
reason;
|
|
161
|
+
tx;
|
|
162
|
+
constructor(id, reason, tx) {
|
|
163
|
+
super(`tx ${id} failed: ${reason}`);
|
|
164
|
+
this.id = id;
|
|
165
|
+
this.reason = reason;
|
|
166
|
+
this.tx = tx;
|
|
167
|
+
this.name = "TxFailedError";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
119
170
|
export class Client {
|
|
120
171
|
rpc;
|
|
121
172
|
constructor(rpc = DEFAULT_RPC) {
|
|
@@ -123,9 +174,16 @@ export class Client {
|
|
|
123
174
|
}
|
|
124
175
|
async call(path, body) {
|
|
125
176
|
const res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, body === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
126
|
-
const
|
|
177
|
+
const text = await res.text();
|
|
178
|
+
let json;
|
|
179
|
+
try {
|
|
180
|
+
json = JSON.parse(text);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new ApiError(res.status, res.ok ? "the node returned something that isn't JSON" : `node error ${res.status} ${res.statusText}`.trim());
|
|
184
|
+
}
|
|
127
185
|
if (!res.ok)
|
|
128
|
-
throw new
|
|
186
|
+
throw new ApiError(res.status, json.error ?? res.statusText);
|
|
129
187
|
return json;
|
|
130
188
|
}
|
|
131
189
|
status() { return this.call("/status"); }
|
|
@@ -137,9 +195,13 @@ export class Client {
|
|
|
137
195
|
/** Accounts a key is authorized on (its own address, plus any it was added to). */
|
|
138
196
|
keyAccounts(keyIdHex) { return this.call(`/key/${keyIdHex}`); }
|
|
139
197
|
search(q) { return this.call(`/search/${encodeURIComponent(q)}`); }
|
|
198
|
+
/** Active set for the current epoch, pending/approved validators, next-epoch set, epoch timing. */
|
|
199
|
+
validators() { return this.call("/validators"); }
|
|
140
200
|
async resolve(nameOrAddress) {
|
|
141
201
|
if (isAddress(nameOrAddress))
|
|
142
202
|
return nameOrAddress;
|
|
203
|
+
if (/^boo1/i.test(nameOrAddress.trim()))
|
|
204
|
+
throw new Error(`${nameOrAddress} isn't a valid address (check for typos; addresses are lowercase)`);
|
|
143
205
|
const r = await this.name(nameOrAddress);
|
|
144
206
|
if (r.available)
|
|
145
207
|
throw new Error(`${r.name} isn't registered`);
|
|
@@ -159,6 +221,8 @@ export class Client {
|
|
|
159
221
|
const tx = await this.tx(id);
|
|
160
222
|
if (tx.status === "finalized")
|
|
161
223
|
return tx;
|
|
224
|
+
if (tx.status === "failed")
|
|
225
|
+
throw new TxFailedError(id, tx.error ?? "unknown reason", tx);
|
|
162
226
|
if (Date.now() - start > timeoutMs)
|
|
163
227
|
throw new Error(`tx ${id} still pending after ${timeoutMs}ms`);
|
|
164
228
|
await new Promise((r) => setTimeout(r, 250));
|
package/dist/passkey.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ export declare function createPasskeyWallet(opts: {
|
|
|
4
4
|
username: string;
|
|
5
5
|
client?: Client;
|
|
6
6
|
rpName?: string;
|
|
7
|
+
rpId?: string;
|
|
7
8
|
}): Promise<Wallet>;
|
|
8
9
|
/** Unlock an existing passkey wallet (shows the OS passkey picker). */
|
|
9
10
|
export declare function unlockPasskeyWallet(opts?: {
|
|
10
11
|
client?: Client;
|
|
11
12
|
credentialId?: Uint8Array;
|
|
13
|
+
rpId?: string;
|
|
12
14
|
}): Promise<Wallet>;
|
package/dist/passkey.js
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
// used on-chain, so the account stays quantum-safe.
|
|
7
7
|
import { Wallet } from "./index.js";
|
|
8
8
|
const PRF_SALT = new TextEncoder().encode("blockula/wallet/v1");
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The WebAuthn relying-party id. Passkeys (and so wallets) are bound to it: a wallet created
|
|
11
|
+
* under one rpId can't be unlocked under another. Pin it to your production domain; it must be
|
|
12
|
+
* the page's hostname or a registrable parent of it.
|
|
13
|
+
*/
|
|
14
|
+
function rpId(explicit) {
|
|
15
|
+
return explicit ?? location.hostname;
|
|
11
16
|
}
|
|
12
17
|
function walletFrom(cred, prf, client) {
|
|
13
18
|
const wallet = Wallet.fromPasskeyPrf(prf, client);
|
|
@@ -27,7 +32,7 @@ async function prfFromCredential(cred) {
|
|
|
27
32
|
export async function createPasskeyWallet(opts) {
|
|
28
33
|
const cred = (await navigator.credentials.create({
|
|
29
34
|
publicKey: {
|
|
30
|
-
rp: { id: rpId(), name: opts.rpName ?? "Blockula" },
|
|
35
|
+
rp: { id: rpId(opts.rpId), name: opts.rpName ?? "Blockula" },
|
|
31
36
|
user: { id: crypto.getRandomValues(new Uint8Array(16)), name: opts.username, displayName: opts.username },
|
|
32
37
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
33
38
|
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -8 }, { type: "public-key", alg: -257 }],
|
|
@@ -41,14 +46,14 @@ export async function createPasskeyWallet(opts) {
|
|
|
41
46
|
if (ext?.prf?.results?.first)
|
|
42
47
|
return walletFrom(cred, new Uint8Array(ext.prf.results.first), opts.client);
|
|
43
48
|
if (ext?.prf?.enabled)
|
|
44
|
-
return unlockPasskeyWallet({ client: opts.client, credentialId: cred ? new Uint8Array(cred.rawId) : undefined });
|
|
49
|
+
return unlockPasskeyWallet({ client: opts.client, rpId: opts.rpId, credentialId: cred ? new Uint8Array(cred.rawId) : undefined });
|
|
45
50
|
return walletFrom(cred, await prfFromCredential(cred), opts.client);
|
|
46
51
|
}
|
|
47
52
|
/** Unlock an existing passkey wallet (shows the OS passkey picker). */
|
|
48
53
|
export async function unlockPasskeyWallet(opts = {}) {
|
|
49
54
|
const cred = (await navigator.credentials.get({
|
|
50
55
|
publicKey: {
|
|
51
|
-
rpId: rpId(),
|
|
56
|
+
rpId: rpId(opts.rpId),
|
|
52
57
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
53
58
|
userVerification: "required",
|
|
54
59
|
allowCredentials: opts.credentialId ? [{ type: "public-key", id: opts.credentialId }] : [],
|
package/dist/tx.d.ts
CHANGED
|
@@ -23,6 +23,29 @@ export type Action = {
|
|
|
23
23
|
} | {
|
|
24
24
|
type: "remove_key";
|
|
25
25
|
key_id: string;
|
|
26
|
+
}
|
|
27
|
+
/** Bond `bond` drops and offer `consensus_key` (hex ML-DSA-65 public key) as a validator. */
|
|
28
|
+
| {
|
|
29
|
+
type: "register_validator";
|
|
30
|
+
consensus_key: string;
|
|
31
|
+
p2p_addr: string;
|
|
32
|
+
http_addr: string;
|
|
33
|
+
bond: string;
|
|
34
|
+
/** Proof of possession: the consensus key's ML-DSA-65 signature over `popMessage(...)` with context `POP_CONTEXT` (see `blockula validator pop`). */
|
|
35
|
+
pop: string;
|
|
36
|
+
}
|
|
37
|
+
/** Season 0 allowlist (admin accounts only). */
|
|
38
|
+
| {
|
|
39
|
+
type: "approve_validator";
|
|
40
|
+
validator: string;
|
|
41
|
+
} | {
|
|
42
|
+
type: "remove_validator";
|
|
43
|
+
validator: string;
|
|
44
|
+
}
|
|
45
|
+
/** Reclaim the bond of a removed validator you own, one epoch after it left. */
|
|
46
|
+
| {
|
|
47
|
+
type: "unbond";
|
|
48
|
+
validator: string;
|
|
26
49
|
};
|
|
27
50
|
export interface UnsignedTx {
|
|
28
51
|
chain_id: string;
|
|
@@ -41,6 +64,10 @@ export declare const PUBLIC_KEY_LEN = 1952;
|
|
|
41
64
|
export declare const SIGNATURE_LEN = 3309;
|
|
42
65
|
export declare const DROPS_PER_FANG = 1000000000n;
|
|
43
66
|
export declare function signingBytes(tx: UnsignedTx): Uint8Array;
|
|
67
|
+
/** FIPS 204 context string for validator proofs of possession (node/src/types.rs `POP_CONTEXT`). */
|
|
68
|
+
export declare const POP_CONTEXT: Uint8Array<ArrayBuffer>;
|
|
69
|
+
/** What a validator's consensus key signs to register (node/src/types.rs `pop_message`). */
|
|
70
|
+
export declare function popMessage(chainId: string, owner: string, p2pAddr: string, httpAddr: string): Uint8Array;
|
|
44
71
|
/** Mirrors `min_fee` in node/src/types.rs: base fee is priced per byte and burned. */
|
|
45
72
|
export declare function minFee(tx: UnsignedTx): bigint;
|
|
46
73
|
/** "1.5" → 1500000000n drops. Accepts numbers, decimal strings or bigint drops. */
|
package/dist/tx.js
CHANGED
|
@@ -49,10 +49,40 @@ export function signingBytes(tx) {
|
|
|
49
49
|
w.u8(3);
|
|
50
50
|
w.str(a.key_id);
|
|
51
51
|
break;
|
|
52
|
+
case "register_validator":
|
|
53
|
+
w.u8(4);
|
|
54
|
+
w.str(a.consensus_key);
|
|
55
|
+
w.str(a.p2p_addr);
|
|
56
|
+
w.str(a.http_addr);
|
|
57
|
+
w.u64(BigInt(a.bond));
|
|
58
|
+
w.str(a.pop);
|
|
59
|
+
break;
|
|
60
|
+
case "approve_validator":
|
|
61
|
+
w.u8(5);
|
|
62
|
+
w.str(a.validator);
|
|
63
|
+
break;
|
|
64
|
+
case "remove_validator":
|
|
65
|
+
w.u8(6);
|
|
66
|
+
w.str(a.validator);
|
|
67
|
+
break;
|
|
68
|
+
case "unbond":
|
|
69
|
+
w.u8(7);
|
|
70
|
+
w.str(a.validator);
|
|
71
|
+
break;
|
|
52
72
|
}
|
|
53
73
|
w.str(tx.memo);
|
|
54
74
|
return w.done();
|
|
55
75
|
}
|
|
76
|
+
/** FIPS 204 context string for validator proofs of possession (node/src/types.rs `POP_CONTEXT`). */
|
|
77
|
+
export const POP_CONTEXT = utf8.encode("blockula/validator-pop/v1");
|
|
78
|
+
/** What a validator's consensus key signs to register (node/src/types.rs `pop_message`). */
|
|
79
|
+
export function popMessage(chainId, owner, p2pAddr, httpAddr) {
|
|
80
|
+
const w = new Writer();
|
|
81
|
+
w.bytes(utf8.encode("BLOCKULA_VALIDATOR_POP_V1"));
|
|
82
|
+
for (const s of [chainId, owner, p2pAddr, httpAddr])
|
|
83
|
+
w.str(s);
|
|
84
|
+
return w.done();
|
|
85
|
+
}
|
|
56
86
|
/** Mirrors `min_fee` in node/src/types.rs: base fee is priced per byte and burned. */
|
|
57
87
|
export function minFee(tx) {
|
|
58
88
|
const size = signingBytes(tx).length + PUBLIC_KEY_LEN + SIGNATURE_LEN;
|
package/package.json
CHANGED