@agentgrant.cash/cli 1.0.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.
Files changed (45) hide show
  1. package/.env.example +21 -0
  2. package/README.md +48 -0
  3. package/dist/cli/commands/agent.js +139 -0
  4. package/dist/cli/commands/auth.js +248 -0
  5. package/dist/cli/commands/meta.js +77 -0
  6. package/dist/cli/commands/money.js +85 -0
  7. package/dist/cli/commands/portfolio.js +224 -0
  8. package/dist/cli/index.js +94 -0
  9. package/dist/cli/money-helpers.js +189 -0
  10. package/dist/cli/perfolio-commands/account.js +272 -0
  11. package/dist/cli/perfolio-commands/borrow.js +75 -0
  12. package/dist/cli/perfolio-commands/discover.js +30 -0
  13. package/dist/cli/perfolio-commands/earn.js +193 -0
  14. package/dist/cli/perfolio-commands/hyperliquid.js +408 -0
  15. package/dist/cli/perfolio-commands/loans.js +34 -0
  16. package/dist/cli/perfolio-commands/market.js +76 -0
  17. package/dist/cli/perfolio-commands/polymarket.js +304 -0
  18. package/dist/cli/perfolio-commands/session.js +19 -0
  19. package/dist/cli/perfolio-commands/trade.js +94 -0
  20. package/dist/cli/perfolio-commands/tx.js +22 -0
  21. package/dist/lib/agent-client.js +166 -0
  22. package/dist/lib/agent-device.js +173 -0
  23. package/dist/lib/amounts.js +45 -0
  24. package/dist/lib/assets.js +47 -0
  25. package/dist/lib/client.js +284 -0
  26. package/dist/lib/config.js +46 -0
  27. package/dist/lib/context.js +35 -0
  28. package/dist/lib/currency.js +91 -0
  29. package/dist/lib/device.js +163 -0
  30. package/dist/lib/errors.js +59 -0
  31. package/dist/lib/format.js +22 -0
  32. package/dist/lib/index.js +24 -0
  33. package/dist/lib/kyc-status.js +28 -0
  34. package/dist/lib/money-client.js +157 -0
  35. package/dist/lib/money-input.js +176 -0
  36. package/dist/lib/output.js +45 -0
  37. package/dist/lib/polygon-balance.js +125 -0
  38. package/dist/lib/portfolio-format.js +224 -0
  39. package/dist/lib/relay.js +19 -0
  40. package/dist/lib/sign.js +29 -0
  41. package/dist/lib/tx-wait.js +35 -0
  42. package/dist/lib/types.js +10 -0
  43. package/dist/lib/verify.js +38 -0
  44. package/package.json +37 -0
  45. package/skills/grant-cash/SKILL.md +152 -0
