@blockula/sdk 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +35 -2
- package/dist/index.d.ts +110 -0
- package/dist/index.js +231 -0
- package/dist/passkey.d.ts +14 -0
- package/dist/passkey.js +64 -0
- package/dist/tx.d.ts +75 -0
- package/dist/tx.js +106 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jack Dishman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @blockula/sdk
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @blockula/sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { Client, Wallet } from "@blockula/sdk";
|
|
11
|
+
|
|
12
|
+
const wallet = Wallet.generate(new Client("http://localhost:7070"));
|
|
13
|
+
await wallet.faucet(); // test chains: 100 FANG
|
|
14
|
+
await wallet.registerName("vlad"); // vlad.boo
|
|
15
|
+
await wallet.send("alice.boo", "1.5", { memo: "trick or treat" });
|
|
16
|
+
|
|
17
|
+
// Let an agent spend up to 10 FANG/day from this account (enforced on-chain)
|
|
18
|
+
await wallet.addSessionKey(agentKeyId, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
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
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createPasskeyWallet, unlockPasskeyWallet } from "@blockula/sdk/passkey";
|
|
25
|
+
const wallet = await createPasskeyWallet({ username: "vlad" });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Amounts are FANG strings (`"1.5"`) or bigint drops (1 FANG = 10⁹ drops). Every transaction result includes a plain-English `summary`.
|
|
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
|
+
|
|
36
|
+
⚠️ Early and unaudited: test chains only.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type Action, type Tx, type UnsignedTx } from "./tx.js";
|
|
2
|
+
export * from "./tx.js";
|
|
3
|
+
export declare const DEFAULT_RPC = "http://localhost:7070";
|
|
4
|
+
export declare function addressFromPublicKey(publicKey: Uint8Array): string;
|
|
5
|
+
/** A key's id: BLAKE3(public key) as hex. Same 32 bytes as inside the `boo1…` address it derives. */
|
|
6
|
+
export declare function keyId(publicKey: Uint8Array): string;
|
|
7
|
+
export declare function isAddress(s: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Wallet = one ML-DSA-65 key (from a 32-byte seed, FIPS 204 ξ) acting for one account.
|
|
10
|
+
* By default the account is the key's own address. Call `actAs(account)` to use a key that
|
|
11
|
+
* was added to another account (a second device, or an agent session key).
|
|
12
|
+
*/
|
|
13
|
+
export declare class Wallet {
|
|
14
|
+
readonly seed: Uint8Array;
|
|
15
|
+
client: Client;
|
|
16
|
+
readonly publicKey: Uint8Array;
|
|
17
|
+
/** This key's own address. */
|
|
18
|
+
readonly address: string;
|
|
19
|
+
/** The account this wallet acts for (defaults to `address`). */
|
|
20
|
+
account: string;
|
|
21
|
+
/** WebAuthn credential id, when the wallet was unlocked by a passkey. Not secret. */
|
|
22
|
+
credentialId?: Uint8Array;
|
|
23
|
+
private readonly secretKey;
|
|
24
|
+
private constructor();
|
|
25
|
+
get keyId(): string;
|
|
26
|
+
/** Act for another account this key has been authorized on. Accepts a name or address. */
|
|
27
|
+
actAs(account: string): Promise<this>;
|
|
28
|
+
static generate(client?: Client): Wallet;
|
|
29
|
+
static fromSeed(seed: string | Uint8Array, client?: Client): Wallet;
|
|
30
|
+
/**
|
|
31
|
+
* Derive a wallet from a WebAuthn PRF output. The passkey never signs transactions
|
|
32
|
+
* (its P-256 signatures aren't quantum-safe); it only unlocks this ML-DSA key.
|
|
33
|
+
*/
|
|
34
|
+
static fromPasskeyPrf(prfOutput: Uint8Array, client?: Client): Wallet;
|
|
35
|
+
get seedHex(): string;
|
|
36
|
+
sign(tx: UnsignedTx): Tx;
|
|
37
|
+
build(action: Action, memo?: string): Promise<Tx>;
|
|
38
|
+
/** Send FANG to a `.boo` name or `boo1…` address. `amount` is in FANG ("1.5") or bigint drops. */
|
|
39
|
+
send(to: string, amount: string | number | bigint, opts?: {
|
|
40
|
+
memo?: string;
|
|
41
|
+
wait?: boolean;
|
|
42
|
+
}): Promise<any>;
|
|
43
|
+
registerName(name: string, opts?: {
|
|
44
|
+
wait?: boolean;
|
|
45
|
+
}): Promise<any>;
|
|
46
|
+
/** Authorize another device's key (e.g. a second passkey) with full control. */
|
|
47
|
+
addOwnerKey(keyIdHex: string, label: string): Promise<any>;
|
|
48
|
+
/**
|
|
49
|
+
* Let an agent/app key spend from this account, transfers only, within a limit.
|
|
50
|
+
* Example: `addSessionKey(agentKeyId, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 })`.
|
|
51
|
+
*/
|
|
52
|
+
addSessionKey(keyIdHex: string, label: string, opts: {
|
|
53
|
+
limit: string | number | bigint;
|
|
54
|
+
periodBlocks: number;
|
|
55
|
+
expiresInBlocks: number;
|
|
56
|
+
}): Promise<any>;
|
|
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>;
|
|
75
|
+
balance(): Promise<string>;
|
|
76
|
+
/** Dev/test chains only: drip 100 FANG to this wallet. */
|
|
77
|
+
faucet(wait?: boolean): Promise<any>;
|
|
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
|
+
}
|
|
91
|
+
export declare class Client {
|
|
92
|
+
readonly rpc: string;
|
|
93
|
+
constructor(rpc?: string);
|
|
94
|
+
private call;
|
|
95
|
+
status(): Promise<any>;
|
|
96
|
+
blocks(limit?: number): Promise<any>;
|
|
97
|
+
block(heightOrHash: number | string): Promise<any>;
|
|
98
|
+
tx(id: string): Promise<any>;
|
|
99
|
+
account(addressOrName: string): Promise<any>;
|
|
100
|
+
name(name: string): Promise<any>;
|
|
101
|
+
/** Accounts a key is authorized on (its own address, plus any it was added to). */
|
|
102
|
+
keyAccounts(keyIdHex: string): Promise<any>;
|
|
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>;
|
|
106
|
+
resolve(nameOrAddress: string): Promise<string>;
|
|
107
|
+
submit(tx: Tx, wait?: boolean): Promise<any>;
|
|
108
|
+
faucet(to: string, wait?: boolean): Promise<any>;
|
|
109
|
+
waitForTx(id: string, timeoutMs?: number): Promise<any>;
|
|
110
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
2
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
3
|
+
import { bytesToHex, hexToBytes, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
4
|
+
import { bech32m } from "@scure/base";
|
|
5
|
+
import { minFee, parseFang, signingBytes } from "./tx.js";
|
|
6
|
+
export * from "./tx.js";
|
|
7
|
+
export const DEFAULT_RPC = "http://localhost:7070";
|
|
8
|
+
export function addressFromPublicKey(publicKey) {
|
|
9
|
+
return bech32m.encode("boo", bech32m.toWords(blake3(publicKey)));
|
|
10
|
+
}
|
|
11
|
+
/** A key's id: BLAKE3(public key) as hex. Same 32 bytes as inside the `boo1…` address it derives. */
|
|
12
|
+
export function keyId(publicKey) {
|
|
13
|
+
return bytesToHex(blake3(publicKey));
|
|
14
|
+
}
|
|
15
|
+
export function isAddress(s) {
|
|
16
|
+
// Addresses are canonical lowercase; the node rejects any other casing.
|
|
17
|
+
if (s !== s.toLowerCase())
|
|
18
|
+
return false;
|
|
19
|
+
try {
|
|
20
|
+
const { prefix, words } = bech32m.decode(s);
|
|
21
|
+
return prefix === "boo" && bech32m.fromWords(words).length === 32;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Wallet = one ML-DSA-65 key (from a 32-byte seed, FIPS 204 ξ) acting for one account.
|
|
29
|
+
* By default the account is the key's own address. Call `actAs(account)` to use a key that
|
|
30
|
+
* was added to another account (a second device, or an agent session key).
|
|
31
|
+
*/
|
|
32
|
+
export class Wallet {
|
|
33
|
+
seed;
|
|
34
|
+
client;
|
|
35
|
+
publicKey;
|
|
36
|
+
/** This key's own address. */
|
|
37
|
+
address;
|
|
38
|
+
/** The account this wallet acts for (defaults to `address`). */
|
|
39
|
+
account;
|
|
40
|
+
/** WebAuthn credential id, when the wallet was unlocked by a passkey. Not secret. */
|
|
41
|
+
credentialId;
|
|
42
|
+
secretKey;
|
|
43
|
+
constructor(seed, client = new Client()) {
|
|
44
|
+
this.seed = seed;
|
|
45
|
+
this.client = client;
|
|
46
|
+
const keys = ml_dsa65.keygen(seed);
|
|
47
|
+
this.publicKey = keys.publicKey;
|
|
48
|
+
this.secretKey = keys.secretKey;
|
|
49
|
+
this.address = addressFromPublicKey(keys.publicKey);
|
|
50
|
+
this.account = this.address;
|
|
51
|
+
}
|
|
52
|
+
get keyId() {
|
|
53
|
+
return keyId(this.publicKey);
|
|
54
|
+
}
|
|
55
|
+
/** Act for another account this key has been authorized on. Accepts a name or address. */
|
|
56
|
+
async actAs(account) {
|
|
57
|
+
this.account = await this.client.resolve(account);
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
static generate(client) {
|
|
61
|
+
return new Wallet(randomBytes(32), client);
|
|
62
|
+
}
|
|
63
|
+
static fromSeed(seed, client) {
|
|
64
|
+
const bytes = typeof seed === "string" ? hexToBytes(seed) : seed;
|
|
65
|
+
if (bytes.length !== 32)
|
|
66
|
+
throw new Error("seed must be 32 bytes");
|
|
67
|
+
return new Wallet(bytes, client);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Derive a wallet from a WebAuthn PRF output. The passkey never signs transactions
|
|
71
|
+
* (its P-256 signatures aren't quantum-safe); it only unlocks this ML-DSA key.
|
|
72
|
+
*/
|
|
73
|
+
static fromPasskeyPrf(prfOutput, client) {
|
|
74
|
+
return new Wallet(blake3(prfOutput, { context: utf8ToBytes("blockula passkey wallet v1") }), client);
|
|
75
|
+
}
|
|
76
|
+
get seedHex() {
|
|
77
|
+
return bytesToHex(this.seed);
|
|
78
|
+
}
|
|
79
|
+
sign(tx) {
|
|
80
|
+
const signature = ml_dsa65.sign(signingBytes(tx), this.secretKey);
|
|
81
|
+
return { ...tx, public_key: bytesToHex(this.publicKey), signature: bytesToHex(signature) };
|
|
82
|
+
}
|
|
83
|
+
async build(action, memo = "") {
|
|
84
|
+
const [status, account] = await Promise.all([this.client.status(), this.client.account(this.account).catch(() => null)]);
|
|
85
|
+
const unsigned = { chain_id: status.chain_id, from: this.account, nonce: account?.next_nonce ?? 0, fee: "0", action, memo };
|
|
86
|
+
unsigned.fee = minFee(unsigned).toString();
|
|
87
|
+
return this.sign(unsigned);
|
|
88
|
+
}
|
|
89
|
+
/** Send FANG to a `.boo` name or `boo1…` address. `amount` is in FANG ("1.5") or bigint drops. */
|
|
90
|
+
async send(to, amount, opts = {}) {
|
|
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);
|
|
95
|
+
return this.client.submit(tx, opts.wait ?? true);
|
|
96
|
+
}
|
|
97
|
+
async registerName(name, opts = {}) {
|
|
98
|
+
const tx = await this.build({ type: "register_name", name });
|
|
99
|
+
return this.client.submit(tx, opts.wait ?? true);
|
|
100
|
+
}
|
|
101
|
+
/** Authorize another device's key (e.g. a second passkey) with full control. */
|
|
102
|
+
async addOwnerKey(keyIdHex, label) {
|
|
103
|
+
return this.client.submit(await this.build({ type: "add_key", key_id: keyIdHex, label, role: { type: "owner" } }));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Let an agent/app key spend from this account, transfers only, within a limit.
|
|
107
|
+
* Example: `addSessionKey(agentKeyId, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 })`.
|
|
108
|
+
*/
|
|
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");
|
|
114
|
+
const { height } = await this.client.status();
|
|
115
|
+
const role = { type: "session", limit: parseFang(opts.limit).toString(), period_blocks: opts.periodBlocks, expires_at: height + opts.expiresInBlocks };
|
|
116
|
+
return this.client.submit(await this.build({ type: "add_key", key_id: keyIdHex, label, role }));
|
|
117
|
+
}
|
|
118
|
+
async removeKey(keyIdHex) {
|
|
119
|
+
return this.client.submit(await this.build({ type: "remove_key", key_id: keyIdHex }));
|
|
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
|
+
}
|
|
140
|
+
balance() {
|
|
141
|
+
return this.client.account(this.account).then((a) => a.balance_display, () => "0 FANG");
|
|
142
|
+
}
|
|
143
|
+
/** Dev/test chains only: drip 100 FANG to this wallet. */
|
|
144
|
+
faucet(wait = true) {
|
|
145
|
+
return this.client.faucet(this.account, wait);
|
|
146
|
+
}
|
|
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
|
+
}
|
|
170
|
+
export class Client {
|
|
171
|
+
rpc;
|
|
172
|
+
constructor(rpc = DEFAULT_RPC) {
|
|
173
|
+
this.rpc = rpc;
|
|
174
|
+
}
|
|
175
|
+
async call(path, body) {
|
|
176
|
+
const res = await fetch(this.rpc.replace(/\/$/, "") + "/api" + path, body === undefined ? undefined : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
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
|
+
}
|
|
185
|
+
if (!res.ok)
|
|
186
|
+
throw new ApiError(res.status, json.error ?? res.statusText);
|
|
187
|
+
return json;
|
|
188
|
+
}
|
|
189
|
+
status() { return this.call("/status"); }
|
|
190
|
+
blocks(limit = 20) { return this.call(`/blocks?limit=${limit}`); }
|
|
191
|
+
block(heightOrHash) { return this.call(`/block/${heightOrHash}`); }
|
|
192
|
+
tx(id) { return this.call(`/tx/${id}`); }
|
|
193
|
+
account(addressOrName) { return this.call(`/account/${encodeURIComponent(addressOrName)}`); }
|
|
194
|
+
name(name) { return this.call(`/name/${encodeURIComponent(name)}`); }
|
|
195
|
+
/** Accounts a key is authorized on (its own address, plus any it was added to). */
|
|
196
|
+
keyAccounts(keyIdHex) { return this.call(`/key/${keyIdHex}`); }
|
|
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"); }
|
|
200
|
+
async resolve(nameOrAddress) {
|
|
201
|
+
if (isAddress(nameOrAddress))
|
|
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)`);
|
|
205
|
+
const r = await this.name(nameOrAddress);
|
|
206
|
+
if (r.available)
|
|
207
|
+
throw new Error(`${r.name} isn't registered`);
|
|
208
|
+
return r.owner.address;
|
|
209
|
+
}
|
|
210
|
+
async submit(tx, wait = true) {
|
|
211
|
+
const { id } = await this.call("/tx", tx);
|
|
212
|
+
return wait ? this.waitForTx(id) : this.tx(id);
|
|
213
|
+
}
|
|
214
|
+
async faucet(to, wait = true) {
|
|
215
|
+
const { id } = await this.call("/faucet", { to });
|
|
216
|
+
return wait ? this.waitForTx(id) : this.tx(id);
|
|
217
|
+
}
|
|
218
|
+
async waitForTx(id, timeoutMs = 15_000) {
|
|
219
|
+
const start = Date.now();
|
|
220
|
+
for (;;) {
|
|
221
|
+
const tx = await this.tx(id);
|
|
222
|
+
if (tx.status === "finalized")
|
|
223
|
+
return tx;
|
|
224
|
+
if (tx.status === "failed")
|
|
225
|
+
throw new TxFailedError(id, tx.error ?? "unknown reason", tx);
|
|
226
|
+
if (Date.now() - start > timeoutMs)
|
|
227
|
+
throw new Error(`tx ${id} still pending after ${timeoutMs}ms`);
|
|
228
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Client, Wallet } from "./index.js";
|
|
2
|
+
/** Create a new passkey and the wallet it unlocks. */
|
|
3
|
+
export declare function createPasskeyWallet(opts: {
|
|
4
|
+
username: string;
|
|
5
|
+
client?: Client;
|
|
6
|
+
rpName?: string;
|
|
7
|
+
rpId?: string;
|
|
8
|
+
}): Promise<Wallet>;
|
|
9
|
+
/** Unlock an existing passkey wallet (shows the OS passkey picker). */
|
|
10
|
+
export declare function unlockPasskeyWallet(opts?: {
|
|
11
|
+
client?: Client;
|
|
12
|
+
credentialId?: Uint8Array;
|
|
13
|
+
rpId?: string;
|
|
14
|
+
}): Promise<Wallet>;
|
package/dist/passkey.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Browser-only: passkey-backed Blockula wallets via the WebAuthn PRF extension.
|
|
2
|
+
//
|
|
3
|
+
// The passkey (Face ID / Touch ID / security key) produces a symmetric PRF secret,
|
|
4
|
+
// which deterministically derives the ML-DSA-65 wallet key. Same passkey → same wallet
|
|
5
|
+
// on every synced device, with no seed phrase. The passkey's own P-256 signature is never
|
|
6
|
+
// used on-chain, so the account stays quantum-safe.
|
|
7
|
+
import { Wallet } from "./index.js";
|
|
8
|
+
const PRF_SALT = new TextEncoder().encode("blockula/wallet/v1");
|
|
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;
|
|
16
|
+
}
|
|
17
|
+
function walletFrom(cred, prf, client) {
|
|
18
|
+
const wallet = Wallet.fromPasskeyPrf(prf, client);
|
|
19
|
+
if (cred)
|
|
20
|
+
wallet.credentialId = new Uint8Array(cred.rawId);
|
|
21
|
+
return wallet;
|
|
22
|
+
}
|
|
23
|
+
async function prfFromCredential(cred) {
|
|
24
|
+
const ext = cred?.getClientExtensionResults();
|
|
25
|
+
const first = ext?.prf?.results?.first;
|
|
26
|
+
if (!first) {
|
|
27
|
+
throw new Error("This passkey provider doesn't support the PRF extension. Try iCloud Keychain, Google Password Manager or 1Password.");
|
|
28
|
+
}
|
|
29
|
+
return new Uint8Array(first);
|
|
30
|
+
}
|
|
31
|
+
/** Create a new passkey and the wallet it unlocks. */
|
|
32
|
+
export async function createPasskeyWallet(opts) {
|
|
33
|
+
const cred = (await navigator.credentials.create({
|
|
34
|
+
publicKey: {
|
|
35
|
+
rp: { id: rpId(opts.rpId), name: opts.rpName ?? "Blockula" },
|
|
36
|
+
user: { id: crypto.getRandomValues(new Uint8Array(16)), name: opts.username, displayName: opts.username },
|
|
37
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
38
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -8 }, { type: "public-key", alg: -257 }],
|
|
39
|
+
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
|
40
|
+
extensions: { prf: { eval: { first: PRF_SALT } } },
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
// Some authenticators only return PRF output on get(), not create(). Only retry with get()
|
|
44
|
+
// when the authenticator said PRF is enabled, so PRF-less providers fail fast with one prompt.
|
|
45
|
+
const ext = cred?.getClientExtensionResults();
|
|
46
|
+
if (ext?.prf?.results?.first)
|
|
47
|
+
return walletFrom(cred, new Uint8Array(ext.prf.results.first), opts.client);
|
|
48
|
+
if (ext?.prf?.enabled)
|
|
49
|
+
return unlockPasskeyWallet({ client: opts.client, rpId: opts.rpId, credentialId: cred ? new Uint8Array(cred.rawId) : undefined });
|
|
50
|
+
return walletFrom(cred, await prfFromCredential(cred), opts.client);
|
|
51
|
+
}
|
|
52
|
+
/** Unlock an existing passkey wallet (shows the OS passkey picker). */
|
|
53
|
+
export async function unlockPasskeyWallet(opts = {}) {
|
|
54
|
+
const cred = (await navigator.credentials.get({
|
|
55
|
+
publicKey: {
|
|
56
|
+
rpId: rpId(opts.rpId),
|
|
57
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
58
|
+
userVerification: "required",
|
|
59
|
+
allowCredentials: opts.credentialId ? [{ type: "public-key", id: opts.credentialId }] : [],
|
|
60
|
+
extensions: { prf: { eval: { first: PRF_SALT } } },
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
return walletFrom(cred, await prfFromCredential(cred), opts.client);
|
|
64
|
+
}
|
package/dist/tx.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type KeyRole = {
|
|
2
|
+
type: "owner";
|
|
3
|
+
}
|
|
4
|
+
/** Transfers only, at most `limit` drops (amount + fee) per `period_blocks`, until block `expires_at`. */
|
|
5
|
+
| {
|
|
6
|
+
type: "session";
|
|
7
|
+
limit: string;
|
|
8
|
+
period_blocks: number;
|
|
9
|
+
expires_at: number;
|
|
10
|
+
};
|
|
11
|
+
export type Action = {
|
|
12
|
+
type: "transfer";
|
|
13
|
+
to: string;
|
|
14
|
+
amount: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "register_name";
|
|
17
|
+
name: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: "add_key";
|
|
20
|
+
key_id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
role: KeyRole;
|
|
23
|
+
} | {
|
|
24
|
+
type: "remove_key";
|
|
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;
|
|
49
|
+
};
|
|
50
|
+
export interface UnsignedTx {
|
|
51
|
+
chain_id: string;
|
|
52
|
+
/** The account acting; the signing key must be authorized on it. */
|
|
53
|
+
from: string;
|
|
54
|
+
nonce: number;
|
|
55
|
+
fee: string;
|
|
56
|
+
action: Action;
|
|
57
|
+
memo: string;
|
|
58
|
+
}
|
|
59
|
+
export interface Tx extends UnsignedTx {
|
|
60
|
+
public_key: string;
|
|
61
|
+
signature: string;
|
|
62
|
+
}
|
|
63
|
+
export declare const PUBLIC_KEY_LEN = 1952;
|
|
64
|
+
export declare const SIGNATURE_LEN = 3309;
|
|
65
|
+
export declare const DROPS_PER_FANG = 1000000000n;
|
|
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;
|
|
71
|
+
/** Mirrors `min_fee` in node/src/types.rs: base fee is priced per byte and burned. */
|
|
72
|
+
export declare function minFee(tx: UnsignedTx): bigint;
|
|
73
|
+
/** "1.5" → 1500000000n drops. Accepts numbers, decimal strings or bigint drops. */
|
|
74
|
+
export declare function parseFang(amount: string | number | bigint): bigint;
|
|
75
|
+
export declare function formatFang(drops: bigint | string): string;
|
package/dist/tx.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Canonical transaction encoding. Must stay byte-identical to node/src/types.rs `Tx::signing_bytes`.
|
|
2
|
+
export const PUBLIC_KEY_LEN = 1952;
|
|
3
|
+
export const SIGNATURE_LEN = 3309;
|
|
4
|
+
export const DROPS_PER_FANG = 1000000000n;
|
|
5
|
+
const utf8 = new TextEncoder();
|
|
6
|
+
class Writer {
|
|
7
|
+
parts = [];
|
|
8
|
+
bytes(b) { for (const x of b)
|
|
9
|
+
this.parts.push(x); }
|
|
10
|
+
u8(n) { this.parts.push(n & 0xff); }
|
|
11
|
+
u16(n) { this.u8(n); this.u8(n >> 8); }
|
|
12
|
+
u64(n) { for (let i = 0n; i < 8n; i++)
|
|
13
|
+
this.u8(Number((n >> (8n * i)) & 0xffn)); }
|
|
14
|
+
str(s) { const b = utf8.encode(s); this.u16(b.length); this.bytes(b); }
|
|
15
|
+
done() { return Uint8Array.from(this.parts); }
|
|
16
|
+
}
|
|
17
|
+
export function signingBytes(tx) {
|
|
18
|
+
const w = new Writer();
|
|
19
|
+
w.bytes(utf8.encode("BLOCKULA_TX_V2"));
|
|
20
|
+
w.str(tx.chain_id);
|
|
21
|
+
w.str(tx.from);
|
|
22
|
+
w.u64(BigInt(tx.nonce));
|
|
23
|
+
w.u64(BigInt(tx.fee));
|
|
24
|
+
const a = tx.action;
|
|
25
|
+
switch (a.type) {
|
|
26
|
+
case "transfer":
|
|
27
|
+
w.u8(0);
|
|
28
|
+
w.str(a.to);
|
|
29
|
+
w.u64(BigInt(a.amount));
|
|
30
|
+
break;
|
|
31
|
+
case "register_name":
|
|
32
|
+
w.u8(1);
|
|
33
|
+
w.str(a.name);
|
|
34
|
+
break;
|
|
35
|
+
case "add_key":
|
|
36
|
+
w.u8(2);
|
|
37
|
+
w.str(a.key_id);
|
|
38
|
+
w.str(a.label);
|
|
39
|
+
if (a.role.type === "owner")
|
|
40
|
+
w.u8(0);
|
|
41
|
+
else {
|
|
42
|
+
w.u8(1);
|
|
43
|
+
w.u64(BigInt(a.role.limit));
|
|
44
|
+
w.u64(BigInt(a.role.period_blocks));
|
|
45
|
+
w.u64(BigInt(a.role.expires_at));
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
case "remove_key":
|
|
49
|
+
w.u8(3);
|
|
50
|
+
w.str(a.key_id);
|
|
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;
|
|
72
|
+
}
|
|
73
|
+
w.str(tx.memo);
|
|
74
|
+
return w.done();
|
|
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
|
+
}
|
|
86
|
+
/** Mirrors `min_fee` in node/src/types.rs: base fee is priced per byte and burned. */
|
|
87
|
+
export function minFee(tx) {
|
|
88
|
+
const size = signingBytes(tx).length + PUBLIC_KEY_LEN + SIGNATURE_LEN;
|
|
89
|
+
return 10000n + BigInt(size) * 10n;
|
|
90
|
+
}
|
|
91
|
+
/** "1.5" → 1500000000n drops. Accepts numbers, decimal strings or bigint drops. */
|
|
92
|
+
export function parseFang(amount) {
|
|
93
|
+
if (typeof amount === "bigint")
|
|
94
|
+
return amount;
|
|
95
|
+
const s = String(amount).trim();
|
|
96
|
+
if (!/^\d+(\.\d{1,9})?$/.test(s))
|
|
97
|
+
throw new Error(`invalid FANG amount: ${s}`);
|
|
98
|
+
const [whole, frac = ""] = s.split(".");
|
|
99
|
+
return BigInt(whole) * DROPS_PER_FANG + BigInt(frac.padEnd(9, "0"));
|
|
100
|
+
}
|
|
101
|
+
export function formatFang(drops) {
|
|
102
|
+
const d = BigInt(drops);
|
|
103
|
+
const whole = d / DROPS_PER_FANG;
|
|
104
|
+
const frac = (d % DROPS_PER_FANG).toString().padStart(9, "0").replace(/0+$/, "");
|
|
105
|
+
return `${whole}${frac ? "." + frac : ""} FANG`;
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockula/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "\ud83e\udddb TypeScript SDK for Blockula \u2014 post-quantum wallets (ML-DSA-65), .boo names, transfers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./passkey": "./dist/passkey.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"test": "tsx test/e2e.ts"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@noble/hashes": "^2.4.0",
|
|
24
|
+
"@noble/post-quantum": "^0.7.1",
|
|
25
|
+
"@scure/base": "^2.4.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "*",
|
|
29
|
+
"tsx": "*",
|
|
30
|
+
"typescript": "*"
|
|
31
|
+
},
|
|
32
|
+
"author": "Jack Dishman",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"blockula",
|
|
35
|
+
"post-quantum",
|
|
36
|
+
"ml-dsa",
|
|
37
|
+
"passkey",
|
|
38
|
+
"webauthn",
|
|
39
|
+
"blockchain",
|
|
40
|
+
"wallet"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|