nat-cli 0.0.3 → 0.3.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/lib/chain.mjs ADDED
@@ -0,0 +1,238 @@
1
+ // Chain side of the bnat CLI: deployment, reads, calldata and the contract arithmetic.
2
+ // Every number that decides a transaction is read from the chain, except a mint's burn, which
3
+ // comes signed from the site's signer. The rest of the site API only feeds lists.
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import {
7
+ createPublicClient, decodeEventLog, encodeFunctionData, http, parseAbi, recoverTypedDataAddress,
8
+ } from 'viem';
9
+ import { lang, t } from './i18n.mjs';
10
+
11
+ const REVERTS = JSON.parse(readFileSync(new URL('../i18n/revert.json', import.meta.url), 'utf8'));
12
+
13
+ export const ZERO = '0x0000000000000000000000000000000000000000';
14
+ export const UNIT = 10n ** 18n;
15
+ /** BNAT.FEE_BPS: 1% on wrap and on unwrap. */
16
+ export const VAULT_FEE_BPS = 100n;
17
+ /** Max NFTs per NAT.wrapBatch / BNAT.unwrapBatch (~1.0M gas warm, ~1.3M cold). */
18
+ export const BATCH_MAX = 50;
19
+ export const MARKET_FEE_BPS = 100n;
20
+
21
+ export const ABI = {
22
+ // NAT: the NFT.
23
+ nat: parseAbi([
24
+ 'function mint(uint256 blk, uint256 burnGwei, bytes sig)',
25
+ 'function mint(uint256 blk, uint256 burnGwei, bytes sig, address to)',
26
+ 'function wrap(uint256 blk)',
27
+ 'function wrapBatch(uint256[] blks)',
28
+ 'function burnOf(uint256) view returns (uint256)',
29
+ 'function ownerOf(uint256) view returns (address)',
30
+ 'function balanceOf(address) view returns (uint256)',
31
+ 'function getApproved(uint256) view returns (address)',
32
+ 'function isApprovedForAll(address, address) view returns (bool)',
33
+ 'function signer() view returns (address)',
34
+ 'function agentId() view returns (address)',
35
+ 'function eip712Domain() view returns (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)',
36
+ 'event Minted(uint256 indexed blk, address indexed minter, address indexed to, uint256 burnGwei)',
37
+ 'event Wrapped(uint256 indexed blk, address indexed owner, address indexed to, uint256 amount, uint256 fee)',
38
+ 'error BeforeActivation()', 'error FutureBlock()', 'error ZeroBurn()', 'error AlreadyMinted()', 'error BadSignature()',
39
+ 'error NotAuthorized()', 'error ZeroAddress()', 'error NoAgentId()', 'error EmptyBatch()', 'error ERC721NonexistentToken(uint256)',
40
+ ]),
41
+ // BNAT: the ERC-20 (BNAT) and the vault that holds wrapped NFTs.
42
+ bnat: parseAbi([
43
+ 'function unwrap(uint256 blk)',
44
+ 'function unwrapBatch(uint256[] blks)',
45
+ 'function quote(uint256 blk) view returns (uint256 amount, uint256 fee)',
46
+ 'function FEE_BPS() view returns (uint256)',
47
+ 'function balanceOf(address) view returns (uint256)',
48
+ 'function allowance(address, address) view returns (uint256)',
49
+ 'function approve(address, uint256) returns (bool)',
50
+ 'event Unwrapped(uint256 indexed blk, address indexed by, uint256 amount, uint256 fee)',
51
+ 'error NotInVault()', 'error EmptyBatch()', 'error CannotWithdrawNft()', 'error OnlyNft()', 'error OnlyDev()',
52
+ 'error ZeroAddress()', 'error TransferFailed()',
53
+ 'error ERC20InsufficientBalance(address, uint256, uint256)',
54
+ 'error ERC20InsufficientAllowance(address, uint256, uint256)',
55
+ ]),
56
+ registry: parseAbi(['function balanceOf(address) view returns (uint256)']),
57
+ market: parseAbi([
58
+ 'function bid(uint256 price, uint256 amount) payable returns (uint256)',
59
+ 'function fill(uint256 id, uint256 amount)',
60
+ 'function cancel(uint256 id)',
61
+ 'function bids(uint256) view returns ((address buyer, uint256 price, uint256 remaining, uint256 escrow))',
62
+ 'function bidCount() view returns (uint256)',
63
+ 'event BidPlaced(uint256 indexed id, address indexed buyer, uint256 price, uint256 amount, uint256 escrow)',
64
+ 'event BidFilled(uint256 indexed id, address indexed seller, address indexed buyer, uint256 amount, uint256 paid, uint256 fee)',
65
+ 'event BidCancelled(uint256 indexed id, address indexed buyer, uint256 refund)',
66
+ 'error ZeroAmount()', 'error WrongValue(uint256)', 'error NotBuyer()', 'error BidClosed()',
67
+ 'error TooMuch(uint256)', 'error NothingToPay()', 'error TransferFailed()',
68
+ ]),
69
+ };
70
+
71
+ /** Revert name -> what the user should know (i18n/revert.json); read at lookup so it follows the
72
+ * current language. */
73
+ export const REVERT_TEXT = Object.defineProperties({}, Object.fromEntries(
74
+ Object.entries(REVERTS).map(([k, v]) => [k, { get: () => v[lang], enumerable: true }]),
75
+ ));
76
+
77
+ export function explainRevert(e) {
78
+ for (let cur = e; cur; cur = cur.cause) {
79
+ const name = cur?.data?.errorName ?? cur?.errorName;
80
+ if (name) return `${REVERT_TEXT[name] ?? name}${t('chain.revertNameSuffix', { name })}`;
81
+ }
82
+ return e?.shortMessage ?? e?.message ?? String(e);
83
+ }
84
+
85
+ // ---- deployment & config ----
86
+
87
+ // A configured RPC URL often carries an API key in its path or query, and viem puts the full URL
88
+ // into its error messages. Everything the CLI prints goes through redact() (scripts/cli.mjs).
89
+ const SECRET_URLS = new Set();
90
+ export function redact(s) {
91
+ let out = String(s);
92
+ for (const u of SECRET_URLS) out = out.split(u).join(`${new URL(u).origin}/…`);
93
+ return out;
94
+ }
95
+
96
+ const ASSET = fileURLToPath(new URL('../assets/deployment.json', import.meta.url));
97
+ const REQUIRED = ['chainId', 'nat', 'bnat', 'market', 'agentId'];
98
+ const DEFAULT_RPC = { 56: 'https://bsc-dataseed.bnbchain.org' };
99
+
100
+ /**
101
+ * BNAT_DEPLOYMENT overrides the bundled deployment (local dev chains only).
102
+ * BNAT_SITE is the site serving /api/v1 and /api/signer; BNAT_RPC the contract chain RPC.
103
+ */
104
+ export function loadConfig(env = process.env) {
105
+ const path = env.BNAT_DEPLOYMENT ?? ASSET;
106
+ let d;
107
+ try { d = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { throw Error(t('chain.cannotReadTheDeploymentFile', { path }) + e.message); }
108
+ for (const k of REQUIRED) if (d[k] === undefined) throw Error(t('chain.theDeploymentFileLacks', { k }));
109
+ const site = (env.BNAT_SITE ?? d.site ?? '').replace(/\/+$/, '');
110
+ // A mock deployment runs on a local chain that may also use chainId 56; never fall back to the real BSC RPC for it.
111
+ if (d.mockAgentId && !env.BNAT_RPC) throw Error(t('chain.aDevDeploymentMockagentidNeeds'));
112
+ const rpc = env.BNAT_RPC ?? DEFAULT_RPC[d.chainId];
113
+ if (!rpc) throw Error(t('chain.noDefaultRpcForChainid', { chainId: d.chainId }));
114
+ if (env.BNAT_RPC) for (const u of [rpc, rpc.replace(/\/+$/, '')]) if (URL.canParse(u) && new URL(u).origin !== u) SECRET_URLS.add(u);
115
+ return { d, site, rpc };
116
+ }
117
+
118
+ /**
119
+ * Native balance by plain eth_getBalance. viem >= 2.5x sends getBalance through Multicall3.getEthBalance
120
+ * when multicall batching is on, which needs a full Multicall3 on the RPC's chain.
121
+ */
122
+ export const nativeBalance = async (client, address) => BigInt(await client.request({ method: 'eth_getBalance', params: [address, 'latest'] }));
123
+
124
+ /** Multicall3 on BSC; a deployment file may name another ("multicall3"). */
125
+ export const MULTICALL3 = '0xcA11bde05977b3631167028862bE2a173976CA11';
126
+
127
+ /**
128
+ * Reads issued together (e.g. ownerOf / burnOf for every block of a 50-NFT wrap) go out as one
129
+ * Multicall3 aggregate3 eth_call. A revert in one read still surfaces as that read's own error.
130
+ * Without a known Multicall3 (an unknown chain) reads are sent one by one. batchSize: viem splits
131
+ * at 1 KB of calldata by default; the reads of a 50-block batch are ~20 KB.
132
+ */
133
+ export function makeClient(cfg) {
134
+ const mc = cfg.d.multicall3 ?? (cfg.d.chainId === 56 && !cfg.d.mockAgentId ? MULTICALL3 : undefined);
135
+ const chain = mc ? { id: cfg.d.chainId, name: 'contract chain', nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 },
136
+ rpcUrls: { default: { http: [cfg.rpc] } }, contracts: { multicall3: { address: mc } } } : undefined;
137
+ return createPublicClient({ chain, transport: http(cfg.rpc, { timeout: 30_000 }), batch: mc ? { multicall: { batchSize: 65_536 } } : undefined });
138
+ }
139
+
140
+ // ---- parsing & formatting (18-decimal fixed point, exact) ----
141
+
142
+ /** "0.05" -> 5e16. Rejects anything that is not a plain decimal with at most 18 decimals. */
143
+ export function parseUnits18(s) {
144
+ const m = /^(\d+)(?:\.(\d{1,18}))?$/.exec(String(s ?? '').trim());
145
+ if (!m) return null;
146
+ return BigInt(m[1]) * UNIT + BigInt((m[2] ?? '').padEnd(18, '0') || '0');
147
+ }
148
+
149
+ export function fmt18(v) {
150
+ const neg = v < 0n; if (neg) v = -v;
151
+ const w = v / UNIT, f = (v % UNIT).toString().padStart(18, '0').replace(/0+$/, '');
152
+ return `${neg ? '-' : ''}${w}${f ? '.' + f : ''}`;
153
+ }
154
+
155
+ export const parseBlk = (s) => (/^[1-9]\d{0,11}$/.test(String(s ?? '')) ? Number(s) : null);
156
+
157
+ // ---- mirrors of contract arithmetic (must match the contracts' integer math exactly) ----
158
+
159
+ /** NATMarket.costOf: BNB a bid locks, rounded up. */
160
+ export const costOf = (price, amount) => (amount * price + UNIT - 1n) / UNIT;
161
+ /** NATMarket.payOf: the last fill releases everything left, including round-up dust. */
162
+ export const payOf = (bid, amount) => (amount === bid.remaining ? bid.escrow : (amount * bid.price) / UNIT);
163
+ export const marketFee = (pay) => (pay * MARKET_FEE_BPS) / 10_000n;
164
+ /** BNAT.quote: amount = burnGwei * 1e9, fee = 1% floored, per block. Wrap pays out amount - fee; unwrap costs amount + fee. */
165
+ export function wrapAmounts(burnGwei) {
166
+ const amount = burnGwei * 1_000_000_000n;
167
+ const fee = (amount * VAULT_FEE_BPS) / 10_000n;
168
+ return { amount, fee, toHolder: amount - fee, unwrapCost: amount + fee };
169
+ }
170
+
171
+ /** First block with a BEP-95 burn record; earlier blocks cannot be minted. */
172
+ export const ELEMENT_ACTIVATION_BLOCK = 13_082_000;
173
+
174
+ // ---- site API ----
175
+
176
+ export async function api(cfg, path) {
177
+ if (!cfg.site) throw Error(t('chain.noSiteAddressSetBnat'));
178
+ const res = await fetch(`${cfg.site}${path}`, { signal: AbortSignal.timeout(30_000) });
179
+ const body = await res.json().catch(() => null);
180
+ if (res.status === 503 && /index syncing/.test(body?.error ?? '')) throw Error(`${path}: ${t('chain.indexSyncing')}`);
181
+ if (!res.ok || body === null || typeof body !== 'object') throw Error(`${path}: ${body?.error ?? `HTTP ${res.status}`}`);
182
+ return body;
183
+ }
184
+
185
+ export const MINT_TYPES = { Mint: [{ name: 'blk', type: 'uint256' }, { name: 'burnGwei', type: 'uint256' }] };
186
+
187
+ /**
188
+ * Ask the signer for a mint signature and its burn, and check before use that it is for this
189
+ * contract and recovers to the contract's current signer. The burn is the signer's (the site's
190
+ * burn index): the CLI does not recompute it.
191
+ */
192
+ export async function attest(cfg, client, blk) {
193
+ const body = await api(cfg, `/api/signer/mint/${blk}`);
194
+ if (typeof body.sig !== 'string' || typeof body.burnGwei !== 'string' || !/^[0-9]+$/.test(body.burnGwei) || body.blk !== blk) throw Error(t('chain.unexpectedReplyFromTheSigning'));
195
+ if (body.chainId !== cfg.d.chainId || String(body.contract).toLowerCase() !== cfg.d.nat.toLowerCase()) throw Error(t('chain.theSignatureIsNotFor'));
196
+ const burn = BigInt(body.burnGwei);
197
+ // The domain comes from the contract itself, so the check can never drift from what mint() verifies.
198
+ const [[, name, version, chainId, verifyingContract], onchain] = await Promise.all([
199
+ client.readContract({ address: cfg.d.nat, abi: ABI.nat, functionName: 'eip712Domain' }),
200
+ client.readContract({ address: cfg.d.nat, abi: ABI.nat, functionName: 'signer' }),
201
+ ]);
202
+ const rec = await recoverTypedDataAddress({
203
+ domain: { name, version, chainId: Number(chainId), verifyingContract }, // viem drops a bigint chainId from the domain type
204
+ types: MINT_TYPES, primaryType: 'Mint', message: { blk: BigInt(blk), burnGwei: burn }, signature: body.sig,
205
+ });
206
+ if (rec.toLowerCase() !== onchain.toLowerCase()) throw Error(t('chain.theSignerDiffersFromThe'));
207
+ return { sig: body.sig, burn };
208
+ }
209
+
210
+ // ---- reads ----
211
+
212
+ export async function walletState(cfg, client, a) {
213
+ const [agent, bnb, nfts, bnat] = await Promise.all([
214
+ client.readContract({ address: cfg.d.agentId, abi: ABI.registry, functionName: 'balanceOf', args: [a] }),
215
+ nativeBalance(client, a),
216
+ client.readContract({ address: cfg.d.nat, abi: ABI.nat, functionName: 'balanceOf', args: [a] }),
217
+ client.readContract({ address: cfg.d.bnat, abi: ABI.bnat, functionName: 'balanceOf', args: [a] }),
218
+ ]);
219
+ return { address: a, agentId: agent > 0n, bnb, nfts, bnat };
220
+ }
221
+
222
+ export async function readBid(cfg, client, id) {
223
+ const n = await client.readContract({ address: cfg.d.market, abi: ABI.market, functionName: 'bidCount' });
224
+ if (BigInt(id) >= n) throw Error(t('chain.bidDoesnTExistIn', { id, n }));
225
+ return client.readContract({ address: cfg.d.market, abi: ABI.market, functionName: 'bids', args: [BigInt(id)] });
226
+ }
227
+
228
+ export const calldata = (abi, functionName, args) => encodeFunctionData({ abi, functionName, args });
229
+
230
+ /** Decode this contract's events out of a receipt (unknown logs are skipped). */
231
+ export function eventsOf(receipt, abi, address) {
232
+ const out = [];
233
+ for (const l of receipt.logs) {
234
+ if (l.address.toLowerCase() !== address.toLowerCase()) continue;
235
+ try { out.push(decodeEventLog({ abi, data: l.data, topics: l.topics })); } catch { /* not ours */ }
236
+ }
237
+ return out;
238
+ }
package/lib/i18n.mjs ADDED
@@ -0,0 +1,96 @@
1
+ // Two languages: the texts live in i18n/zh.json and i18n/en.json, looked up with t(key, params);
2
+ // a text names its parameters as {name}. The language is decided at start, before any other module
3
+ // evaluates (they import this one, and the first-run question below is a top-level await):
4
+ // 1. `--lang zh|en` on the command line (this run only)
5
+ // 2. BNAT_LANG from the environment
6
+ // 3. "lang" in ~/.config/bnat/config.json; on the first run in a terminal, ask once and save it there
7
+ // 4. otherwise (agents, pipes, --json) the locale: Chinese -> zh, anything else -> en
8
+ // The TUI can switch it later with setLang(); texts are looked up when shown, not cached.
9
+ // Children started by the TUI inherit the result through BNAT_LANG.
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import { createInterface } from 'node:readline/promises';
14
+
15
+ const ok = (v) => v === 'zh' || v === 'en';
16
+
17
+ /** The settings file: ~/.config/bnat/config.json (XDG_CONFIG_HOME respected; BNAT_CONFIG_DIR moves it, for tests). */
18
+ export const configFile = (env = process.env) =>
19
+ join(env.BNAT_CONFIG_DIR ?? join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'bnat'), 'config.json');
20
+
21
+ function readConfig(path) {
22
+ try { const c = JSON.parse(readFileSync(path, 'utf8')); return c && typeof c === 'object' ? c : {}; } catch { return {}; }
23
+ }
24
+
25
+ /** Set one key in config.json, keeping the others (undefined removes it). */
26
+ export function saveSetting(key, value, path = configFile()) {
27
+ const c = { ...readConfig(path), [key]: value };
28
+ mkdirSync(dirname(path), { recursive: true });
29
+ writeFileSync(`${path}.tmp`, `${JSON.stringify(c, null, 2)}\n`);
30
+ renameSync(`${path}.tmp`, path);
31
+ }
32
+
33
+ function argLang(argv) {
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const m = /^--lang(?:=(.*))?$/.exec(argv[i]);
36
+ if (m) return m[1] ?? argv[i + 1];
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ const localeLang = (env) => ((env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
42
+
43
+ async function ask(def) {
44
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
45
+ try {
46
+ for (;;) {
47
+ const a = (await rl.question(`选择语言 / Choose language: 1) 中文 2) English [${def === 'zh' ? 1 : 2}] `)).trim().toLowerCase();
48
+ if (a === '') return def;
49
+ if (a === '1' || a === 'zh') return 'zh';
50
+ if (a === '2' || a === 'en') return 'en';
51
+ }
52
+ } finally { rl.close(); }
53
+ }
54
+
55
+ async function decide(argv = process.argv, env = process.env) {
56
+ const flag = argLang(argv);
57
+ if (ok(flag)) return flag;
58
+ if (ok(env.BNAT_LANG)) return env.BNAT_LANG;
59
+ const saved = readConfig(configFile(env)).lang;
60
+ if (ok(saved)) return saved;
61
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !argv.includes('--json') && argv[2] !== 'lang';
62
+ if (!interactive) return localeLang(env);
63
+ const chosen = await ask(localeLang(env));
64
+ try {
65
+ saveSetting('lang', chosen, configFile(env));
66
+ console.log(chosen === 'zh' ? `已保存到 ${configFile(env)}(改用 bnat lang en)\n` : `Saved to ${configFile(env)} (change with bnat lang zh)\n`);
67
+ } catch (e) {
68
+ console.log(chosen === 'zh' ? `没能保存(${e.message}),下次还会再问\n` : `Could not save it (${e.message}); you will be asked again\n`);
69
+ }
70
+ return chosen;
71
+ }
72
+
73
+ export let lang = await decide();
74
+ process.env.BNAT_LANG = lang;
75
+
76
+ /** Switch the language of this process (and of the children it starts from now on). */
77
+ export function setLang(l) {
78
+ if (!ok(l)) throw Error(`bad language ${l}`);
79
+ lang = l;
80
+ process.env.BNAT_LANG = l;
81
+ }
82
+
83
+ /** The saved settings (config.json), {} when there are none. */
84
+ export const settings = () => readConfig(configFile());
85
+
86
+ const dicts = {};
87
+ const dict = (l) => (dicts[l] ??= JSON.parse(readFileSync(new URL(`../i18n/${l}.json`, import.meta.url), 'utf8')));
88
+
89
+ /** The text for `key` in language `l`, with {name} filled from `params`. */
90
+ export function tIn(l, key, params) {
91
+ const s = dict(l)[key] ?? key;
92
+ return params ? s.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m)) : s;
93
+ }
94
+
95
+ /** The text for `key` in the current language. */
96
+ export const t = (key, params) => tIn(lang, key, params);
@@ -0,0 +1,59 @@
1
+ // Append-only journal of every write. An entry is written before `execute`, so a crash
2
+ // between "sent" and "recorded" leaves an open entry that blocks new writes until it is
3
+ // settled with `bnat resume`. Nothing is ever re-sent automatically.
4
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { t } from './i18n.mjs';
9
+
10
+ export const stateDir = (env = process.env) => env.BNAT_STATE_DIR ?? join(env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'bnat');
11
+
12
+ export class Journal {
13
+ constructor(dir = stateDir()) {
14
+ mkdirSync(dir, { recursive: true });
15
+ this.path = join(dir, 'journal.jsonl');
16
+ }
17
+
18
+ /** Entries folded by id, oldest first. */
19
+ entries() {
20
+ let text = '';
21
+ try { text = readFileSync(this.path, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
22
+ const by = new Map();
23
+ for (const [i, line] of text.split('\n').entries()) {
24
+ if (!line.trim()) continue;
25
+ let ev; // a corrupt line is a hard stop, not a skipped one
26
+ try { ev = JSON.parse(line); } catch { throw Error(t('journal.corruptLine', { path: this.path, n: i + 1 })); }
27
+ by.set(ev.id, { ...(by.get(ev.id) ?? {}), ...ev });
28
+ }
29
+ return [...by.values()];
30
+ }
31
+
32
+ /** Writes whose outcome is not known yet. */
33
+ open() {
34
+ return this.entries().filter((e) => e.status === 'executing' || e.status === 'sent');
35
+ }
36
+
37
+ /** Appended and fsynced before the caller goes on: the "executing" entry must survive a crash
38
+ * or power loss that happens while the wallet is sending, or the next run could resend. */
39
+ #append(ev) {
40
+ const line = JSON.stringify({ ...ev, at: new Date().toISOString() }, (_, v) => (typeof v === 'bigint' ? v.toString() : v)) + '\n';
41
+ const fd = openSync(this.path, 'a');
42
+ try {
43
+ writeSync(fd, line);
44
+ fsyncSync(fd);
45
+ } finally {
46
+ closeSync(fd);
47
+ }
48
+ }
49
+
50
+ begin(plan) {
51
+ const id = randomUUID();
52
+ this.#append({ id, status: 'executing', ...plan });
53
+ return id;
54
+ }
55
+
56
+ update(id, fields) {
57
+ this.#append({ id, ...fields });
58
+ }
59
+ }