gachadex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/index.js +524 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GachaDex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# gachadex
|
|
2
|
+
|
|
3
|
+
Command-line client for the [GachaDex](https://github.com/gachadex) perpetual-futures exchange —
|
|
4
|
+
built for humans and AI agents. Trade leveraged perps on TCG-card prices from your terminal.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i -g gachadex # or: npx gachadex <command>
|
|
8
|
+
export GACHADEX_API_URL=https://your-gachadex-api
|
|
9
|
+
gachadex markets --search pikachu
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Authentication
|
|
13
|
+
|
|
14
|
+
Use a **delegated trade-only key** (it can trade but never withdraw). Point `GACHADEX_KEY` at a
|
|
15
|
+
base58 secret or a Solana keyfile path:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# a master wallet mints a delegated key for a bot:
|
|
19
|
+
gachadex keys create --master-keyfile ~/master.json --label bot --save bot
|
|
20
|
+
# then the bot trades with it:
|
|
21
|
+
export GACHADEX_KEY=~/.config/gachadex/keys/bot.json
|
|
22
|
+
gachadex balance
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Keys live under `~/.config/gachadex/keys/` (chmod 0600). `--keyfile` and `--master-keyfile`
|
|
26
|
+
override the env/defaults per command.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `markets [--kind --game --search]` · `market <id\|symbol>` · `candles <m> [--tf]` | browse markets + card detail |
|
|
33
|
+
| `balance` · `positions` · `history <kind>` | account state |
|
|
34
|
+
| `long <m> --margin 100 --lev 5 [--max-slippage 1]` · `short …` · `close <id> [--fraction 50]` | trade |
|
|
35
|
+
| `keys create\|list\|revoke` | manage delegated keys (master) |
|
|
36
|
+
| `watch <m…>` · `events` | live mark / private streams (NDJSON) |
|
|
37
|
+
| `faucet` · `leaderboard` · `config get\|set` | misc |
|
|
38
|
+
|
|
39
|
+
## Built for agents
|
|
40
|
+
|
|
41
|
+
- **JSON when piped** (or `--json`); data → stdout, errors → stderr as `{error, code}`.
|
|
42
|
+
- **Typed exit codes:** `0` ok · `2` auth · `3` validation · `4` confirmation required · `5` retry (rate-limit/5xx/network).
|
|
43
|
+
- **Confirmation protocol:** `long`/`short`/`close` print a JSON preview and exit `4` unless `--yes`.
|
|
44
|
+
The preview carries the exact re-run `command` (with a fixed `--idempotency-key`) — run it to
|
|
45
|
+
execute. Reuse that key on retries so a timeout can't double-open. `--dry-run` previews at exit 0
|
|
46
|
+
(no key needed).
|
|
47
|
+
|
|
48
|
+
See [`skills/gachadex-trading/SKILL.md`](../../skills/gachadex-trading/SKILL.md) for the agent skill.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
[MIT](../../LICENSE) © GachaDex
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/lib/output.ts
|
|
7
|
+
function jsonMode(force) {
|
|
8
|
+
return !!force || !process.stdout.isTTY;
|
|
9
|
+
}
|
|
10
|
+
var bigintReplacer = (_k, v) => typeof v === "bigint" ? v.toString() : v;
|
|
11
|
+
function emit(data, human, opts = {}) {
|
|
12
|
+
if (jsonMode(opts.json)) process.stdout.write(JSON.stringify(data, bigintReplacer, 2) + "\n");
|
|
13
|
+
else process.stdout.write(human() + "\n");
|
|
14
|
+
}
|
|
15
|
+
function emitError(err) {
|
|
16
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17
|
+
const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
|
|
18
|
+
if (!process.stderr.isTTY) {
|
|
19
|
+
process.stderr.write(JSON.stringify({ error: message, ...typeof code === "string" ? { code } : {} }) + "\n");
|
|
20
|
+
} else {
|
|
21
|
+
process.stderr.write(`${useColor() ? "\x1B[31merror\x1B[0m" : "error"}: ${message}${typeof code === "string" ? ` (${code})` : ""}
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function useColor() {
|
|
26
|
+
return process.stdout.isTTY && !process.env.NO_COLOR;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/lib/exit.ts
|
|
30
|
+
import { GachaDexError, isRetryable } from "@gachadex/sdk";
|
|
31
|
+
var EXIT = {
|
|
32
|
+
OK: 0,
|
|
33
|
+
GENERAL: 1,
|
|
34
|
+
AUTH: 2,
|
|
35
|
+
// missing/invalid key, 401/403
|
|
36
|
+
VALIDATION: 3,
|
|
37
|
+
// bad args / 400 / 404 / 409
|
|
38
|
+
CONFIRM: 4,
|
|
39
|
+
// a mutation needs --yes
|
|
40
|
+
RETRY: 5
|
|
41
|
+
// transient: 429 / 5xx / network
|
|
42
|
+
};
|
|
43
|
+
var CliError = class extends Error {
|
|
44
|
+
constructor(exitCode, message, code) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.exitCode = exitCode;
|
|
47
|
+
this.code = code;
|
|
48
|
+
this.name = "CliError";
|
|
49
|
+
}
|
|
50
|
+
exitCode;
|
|
51
|
+
code;
|
|
52
|
+
};
|
|
53
|
+
function exitCodeFor(err) {
|
|
54
|
+
if (err instanceof CliError) return err.exitCode;
|
|
55
|
+
if (err instanceof GachaDexError) {
|
|
56
|
+
if (isRetryable(err)) return EXIT.RETRY;
|
|
57
|
+
if (err.status === 401 || err.status === 403) return EXIT.AUTH;
|
|
58
|
+
if (err.status === 400 || err.status === 404 || err.status === 409) return EXIT.VALIDATION;
|
|
59
|
+
return EXIT.GENERAL;
|
|
60
|
+
}
|
|
61
|
+
return EXIT.GENERAL;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/commands/read.ts
|
|
65
|
+
import { formatUsd, formatSignedUsd } from "@gachadex/sdk";
|
|
66
|
+
|
|
67
|
+
// src/lib/dex.ts
|
|
68
|
+
import { GachaDex } from "@gachadex/sdk";
|
|
69
|
+
|
|
70
|
+
// src/lib/config.ts
|
|
71
|
+
import { homedir } from "os";
|
|
72
|
+
import { join } from "path";
|
|
73
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "fs";
|
|
74
|
+
import { keypairSigner } from "@gachadex/sdk";
|
|
75
|
+
function configDir() {
|
|
76
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
77
|
+
return join(base, "gachadex");
|
|
78
|
+
}
|
|
79
|
+
function keysDir() {
|
|
80
|
+
return join(configDir(), "keys");
|
|
81
|
+
}
|
|
82
|
+
function configPath() {
|
|
83
|
+
return join(configDir(), "config.json");
|
|
84
|
+
}
|
|
85
|
+
function ensureDir(d) {
|
|
86
|
+
mkdirSync(d, { recursive: true, mode: 448 });
|
|
87
|
+
}
|
|
88
|
+
function loadConfig() {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(readFileSync(configPath(), "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function saveConfig(c) {
|
|
96
|
+
ensureDir(configDir());
|
|
97
|
+
writeFileSync(configPath(), JSON.stringify(c, null, 2) + "\n", { mode: 384 });
|
|
98
|
+
}
|
|
99
|
+
function keyPath(name) {
|
|
100
|
+
return join(keysDir(), `${name}.json`);
|
|
101
|
+
}
|
|
102
|
+
function saveKey(name, secretKey) {
|
|
103
|
+
ensureDir(keysDir());
|
|
104
|
+
const p = keyPath(name);
|
|
105
|
+
writeFileSync(p, JSON.stringify([...secretKey]), { mode: 384 });
|
|
106
|
+
chmodSync(p, 384);
|
|
107
|
+
return p;
|
|
108
|
+
}
|
|
109
|
+
function signerFromValue(v) {
|
|
110
|
+
const val = v.trim();
|
|
111
|
+
if (existsSync(val)) return keypairSigner(readFileSync(val, "utf8").trim());
|
|
112
|
+
return keypairSigner(val);
|
|
113
|
+
}
|
|
114
|
+
function resolveSigner(opts = {}) {
|
|
115
|
+
if (opts.keyfile) return signerFromValue(opts.keyfile);
|
|
116
|
+
if (process.env.GACHADEX_KEY) return signerFromValue(process.env.GACHADEX_KEY);
|
|
117
|
+
const def = keyPath(loadConfig().defaultKey || "default");
|
|
118
|
+
if (existsSync(def)) return keypairSigner(readFileSync(def, "utf8").trim());
|
|
119
|
+
throw new CliError(EXIT.AUTH, "no key configured \u2014 run `gachadex login --keyfile <path>` or set GACHADEX_KEY", "no_key");
|
|
120
|
+
}
|
|
121
|
+
function resolveMaster(opts = {}) {
|
|
122
|
+
if (opts.masterKeyfile) return signerFromValue(opts.masterKeyfile);
|
|
123
|
+
if (process.env.GACHADEX_MASTER_KEY) return signerFromValue(process.env.GACHADEX_MASTER_KEY);
|
|
124
|
+
return resolveSigner();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/lib/dex.ts
|
|
128
|
+
function resolveApiUrl(flagApi) {
|
|
129
|
+
return flagApi || process.env.GACHADEX_API_URL || loadConfig().apiUrl || "http://localhost:4000";
|
|
130
|
+
}
|
|
131
|
+
function makeDex(flagApi) {
|
|
132
|
+
return new GachaDex({ apiUrl: resolveApiUrl(flagApi) });
|
|
133
|
+
}
|
|
134
|
+
async function connect(signer, flagApi) {
|
|
135
|
+
const dex = makeDex(flagApi);
|
|
136
|
+
const session = await dex.login(signer);
|
|
137
|
+
return { dex, session };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/commands/_common.ts
|
|
141
|
+
function withApi(c) {
|
|
142
|
+
return c.option("--api <url>", "API base URL (or set GACHADEX_API_URL)").option("--json", "force JSON output");
|
|
143
|
+
}
|
|
144
|
+
function withKey(c) {
|
|
145
|
+
return withApi(c).option("-k, --keyfile <path>", "trade key file (or set GACHADEX_KEY)");
|
|
146
|
+
}
|
|
147
|
+
function withConfirm(c) {
|
|
148
|
+
return withKey(c).option("--yes", "execute (without this, prints a preview and exits 4)").option("--dry-run", "print the preview and exit 0 without calling the API").option("--idempotency-key <key>", "reuse a key across retries (auto-generated otherwise)");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/commands/read.ts
|
|
152
|
+
var priceOf = (m) => m.markE6 ? formatUsd(BigInt(m.markE6)) : "\u2014";
|
|
153
|
+
function marketLine(m) {
|
|
154
|
+
return `${m.symbol.padEnd(26)} ${priceOf(m).padStart(12)} ${m.displayName}`;
|
|
155
|
+
}
|
|
156
|
+
function renderMarket(m, d) {
|
|
157
|
+
const meta = d?.metadata;
|
|
158
|
+
const lines = [
|
|
159
|
+
`${m.displayName} (${m.symbol})`,
|
|
160
|
+
` market ${priceOf(m)} ${m.kind} \xB7 ${m.game} \xB7 ${m.status}${m.tradeable ? "" : " \xB7 not tradeable"}`,
|
|
161
|
+
` leverage up to ${m.maxLeverage}x fee ${m.feeBps ?? 0} bps maint ${m.maintMarginBps} bps`
|
|
162
|
+
];
|
|
163
|
+
if (meta?.setName) lines.push(` set ${meta.setName}`);
|
|
164
|
+
if (d?.gradedPsa10E6) lines.push(` PSA-10 ${formatUsd(BigInt(d.gradedPsa10E6))}`);
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
function positionLine(p) {
|
|
168
|
+
const qty = Number(p.qtyE6) / 1e6;
|
|
169
|
+
return `${p.symbol.padEnd(22)} ${p.side.padEnd(5)} ${qty} @ ${formatUsd(BigInt(p.avgEntryE6))} mark ${formatUsd(BigInt(p.markE6))} uPnL ${formatSignedUsd(p.unrealizedPnlUusdc)} liq ${formatUsd(BigInt(p.liqPriceE6))}`;
|
|
170
|
+
}
|
|
171
|
+
var HISTORY_KINDS = ["orders", "trades", "transactions", "positions"];
|
|
172
|
+
function registerRead(program2) {
|
|
173
|
+
withApi(
|
|
174
|
+
program2.command("markets").description("List markets").option("--kind <kind>", "card | index").option("--game <game>", "pokemon | onepiece | mtg").option("--search <q>", "filter by symbol or name")
|
|
175
|
+
).action(async (o) => {
|
|
176
|
+
let markets = await makeDex(o.api).markets();
|
|
177
|
+
if (o.kind) markets = markets.filter((m) => m.kind === o.kind);
|
|
178
|
+
if (o.game) markets = markets.filter((m) => m.game === o.game);
|
|
179
|
+
if (o.search) {
|
|
180
|
+
const q = String(o.search).toLowerCase();
|
|
181
|
+
markets = markets.filter((m) => m.symbol.toLowerCase().includes(q) || m.displayName.toLowerCase().includes(q));
|
|
182
|
+
}
|
|
183
|
+
emit(markets, () => markets.map(marketLine).join("\n"), { json: o.json });
|
|
184
|
+
});
|
|
185
|
+
withApi(program2.command("market <market>").description("Show one market (id or symbol) with card details")).action(async (market, o) => {
|
|
186
|
+
const dex = makeDex(o.api);
|
|
187
|
+
const m = await dex.market(market);
|
|
188
|
+
const d = await dex.marketDetails(m.id).catch(() => null);
|
|
189
|
+
emit({ ...m, details: d }, () => renderMarket(m, d), { json: o.json });
|
|
190
|
+
});
|
|
191
|
+
withApi(program2.command("candles <market>").description("Price history").option("--tf <tf>", "1D|1W|1M|3M|1Y", "1M")).action(async (market, o) => {
|
|
192
|
+
const dex = makeDex(o.api);
|
|
193
|
+
const m = await dex.market(market);
|
|
194
|
+
const candles = await dex.candles(m.id, o.tf);
|
|
195
|
+
emit(candles, () => candles.map((c) => `${new Date(c.time * 1e3).toISOString().slice(0, 10)} ${formatUsd(c.value)}`).join("\n"), { json: o.json });
|
|
196
|
+
});
|
|
197
|
+
withApi(program2.command("leaderboard").description("Top traders").option("--limit <n>", "rows", "100")).action(async (o) => {
|
|
198
|
+
const data = await makeDex(o.api).leaderboard(Number(o.limit));
|
|
199
|
+
emit(data, () => JSON.stringify(data, null, 2), { json: o.json });
|
|
200
|
+
});
|
|
201
|
+
withKey(program2.command("balance").description("Account balance + equity")).action(async (o) => {
|
|
202
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
203
|
+
const b = await session.balance();
|
|
204
|
+
emit(b, () => [
|
|
205
|
+
`available ${formatUsd(BigInt(b.availableUusdc))}`,
|
|
206
|
+
`locked ${formatUsd(BigInt(b.lockedMarginUusdc))}`,
|
|
207
|
+
`uPnL ${formatSignedUsd(b.unrealizedPnlUusdc)}`,
|
|
208
|
+
`equity ${formatUsd(BigInt(b.equityUusdc))}`
|
|
209
|
+
].join("\n"), { json: o.json });
|
|
210
|
+
});
|
|
211
|
+
withKey(program2.command("positions").description("Open positions")).action(async (o) => {
|
|
212
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
213
|
+
const positions = await session.positions();
|
|
214
|
+
emit(positions, () => positions.length ? positions.map(positionLine).join("\n") : "no open positions", { json: o.json });
|
|
215
|
+
});
|
|
216
|
+
withKey(
|
|
217
|
+
program2.command("history <kind>").description("Transaction history: orders | trades | transactions | positions").option("--limit <n>", "rows").option("--before <cursor>", "page cursor")
|
|
218
|
+
).action(async (kind, o) => {
|
|
219
|
+
if (!HISTORY_KINDS.includes(kind)) throw new CliError(EXIT.VALIDATION, `kind must be one of: ${HISTORY_KINDS.join(", ")}`, "bad_history_kind");
|
|
220
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
221
|
+
const rows = await session.history(kind, { limit: o.limit ? Number(o.limit) : void 0, before: o.before });
|
|
222
|
+
emit(rows, () => JSON.stringify(rows, null, 2), { json: o.json });
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/commands/trade.ts
|
|
227
|
+
import { randomUUID } from "crypto";
|
|
228
|
+
import {
|
|
229
|
+
notional,
|
|
230
|
+
initialMargin,
|
|
231
|
+
fee,
|
|
232
|
+
liquidationPrice,
|
|
233
|
+
qtyFromMargin,
|
|
234
|
+
toE6,
|
|
235
|
+
formatUsd as formatUsd2,
|
|
236
|
+
formatSignedUsd as formatSignedUsd2
|
|
237
|
+
} from "@gachadex/sdk";
|
|
238
|
+
function sizeQty(o, m, levE2, markE6) {
|
|
239
|
+
const step = BigInt(m.qtyStepE6);
|
|
240
|
+
let qtyE6;
|
|
241
|
+
if (o.qty != null) {
|
|
242
|
+
const n = Number(o.qty);
|
|
243
|
+
if (!Number.isFinite(n) || n <= 0) throw new CliError(EXIT.VALIDATION, "--qty must be a positive number", "bad_order");
|
|
244
|
+
qtyE6 = BigInt(Math.round(n * 1e6)) / step * step;
|
|
245
|
+
} else if (o.margin != null) {
|
|
246
|
+
const n = Number(o.margin);
|
|
247
|
+
if (!Number.isFinite(n) || n <= 0) throw new CliError(EXIT.VALIDATION, "--margin must be a positive number", "bad_order");
|
|
248
|
+
qtyE6 = qtyFromMargin({ marginUusdc: toE6(n), leverageE2: levE2, priceE6: markE6, qtyStepE6: step });
|
|
249
|
+
} else {
|
|
250
|
+
throw new CliError(EXIT.VALIDATION, "provide --margin <usd> or --qty <units>", "bad_order");
|
|
251
|
+
}
|
|
252
|
+
if (qtyE6 < BigInt(m.minQtyE6)) throw new CliError(EXIT.VALIDATION, `size is below the market minimum (${m.minQtyE6})`, "below_min");
|
|
253
|
+
return qtyE6;
|
|
254
|
+
}
|
|
255
|
+
function planOpen(side, m, o) {
|
|
256
|
+
if (!m.tradeable || m.status !== "active") throw new CliError(EXIT.VALIDATION, `${m.symbol} is not tradeable`, "market_halted");
|
|
257
|
+
if (!m.markE6) throw new CliError(EXIT.VALIDATION, `${m.symbol} has no price yet`, "no_price");
|
|
258
|
+
const lev = Number(o.lev);
|
|
259
|
+
if (!Number.isFinite(lev) || lev < 1 || lev > m.maxLeverage) throw new CliError(EXIT.VALIDATION, `--lev must be 1..${m.maxLeverage}`, "bad_leverage");
|
|
260
|
+
const levE2 = Math.round(lev * 100);
|
|
261
|
+
const markE6 = BigInt(m.markE6);
|
|
262
|
+
const qtyE6 = sizeQty(o, m, levE2, markE6);
|
|
263
|
+
const notionUusdc = notional(qtyE6, markE6);
|
|
264
|
+
const marginUusdc = initialMargin(notionUusdc, levE2);
|
|
265
|
+
const feeUusdc = fee(notionUusdc, m.feeBps ?? 0);
|
|
266
|
+
const liqE6 = liquidationPrice({ side, entryE6: markE6, leverageE2: levE2, maintMarginBps: m.maintMarginBps });
|
|
267
|
+
let limitPriceE6;
|
|
268
|
+
if (o.maxSlippage != null) {
|
|
269
|
+
const bps = BigInt(Math.round(Number(o.maxSlippage) * 100));
|
|
270
|
+
limitPriceE6 = side === "long" ? markE6 * (10000n + bps) / 10000n : markE6 * (10000n - bps) / 10000n;
|
|
271
|
+
}
|
|
272
|
+
const preview = {
|
|
273
|
+
action: "open",
|
|
274
|
+
side,
|
|
275
|
+
market: m.symbol,
|
|
276
|
+
marketId: m.id,
|
|
277
|
+
qtyE6: qtyE6.toString(),
|
|
278
|
+
qty: Number(qtyE6) / 1e6,
|
|
279
|
+
leverage: lev,
|
|
280
|
+
entryMark: formatUsd2(markE6),
|
|
281
|
+
notional: formatUsd2(notionUusdc),
|
|
282
|
+
margin: formatUsd2(marginUusdc),
|
|
283
|
+
fee: formatUsd2(feeUusdc),
|
|
284
|
+
liqPrice: formatUsd2(liqE6),
|
|
285
|
+
...limitPriceE6 !== void 0 ? { limitPrice: formatUsd2(limitPriceE6) } : {},
|
|
286
|
+
idempotencyKey: o.idempotencyKey || randomUUID()
|
|
287
|
+
};
|
|
288
|
+
return { qtyE6, limitPriceE6, preview };
|
|
289
|
+
}
|
|
290
|
+
function rerunOpen(side, market, o, idem) {
|
|
291
|
+
const parts = [`gachadex ${side} ${market}`];
|
|
292
|
+
if (o.margin != null) parts.push(`--margin ${o.margin}`);
|
|
293
|
+
if (o.qty != null) parts.push(`--qty ${o.qty}`);
|
|
294
|
+
if (o.lev != null) parts.push(`--lev ${o.lev}`);
|
|
295
|
+
if (o.maxSlippage != null) parts.push(`--max-slippage ${o.maxSlippage}`);
|
|
296
|
+
if (o.api) parts.push(`--api ${o.api}`);
|
|
297
|
+
if (o.keyfile) parts.push(`-k ${o.keyfile}`);
|
|
298
|
+
parts.push(`--idempotency-key ${idem}`, "--yes");
|
|
299
|
+
return parts.join(" ");
|
|
300
|
+
}
|
|
301
|
+
function previewLines(p) {
|
|
302
|
+
return [
|
|
303
|
+
`${p.side.toUpperCase()} ${p.qty} ${p.market} @ ${p.entryMark} (${p.leverage}x)`,
|
|
304
|
+
` notional ${p.notional} margin ${p.margin} fee ${p.fee} liq ${p.liqPrice}` + (p.limitPrice ? ` limit ${p.limitPrice}` : "")
|
|
305
|
+
].join("\n");
|
|
306
|
+
}
|
|
307
|
+
async function runOpen(side, market, o) {
|
|
308
|
+
const dex = makeDex(o.api);
|
|
309
|
+
const m = await dex.market(market);
|
|
310
|
+
const plan = planOpen(side, m, o);
|
|
311
|
+
const p = plan.preview;
|
|
312
|
+
if (o.dryRun) {
|
|
313
|
+
emit({ dryRun: true, ...p }, () => previewLines(p), { json: o.json });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (!o.yes) {
|
|
317
|
+
const command = rerunOpen(side, market, o, p.idempotencyKey);
|
|
318
|
+
emit({ confirm: true, command, ...p }, () => `${previewLines(p)}
|
|
319
|
+
|
|
320
|
+
confirm with --yes (or re-run: ${command})`, { json: o.json });
|
|
321
|
+
process.exitCode = EXIT.CONFIRM;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
325
|
+
const result = await session.openPosition({
|
|
326
|
+
market: m.id,
|
|
327
|
+
side,
|
|
328
|
+
leverage: Number(o.lev),
|
|
329
|
+
qtyE6: plan.qtyE6.toString(),
|
|
330
|
+
...plan.limitPriceE6 !== void 0 ? { limitPriceE6: plan.limitPriceE6.toString() } : {},
|
|
331
|
+
idempotencyKey: p.idempotencyKey
|
|
332
|
+
});
|
|
333
|
+
emit({ ...p, result }, () => `opened ${side} ${p.qty} ${p.market} \u2014 position ${result.positionId}`, { json: o.json });
|
|
334
|
+
}
|
|
335
|
+
function registerTrade(program2) {
|
|
336
|
+
for (const side of ["long", "short"]) {
|
|
337
|
+
withConfirm(
|
|
338
|
+
program2.command(`${side} <market>`).description(`Open a ${side} (size with --margin or --qty)`).option("--margin <usd>", "margin to commit, in USD").option("--qty <units>", "quantity in units").option("--lev <x>", "leverage").option("--max-slippage <pct>", "reject if the fill moves more than this %")
|
|
339
|
+
).action((market, o) => runOpen(side, market, o));
|
|
340
|
+
}
|
|
341
|
+
withConfirm(
|
|
342
|
+
program2.command("close <positionId>").description("Close a position (full, or --fraction <pct>)").option("--fraction <pct>", "percent to close (default 100)")
|
|
343
|
+
).action(async (positionId, o) => {
|
|
344
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
345
|
+
const pos = (await session.positions()).find((x) => x.id === positionId);
|
|
346
|
+
if (!pos) throw new CliError(EXIT.VALIDATION, `no open position ${positionId}`, "position_not_found");
|
|
347
|
+
const fractionBps = o.fraction != null ? Math.round(Number(o.fraction) * 100) : 1e4;
|
|
348
|
+
if (fractionBps <= 0 || fractionBps > 1e4) throw new CliError(EXIT.VALIDATION, "--fraction must be 0..100", "bad_fraction");
|
|
349
|
+
const idem = o.idempotencyKey || randomUUID();
|
|
350
|
+
const preview = {
|
|
351
|
+
action: "close",
|
|
352
|
+
positionId,
|
|
353
|
+
market: pos.symbol,
|
|
354
|
+
side: pos.side,
|
|
355
|
+
closingPct: fractionBps / 100,
|
|
356
|
+
uPnl: formatSignedUsd2(pos.unrealizedPnlUusdc),
|
|
357
|
+
idempotencyKey: idem
|
|
358
|
+
};
|
|
359
|
+
const human = () => `CLOSE ${preview.closingPct}% of ${pos.symbol} (${pos.side}) \u2014 unrealized ${preview.uPnl}`;
|
|
360
|
+
if (o.dryRun) {
|
|
361
|
+
emit({ dryRun: true, ...preview }, human, { json: o.json });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!o.yes) {
|
|
365
|
+
const command = `gachadex close ${positionId} --fraction ${preview.closingPct}${o.api ? ` --api ${o.api}` : ""} --idempotency-key ${idem} --yes`;
|
|
366
|
+
emit({ confirm: true, command, ...preview }, () => `${human()}
|
|
367
|
+
|
|
368
|
+
confirm with --yes (or re-run: ${command})`, { json: o.json });
|
|
369
|
+
process.exitCode = EXIT.CONFIRM;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const result = await session.closePosition(positionId, { fractionBps, idempotencyKey: idem });
|
|
373
|
+
emit({ ...preview, result }, () => `closed ${preview.closingPct}% of ${pos.symbol} \u2014 realized ${formatSignedUsd2(result.realizedPnlUusdc)}`, { json: o.json });
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/commands/keys.ts
|
|
378
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
379
|
+
import { parseSecretKey, formatUsd as formatUsd3 } from "@gachadex/sdk";
|
|
380
|
+
function parseExpires(s) {
|
|
381
|
+
if (!s) return {};
|
|
382
|
+
const m = /^(\d+)\s*d?$/.exec(s.trim());
|
|
383
|
+
if (m) return { expiresInDays: Number(m[1]) };
|
|
384
|
+
const d = new Date(s);
|
|
385
|
+
if (Number.isNaN(d.getTime())) throw new CliError(EXIT.VALIDATION, '--expires must be a number of days (e.g. "30") or an ISO date', "bad_expiry");
|
|
386
|
+
return { expiresAt: d.toISOString() };
|
|
387
|
+
}
|
|
388
|
+
function registerKeys(program2) {
|
|
389
|
+
withKey(program2.command("login").description("Verify a key and show the account it controls").option("--save", "save this key as the default")).action(async (o) => {
|
|
390
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
391
|
+
const me = await session.me();
|
|
392
|
+
let savedTo;
|
|
393
|
+
if (o.save && o.keyfile) {
|
|
394
|
+
saveKey("default", parseSecretKey(readFileSync2(o.keyfile, "utf8").trim()));
|
|
395
|
+
saveConfig({ ...loadConfig(), defaultKey: "default" });
|
|
396
|
+
savedTo = "default";
|
|
397
|
+
}
|
|
398
|
+
emit({ account: me.pubkey, scope: me.scope, ...me.act ? { actingKey: me.act } : {}, ...savedTo ? { savedAs: savedTo } : {} }, () => `account ${me.pubkey}
|
|
399
|
+
scope ${me.scope}${me.act ? `
|
|
400
|
+
key ${me.act}` : ""}${savedTo ? `
|
|
401
|
+
saved as "${savedTo}" (default)` : ""}`, { json: o.json });
|
|
402
|
+
});
|
|
403
|
+
const keys = program2.command("keys").description("Manage delegated trade-only keys");
|
|
404
|
+
withApi(
|
|
405
|
+
keys.command("create").description("Mint a delegated trade-only key (needs the master key)").option("--label <label>", "a name for the key").option("--expires <when>", 'e.g. "30d" or an ISO date').option("--master-keyfile <path>", "master wallet key (or GACHADEX_MASTER_KEY)").option("--save <name>", "save the new key under this name")
|
|
406
|
+
).action(async (o) => {
|
|
407
|
+
const { session } = await connect(resolveMaster(o), o.api);
|
|
408
|
+
const del = await session.delegateKey({ label: o.label, ...parseExpires(o.expires) });
|
|
409
|
+
const name = o.save || o.label || del.delegate.pubkey.slice(0, 8);
|
|
410
|
+
const savedTo = saveKey(name, del.secretKey);
|
|
411
|
+
emit(
|
|
412
|
+
{ delegate: del.delegate, savedAs: name, savedTo, secretKeyBase58: del.secretKeyBase58 },
|
|
413
|
+
() => `minted trade-only key ${del.delegate.pubkey}
|
|
414
|
+
label ${del.delegate.label || "(none)"}
|
|
415
|
+
expires ${del.delegate.expiresAt ?? "never"}
|
|
416
|
+
saved ${savedTo}
|
|
417
|
+
use it: GACHADEX_KEY=${savedTo} gachadex balance`,
|
|
418
|
+
{ json: o.json }
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
withApi(keys.command("list").description("List this account's delegated keys").option("--master-keyfile <path>", "master wallet key")).action(async (o) => {
|
|
422
|
+
const { session } = await connect(resolveMaster(o), o.api);
|
|
423
|
+
const delegates = await session.listDelegates();
|
|
424
|
+
emit(delegates, () => delegates.length ? delegates.map((d) => `${d.pubkey} ${d.active ? "active " : "revoked"} ${d.label || "(no label)"} expires ${d.expiresAt ?? "never"}`).join("\n") : "no delegated keys", { json: o.json });
|
|
425
|
+
});
|
|
426
|
+
withApi(keys.command("revoke <pubkey>").description("Revoke a delegated key (permanent)").option("--master-keyfile <path>", "master wallet key")).action(async (pubkey, o) => {
|
|
427
|
+
const { session } = await connect(resolveMaster(o), o.api);
|
|
428
|
+
await session.revokeDelegate(pubkey);
|
|
429
|
+
emit({ ok: true, revoked: pubkey }, () => `revoked ${pubkey}`, { json: o.json });
|
|
430
|
+
});
|
|
431
|
+
withKey(program2.command("faucet").description("Claim play-money USDC (play-money instances only)").option("--amount <usd>", "amount in USD")).action(async (o) => {
|
|
432
|
+
if ((await makeDex(o.api).health()).realFunds) {
|
|
433
|
+
throw new CliError(EXIT.VALIDATION, "faucet is disabled on this real-funds instance \u2014 deposit USDC instead", "faucet_disabled");
|
|
434
|
+
}
|
|
435
|
+
const { session } = await connect(resolveSigner(o), o.api);
|
|
436
|
+
const r = await session.faucet(o.amount ? Number(o.amount) : void 0);
|
|
437
|
+
emit(r, () => `faucet ok \u2014 available ${formatUsd3(BigInt(r.availableUusdc))}`, { json: o.json });
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/commands/stream.ts
|
|
442
|
+
import { formatUsd as formatUsd4 } from "@gachadex/sdk";
|
|
443
|
+
function streamPrinter(json, label) {
|
|
444
|
+
return (m) => {
|
|
445
|
+
if (m.ch === "_") return;
|
|
446
|
+
if (jsonMode(json)) process.stdout.write(JSON.stringify(m) + "\n");
|
|
447
|
+
else process.stdout.write((label ? label(m) : `${m.ch} ${m.type} ${JSON.stringify(m.data)}`) + "\n");
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function onSigint(close) {
|
|
451
|
+
process.on("SIGINT", () => {
|
|
452
|
+
close();
|
|
453
|
+
process.exit(0);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
function registerStream(program2) {
|
|
457
|
+
withApi(program2.command("watch <markets...>").description("Stream live marks for one or more markets (Ctrl+C to stop)")).action(async (markets, o) => {
|
|
458
|
+
const dex = makeDex(o.api);
|
|
459
|
+
const resolved = await Promise.all(markets.map((x) => dex.market(x)));
|
|
460
|
+
const idToSymbol = new Map(resolved.map((m) => [m.id, m.symbol]));
|
|
461
|
+
const stream = dex.stream();
|
|
462
|
+
onSigint(() => stream.close());
|
|
463
|
+
process.stderr.write(`watching ${resolved.map((m) => m.symbol).join(", ")} \u2026 (Ctrl+C to stop)
|
|
464
|
+
`);
|
|
465
|
+
stream.subscribe(
|
|
466
|
+
resolved.map((m) => `mark:${m.id}`),
|
|
467
|
+
streamPrinter(o.json, (m) => {
|
|
468
|
+
const id = m.ch.split(":")[1] ?? "";
|
|
469
|
+
const d = m.data;
|
|
470
|
+
return `${(idToSymbol.get(id) ?? id).padEnd(24)} ${d.markE6 ? formatUsd4(BigInt(d.markE6)) : "\u2014"}`;
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
withKey(program2.command("events").description("Stream your private events: fills, orders, liquidations, balance (Ctrl+C to stop)")).action(async (o) => {
|
|
475
|
+
const { dex, session } = await connect(resolveSigner(o), o.api);
|
|
476
|
+
const stream = dex.stream();
|
|
477
|
+
onSigint(() => stream.close());
|
|
478
|
+
process.stderr.write(`streaming account events for ${session.user.pubkey} \u2026 (Ctrl+C to stop)
|
|
479
|
+
`);
|
|
480
|
+
stream.privateChannels(session, ["positions", "orders", "balance", "liquidations"], streamPrinter(o.json, (m) => `${m.ch.split(":")[0]} ${m.type} ${JSON.stringify(m.data)}`));
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/commands/config.ts
|
|
485
|
+
var KEYS = ["apiUrl", "defaultKey"];
|
|
486
|
+
function registerConfig(program2) {
|
|
487
|
+
const config = program2.command("config").description(`Read/write CLI config (${configDir()}/config.json)`);
|
|
488
|
+
config.command("get [key]").option("--json", "force JSON output").action((key, o) => {
|
|
489
|
+
const c = loadConfig();
|
|
490
|
+
const out = key ? { [key]: c[key] } : c;
|
|
491
|
+
emit(out, () => key ? String(c[key] ?? "") : KEYS.map((k) => `${k}=${c[k] ?? ""}`).join("\n"), { json: o.json });
|
|
492
|
+
});
|
|
493
|
+
config.command("set <key> <value>").action((key, value, o) => {
|
|
494
|
+
if (!KEYS.includes(key)) throw new CliError(EXIT.VALIDATION, `unknown config key "${key}" (one of: ${KEYS.join(", ")})`, "bad_config_key");
|
|
495
|
+
const c = loadConfig();
|
|
496
|
+
c[key] = value;
|
|
497
|
+
saveConfig(c);
|
|
498
|
+
emit({ ok: true, [key]: value }, () => `${key} = ${value}`, { json: o.json });
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// src/index.ts
|
|
503
|
+
var program = new Command();
|
|
504
|
+
program.name("gachadex").description("Command-line client for the GachaDex perpetual-futures exchange \u2014 for humans and AI agents.").version("0.1.0", "-v, --version").showSuggestionAfterError(false);
|
|
505
|
+
registerRead(program);
|
|
506
|
+
registerTrade(program);
|
|
507
|
+
registerKeys(program);
|
|
508
|
+
registerStream(program);
|
|
509
|
+
registerConfig(program);
|
|
510
|
+
if (process.argv.slice(2).length === 0) {
|
|
511
|
+
program.outputHelp();
|
|
512
|
+
process.exit(EXIT.OK);
|
|
513
|
+
}
|
|
514
|
+
program.exitOverride();
|
|
515
|
+
try {
|
|
516
|
+
await program.parseAsync(process.argv);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
if (err instanceof CommanderError) {
|
|
519
|
+
if (err.code === "commander.version" || err.code.startsWith("commander.help")) process.exit(EXIT.OK);
|
|
520
|
+
process.exit(EXIT.VALIDATION);
|
|
521
|
+
}
|
|
522
|
+
emitError(err);
|
|
523
|
+
process.exit(exitCodeFor(err));
|
|
524
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gachadex",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for the GachaDex perpetual-futures exchange — built for humans and AI agents.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"gachadex": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/gachadex/cli.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/gachadex/cli/tree/main/packages/cli",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"gachadex",
|
|
27
|
+
"perpetuals",
|
|
28
|
+
"trading",
|
|
29
|
+
"solana",
|
|
30
|
+
"tcg",
|
|
31
|
+
"cli",
|
|
32
|
+
"agent"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"commander": "^12.1.0",
|
|
36
|
+
"@gachadex/sdk": "0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
41
|
+
"test": "tsx --test \"test/**/*.test.ts\""
|
|
42
|
+
}
|
|
43
|
+
}
|