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 +82 -0
- package/cli/mybitduit.mjs +553 -0
- package/core/portfolio.mjs +131 -0
- package/core/prices.mjs +112 -0
- package/package.json +23 -0
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
|
+
}
|
package/core/prices.mjs
ADDED
|
@@ -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
|
+
}
|