mybitduit 0.0.1

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 ADDED
@@ -0,0 +1,82 @@
1
+ # mybitduit
2
+
3
+ > **bit + duit** — your Solana money terminal. A beautiful, simple, non-custodial
4
+ > tool to check prices (and, later, view your wallet and swap) — on the web and
5
+ > right inside your terminal.
6
+
7
+ Two faces, one brain: a [`core/`](core) price engine shared by a CLI and a website.
8
+
9
+ ## CLI
10
+
11
+ A full-screen, live money terminal in any shell.
12
+
13
+ ### Just type `mybitduit`
14
+
15
+ Install once, then it's a real command on your machine:
16
+
17
+ ```bash
18
+ git clone <repo> && cd mybitduit
19
+ npm install
20
+ npm install -g . # (or: npm link) — registers the `mybitduit` command
21
+
22
+ mybitduit # → launches the full-screen terminal
23
+ ```
24
+
25
+ That's the whole dev experience: **`mybitduit` and it starts.** Run it with no
26
+ args in a real terminal and you get the interactive app; pipe it or run it in a
27
+ non-TTY (CI, scripts) and the same command prints the top 10 and exits.
28
+
29
+ ### One-shot / scriptable
30
+
31
+ ```bash
32
+ mybitduit price sol btc eth # print prices once, exit
33
+ mybitduit top # top 10 by market cap, exit
34
+ mybitduit watch <address> # read-only Solana wallet (add --json)
35
+ mybitduit help # all commands + env vars
36
+ ```
37
+
38
+ ### Inside the full-screen app
39
+
40
+ - **Home** — arrow keys `↑↓` + `↵` through the menu, or type a command at the `›`
41
+ prompt: a coin (`sol`), `live`, `wallet`, `watch <address>`, `about`, `q`.
42
+ - **Live** — top-10 list + 7-day ASCII chart. `↑↓` / `j k` select, `r` refresh,
43
+ `esc` home, `q` quit. Auto-refreshes every 60s.
44
+ - **Wallet** — paste any Solana address to read its balances and USD value.
45
+ Read-only: it never sees a key. `r` refresh, `n` new address, `esc` home.
46
+ - **About** — the roadmap.
47
+
48
+ ### Optional env
49
+
50
+ - `COINGECKO_API_KEY` — free CoinGecko demo key → higher price rate limits.
51
+ - `SOLANA_RPC_URL` — your own RPC (e.g. Helius) for faster, unthrottled wallet reads.
52
+
53
+ ### Install without cloning (once published to npm)
54
+
55
+ ```bash
56
+ npm install -g mybitduit # the dev way
57
+ # or
58
+ curl -fsSL https://mybitduit.com/install.sh | sh # the everyone way
59
+ ```
60
+
61
+ [`install.sh`](install.sh) checks Node 18+, installs globally, and fixes PATH if
62
+ npm's global bin dir isn't on it — so `mybitduit` just works in a new terminal.
63
+ (Serve it from `web/public/install.sh` when the site deploys.)
64
+
65
+ ## Web
66
+
67
+ `web/` will hold the single-file `mybitduit.com` site — the same engine, rendered
68
+ as a browser terminal. Deploys to GitHub Pages + custom domain (like qrdurian).
69
+
70
+ ## Roadmap
71
+
72
+ See [BUSINESS_PLAN.md](BUSINESS_PLAN.md). Short version:
73
+
74
+ - **Phase 0 — prices** ✅ *(CLI live; web next)* — no wallet, no risk
75
+ - **Phase 1 — wallet view** ✅ *(CLI: read any address; web: Phantom next)* — read balances, non-custodial.
76
+ - **Phase 2 — swaps via Jupiter** — your wallet signs, we never hold funds.
77
+ - **Phase 3 — fiat on-ramp** — partner handles cash↔crypto + compliance.
78
+
79
+ ## Principle
80
+
81
+ **Non-custodial, always.** mybitduit never holds anyone's money — it shows info,
82
+ or asks *your* wallet to approve an action. No licenses, no custody risk.
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env node
2
+ /* ============================================================
3
+ mybitduit — Solana money terminal (CLI)
4
+ - `mybitduit` → full-screen app: home menu →
5
+ live top-10 list + ASCII charts
6
+ - `mybitduit price sol btc` → print once and exit (scriptable)
7
+ - `mybitduit top` → print top 10 once and exit
8
+ Phase 0: prices only. No wallet, no keys, no risk.
9
+ ============================================================ */
10
+ import { fetchPrices, fetchTopCoins, downsample, fmtPrice, fmtChange } from '../core/prices.mjs';
11
+ import { fetchPortfolio, isValidSolanaAddress } from '../core/portfolio.mjs';
12
+
13
+ const VOID = '#07090d', GREEN = '#00ff9c', DIM = '#5f7a6e', INK = '#cfeede',
14
+ RED = '#ff4fa3', AMBER = '#ffc24b', BORDER = '#27352f', LOGO = '#2a7d5c';
15
+
16
+ /* ---- tiny ANSI helpers (one-shot mode, no deps) ---- */
17
+ const truecolor = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
18
+ const A = {
19
+ green: truecolor(0, 255, 156),
20
+ red: truecolor(255, 79, 163),
21
+ dim: truecolor(95, 122, 110),
22
+ ink: truecolor(207, 238, 222),
23
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
24
+ };
25
+
26
+ function shortNum(x) {
27
+ if (x == null) return '—';
28
+ const a = Math.abs(x);
29
+ if (a >= 1000) return (x / 1000).toFixed(a >= 100000 ? 0 : 1) + 'k';
30
+ if (a >= 1) return x.toFixed(2);
31
+ if (a > 0) return Number(x.toPrecision(2)).toString();
32
+ return '0';
33
+ }
34
+
35
+ /** y-axis formatter whose precision adapts to the series range (fixes "1,1,1,1"). */
36
+ function axisFormatter(series, height) {
37
+ const min = Math.min(...series), max = Math.max(...series);
38
+ const span = Math.max(Math.abs(max - min), Math.abs(max) * 1e-6, 1e-9);
39
+ let dec = Math.ceil(-Math.log10(span / height)) + 1;
40
+ dec = Math.max(0, Math.min(8, dec));
41
+ return (x) => {
42
+ const s = '$' + Number(x).toLocaleString('en-US', {
43
+ minimumFractionDigits: dec,
44
+ maximumFractionDigits: dec,
45
+ });
46
+ return (' ' + s).slice(-11);
47
+ };
48
+ }
49
+
50
+ async function oneShot(symbols) {
51
+ let rows;
52
+ try {
53
+ rows = await fetchPrices(symbols);
54
+ } catch (e) {
55
+ console.error(A.red('✗ ' + e.message));
56
+ process.exit(1);
57
+ }
58
+ console.log(A.bold(A.green('mybitduit')) + A.dim(' // prices'));
59
+ for (const r of rows) {
60
+ const sym = r.symbol.padEnd(6);
61
+ const priceStr = (r.price == null ? '—' : '$' + fmtPrice(r.price)).padEnd(14);
62
+ const color = r.change == null ? A.dim : r.change >= 0 ? A.green : A.red;
63
+ console.log(' ' + A.bold(A.ink(sym)) + color(priceStr) + color(fmtChange(r.change)));
64
+ }
65
+ }
66
+
67
+ async function oneShotTop() {
68
+ let coins;
69
+ try {
70
+ coins = await fetchTopCoins(10);
71
+ } catch (e) {
72
+ console.error(A.red('✗ ' + e.message));
73
+ process.exit(1);
74
+ }
75
+ console.log(A.bold(A.green('mybitduit')) + A.dim(' // top 10 by market cap'));
76
+ for (const c of coins) {
77
+ const rank = String(c.rank).padStart(2);
78
+ const sym = c.symbol.slice(0, 8).padEnd(9);
79
+ const name = c.name.slice(0, 16).padEnd(17);
80
+ const price = ('$' + fmtPrice(c.price)).padEnd(13);
81
+ const color = c.change == null ? A.dim : c.change >= 0 ? A.green : A.red;
82
+ console.log(' ' + A.dim(rank) + ' ' + A.bold(A.ink(sym)) + A.dim(name) + color(price + fmtChange(c.change)));
83
+ }
84
+ }
85
+
86
+ async function watchCmd(address, asJson) {
87
+ let p;
88
+ try {
89
+ p = await fetchPortfolio(address);
90
+ } catch (e) {
91
+ if (asJson) console.log(JSON.stringify({ error: e.message }));
92
+ else console.error(A.red('✗ ' + e.message));
93
+ process.exit(1);
94
+ }
95
+ if (asJson) {
96
+ console.log(JSON.stringify(p, null, 2));
97
+ return;
98
+ }
99
+ const short = p.address.slice(0, 4) + '…' + p.address.slice(-4);
100
+ const chg = p.change24h == null ? '' : (p.change24h >= 0 ? A.green : A.red)(fmtChange(p.change24h) + ' (24h)');
101
+ const amtStr = (n) => (n >= 1 ? fmtPrice(n) : Number(n.toPrecision(4)).toString());
102
+ console.log(A.bold(A.green('mybitduit')) + A.dim(' // wallet ' + short));
103
+ console.log(' ' + A.bold(A.ink('total'.padEnd(11))) + A.bold(('$' + fmtPrice(p.total)).padEnd(15)) + chg);
104
+ console.log(A.dim(' ' + '─'.repeat(34)));
105
+ const LIMIT = 12;
106
+ const top = p.holdings.slice(0, LIMIT);
107
+ for (const h of top) {
108
+ const sym = h.symbol.slice(0, 10).padEnd(11);
109
+ const amt = amtStr(h.amount).padEnd(15);
110
+ const val = h.price != null ? '$' + fmtPrice(h.value) : '—';
111
+ const c = h.change == null ? '' : (h.change >= 0 ? A.green : A.red)(' ' + fmtChange(h.change));
112
+ console.log(' ' + A.ink(sym) + A.dim(amt) + (h.price != null ? A.ink(val) : A.dim(val)) + c);
113
+ }
114
+ const more = p.holdings.length - top.length;
115
+ if (more > 0) console.log(A.dim(` +${more} more holding(s)`));
116
+ if (p.hiddenCount > 0) console.log(A.dim(` +${p.hiddenCount} dust / unpriced token(s) hidden`));
117
+ console.log(A.dim(' via ' + p.rpc.replace(/^https?:\/\//, '')));
118
+ }
119
+
120
+ /* ---- full-screen interactive app (Ink) ---- */
121
+ async function tui() {
122
+ const React = (await import('react')).default;
123
+ const { render, Box, Text, useApp, useInput } = await import('ink');
124
+ const htm = (await import('htm')).default;
125
+ const asciichart = (await import('asciichart')).default;
126
+ const { useState, useEffect, useRef } = React;
127
+ const html = htm.bind(React.createElement);
128
+
129
+ // hand-drawn coin logo (bit + duit = money)
130
+ const COIN = [
131
+ ' ▗▄▄▄▄▖ ',
132
+ ' ▟▛ ▜▙ ',
133
+ '▐▌ $$ ▐▌',
134
+ '▐▌ $$ ▐▌',
135
+ ' ▜▙ ▟▛ ',
136
+ ' ▝▀▀▀▀▘ ',
137
+ ];
138
+
139
+ const MENU = [
140
+ { label: 'Live prices · top 10', hint: '↵', action: 'live' },
141
+ { label: 'Wallet · read-only', hint: 'w', action: 'wallet' },
142
+ { label: 'About / roadmap', hint: 'a', action: 'about' },
143
+ { label: 'Quit', hint: 'q', action: 'quit' },
144
+ ];
145
+
146
+ // take over the screen (alternate buffer) so we get the full black canvas
147
+ process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H');
148
+ const restore = () => process.stdout.write('\x1b[?1049l');
149
+ process.on('exit', restore);
150
+
151
+ function App() {
152
+ const { exit } = useApp();
153
+ const [screen, setScreen] = useState('home'); // home | live | about
154
+ const [menuSel, setMenuSel] = useState(0);
155
+ const [buf, setBuf] = useState('');
156
+ const [toast, setToast] = useState('');
157
+ const [coins, setCoins] = useState([]);
158
+ const [loaded, setLoaded] = useState(false);
159
+ const [sel, setSel] = useState(0);
160
+ const [err, setErr] = useState('');
161
+ const [retryAt, setRetryAt] = useState(0);
162
+ const [updated, setUpdated] = useState('');
163
+ const [cols, setCols] = useState(process.stdout.columns || 100);
164
+ const [rows, setRows] = useState(process.stdout.rows || 32);
165
+ const [tick, setTick] = useState(0);
166
+ const retryRef = useRef(null);
167
+ const RETRY_MS = 12000;
168
+
169
+ // wallet (read-only portfolio) state
170
+ const [walletInput, setWalletInput] = useState('');
171
+ const [walletData, setWalletData] = useState(null);
172
+ const [walletErr, setWalletErr] = useState('');
173
+ const [walletLoading, setWalletLoading] = useState(false);
174
+
175
+ const quit = () => { restore(); exit(); };
176
+
177
+ async function loadWallet(addr) {
178
+ const a = String(addr || '').trim();
179
+ if (!isValidSolanaAddress(a)) { setWalletErr('that doesn’t look like a Solana address'); return; }
180
+ setScreen('wallet'); setWalletLoading(true); setWalletErr(''); setWalletData(null);
181
+ try {
182
+ setWalletData(await fetchPortfolio(a));
183
+ } catch (e) {
184
+ setWalletErr(e.message);
185
+ } finally {
186
+ setWalletLoading(false);
187
+ }
188
+ }
189
+
190
+ async function load() {
191
+ try {
192
+ const data = await fetchTopCoins(10);
193
+ setCoins(data); setLoaded(true); setErr(''); setRetryAt(0);
194
+ setUpdated(new Date().toLocaleTimeString());
195
+ if (retryRef.current) { clearTimeout(retryRef.current); retryRef.current = null; }
196
+ } catch (e) {
197
+ setErr(e.message);
198
+ if (retryRef.current) clearTimeout(retryRef.current);
199
+ setRetryAt(Date.now() + RETRY_MS);
200
+ retryRef.current = setTimeout(load, RETRY_MS);
201
+ }
202
+ }
203
+
204
+ useEffect(() => {
205
+ load();
206
+ const t = setInterval(() => setTick((x) => x + 1), 120);
207
+ const refresh = setInterval(load, 60000);
208
+ const onResize = () => {
209
+ setCols(process.stdout.columns || 100);
210
+ setRows(process.stdout.rows || 32);
211
+ };
212
+ process.stdout.on('resize', onResize);
213
+ return () => {
214
+ clearInterval(t); clearInterval(refresh);
215
+ if (retryRef.current) clearTimeout(retryRef.current);
216
+ process.stdout.off('resize', onResize);
217
+ };
218
+ }, []);
219
+
220
+ function activate(action) {
221
+ setToast('');
222
+ if (action === 'live') return setScreen('live');
223
+ if (action === 'about') return setScreen('about');
224
+ if (action === 'wallet') { setWalletErr(''); return setScreen('wallet'); }
225
+ if (action === 'quit') return quit();
226
+ }
227
+
228
+ function runCommand(raw) {
229
+ const c = raw.trim().toLowerCase();
230
+ setBuf('');
231
+ if (!c) return activate(MENU[menuSel].action);
232
+ if (['q', 'quit', 'exit'].includes(c)) return quit();
233
+ if (['about', 'roadmap'].includes(c)) return setScreen('about');
234
+ if (['live', 'prices', 'top', 'p'].includes(c)) return setScreen('live');
235
+ const parts = c.split(/\s+/);
236
+ if (['wallet', 'watch', 'w'].includes(parts[0])) {
237
+ if (parts[1]) return loadWallet(raw.trim().split(/\s+/)[1]);
238
+ setWalletErr(''); return setScreen('wallet');
239
+ }
240
+ const idx = coins.findIndex((x) => x.symbol.toLowerCase() === c || x.id === c);
241
+ if (idx >= 0) { setSel(idx); return setScreen('live'); }
242
+ setToast(`"${c}" not in top 10 — coin search coming soon`);
243
+ }
244
+
245
+ useInput((input, key) => {
246
+ if (key.ctrl && input === 'c') return quit();
247
+ if (screen === 'home') {
248
+ if (key.upArrow) return setMenuSel((s) => (s + MENU.length - 1) % MENU.length);
249
+ if (key.downArrow) return setMenuSel((s) => (s + 1) % MENU.length);
250
+ if (key.return) return runCommand(buf);
251
+ if (key.backspace || key.delete) return setBuf((b) => b.slice(0, -1));
252
+ if (input && !key.ctrl && !key.meta && !key.tab && !key.escape) return setBuf((b) => b + input);
253
+ return;
254
+ }
255
+ // wallet address entry: capture every printable key (base58 includes q/b/r)
256
+ if (screen === 'wallet' && !walletData && !walletLoading) {
257
+ if (key.escape) { setWalletInput(''); setWalletErr(''); return setScreen('home'); }
258
+ if (key.return) { const a = walletInput; setWalletInput(''); return loadWallet(a); }
259
+ if (key.backspace || key.delete) return setWalletInput((b) => b.slice(0, -1));
260
+ if (input && !key.ctrl && !key.meta && !key.tab) return setWalletInput((b) => b + input);
261
+ return;
262
+ }
263
+ // live / about / wallet-result
264
+ if (key.escape || input === 'b') { setToast(''); return setScreen('home'); }
265
+ if (input === 'q') return quit();
266
+ if (input === 'r') {
267
+ if (screen === 'wallet' && walletData) return loadWallet(walletData.address);
268
+ return load();
269
+ }
270
+ if (screen === 'wallet' && input === 'n') { setWalletData(null); setWalletErr(''); return; }
271
+ if (screen === 'live') {
272
+ if (key.upArrow || input === 'k') return setSel((s) => Math.max(0, s - 1));
273
+ if (key.downArrow || input === 'j') return setSel((s) => Math.min(coins.length - 1, s + 1));
274
+ }
275
+ });
276
+
277
+ const cursorOn = tick % 2 === 0;
278
+ const retryTxt = retryAt ? ` · retrying in ${Math.max(0, Math.ceil((retryAt - Date.now()) / 1000))}s` : '';
279
+ const statusTxt = err
280
+ ? `✗ ${err}${retryTxt} · press r`
281
+ : toast || 'non-custodial · nothing leaves your machine · prices via coingecko';
282
+
283
+ const Shell = (children) =>
284
+ html`<${Box} flexDirection="column" width=${cols} height=${rows} backgroundColor=${VOID} paddingX=${2} paddingTop=${1}>
285
+ ${children}
286
+ <//>`;
287
+
288
+ /* ---------------- HOME ---------------- */
289
+ if (screen === 'home') {
290
+ const menu = MENU.map((m, i) => {
291
+ const s = i === menuSel;
292
+ return html`<${Box} key=${i} width=${44}>
293
+ <${Text} bold=${s} color=${s ? GREEN : INK}>${(s ? '▸ ' : ' ') + '[ ' + m.label}<//>
294
+ <${Box} flexGrow=${1}><//>
295
+ <${Text} color=${DIM}>${m.hint + ' ]'}<//>
296
+ <//>`;
297
+ });
298
+
299
+ return Shell(html`
300
+ <${Box} flexGrow=${1}><//>
301
+ <${Box} justifyContent="center">
302
+ <${Box} borderStyle="round" borderColor=${BORDER} paddingX=${3} paddingY=${1}>
303
+ <${Box} flexDirection="column" marginRight=${3}>
304
+ ${COIN.map((l, i) => html`<${Text} key=${i} color=${LOGO}>${l}<//>`)}
305
+ <//>
306
+ <${Box} flexDirection="column">
307
+ <${Box}>
308
+ <${Text} bold color=${INK}>mybitduit<//>
309
+ <${Text} color=${DIM}> Beta 0.0.1<//>
310
+ <//>
311
+ <${Text} color=${DIM}>your non-custodial solana money terminal<//>
312
+ <${Box} marginTop=${1}><${Text} bold color=${AMBER}>live · prices + read-only wallet<//><//>
313
+ <${Text} color=${DIM}>Top 10 + 7d charts, and any wallet by address. Swaps next.<//>
314
+ <${Box} flexDirection="column" marginTop=${1}>
315
+ ${menu}
316
+ <//>
317
+ <//>
318
+ <//>
319
+ <//>
320
+ <${Box} flexGrow=${1}><//>
321
+ <${Box}><${Text} color=${err ? RED : DIM}>${statusTxt}<//><//>
322
+ <${Box} borderStyle="round" borderColor=${BORDER} paddingX=${1} marginTop=${0}>
323
+ <${Text} color=${GREEN}>${'› '}<//>
324
+ <${Text} color=${INK}>${buf}<//>
325
+ <${Text} color=${buf ? INK : DIM}>${buf ? (cursorOn ? '▌' : ' ') : 'type a coin (sol) or ↑↓ ↵'}<//>
326
+ <${Box} flexGrow=${1}><//>
327
+ <${Text} color=${DIM}>mybitduit · prices<//>
328
+ <//>
329
+ <${Box} justifyContent="flex-end"><${Text} color=${DIM}>Beta<//><//>
330
+ `);
331
+ }
332
+
333
+ /* ---------------- ABOUT ---------------- */
334
+ if (screen === 'about') {
335
+ const lines = [
336
+ ['Phase 0 — prices', 'live · top 10 + 7d charts'],
337
+ ['Phase 1 — wallet view', 'live · read any address, no key (web: Phantom next)'],
338
+ ['Phase 2 — swaps (Jupiter)', 'your wallet signs · we never hold funds'],
339
+ ['Phase 3 — fiat on-ramp', 'buy with card via a partner'],
340
+ ];
341
+ return Shell(html`
342
+ <${Box} flexGrow=${1}><//>
343
+ <${Box} justifyContent="center">
344
+ <${Box} flexDirection="column" borderStyle="round" borderColor=${BORDER} paddingX=${3} paddingY=${1} width=${64}>
345
+ <${Box}><${Text} bold color=${INK}>mybitduit<//><${Text} color=${DIM}> · roadmap<//><//>
346
+ <${Text} color=${DIM}>bit + duit · a non-custodial Solana money terminal<//>
347
+ <${Box} flexDirection="column" marginTop=${1}>
348
+ ${lines.map((l, i) => html`<${Box} key=${i}>
349
+ <${Box} width=${28}><${Text} color=${i <= 1 ? GREEN : INK}>${(i <= 1 ? '▸ ' : ' ') + l[0]}<//><//>
350
+ <${Text} color=${DIM}>${l[1]}<//>
351
+ <//>`)}
352
+ <//>
353
+ <${Box} marginTop=${1}><${Text} color=${AMBER}>principle: <//><${Text} color=${DIM}>we never hold your money.<//><//>
354
+ <//>
355
+ <//>
356
+ <${Box} flexGrow=${1}><//>
357
+ <${Box} justifyContent="flex-end"><${Text} color=${DIM}>esc / b back · q quit<//><//>
358
+ `);
359
+ }
360
+
361
+ /* ---------------- WALLET (read-only portfolio) ---------------- */
362
+ if (screen === 'wallet') {
363
+ // loading
364
+ if (walletLoading) {
365
+ const w = 24, pos = tick % w;
366
+ const barr = Array.from({ length: w }, (_, i) =>
367
+ i === pos || i === (pos + 1) % w ? '█' : '░').join('');
368
+ return Shell(html`
369
+ <${Box} flexGrow=${1}><//>
370
+ <${Box} justifyContent="center">
371
+ <${Box} flexDirection="column" alignItems="center">
372
+ ${COIN.map((l, i) => html`<${Text} key=${i} color=${LOGO}>${l}<//>`)}
373
+ <${Box} marginTop=${1}><${Text} color=${DIM}>reading wallet · on-chain<//><//>
374
+ <${Box}><${Text} color=${GREEN}>[${barr}]<//><//>
375
+ <//>
376
+ <//>
377
+ <${Box} flexGrow=${1}><//>
378
+ <${Box} justifyContent="flex-end"><${Text} color=${DIM}>read-only · we never see a key<//><//>
379
+ `);
380
+ }
381
+ // result
382
+ if (walletData) {
383
+ const p = walletData;
384
+ const short = p.address.slice(0, 4) + '…' + p.address.slice(-4);
385
+ const amtStr = (n) => (n >= 1 ? fmtPrice(n) : Number(n.toPrecision(4)).toString());
386
+ const top = p.holdings.slice(0, 12);
387
+ const more = p.holdings.length - top.length;
388
+ return Shell(html`
389
+ <${Box} marginBottom=${1}>
390
+ <${Text} bold color=${GREEN}>mybitduit<//>
391
+ <${Text} color=${DIM}> // wallet ${short} · read-only<//>
392
+ <//>
393
+ <${Box} justifyContent="center">
394
+ <${Box} flexDirection="column" borderStyle="round" borderColor=${BORDER} paddingX=${2} paddingY=${1} width=${66}>
395
+ <${Box}>
396
+ <${Box} width=${14}><${Text} bold color=${INK}>total<//><//>
397
+ <${Text} bold color=${INK}>$${fmtPrice(p.total)}<//>
398
+ <${Text} color=${p.change24h == null ? DIM : p.change24h >= 0 ? GREEN : RED}>${p.change24h == null ? '' : ' ' + fmtChange(p.change24h) + ' 24h'}<//>
399
+ <//>
400
+ <${Box}><${Text} color=${BORDER}>${'─'.repeat(60)}<//><//>
401
+ ${top.map((h, i) => html`<${Box} key=${i}>
402
+ <${Box} width=${10}><${Text} color=${h.price != null ? INK : DIM}>${h.symbol.slice(0, 9)}<//><//>
403
+ <${Box} width=${16}><${Text} color=${DIM}>${amtStr(h.amount)}<//><//>
404
+ <${Box} width=${12}><${Text} color=${h.price != null ? INK : DIM}>${h.price != null ? '$' + fmtPrice(h.value) : '—'}<//><//>
405
+ <${Text} color=${h.change == null ? DIM : h.change >= 0 ? GREEN : RED}>${fmtChange(h.change)}<//>
406
+ <//>`)}
407
+ ${more > 0 && html`<${Box}><${Text} color=${DIM}>+${more} more holding(s)<//><//>`}
408
+ ${p.hiddenCount > 0 && html`<${Box}><${Text} color=${DIM}>+${p.hiddenCount} dust / unpriced hidden<//><//>`}
409
+ <//>
410
+ <//>
411
+ <${Box} flexGrow=${1}><//>
412
+ <${Box}><${Text} color=${DIM}>via ${p.rpc.replace(/^https?:\/\//, '')}<//><//>
413
+ <${Box} justifyContent="flex-end"><${Text} color=${DIM}>r refresh · n new address · esc home · q quit<//><//>
414
+ `);
415
+ }
416
+ // address entry
417
+ return Shell(html`
418
+ <${Box} flexGrow=${1}><//>
419
+ <${Box} justifyContent="center">
420
+ <${Box} flexDirection="column" borderStyle="round" borderColor=${BORDER} paddingX=${3} paddingY=${1} width=${68}>
421
+ <${Box}><${Text} bold color=${INK}>wallet<//><${Text} color=${DIM}> · read-only portfolio<//><//>
422
+ <${Text} color=${DIM}>paste a Solana address — we only ever look, never touch a key.<//>
423
+ <${Box} borderStyle="round" borderColor=${walletErr ? RED : BORDER} paddingX=${1} marginTop=${1}>
424
+ <${Text} color=${GREEN}>${'› '}<//>
425
+ <${Text} color=${INK}>${walletInput || ''}<//>
426
+ <${Text} color=${walletInput ? INK : DIM}>${walletInput ? (cursorOn ? '▌' : ' ') : 'e.g. a base58 address, then ↵'}<//>
427
+ <//>
428
+ ${walletErr && html`<${Box} marginTop=${1}><${Text} color=${RED}>✗ ${walletErr}<//><//>`}
429
+ <//>
430
+ <//>
431
+ <${Box} flexGrow=${1}><//>
432
+ <${Box} justifyContent="flex-end"><${Text} color=${DIM}>↵ look up · esc home<//><//>
433
+ `);
434
+ }
435
+
436
+ /* ---------------- LIVE (loading) ---------------- */
437
+ if (screen === 'live' && !loaded) {
438
+ const w = 24, pos = tick % w;
439
+ const barr = Array.from({ length: w }, (_, i) =>
440
+ i === pos || i === (pos + 1) % w ? '█' : '░').join('');
441
+ return Shell(html`
442
+ <${Box} flexGrow=${1}><//>
443
+ <${Box} justifyContent="center">
444
+ <${Box} flexDirection="column" alignItems="center">
445
+ ${COIN.map((l, i) => html`<${Text} key=${i} color=${LOGO}>${l}<//>`)}
446
+ <${Box} marginTop=${1}><${Text} color=${DIM}>loading top 10 markets<//><//>
447
+ <${Box}><${Text} color=${GREEN}>[${barr}]<//><//>
448
+ ${err && html`<${Box} marginTop=${1}><${Text} color=${RED}>✗ ${err}${retryTxt}<//><//>`}
449
+ <//>
450
+ <//>
451
+ <${Box} flexGrow=${1}><//>
452
+ <${Box} justifyContent="flex-end"><${Text} color=${DIM}>esc back · q quit<//><//>
453
+ `);
454
+ }
455
+
456
+ /* ---------------- LIVE (data) ---------------- */
457
+ const cur = coins[Math.min(sel, coins.length - 1)];
458
+ const LEFT_W = 46;
459
+ const chartPoints = Math.max(16, Math.min(72, cols - LEFT_W - 18));
460
+ const series = downsample(cur.spark, chartPoints);
461
+ const first = cur.spark[0], last = cur.spark[cur.spark.length - 1];
462
+ const up = last >= first;
463
+ const chartColor = up ? GREEN : RED;
464
+ const pct7d = first ? ((last - first) / first) * 100 : null;
465
+ let chartLines = ['(no chart data)'];
466
+ if (series && series.length > 1) {
467
+ try {
468
+ chartLines = asciichart.plot(series, { height: 12, format: axisFormatter(series, 12) }).split('\n');
469
+ } catch (e) {
470
+ chartLines = ['(chart unavailable)'];
471
+ }
472
+ }
473
+ const hi = Math.max(...cur.spark), lo = Math.min(...cur.spark);
474
+
475
+ return Shell(html`
476
+ <${Box} marginBottom=${1}>
477
+ <${Text} bold color=${GREEN}>mybitduit<//>
478
+ <${Text} color=${DIM}> // live · top 10 · ${updated}<//>
479
+ ${err && html`<${Text} color=${RED}> (stale: ${err})<//>`}
480
+ <//>
481
+ <${Box}>
482
+ <${Box} flexDirection="column" width=${LEFT_W} borderStyle="round" borderColor=${BORDER} paddingX=${1}>
483
+ ${coins.map((c, i) => {
484
+ const s = i === sel;
485
+ return html`<${Box} key=${c.id}>
486
+ <${Text} color=${s ? GREEN : DIM}>${s ? '▸' : ' '} <//>
487
+ <${Box} width=${3}><${Text} color=${DIM}>${String(c.rank)}<//><//>
488
+ <${Box} width=${7}><${Text} bold color=${s ? GREEN : INK}>${c.symbol.slice(0, 6)}<//><//>
489
+ <${Box} width=${12}><${Text} color=${s ? INK : DIM}>${c.name.slice(0, 11)}<//><//>
490
+ <${Box} width=${10}><${Text} color=${INK}>$${fmtPrice(c.price)}<//><//>
491
+ <${Text} color=${c.change == null ? DIM : c.change >= 0 ? GREEN : RED}>${fmtChange(c.change)}<//>
492
+ <//>`;
493
+ })}
494
+ <//>
495
+ <${Box} flexDirection="column" flexGrow=${1} borderStyle="round" borderColor=${BORDER} paddingX=${1} marginLeft=${1}>
496
+ <${Box}>
497
+ <${Text} bold color=${INK}>${cur.symbol}<//>
498
+ <${Text} color=${DIM}> / USD · 7d <//>
499
+ <${Text} color=${chartColor}>${pct7d == null ? '' : fmtChange(pct7d)}<//>
500
+ <//>
501
+ ${chartLines.map((l, i) => html`<${Text} key=${i} color=${chartColor}>${l}<//>`)}
502
+ <${Box} marginTop=${1}>
503
+ <${Text} color=${INK}>now $${fmtPrice(cur.price)} <//>
504
+ <${Text} color=${cur.change == null ? DIM : cur.change >= 0 ? GREEN : RED}>${fmtChange(cur.change)}<//>
505
+ <${Text} color=${DIM}> 7d $${shortNum(lo)}–$${shortNum(hi)}<//>
506
+ <//>
507
+ <//>
508
+ <//>
509
+ <${Box} marginTop=${1}>
510
+ <${Text} color=${DIM}>↑↓ / j k select · r refresh · esc home · q quit<//>
511
+ <//>
512
+ `);
513
+ }
514
+
515
+ render(html`<${App} />`);
516
+ }
517
+
518
+ /* ---- help ---- */
519
+ function printHelp() {
520
+ const b = A.bold, g = A.green, d = A.dim, k = A.ink;
521
+ console.log(b(g('mybitduit')) + d(' // your non-custodial Solana money terminal'));
522
+ console.log('');
523
+ console.log(d(' USAGE'));
524
+ console.log(' ' + k('mybitduit') + d(' full-screen live terminal (no args)'));
525
+ console.log(' ' + k('mybitduit price ') + d('sol btc eth') + d(' print prices once, then exit'));
526
+ console.log(' ' + k('mybitduit top') + d(' print top 10 by market cap, exit'));
527
+ console.log(' ' + k('mybitduit watch ') + d('<address>') + d(' read-only Solana wallet (add --json)'));
528
+ console.log(' ' + k('mybitduit help') + d(' this'));
529
+ console.log('');
530
+ console.log(d(' ENV (optional)'));
531
+ console.log(' ' + k('COINGECKO_API_KEY') + d(' free demo key → higher price rate limits'));
532
+ console.log(' ' + k('SOLANA_RPC_URL') + d(' your own RPC (e.g. Helius) for wallet reads'));
533
+ console.log('');
534
+ console.log(d(' Non-custodial, always. We show info or ask your wallet to sign — never hold funds.'));
535
+ }
536
+
537
+ /* ---- entry ---- */
538
+ const args = process.argv.slice(2);
539
+ const wantsTui = args.length === 0 || args[0] === 'tui';
540
+ if (['help', '--help', '-h'].includes(args[0])) {
541
+ printHelp();
542
+ } else if (wantsTui && process.stdout.isTTY) {
543
+ tui();
544
+ } else if (wantsTui) {
545
+ oneShotTop();
546
+ } else if (args[0] === 'top') {
547
+ oneShotTop();
548
+ } else if (args[0] === 'watch') {
549
+ watchCmd(args[1], args.includes('--json'));
550
+ } else {
551
+ const symbols = (args[0] === 'price' ? args.slice(1) : args).filter(Boolean);
552
+ oneShot(symbols.length ? symbols : ['sol', 'btc', 'eth']);
553
+ }
@@ -0,0 +1,131 @@
1
+ /* ============================================================
2
+ mybitduit — read-only Solana portfolio engine
3
+ Given a wallet ADDRESS (never a key), returns SOL + SPL token
4
+ holdings with USD values and a weighted 24h change.
5
+ - RPC: env SOLANA_RPC_URL (e.g. a Helius URL) else public mainnet.
6
+ - Prices: Jupiter Price API v3 (no key).
7
+ 100% read-only and non-custodial — we only ever look.
8
+ ============================================================ */
9
+
10
+ const SOL_MINT = 'So11111111111111111111111111111111111111112';
11
+ const TOKEN_PROGRAMS = [
12
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', // SPL Token
13
+ 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', // Token-2022
14
+ ];
15
+
16
+ // common Solana mints → ticker (values come from Jupiter regardless of this map)
17
+ const KNOWN = {
18
+ [SOL_MINT]: 'SOL',
19
+ EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 'USDC',
20
+ Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 'USDT',
21
+ JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN: 'JUP',
22
+ DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263: 'BONK',
23
+ jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL: 'JTO',
24
+ EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm: 'WIF',
25
+ mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So: 'mSOL',
26
+ 'So11111111111111111111111111111111111111112': 'SOL',
27
+ };
28
+
29
+ export function isValidSolanaAddress(a) {
30
+ return typeof a === 'string' && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(a);
31
+ }
32
+
33
+ function rpcUrl() {
34
+ return (typeof process !== 'undefined' && process.env && process.env.SOLANA_RPC_URL) ||
35
+ 'https://api.mainnet-beta.solana.com';
36
+ }
37
+
38
+ async function rpc(method, params) {
39
+ const r = await fetch(rpcUrl(), {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json' },
42
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
43
+ });
44
+ if (!r.ok) throw new Error(`RPC ${method} failed (${r.status})`);
45
+ const j = await r.json();
46
+ if (j.error) throw new Error(`RPC ${method}: ${j.error.message || 'error'}`);
47
+ return j.result;
48
+ }
49
+
50
+ async function jupPrices(mints) {
51
+ const out = {};
52
+ for (let i = 0; i < mints.length; i += 80) {
53
+ const chunk = mints.slice(i, i + 80);
54
+ try {
55
+ const r = await fetch(`https://lite-api.jup.ag/price/v3?ids=${chunk.join(',')}`, {
56
+ headers: { accept: 'application/json' },
57
+ });
58
+ if (r.ok) Object.assign(out, await r.json());
59
+ } catch {
60
+ /* price fetch best-effort */
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ const shortMint = (m) => m.slice(0, 4) + '…' + m.slice(-4);
67
+
68
+ /**
69
+ * @returns {address, sol, total, change24h|null, holdings:[{symbol,mint,amount,price,value,change}], hiddenCount, rpc}
70
+ */
71
+ export async function fetchPortfolio(address) {
72
+ if (!isValidSolanaAddress(address)) throw new Error('not a valid Solana address');
73
+
74
+ const [bal, ...tokenSets] = await Promise.all([
75
+ rpc('getBalance', [address]),
76
+ ...TOKEN_PROGRAMS.map((pid) =>
77
+ rpc('getTokenAccountsByOwner', [address, { programId: pid }, { encoding: 'jsonParsed' }]).catch(
78
+ () => ({ value: [] }),
79
+ ),
80
+ ),
81
+ ]);
82
+
83
+ const solAmount = (bal?.value || 0) / 1e9;
84
+
85
+ const byMint = {};
86
+ for (const set of tokenSets) {
87
+ for (const it of set?.value || []) {
88
+ const info = it.account?.data?.parsed?.info;
89
+ const amt = info?.tokenAmount?.uiAmount;
90
+ if (info?.mint && amt > 0) byMint[info.mint] = (byMint[info.mint] || 0) + amt;
91
+ }
92
+ }
93
+
94
+ const mints = [SOL_MINT, ...Object.keys(byMint)];
95
+ const prices = await jupPrices(mints);
96
+
97
+ const holdings = [];
98
+ const solP = prices[SOL_MINT];
99
+ holdings.push({
100
+ symbol: 'SOL', mint: SOL_MINT, amount: solAmount,
101
+ price: solP?.usdPrice ?? null, value: solAmount * (solP?.usdPrice ?? 0),
102
+ change: solP?.priceChange24h ?? null,
103
+ });
104
+ for (const [mint, amount] of Object.entries(byMint)) {
105
+ const p = prices[mint];
106
+ const price = p?.usdPrice ?? null;
107
+ holdings.push({
108
+ symbol: mint === SOL_MINT ? 'wSOL' : KNOWN[mint] || shortMint(mint), mint, amount,
109
+ price, value: price != null ? amount * price : 0,
110
+ change: p?.priceChange24h ?? null,
111
+ });
112
+ }
113
+
114
+ const shown = holdings
115
+ .filter((h) => h.mint === SOL_MINT || h.value >= 0.01)
116
+ .sort((a, b) => b.value - a.value);
117
+ const hiddenCount = holdings.length - shown.length;
118
+ const total = shown.reduce((s, h) => s + (h.value || 0), 0);
119
+
120
+ // weighted 24h change across priced holdings
121
+ let prev = 0, cur = 0;
122
+ for (const h of shown) {
123
+ if (h.value && h.change != null) {
124
+ prev += h.value / (1 + h.change / 100);
125
+ cur += h.value;
126
+ }
127
+ }
128
+ const change24h = prev > 0 ? ((cur - prev) / prev) * 100 : null;
129
+
130
+ return { address, sol: solAmount, total, change24h, holdings: shown, hiddenCount, rpc: rpcUrl() };
131
+ }
@@ -0,0 +1,112 @@
1
+ /* ============================================================
2
+ mybitduit — shared price engine
3
+ One brain, two faces (CLI + web). No API key needed for v1:
4
+ uses CoinGecko's free public endpoint.
5
+ ============================================================ */
6
+
7
+ // short ticker -> CoinGecko id. Unknown symbols fall back to the
8
+ // raw input as an id, so `price <coingecko-id>` still works.
9
+ export const SYMBOL_TO_ID = {
10
+ sol: 'solana',
11
+ btc: 'bitcoin',
12
+ eth: 'ethereum',
13
+ usdc: 'usd-coin',
14
+ usdt: 'tether',
15
+ bonk: 'bonk',
16
+ jup: 'jupiter-exchange-solana',
17
+ };
18
+
19
+ export function resolveId(sym) {
20
+ const s = String(sym).toLowerCase();
21
+ return SYMBOL_TO_ID[s] || s;
22
+ }
23
+
24
+ // Optional free CoinGecko "Demo" API key (https://www.coingecko.com/en/api)
25
+ // raises rate limits. Set COINGECKO_API_KEY in the env; harmless if absent.
26
+ function cgHeaders() {
27
+ const h = { accept: 'application/json' };
28
+ const key =
29
+ typeof process !== 'undefined' && process.env ? process.env.COINGECKO_API_KEY : '';
30
+ if (key) h['x-cg-demo-api-key'] = key;
31
+ return h;
32
+ }
33
+
34
+ /**
35
+ * Fetch USD prices + 24h change for a list of symbols.
36
+ * Returns: [{ symbol, id, price|null, change|null }]
37
+ */
38
+ export async function fetchPrices(symbols) {
39
+ const list = [...new Set(symbols.map((s) => String(s).toLowerCase()))];
40
+ const ids = list.map(resolveId);
41
+ const url =
42
+ 'https://api.coingecko.com/api/v3/simple/price' +
43
+ `?ids=${encodeURIComponent(ids.join(','))}` +
44
+ '&vs_currencies=usd&include_24hr_change=true';
45
+
46
+ const res = await fetch(url, { headers: cgHeaders() });
47
+ if (!res.ok) {
48
+ if (res.status === 429) throw new Error('rate limited — try again shortly');
49
+ throw new Error(`price fetch failed (${res.status})`);
50
+ }
51
+ const data = await res.json();
52
+ return list.map((sym, i) => {
53
+ const d = data[ids[i]];
54
+ return {
55
+ symbol: sym.toUpperCase(),
56
+ id: ids[i],
57
+ price: d?.usd ?? null,
58
+ change: d?.usd_24h_change ?? null,
59
+ };
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Fetch the top N coins by market cap, including a 7-day sparkline.
65
+ * Returns: [{ rank, symbol, name, id, price, change, spark:[...] }]
66
+ */
67
+ export async function fetchTopCoins(n = 10) {
68
+ const url =
69
+ 'https://api.coingecko.com/api/v3/coins/markets' +
70
+ '?vs_currency=usd&order=market_cap_desc' +
71
+ `&per_page=${n}&page=1&sparkline=true&price_change_percentage=24h`;
72
+
73
+ const res = await fetch(url, { headers: cgHeaders() });
74
+ if (!res.ok) {
75
+ if (res.status === 429) throw new Error('rate limited — try again shortly');
76
+ throw new Error(`market fetch failed (${res.status})`);
77
+ }
78
+ const data = await res.json();
79
+ if (!Array.isArray(data)) throw new Error('rate limited — try again shortly');
80
+ return data.map((c, i) => ({
81
+ rank: c.market_cap_rank ?? i + 1,
82
+ symbol: String(c.symbol || '').toUpperCase(),
83
+ name: c.name || c.id,
84
+ id: c.id,
85
+ price: c.current_price ?? null,
86
+ change: c.price_change_percentage_24h ?? null,
87
+ spark: c.sparkline_in_7d?.price || [],
88
+ }));
89
+ }
90
+
91
+ /** Evenly downsample a number array to `target` points (for fitting a chart). */
92
+ export function downsample(arr, target) {
93
+ if (!arr || arr.length <= target) return arr || [];
94
+ const out = [];
95
+ const step = (arr.length - 1) / (target - 1);
96
+ for (let i = 0; i < target; i++) out.push(arr[Math.round(i * step)]);
97
+ return out;
98
+ }
99
+
100
+ /* ---- formatting helpers (shared so CLI + web look identical) ---- */
101
+ export function fmtPrice(n) {
102
+ if (n == null) return '—';
103
+ if (n >= 1) return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
104
+ // sub-$1 tokens: show enough significant digits (e.g. BONK)
105
+ return Number(n.toPrecision(4)).toString();
106
+ }
107
+
108
+ export function fmtChange(c) {
109
+ if (c == null) return '';
110
+ const arrow = c >= 0 ? '▲' : '▼';
111
+ return `${arrow} ${Math.abs(c).toFixed(2)}%`;
112
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "mybitduit",
3
+ "version": "0.0.1",
4
+ "description": "mybitduit — your non-custodial Solana money terminal (web + CLI). bit + duit.",
5
+ "type": "module",
6
+ "bin": { "mybitduit": "cli/mybitduit.mjs" },
7
+ "files": ["cli/", "core/", "README.md"],
8
+ "preferGlobal": true,
9
+ "keywords": ["solana", "crypto", "cli", "terminal", "prices", "wallet", "non-custodial"],
10
+ "license": "MIT",
11
+ "scripts": {
12
+ "start": "node cli/mybitduit.mjs",
13
+ "cli": "node cli/mybitduit.mjs",
14
+ "prices": "node cli/mybitduit.mjs price sol btc eth"
15
+ },
16
+ "dependencies": {
17
+ "asciichart": "^1.5.25",
18
+ "htm": "^3.1.1",
19
+ "ink": "^5.0.1",
20
+ "react": "^18.3.1"
21
+ },
22
+ "engines": { "node": ">=18" }
23
+ }