@@ -0,0 +1,173 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { AppError } from "./errors.js";
3
+ /**
4
+ * Generate a PKCE S256 pair. The verifier is 43 base64url chars (32 random
5
+ * bytes), well within RFC 7636's 43–128 range.
6
+ */
7
+ export function generatePkce() {
8
+ const verifier = base64url(randomBytes(32)); // 32 bytes → 43 base64url chars
9
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
10
+ return { verifier, challenge, method: "S256" };
11
+ }
12
+ function base64url(buf) {
13
+ return buf
14
+ .toString("base64")
15
+ .replace(/\+/g, "-")
16
+ .replace(/\//g, "_")
17
+ .replace(/=+$/, "");
18
+ }
19
+ const JSON_HEADERS = { "Content-Type": "application/json", Accept: "application/json" };
20
+ /**
21
+ * POST /onboard/privy-cli/device/start — open the agent-side handshake.
22
+ * Sends only the public challenge; keep the returned `sid` paired with the
23
+ * verifier from the same {@link generatePkce} call.
24
+ */
25
+ export async function startAgentDevice(agent, pkce, clientName, f = fetch) {
26
+ const base = agent.replace(/\/$/, "");
27
+ let res;
28
+ try {
29
+ res = await f(`${base}/onboard/privy-cli/device/start`, {
30
+ method: "POST",
31
+ headers: JSON_HEADERS,
32
+ body: JSON.stringify({
33
+ clientName,
34
+ code_challenge: pkce.challenge,
35
+ code_challenge_method: pkce.method,
36
+ }),
37
+ });
38
+ }
39
+ catch (e) {
40
+ throw new AppError(e instanceof Error ? e.message : String(e), {
41
+ code: "NETWORK",
42
+ recoverable: true,
43
+ });
44
+ }
45
+ const j = (await res.json().catch(() => ({})));
46
+ // A 4xx without a recognizable body means we never reached the agent API
47
+ // (wrong/dead base URL) — point at the misconfig, same as AgentClient.
48
+ if (res.status >= 400 && res.status < 500 && !j?.sid) {
49
+ throw new AppError(`No Grant Cash agent API at ${base} (HTTP ${res.status}) — check GRANTCASH_AGENT_URL.`, { status: res.status, code: "BACKEND_UNREACHABLE", recoverable: false });
50
+ }
51
+ if (!res.ok || !j?.sid) {
52
+ throw new AppError(j?.error?.message || `Could not start spending setup (HTTP ${res.status})`, { status: res.status, code: j?.error?.code ?? "AGENT_DEVICE_START_FAILED" });
53
+ }
54
+ return {
55
+ sid: j.sid,
56
+ pollInterval: typeof j.pollInterval === "number" ? j.pollInterval : 3,
57
+ expiresIn: typeof j.expiresIn === "number" ? j.expiresIn : 300,
58
+ };
59
+ }
60
+ /**
61
+ * POST /onboard/privy-cli/device/poll — one poll with the secret verifier.
62
+ *
63
+ * Resolves a single tick:
64
+ * - 200 { status:"pending" | "complete" | "expired" } → returned as-is
65
+ * (complete may omit `apiKey` = already provisioned → keep existing key).
66
+ * - 401 PRIVY_DEVICE_PKCE_FAILED → throw (terminal; the verifier was rejected).
67
+ * - 429 → throw a RATE_LIMITED AppError carrying `retryInMs` from details so
68
+ * {@link waitForAgentDevice} can back off instead of hammering.
69
+ */
70
+ export async function pollAgentDevice(agent, sid, verifier, f = fetch) {
71
+ const base = agent.replace(/\/$/, "");
72
+ let res;
73
+ try {
74
+ res = await f(`${base}/onboard/privy-cli/device/poll`, {
75
+ method: "POST",
76
+ headers: JSON_HEADERS,
77
+ body: JSON.stringify({ sid, code_verifier: verifier }),
78
+ });
79
+ }
80
+ catch (e) {
81
+ throw new AppError(e instanceof Error ? e.message : String(e), {
82
+ code: "NETWORK",
83
+ recoverable: true,
84
+ });
85
+ }
86
+ const j = (await res.json().catch(() => ({})));
87
+ if (res.status === 429) {
88
+ const retryInMs = j?.details?.retryInMs;
89
+ throw new AppError("Too many attempts — backing off.", {
90
+ status: 429,
91
+ code: "RATE_LIMITED",
92
+ recoverable: true,
93
+ details: retryInMs ? { retryInMs } : undefined,
94
+ });
95
+ }
96
+ if (res.status === 401) {
97
+ throw new AppError(j?.error?.message || "Spending setup could not be verified (PKCE check failed).", {
98
+ status: 401,
99
+ code: j?.error?.code || j?.code || "PRIVY_DEVICE_PKCE_FAILED",
100
+ recoverable: false,
101
+ });
102
+ }
103
+ if (!res.ok) {
104
+ throw new AppError(j?.error?.message || `Spending poll failed (HTTP ${res.status})`, {
105
+ status: res.status,
106
+ code: j?.error?.code ?? `HTTP_${res.status}`,
107
+ recoverable: res.status >= 500,
108
+ });
109
+ }
110
+ if (j.status === "complete") {
111
+ return { status: "complete", apiKey: j.apiKey, sessionStatus: j.sessionStatus };
112
+ }
113
+ if (j.status === "expired")
114
+ return { status: "expired" };
115
+ return { status: "pending" };
116
+ }
117
+ /**
118
+ * Poll the agent device until a terminal status or the link expires.
119
+ *
120
+ * Never throws on the network/rate-limit path: a transient network blip or a
121
+ * 429 (honoring `details.retryInMs`) just backs off and retries within the
122
+ * window. A 401 PRIVY_DEVICE_PKCE_FAILED is terminal and DOES propagate. On
123
+ * window exhaustion returns `{ status: "expired" }`.
124
+ */
125
+ export async function waitForAgentDevice(agent, sid, verifier, intervalMs, timeoutMs, f = fetch, signal) {
126
+ // When a signal is supplied, thread it into each fetch AND check it in the
127
+ // loop guard — so `grant login` can stop waiting on the best-effort spending
128
+ // side the moment money (the primary identity) settles, instead of letting
129
+ // this loop keep the process alive until the full timeout.
130
+ const ff = signal
131
+ ? ((input, init) => f(input, { ...init, signal }))
132
+ : f;
133
+ const start = Date.now();
134
+ while (!signal?.aborted && Date.now() - start < timeoutMs) {
135
+ try {
136
+ const r = await pollAgentDevice(agent, sid, verifier, ff);
137
+ if (r.status !== "pending")
138
+ return r;
139
+ await sleep(intervalMs);
140
+ }
141
+ catch (e) {
142
+ if (signal?.aborted)
143
+ break;
144
+ const err = e;
145
+ // PKCE failure is terminal — surface it.
146
+ if (err?.code === "PRIVY_DEVICE_PKCE_FAILED")
147
+ throw err;
148
+ // Rate-limited: back off by the server hint (or the normal interval).
149
+ if (err?.code === "RATE_LIMITED") {
150
+ const retryInMs = err.details?.retryInMs ?? intervalMs;
151
+ await sleep(Math.max(intervalMs, retryInMs));
152
+ continue;
153
+ }
154
+ // Transient network error: wait one interval and retry within the window.
155
+ await sleep(intervalMs);
156
+ }
157
+ }
158
+ return { status: "expired" };
159
+ }
160
+ function sleep(ms) {
161
+ return new Promise((res) => setTimeout(res, ms));
162
+ }
163
+ /**
164
+ * Resolve the agent-side poll interval in MILLISECONDS.
165
+ *
166
+ * The Agent-mode backend (`onboard/privy-cli/device/start`) reports
167
+ * `pollInterval` in SECONDS (e.g. 2), so it is multiplied by 1000. This differs
168
+ * from the money backend, which already reports milliseconds (see
169
+ * `moneyPollIntervalMs` in `device.ts`). Falls back to 3s.
170
+ */
171
+ export function agentPollIntervalMs(pollInterval) {
172
+ return (typeof pollInterval === "number" && pollInterval > 0 ? pollInterval : 3) * 1000;
173
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Validate a human decimal amount without converting it. The Perfolio backend
3
+ * accepts decimal strings directly (it runs parseUnits server-side), so the
4
+ * CLI must NOT pre-convert to base units — passing base units would be
5
+ * misread by the Enso heuristic. This gives the same early "too many decimal
6
+ * places" feedback toBaseUnits would, then returns the trimmed decimal string.
7
+ */
8
+ export function assertDecimal(value, decimals) {
9
+ const v = value.trim();
10
+ if (!/^\d+(\.\d+)?$/.test(v))
11
+ throw new Error(`Invalid amount: "${value}"`);
12
+ const frac = v.split('.')[1] ?? '';
13
+ if (frac.length > decimals)
14
+ throw new Error(`Too many decimal places for this asset (max ${decimals}).`);
15
+ return v;
16
+ }
17
+ /** Convert a human decimal string to integer base units (no floats). */
18
+ export function toBaseUnits(value, decimals) {
19
+ if (!/^\d+(\.\d+)?$/.test(value))
20
+ throw new Error(`Invalid amount: "${value}"`);
21
+ const [whole, frac = ''] = value.split('.');
22
+ if (frac.length > decimals)
23
+ throw new Error(`Too many decimal places for this asset (max ${decimals}).`);
24
+ const padded = frac.padEnd(decimals, '0');
25
+ const combined = `${whole}${padded}`.replace(/^0+(?=\d)/, '');
26
+ return combined === '' ? '0' : combined;
27
+ }
28
+ /** Convert integer base units back to a trimmed decimal string. */
29
+ export function fromBaseUnits(base, decimals) {
30
+ const neg = base.startsWith('-');
31
+ const digits = (neg ? base.slice(1) : base).padStart(decimals + 1, '0');
32
+ const whole = digits.slice(0, digits.length - decimals);
33
+ const frac = digits.slice(digits.length - decimals).replace(/0+$/, '');
34
+ const out = frac ? `${whole}.${frac}` : whole;
35
+ return neg ? `-${out}` : out;
36
+ }
37
+ /** Friendly display. Cash renders as currency; other assets as "<n> <friendly>". */
38
+ export function formatAmount(base, decimals, friendly) {
39
+ const dec = fromBaseUnits(base, decimals);
40
+ if (friendly === 'cash') {
41
+ const n = Number(dec);
42
+ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
43
+ }
44
+ return `${dec} ${friendly}`;
45
+ }
@@ -0,0 +1,47 @@
1
+ /** class → friendly word (v1 vocabulary). */
2
+ const FRIENDLY_BY_CLASS = {
3
+ gold: 'gold',
4
+ stable: 'cash',
5
+ btc: 'bitcoin',
6
+ eth: 'ethereum',
7
+ };
8
+ /** When a class has >1 active member, pick this canonical symbol for v1. */
9
+ const CANONICAL_SYMBOL_BY_CLASS = {
10
+ gold: 'XAUT',
11
+ stable: 'USDT',
12
+ btc: 'WBTC',
13
+ eth: 'WETH',
14
+ };
15
+ export const FRIENDLY_TERMS = ['gold', 'cash', 'bitcoin', 'ethereum'];
16
+ export class AssetResolver {
17
+ byFriendly = new Map();
18
+ friendlyBySymbol = new Map();
19
+ constructor(assets) {
20
+ for (const [cls, friendly] of Object.entries(FRIENDLY_BY_CLASS)) {
21
+ const canonical = CANONICAL_SYMBOL_BY_CLASS[cls];
22
+ const match = assets.find((a) => a.class === cls && a.symbol === canonical)
23
+ ?? assets.find((a) => a.class === cls);
24
+ if (!match)
25
+ continue;
26
+ const resolved = { friendly, symbol: match.symbol, decimals: match.decimals, class: cls };
27
+ this.byFriendly.set(friendly, resolved);
28
+ this.friendlyBySymbol.set(match.symbol, friendly);
29
+ }
30
+ }
31
+ resolve(input) {
32
+ const key = input.trim().toLowerCase();
33
+ const hit = this.byFriendly.get(key);
34
+ if (!hit) {
35
+ throw new Error(`I don't recognize "${input}". Try: gold, cash, bitcoin, or ethereum.`);
36
+ }
37
+ return hit;
38
+ }
39
+ /** Friendly word for a backend symbol; falls back to the symbol itself. */
40
+ label(symbol) {
41
+ return this.friendlyBySymbol.get(symbol) ?? symbol;
42
+ }
43
+ /** True when a backend symbol maps to a v1 friendly term (canonical asset). */
44
+ isFriendly(symbol) {
45
+ return this.friendlyBySymbol.has(symbol);
46
+ }
47
+ }
@@ -0,0 +1,284 @@
1
+ import { NoSessionError } from './errors.js';
2
+ import { refreshCliToken } from './device.js';
3
+ /** Per-request network timeout. Generous enough for MEE submit, short enough to not hang. */
4
+ const REQUEST_TIMEOUT_MS = 30_000;
5
+ export class PerfolioClient {
6
+ urls;
7
+ token;
8
+ refreshToken;
9
+ onRefresh;
10
+ f;
11
+ constructor(opts) {
12
+ this.urls = opts.urls;
13
+ this.token = opts.token;
14
+ this.refreshToken = opts.refreshToken;
15
+ this.onRefresh = opts.onRefresh;
16
+ this.f = opts.fetchImpl ?? fetch;
17
+ }
18
+ headers(auth) {
19
+ const h = { 'Content-Type': 'application/json' };
20
+ if (auth && this.token)
21
+ h.Authorization = `Bearer ${this.token}`;
22
+ return h;
23
+ }
24
+ /** fetch with an abort-based timeout + one retry on a transient network failure. */
25
+ async fetchWithTimeout(url, init) {
26
+ const attempt = async () => {
27
+ const ctrl = new AbortController();
28
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
29
+ try {
30
+ return await this.f(url, { ...init, signal: ctrl.signal });
31
+ }
32
+ finally {
33
+ clearTimeout(timer);
34
+ }
35
+ };
36
+ try {
37
+ return await attempt();
38
+ }
39
+ catch {
40
+ // One backoff retry for a dropped connection / timeout — not for HTTP errors
41
+ // (an HTTP error is a resolved Response, not a throw, so it won't reach here).
42
+ await new Promise((r) => setTimeout(r, 800));
43
+ return attempt();
44
+ }
45
+ }
46
+ async request(base, path, init, auth, retried = false) {
47
+ const res = await this.fetchWithTimeout(`${base}${path}`, { ...init, headers: this.headers(auth) });
48
+ // Silent refresh: an expired CLI access token (pfk_) → rotate once and retry.
49
+ if (res.status === 401 &&
50
+ auth &&
51
+ !retried &&
52
+ this.refreshToken &&
53
+ this.token?.startsWith('pfk_')) {
54
+ try {
55
+ const t = await refreshCliToken(this.urls.api, this.refreshToken, this.f);
56
+ this.token = t.token;
57
+ this.refreshToken = t.refreshToken;
58
+ this.onRefresh?.(t.token, t.refreshToken, t.expiresAt);
59
+ return this.request(base, path, init, auth, true);
60
+ }
61
+ catch {
62
+ /* refresh failed — fall through to the normal error below */
63
+ }
64
+ }
65
+ let json = { success: false };
66
+ try {
67
+ json = (await res.json());
68
+ }
69
+ catch { /* non-JSON */ }
70
+ if (res.status === 428)
71
+ throw new NoSessionError();
72
+ if (!res.ok || json.success === false) {
73
+ const err = new Error(json.error || json.message || `HTTP ${res.status}`);
74
+ err.status = res.status;
75
+ // Prefer the backend's machine code (e.g. 'MIN_ORDER_NOT_MET'); fall back to
76
+ // the legacy convention where the `error` field carried a code-like string
77
+ // (e.g. 'GRANT_REQUIRED', 'REGISTRY_UNAVAILABLE') for endpoints not yet on
78
+ // the structured contract.
79
+ err.code = json.code ?? json.error;
80
+ err.details = json.details;
81
+ throw err;
82
+ }
83
+ return json.data;
84
+ }
85
+ get(base, path, auth = true) {
86
+ return this.request(base, path, { method: 'GET' }, auth);
87
+ }
88
+ post(base, path, body, auth = true) {
89
+ return this.request(base, path, { method: 'POST', body: JSON.stringify(body) }, auth);
90
+ }
91
+ // ── reads ──
92
+ getUser() { return this.get(this.urls.api, '/user'); }
93
+ getModuleStatus() { return this.get(this.urls.api, '/sessions/module-status'); }
94
+ getAssets() { return this.get(this.urls.api, '/assets', false); }
95
+ getMarkets() { return this.get(this.urls.api, '/markets', false); }
96
+ /** Balances for the authenticated user's own wallet (no wallet-id needed). */
97
+ getBalance() { return this.get(this.urls.api, '/balances/me'); }
98
+ /**
99
+ * Portfolio summary. Pass `'full'` to additionally fetch the multi-asset +
100
+ * perps breakdown (`multiAsset` block) used by the CLI/MCP; the default
101
+ * (gold + cash) matches what the web app reads.
102
+ */
103
+ getPortfolio(view) {
104
+ const q = view === 'full' ? '?view=full' : '';
105
+ return this.get(this.urls.api, `/portfolio/summary${q}`);
106
+ }
107
+ getPosition(collateral, loan) {
108
+ return this.get(this.urls.api, `/protocol/position?protocolId=morpho&market=${collateral}/${loan}`);
109
+ }
110
+ getGoldPrice() { return this.get(this.urls.api, '/prices/gold', false); }
111
+ /**
112
+ * FX rate for a fiat currency: local units per 1 USD (public endpoint).
113
+ * Used to convert USD-denominated amounts into the user's display currency.
114
+ */
115
+ getFiatRate(currency) {
116
+ return this.get(this.urls.api, `/prices/fiat/${encodeURIComponent(currency.toUpperCase())}`, false);
117
+ }
118
+ getCryptoPrices() { return this.get(this.urls.api, '/prices/crypto', false); }
119
+ getBorrowRate() { return this.get(this.urls.api, '/borrow/rate', false); }
120
+ getMarketStats() { return this.get(this.urls.api, '/borrow/market-stats', false); }
121
+ getTxHistory(limit = 20) { return this.get(this.urls.api, `/tx/history?limit=${limit}`); }
122
+ /** Authoritative per-tx status — backend re-checks the MEE explorer for non-terminal states. */
123
+ getTxStatus(txHash) { return this.get(this.urls.api, `/tx/status/${txHash}`); }
124
+ // ── Polymarket prediction markets (read-only) ──
125
+ /** Discover/search active prediction markets (public). */
126
+ polymarketMarkets(query, limit) {
127
+ const params = new URLSearchParams();
128
+ if (query)
129
+ params.set('q', query);
130
+ if (limit)
131
+ params.set('limit', String(limit));
132
+ const qs = params.toString();
133
+ return this.get(this.urls.api, `/polymarket/markets${qs ? `?${qs}` : ''}`, false);
134
+ }
135
+ /** One market's detail + live prices (public). */
136
+ polymarketMarket(id) {
137
+ return this.get(this.urls.api, `/polymarket/markets/${encodeURIComponent(id)}`, false);
138
+ }
139
+ /** The authenticated user's open prediction positions + portfolio totals. */
140
+ polymarketPositions() {
141
+ return this.get(this.urls.api, '/polymarket/positions');
142
+ }
143
+ /** The authenticated user's prediction trade history (most recent first). */
144
+ polymarketActivity(limit) {
145
+ const q = limit ? `?limit=${limit}` : '';
146
+ return this.get(this.urls.api, `/polymarket/activity${q}`);
147
+ }
148
+ /** Whether the betting (write) path is provisioned + enabled. */
149
+ polymarketTradingStatus() {
150
+ return this.get(this.urls.api, '/polymarket/trading-status');
151
+ }
152
+ /** Place a bet (buy outcome shares). Returns 503 until trading is enabled. */
153
+ polymarketBet(b) {
154
+ return this.post(this.urls.api, '/polymarket/bet', b);
155
+ }
156
+ /** Sell shares (cash out). `shares` omitted = sell all. */
157
+ polymarketSell(b) {
158
+ return this.post(this.urls.api, '/polymarket/sell', b);
159
+ }
160
+ /** Redeem winnings on a resolved market. */
161
+ polymarketRedeem(b) {
162
+ return this.post(this.urls.api, '/polymarket/redeem', b);
163
+ }
164
+ /** Deposit-wallet address + deployed status (read-only). */
165
+ polymarketDepositWallet() {
166
+ return this.get(this.urls.api, '/polymarket/deposit-wallet');
167
+ }
168
+ /** Deploy + approve the deposit wallet (gasless, autonomous via the signer). */
169
+ polymarketProvision() {
170
+ return this.post(this.urls.api, '/polymarket/provision', {});
171
+ }
172
+ /** Build a Relay USDT→pUSD funding quote to the deposit wallet (execution is separate). */
173
+ polymarketFundQuote(amount) {
174
+ return this.get(this.urls.api, `/polymarket/fund-quote?amount=${encodeURIComponent(amount)}`);
175
+ }
176
+ /** Execute USDT→pUSD funding to the deposit wallet autonomously via the DeFi session. */
177
+ polymarketDeposit(amount) {
178
+ return this.post(this.urls.api, '/polymarket/deposit', { amount });
179
+ }
180
+ /** Withdraw pUSD from the deposit wallet back to your wallet (USDT on Ethereum). */
181
+ polymarketWithdraw(amount) {
182
+ return this.post(this.urls.api, '/polymarket/withdraw', { amount });
183
+ }
184
+ /** Poll a Relay funding intent (pUSD delivery on Polygon). */
185
+ polymarketFundStatus(requestId) {
186
+ return this.get(this.urls.api, `/polymarket/fund-status?requestId=${encodeURIComponent(requestId)}`);
187
+ }
188
+ // ── swap / trade (session key) ──
189
+ swapQuote(b) {
190
+ return this.post(this.urls.api, '/swap/quote', b);
191
+ }
192
+ swap(b) {
193
+ return this.post(this.urls.api, '/tx/swap', b);
194
+ }
195
+ // ── borrow / collateral (session key) ──
196
+ supply(b) { return this.post(this.urls.api, '/tx/supply', b); }
197
+ borrow(b) { return this.post(this.urls.api, '/tx/borrow', b); }
198
+ repay(b) { return this.post(this.urls.api, '/tx/repay', b); }
199
+ withdrawCollateral(b) { return this.post(this.urls.api, '/tx/withdraw', b); }
200
+ supplyAndBorrow(b) {
201
+ return this.post(this.urls.api, '/tx/supply-and-borrow', b);
202
+ }
203
+ closePosition(b) { return this.post(this.urls.api, '/tx/close-position', b); }
204
+ // ── earn / vaults (Morpho Vault V2, session key) ──
205
+ /** List earn vaults (public read). Pass an asset symbol to filter. */
206
+ getVaults(asset) {
207
+ const q = asset ? `?asset=${encodeURIComponent(asset)}` : '';
208
+ return this.get(this.urls.api, `/protocol/vaults${q}`, false);
209
+ }
210
+ /** The authenticated user's earn position for an asset (on-chain shares + value). */
211
+ getEarnPosition(asset) {
212
+ return this.get(this.urls.api, `/protocol/vault-position?asset=${encodeURIComponent(asset)}`);
213
+ }
214
+ /** Deposit an asset into its canonical earn vault (agent-signed via the DeFi session). */
215
+ earnDeposit(b) { return this.post(this.urls.api, '/tx/earn-deposit', b); }
216
+ /** Withdraw from an earn vault — exact `amount`, or the whole position with `all: true`. */
217
+ earnWithdraw(b) { return this.post(this.urls.api, '/tx/earn-withdraw', b); }
218
+ // ── user-signed ops: prepare (CLI authed, no signing) + hand off to browser-sign ──
219
+ prepareWithdrawal(b) {
220
+ return this.post(this.urls.api, '/tx/prepare', { operation: 'withdrawal', ...b });
221
+ }
222
+ prepareGiftSend(b) {
223
+ return this.post(this.urls.api, '/gift/prepare-send', b);
224
+ }
225
+ /** Hand a self-describing sign task to the browser-sign relay. Returns the approve URL + sid. */
226
+ signStart(b) {
227
+ return this.post(this.urls.api, '/cli/sign/start', b);
228
+ }
229
+ // ── Hyperliquid perps (reads + trade are autonomous via the server-held agent key) ──
230
+ hlStatus() { return this.get(this.urls.api, '/hl/status'); }
231
+ /** Discover tradeable HL markets (crypto + equities/commodities/fx/indices). */
232
+ hlMarkets(search) {
233
+ const q = search ? `?search=${encodeURIComponent(search)}` : '';
234
+ return this.get(this.urls.api, `/hl/markets${q}`);
235
+ }
236
+ hlPositions() { return this.get(this.urls.api, '/hl/positions'); }
237
+ hlOrders() { return this.get(this.urls.api, '/hl/orders'); }
238
+ hlOrderHistory() { return this.get(this.urls.api, '/hl/order-history'); }
239
+ hlTradeHistory(limit) {
240
+ const qs = limit && limit > 0 ? `?limit=${Math.floor(limit)}` : '';
241
+ return this.get(this.urls.api, `/hl/history${qs}`);
242
+ }
243
+ hlPrice(asset) {
244
+ const q = asset ? `?asset=${encodeURIComponent(asset)}` : '';
245
+ return this.get(this.urls.api, `/hl/price${q}`);
246
+ }
247
+ hlSetup() { return this.post(this.urls.api, '/hl/setup', {}); }
248
+ hlOpen(b) { return this.post(this.urls.api, '/hl/open', b); }
249
+ hlClose(b) { return this.post(this.urls.api, '/hl/close', b); }
250
+ hlSetLeverage(b) { return this.post(this.urls.api, '/hl/leverage', b); }
251
+ hlTpsl(b) {
252
+ return this.post(this.urls.api, '/hl/tpsl', b);
253
+ }
254
+ hlCancelOrder(b) { return this.post(this.urls.api, '/hl/cancel-order', b); }
255
+ /**
256
+ * Autonomous USDT → Hyperliquid deposit. Hits POST /tx/bridge-deposit, which
257
+ * builds the USDT-approve + Across bridge calls and executes them via the
258
+ * Biconomy session grant (agent-signed) — no user signature, no browser.
259
+ * (The old /hl/deposit/one-click only PREPARED calldata and executed nothing,
260
+ * so its "submitted" was a false positive that moved no funds.)
261
+ */
262
+ hlDeposit(b) { return this.post(this.urls.api, '/tx/bridge-deposit', b); }
263
+ /** Autonomous HL → Ethereum withdrawal (server-signs via Privy; no browser). */
264
+ hlWithdraw(b) {
265
+ return this.post(this.urls.api, '/hl/withdraw-to-evm/execute', b);
266
+ }
267
+ /** Poll a HL→Ethereum withdrawal to completion (Relay canonical status + delivery tx hashes). */
268
+ hlWithdrawStatus(requestId) {
269
+ return this.get(this.urls.api, `/hl/withdraw-to-evm/status/${encodeURIComponent(requestId)}`);
270
+ }
271
+ // ── fiat / account (reads + settings) ──
272
+ kycStatus() { return this.get(this.urls.fiat, '/kyc/status'); }
273
+ /** Provider (Roma) accounts — fiat accounts carry bankDetails for deposits. Auto-provisioned after KYC. */
274
+ getAccounts() { return this.get(this.urls.fiat, '/accounts'); }
275
+ beneficiaries() { return this.get(this.urls.fiat, '/beneficiaries'); }
276
+ fiatQuote(b) {
277
+ return this.post(this.urls.fiat, '/payout/quote', b);
278
+ }
279
+ giftsSent() { return this.get(this.urls.api, '/gift/sent'); }
280
+ giftsReceived() { return this.get(this.urls.api, '/gift/received'); }
281
+ updateSettings(b) {
282
+ return this.request(this.urls.api, '/user', { method: 'PATCH', body: JSON.stringify(b) }, true);
283
+ }
284
+ }
@@ -0,0 +1,46 @@
1
+ import { homedir } from "node:os";
2
+ import { join, dirname } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
4
+ /**
5
+ * Backend base URLs — env-overridable, production defaults.
6
+ *
7
+ * Money side = Perfolio backend; Agent side = Agent-mode backend. Set the env
8
+ * vars (e.g. in the package-root `.env`) to repoint at dev/staging without
9
+ * touching code. You don't need final URLs to build — the agent default is a
10
+ * placeholder; override GRANTCASH_AGENT_URL when it's known.
11
+ */
12
+ export function baseUrls(env = process.env) {
13
+ return {
14
+ api: env.GRANTCASH_API_URL || "https://api.perfolio.ai/api",
15
+ fiat: env.GRANTCASH_FIAT_URL || "https://api-fiat.perfolio.ai/api",
16
+ app: env.GRANTCASH_APP_URL || "https://app.perfolio.ai",
17
+ agent: env.GRANTCASH_AGENT_URL || "https://api-agent.perfolio.ai",
18
+ };
19
+ }
20
+ export function resolveCredsPath(flag, env = process.env) {
21
+ if (flag)
22
+ return flag;
23
+ if (env.GRANTCASH_CREDS_FILE)
24
+ return env.GRANTCASH_CREDS_FILE;
25
+ return join(homedir(), ".grant-cash", "credentials.json");
26
+ }
27
+ export function loadCredentials(path) {
28
+ try {
29
+ return JSON.parse(readFileSync(path, "utf-8"));
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ export function saveCredentials(creds, path) {
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ writeFileSync(path, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
38
+ }
39
+ export function clearCredentials(path) {
40
+ try {
41
+ unlinkSync(path);
42
+ }
43
+ catch {
44
+ /* already gone */
45
+ }
46
+ }
@@ -0,0 +1,35 @@
1
+ import { baseUrls, resolveCredsPath, loadCredentials, saveCredentials, } from "./config.js";
2
+ import { MoneyClient } from "./money-client.js";
3
+ import { AgentClient } from "./agent-client.js";
4
+ import { isJsonMode } from "./output.js";
5
+ export function buildContext(cmd) {
6
+ // Global opts live on the root program; optsWithGlobals merges them in.
7
+ const opts = cmd.optsWithGlobals();
8
+ const urls = baseUrls();
9
+ const credsPath = resolveCredsPath(opts.credsFile);
10
+ const creds = loadCredentials(credsPath);
11
+ const json = isJsonMode(opts.json);
12
+ const save = (next) => {
13
+ saveCredentials(next, credsPath);
14
+ ctx.creds = next;
15
+ };
16
+ const money = new MoneyClient({
17
+ api: urls.api,
18
+ fiat: urls.fiat,
19
+ token: creds.money?.token,
20
+ refreshToken: creds.money?.refreshToken,
21
+ onRefresh: (token, refreshToken, expiresAt) => {
22
+ const next = {
23
+ ...ctx.creds,
24
+ money: { ...ctx.creds.money, token, refreshToken, expiresAt },
25
+ };
26
+ save(next);
27
+ },
28
+ });
29
+ const agent = new AgentClient({
30
+ agent: urls.agent,
31
+ apiKey: creds.agent?.apiKey,
32
+ });
33
+ const ctx = { urls, creds, credsPath, json, money, agent, save };
34
+ return ctx;
35
+ }