@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.
- package/.env.example +21 -0
- package/README.md +48 -0
- package/dist/cli/commands/agent.js +139 -0
- package/dist/cli/commands/auth.js +248 -0
- package/dist/cli/commands/meta.js +77 -0
- package/dist/cli/commands/money.js +85 -0
- package/dist/cli/commands/portfolio.js +224 -0
- package/dist/cli/index.js +94 -0
- package/dist/cli/money-helpers.js +189 -0
- package/dist/cli/perfolio-commands/account.js +272 -0
- package/dist/cli/perfolio-commands/borrow.js +75 -0
- package/dist/cli/perfolio-commands/discover.js +30 -0
- package/dist/cli/perfolio-commands/earn.js +193 -0
- package/dist/cli/perfolio-commands/hyperliquid.js +408 -0
- package/dist/cli/perfolio-commands/loans.js +34 -0
- package/dist/cli/perfolio-commands/market.js +76 -0
- package/dist/cli/perfolio-commands/polymarket.js +304 -0
- package/dist/cli/perfolio-commands/session.js +19 -0
- package/dist/cli/perfolio-commands/trade.js +94 -0
- package/dist/cli/perfolio-commands/tx.js +22 -0
- package/dist/lib/agent-client.js +166 -0
- package/dist/lib/agent-device.js +173 -0
- package/dist/lib/amounts.js +45 -0
- package/dist/lib/assets.js +47 -0
- package/dist/lib/client.js +284 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/context.js +35 -0
- package/dist/lib/currency.js +91 -0
- package/dist/lib/device.js +163 -0
- package/dist/lib/errors.js +59 -0
- package/dist/lib/format.js +22 -0
- package/dist/lib/index.js +24 -0
- package/dist/lib/kyc-status.js +28 -0
- package/dist/lib/money-client.js +157 -0
- package/dist/lib/money-input.js +176 -0
- package/dist/lib/output.js +45 -0
- package/dist/lib/polygon-balance.js +125 -0
- package/dist/lib/portfolio-format.js +224 -0
- package/dist/lib/relay.js +19 -0
- package/dist/lib/sign.js +29 -0
- package/dist/lib/tx-wait.js +35 -0
- package/dist/lib/types.js +10 -0
- package/dist/lib/verify.js +38 -0
- package/package.json +37 -0
- package/skills/grant-cash/SKILL.md +152 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-currency + experience-mode formatting — the single source of truth
|
|
3
|
+
* shared by the CLI and the MCP server.
|
|
4
|
+
*
|
|
5
|
+
* The platform stores all monetary values internally in USD. The user's profile
|
|
6
|
+
* (GET /api/user) carries their display preferences:
|
|
7
|
+
* - currency ISO code of their local currency (e.g. INR, AED, EUR)
|
|
8
|
+
* - displayCurrency 'USD' (show USD) | 'local' (convert to `currency`)
|
|
9
|
+
* - goldDisplayUnit 'oz' | 'g'
|
|
10
|
+
* - experienceMode 'simple' (plain language, local currency) | 'advanced'
|
|
11
|
+
*
|
|
12
|
+
* The FX rate (local units per 1 USD) comes from GET /api/prices/fiat/{currency}.
|
|
13
|
+
* Conversion is a single multiply; presentation is delegated to Intl so symbols
|
|
14
|
+
* and per-currency decimal conventions (e.g. JPY has 0) are correct and we don't
|
|
15
|
+
* hand-maintain a symbol table.
|
|
16
|
+
*/
|
|
17
|
+
/** 1 troy ounce = 31.1034768 grams (matches the web app's GRAMS_PER_TROY_OUNCE). */
|
|
18
|
+
export const GRAMS_PER_TROY_OUNCE = 31.1034768;
|
|
19
|
+
/** Safe USD-only defaults — what every caller falls back to with no profile. */
|
|
20
|
+
export const DEFAULT_PREFS = {
|
|
21
|
+
currency: 'USD',
|
|
22
|
+
displayCurrency: 'USD',
|
|
23
|
+
goldUnit: 'oz',
|
|
24
|
+
mode: 'simple',
|
|
25
|
+
rate: 1,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Build DisplayPrefs from a raw GET /api/user payload (defensively — the field
|
|
29
|
+
* may be absent on legacy rows) plus a fetched FX rate. Unknown / malformed
|
|
30
|
+
* values fall back to the safe USD defaults rather than throwing.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveDisplayPrefs(user, rate = 1) {
|
|
33
|
+
const u = (user && typeof user === 'object' ? user : {});
|
|
34
|
+
const currency = typeof u.currency === 'string' && u.currency.trim() ? u.currency.trim().toUpperCase() : 'USD';
|
|
35
|
+
const displayCurrency = u.displayCurrency === 'local' ? 'local' : 'USD';
|
|
36
|
+
const goldUnit = u.goldDisplayUnit === 'g' ? 'g' : 'oz';
|
|
37
|
+
const mode = u.experienceMode === 'advanced' ? 'advanced' : 'simple';
|
|
38
|
+
const safeRate = typeof rate === 'number' && Number.isFinite(rate) && rate > 0 ? rate : 1;
|
|
39
|
+
return { currency, displayCurrency, goldUnit, mode, rate: safeRate };
|
|
40
|
+
}
|
|
41
|
+
/** True when money should be shown in the user's local currency (not USD). */
|
|
42
|
+
export function usesLocalCurrency(prefs) {
|
|
43
|
+
return (prefs.displayCurrency === 'local' &&
|
|
44
|
+
!!prefs.currency &&
|
|
45
|
+
prefs.currency.toUpperCase() !== 'USD' &&
|
|
46
|
+
prefs.rate > 0);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Format a USD amount for display per the user's preferences. Converts to the
|
|
50
|
+
* local currency when configured; otherwise renders USD. Always falls back to a
|
|
51
|
+
* valid USD string if the target currency code is not recognized by Intl.
|
|
52
|
+
*/
|
|
53
|
+
export function formatMoney(amountUsd, prefs = DEFAULT_PREFS) {
|
|
54
|
+
const usd = Number.isFinite(amountUsd) ? amountUsd : 0;
|
|
55
|
+
const local = usesLocalCurrency(prefs);
|
|
56
|
+
const code = local ? prefs.currency.toUpperCase() : 'USD';
|
|
57
|
+
const value = local ? usd * prefs.rate : usd;
|
|
58
|
+
try {
|
|
59
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(value);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Unrecognized ISO code → never crash a balance read; show USD instead.
|
|
63
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(usd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Trim a number to at most `maxDp` decimals without trailing zeros. */
|
|
67
|
+
function trim(n, maxDp) {
|
|
68
|
+
if (!Number.isFinite(n))
|
|
69
|
+
return '0';
|
|
70
|
+
return Number(n.toFixed(maxDp)).toLocaleString('en-US', { maximumFractionDigits: maxDp });
|
|
71
|
+
}
|
|
72
|
+
export function displayDescriptor(prefs = DEFAULT_PREFS) {
|
|
73
|
+
return {
|
|
74
|
+
currency: prefs.currency,
|
|
75
|
+
displayCurrency: prefs.displayCurrency,
|
|
76
|
+
goldUnit: prefs.goldUnit,
|
|
77
|
+
rate: prefs.rate,
|
|
78
|
+
usesLocal: usesLocalCurrency(prefs),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Format a gold amount (given in troy ounces) per the user's unit preference.
|
|
83
|
+
* Grams are shown with more precision than ounces since 1 oz ≈ 31 g.
|
|
84
|
+
*/
|
|
85
|
+
export function formatGold(ounces, prefs = DEFAULT_PREFS) {
|
|
86
|
+
const oz = Number.isFinite(ounces) ? ounces : 0;
|
|
87
|
+
if (prefs.goldUnit === 'g') {
|
|
88
|
+
return `${trim(oz * GRAMS_PER_TROY_OUNCE, 2)} g`;
|
|
89
|
+
}
|
|
90
|
+
return `${trim(oz, 4)} oz`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { AppError } from "./errors.js";
|
|
3
|
+
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
4
|
+
export async function startDevice(api, clientName, deviceName, f = fetch) {
|
|
5
|
+
const res = await f(`${api}/cli/device/start`, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: JSON_HEADERS,
|
|
8
|
+
body: JSON.stringify({ clientName, deviceName }),
|
|
9
|
+
});
|
|
10
|
+
const j = (await res.json().catch(() => ({})));
|
|
11
|
+
if (!res.ok || !j.success || !j.data)
|
|
12
|
+
throw new AppError(j.error || `Could not start login (HTTP ${res.status})`, {
|
|
13
|
+
status: res.status,
|
|
14
|
+
});
|
|
15
|
+
return j.data;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One poll tick. Resolves only on a real backend answer (HTTP 200 + success
|
|
19
|
+
* envelope) — `pending` | `denied` | `expired` | `complete`. Any transient
|
|
20
|
+
* failure (429 from the shared `/api` limiter, a 5xx, a malformed body, or a
|
|
21
|
+
* thrown fetch when the dev backend restarts) is raised as an AppError so
|
|
22
|
+
* {@link waitForDevice} can back off and retry inside the window instead of
|
|
23
|
+
* mistaking a blip for a terminal "expired" and giving up. A 429 carries
|
|
24
|
+
* `retryInMs` (from the body or the `Retry-After` header) for the backoff.
|
|
25
|
+
*
|
|
26
|
+
* This mirrors the agent side (`pollAgentDevice`/`waitForAgentDevice`); the two
|
|
27
|
+
* halves of the handshake must be equally fault-tolerant.
|
|
28
|
+
*/
|
|
29
|
+
export async function pollDevice(api, sid, f = fetch) {
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await f(`${api}/cli/device/poll`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: JSON_HEADERS,
|
|
35
|
+
body: JSON.stringify({ sid }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
// Network blip / dev backend mid-restart — transient, retry.
|
|
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
|
+
if (res.status === 429) {
|
|
47
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
48
|
+
const retryInMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : undefined;
|
|
49
|
+
throw new AppError("Too many attempts — backing off.", {
|
|
50
|
+
status: 429,
|
|
51
|
+
code: "RATE_LIMITED",
|
|
52
|
+
recoverable: true,
|
|
53
|
+
details: retryInMs ? { retryInMs } : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Non-2xx or a malformed/unsuccessful envelope = transient (the backend
|
|
57
|
+
// reports a genuine terminal state as 200 + success). Retry within the window.
|
|
58
|
+
if (!res.ok || !j.success || !j.data) {
|
|
59
|
+
throw new AppError(j.error || `Poll failed (HTTP ${res.status})`, {
|
|
60
|
+
status: res.status,
|
|
61
|
+
code: `HTTP_${res.status}`,
|
|
62
|
+
recoverable: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return j.data;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Poll until a genuine terminal status or the link expires.
|
|
69
|
+
*
|
|
70
|
+
* Never bails on a transient failure: network errors and rate-limits (honoring
|
|
71
|
+
* `retryInMs`) just back off and retry until the window closes. Only a real
|
|
72
|
+
* backend answer (`complete` / `denied` / `expired`) ends the loop early. On
|
|
73
|
+
* window exhaustion returns `{ status: "expired" }`.
|
|
74
|
+
*/
|
|
75
|
+
export async function waitForDevice(api, sid, intervalMs, timeoutMs, f = fetch) {
|
|
76
|
+
const start = Date.now();
|
|
77
|
+
while (Date.now() - start < timeoutMs) {
|
|
78
|
+
try {
|
|
79
|
+
const r = await pollDevice(api, sid, f);
|
|
80
|
+
if (r.status !== "pending")
|
|
81
|
+
return r;
|
|
82
|
+
await sleep(intervalMs);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
const err = e;
|
|
86
|
+
const retryInMs = err.details?.retryInMs ?? intervalMs;
|
|
87
|
+
// Rate-limited → wait at least the server's hint; other transients → one interval.
|
|
88
|
+
await sleep(err.code === "RATE_LIMITED" ? Math.max(intervalMs, retryInMs) : intervalMs);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { status: "expired" };
|
|
92
|
+
}
|
|
93
|
+
function sleep(ms) {
|
|
94
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the money-side poll interval in MILLISECONDS.
|
|
98
|
+
*
|
|
99
|
+
* The Perfolio money backend (`cli/device/start`) reports `pollInterval` already
|
|
100
|
+
* in milliseconds (e.g. 3000) — the same contract the published @perfolio/cli
|
|
101
|
+
* consumes as-is — so it is used verbatim. This deliberately differs from the
|
|
102
|
+
* agent backend, which reports SECONDS (see {@link agentPollIntervalMs}).
|
|
103
|
+
*
|
|
104
|
+
* Multiplying this value by 1000 was the cause of the "browser connected but CLI
|
|
105
|
+
* polls forever" bug: 3000 became 3,000,000ms (~50 min), so the CLI polled once
|
|
106
|
+
* and then slept past the whole login window. Falls back to 3s.
|
|
107
|
+
*/
|
|
108
|
+
export function moneyPollIntervalMs(pollInterval) {
|
|
109
|
+
return typeof pollInterval === "number" && pollInterval > 0 ? pollInterval : 3000;
|
|
110
|
+
}
|
|
111
|
+
export async function refreshCliToken(api, refreshToken, f = fetch) {
|
|
112
|
+
const res = await f(`${api}/cli/token/refresh`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: JSON_HEADERS,
|
|
115
|
+
body: JSON.stringify({ refreshToken }),
|
|
116
|
+
});
|
|
117
|
+
const j = (await res.json().catch(() => ({})));
|
|
118
|
+
if (!res.ok || !j.success || !j.data)
|
|
119
|
+
throw new AppError(j.error || "Refresh failed", { status: res.status });
|
|
120
|
+
return j.data;
|
|
121
|
+
}
|
|
122
|
+
/** List the devices/agents connected to this account (Bearer auth). */
|
|
123
|
+
export async function listDevices(api, token, f = fetch) {
|
|
124
|
+
const res = await f(`${api}/cli/devices`, { headers: { Authorization: `Bearer ${token}` } });
|
|
125
|
+
const j = (await res.json().catch(() => ({})));
|
|
126
|
+
if (!res.ok || !j.success || !j.data)
|
|
127
|
+
throw new Error(j.error || "Could not list devices");
|
|
128
|
+
return j.data.devices;
|
|
129
|
+
}
|
|
130
|
+
export async function revokeDevice(api, token, deviceId, f = fetch) {
|
|
131
|
+
await f(`${api}/cli/token/revoke`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { ...JSON_HEADERS, Authorization: `Bearer ${token}` },
|
|
134
|
+
body: JSON.stringify(deviceId ? { deviceId } : {}),
|
|
135
|
+
}).catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
/** Best-effort label for the agent/client making the connection. */
|
|
138
|
+
export function detectClient(env = process.env) {
|
|
139
|
+
if (env.GRANTCASH_CLIENT)
|
|
140
|
+
return env.GRANTCASH_CLIENT;
|
|
141
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE)
|
|
142
|
+
return "Claude Code";
|
|
143
|
+
if (env.CURSOR_TRACE_ID)
|
|
144
|
+
return "Cursor";
|
|
145
|
+
if (env.TERM_PROGRAM)
|
|
146
|
+
return env.TERM_PROGRAM;
|
|
147
|
+
return "Grant Cash CLI";
|
|
148
|
+
}
|
|
149
|
+
/** Open a URL in the user's default browser (best-effort; never throws). */
|
|
150
|
+
export function openBrowser(url) {
|
|
151
|
+
const cmd = process.platform === "darwin"
|
|
152
|
+
? "open"
|
|
153
|
+
: process.platform === "win32"
|
|
154
|
+
? "cmd"
|
|
155
|
+
: "xdg-open";
|
|
156
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
157
|
+
try {
|
|
158
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* headless — the URL is printed for manual open */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified error model across both backends.
|
|
3
|
+
*
|
|
4
|
+
* Carries a machine `code` and a `recoverable` flag (from the agent side's LLM
|
|
5
|
+
* playbook) plus an HTTP `status` (from the money side). `friendlyError` maps the
|
|
6
|
+
* shared connection/availability states to one plain-language line so the same
|
|
7
|
+
* 401/403/428/503 reads the same no matter which backend produced it.
|
|
8
|
+
*/
|
|
9
|
+
export class AppError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
code;
|
|
12
|
+
recoverable;
|
|
13
|
+
details;
|
|
14
|
+
constructor(message, opts = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "AppError";
|
|
17
|
+
this.status = opts.status;
|
|
18
|
+
this.code = opts.code;
|
|
19
|
+
this.recoverable = opts.recoverable;
|
|
20
|
+
this.details = opts.details;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Money side returns 428 when no spending session is installed. */
|
|
24
|
+
export class NoSessionError extends AppError {
|
|
25
|
+
constructor() {
|
|
26
|
+
super("No spending permission set up yet. Run `grant login` and approve the limits in your browser.", { status: 428, code: "NO_SESSION", recoverable: false });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** No credential of the kind a command needs. */
|
|
30
|
+
export class NotConnectedError extends AppError {
|
|
31
|
+
constructor(which) {
|
|
32
|
+
const msg = which === "agent"
|
|
33
|
+
? "Not connected to your agent yet. Run `grant login`."
|
|
34
|
+
: which === "money"
|
|
35
|
+
? "Not signed in yet. Run `grant login`."
|
|
36
|
+
: "Not connected yet. Run `grant login`.";
|
|
37
|
+
super(msg, { code: "NOT_CONNECTED", recoverable: false });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Plain-language override for shared connection states. Returns null when there
|
|
42
|
+
* is no special-cased message (the caller then uses the backend's own message).
|
|
43
|
+
*/
|
|
44
|
+
export function friendlyError(status, code) {
|
|
45
|
+
if (code === "NOT_CONNECTED" || code === "NO_SESSION")
|
|
46
|
+
return null; // already friendly
|
|
47
|
+
switch (status) {
|
|
48
|
+
case 401:
|
|
49
|
+
return "Your session expired. Run `grant login` again.";
|
|
50
|
+
case 403:
|
|
51
|
+
return "That's outside the limits you set, or the session was stopped. Nothing was charged.";
|
|
52
|
+
case 428:
|
|
53
|
+
return "No spending permission set up yet. Run `grant login` and approve the limits.";
|
|
54
|
+
case 503:
|
|
55
|
+
return "That service is briefly unavailable. Try again in a moment.";
|
|
56
|
+
default:
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Display formatting. Plain money, no tickers/chains (Grant Cash house style). */
|
|
2
|
+
export function money(n, currency = "USD") {
|
|
3
|
+
const symbol = currency === "USD" ? "$" : "";
|
|
4
|
+
const abs = Math.abs(n);
|
|
5
|
+
const digits = abs > 0 && abs < 100 ? 2 : 0;
|
|
6
|
+
const body = new Intl.NumberFormat("en-US", {
|
|
7
|
+
minimumFractionDigits: digits,
|
|
8
|
+
maximumFractionDigits: digits,
|
|
9
|
+
}).format(abs);
|
|
10
|
+
return `${n < 0 ? "−" : ""}${symbol}${body}${currency === "USD" ? "" : " " + currency}`;
|
|
11
|
+
}
|
|
12
|
+
/** Agent-side amounts arrive as USDC minor units (1e6). */
|
|
13
|
+
export function usdcMinor(minor) {
|
|
14
|
+
if (minor === undefined || minor === null)
|
|
15
|
+
return "—";
|
|
16
|
+
const n = Number(minor) / 1e6;
|
|
17
|
+
return `$${n.toFixed(n < 0.01 ? 4 : 2)}`;
|
|
18
|
+
}
|
|
19
|
+
export function pct(n, digits = 1) {
|
|
20
|
+
const sign = n > 0 ? "+" : n < 0 ? "−" : "";
|
|
21
|
+
return `${sign}${Math.abs(n).toFixed(digits)}%`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./config.js";
|
|
3
|
+
export * from "./errors.js";
|
|
4
|
+
export * from "./output.js";
|
|
5
|
+
export * from "./device.js";
|
|
6
|
+
export * from "./agent-device.js";
|
|
7
|
+
export * from "./money-client.js";
|
|
8
|
+
export * from "./agent-client.js";
|
|
9
|
+
export * from "./tx-wait.js";
|
|
10
|
+
export * from "./format.js";
|
|
11
|
+
export * from "./currency.js";
|
|
12
|
+
export * from "./portfolio-format.js";
|
|
13
|
+
export * from "./context.js";
|
|
14
|
+
// ── ported Perfolio money engine (full investing surface) ──
|
|
15
|
+
export * from "./client.js";
|
|
16
|
+
export * from "./assets.js";
|
|
17
|
+
export * from "./amounts.js";
|
|
18
|
+
export * from "./money-input.js";
|
|
19
|
+
export * from "./kyc-status.js";
|
|
20
|
+
export * from "./polygon-balance.js";
|
|
21
|
+
export * from "./sign.js";
|
|
22
|
+
export * from "./relay.js";
|
|
23
|
+
// NOTE: ./verify.js (waitForTx for PerfolioClient) is deliberately NOT barreled
|
|
24
|
+
// here — it would collide with tx-wait.js's waitForTx. Import it directly.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Pure KYC status classifiers — mirror the web app's kyc-status semantics. */
|
|
2
|
+
function norm(s) {
|
|
3
|
+
return typeof s === 'string' ? s.trim().toLowerCase() : '';
|
|
4
|
+
}
|
|
5
|
+
const APPROVED = new Set(['approved', 'verified', 'completed', 'success', 'active']);
|
|
6
|
+
const REJECTED = new Set(['rejected', 'declined', 'failed', 'denied']);
|
|
7
|
+
const SUBMITTED = new Set(['submitted', 'in_review', 'processing', 'pending_review', 'under_review']);
|
|
8
|
+
export function isKycApproved(status) { return APPROVED.has(norm(status)); }
|
|
9
|
+
export function isKycRejected(status) { return REJECTED.has(norm(status)); }
|
|
10
|
+
export function isKycSubmitted(status) { return SUBMITTED.has(norm(status)); }
|
|
11
|
+
export function isKycPending(status) {
|
|
12
|
+
const n = norm(status);
|
|
13
|
+
return !APPROVED.has(n) && !REJECTED.has(n) && !SUBMITTED.has(n);
|
|
14
|
+
}
|
|
15
|
+
/** Extract a status string from a raw value (string | {status} | {data:{status}}) and classify it. */
|
|
16
|
+
export function classifyKyc(raw) {
|
|
17
|
+
let status;
|
|
18
|
+
if (typeof raw === 'string')
|
|
19
|
+
status = raw;
|
|
20
|
+
else if (raw && typeof raw === 'object') {
|
|
21
|
+
const o = raw;
|
|
22
|
+
const inner = (o.data && typeof o.data === 'object') ? o.data : o;
|
|
23
|
+
status = typeof inner.status === 'string' ? inner.status : 'unknown';
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
status = 'unknown';
|
|
27
|
+
return { status, approved: isKycApproved(status) };
|
|
28
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { AppError, NoSessionError } from "./errors.js";
|
|
2
|
+
import { refreshCliToken } from "./device.js";
|
|
3
|
+
/**
|
|
4
|
+
* Money side — the Perfolio backend client (gold, portfolio, cash).
|
|
5
|
+
*
|
|
6
|
+
* Bearer auth with a `pfk_` device token; transparently refreshes once on a 401
|
|
7
|
+
* using the refresh token, then persists the rotated pair via onRefresh. Kept
|
|
8
|
+
* deliberately gold-focused per the product scope; adding borrow/earn/perps
|
|
9
|
+
* endpoints later is a one-line method each.
|
|
10
|
+
*/
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
12
|
+
export class MoneyClient {
|
|
13
|
+
api;
|
|
14
|
+
fiat;
|
|
15
|
+
token;
|
|
16
|
+
refreshToken;
|
|
17
|
+
onRefresh;
|
|
18
|
+
f;
|
|
19
|
+
constructor(opts) {
|
|
20
|
+
this.api = opts.api;
|
|
21
|
+
this.fiat = opts.fiat;
|
|
22
|
+
this.token = opts.token;
|
|
23
|
+
this.refreshToken = opts.refreshToken;
|
|
24
|
+
this.onRefresh = opts.onRefresh;
|
|
25
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
26
|
+
}
|
|
27
|
+
get connected() {
|
|
28
|
+
return Boolean(this.token);
|
|
29
|
+
}
|
|
30
|
+
headers(auth) {
|
|
31
|
+
const h = { "Content-Type": "application/json" };
|
|
32
|
+
if (auth && this.token)
|
|
33
|
+
h.Authorization = `Bearer ${this.token}`;
|
|
34
|
+
return h;
|
|
35
|
+
}
|
|
36
|
+
async fetchWithTimeout(url, init) {
|
|
37
|
+
const attempt = async () => {
|
|
38
|
+
const ctrl = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
|
40
|
+
try {
|
|
41
|
+
return await this.f(url, { ...init, signal: ctrl.signal });
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
try {
|
|
48
|
+
return await attempt();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
52
|
+
return attempt();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async request(base, path, init, auth, retried = false) {
|
|
56
|
+
const res = await this.fetchWithTimeout(`${base}${path}`, {
|
|
57
|
+
...init,
|
|
58
|
+
headers: this.headers(auth),
|
|
59
|
+
});
|
|
60
|
+
// Silent refresh: an expired pfk_ access token → rotate once and retry.
|
|
61
|
+
if (res.status === 401 &&
|
|
62
|
+
auth &&
|
|
63
|
+
!retried &&
|
|
64
|
+
this.refreshToken &&
|
|
65
|
+
this.token?.startsWith("pfk_")) {
|
|
66
|
+
try {
|
|
67
|
+
const t = await refreshCliToken(this.api, this.refreshToken, this.f);
|
|
68
|
+
this.token = t.token;
|
|
69
|
+
this.refreshToken = t.refreshToken;
|
|
70
|
+
this.onRefresh?.(t.token, t.refreshToken, t.expiresAt);
|
|
71
|
+
return this.request(base, path, init, auth, true);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* fall through to the normal error */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let json = {};
|
|
78
|
+
try {
|
|
79
|
+
json = (await res.json());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* non-JSON */
|
|
83
|
+
}
|
|
84
|
+
if (res.status === 428)
|
|
85
|
+
throw new NoSessionError();
|
|
86
|
+
if (!res.ok || json.success === false) {
|
|
87
|
+
throw new AppError(json.error || json.message || `HTTP ${res.status}`, {
|
|
88
|
+
status: res.status,
|
|
89
|
+
code: json.code ?? json.error,
|
|
90
|
+
details: json.details,
|
|
91
|
+
recoverable: res.status >= 500,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return json.data;
|
|
95
|
+
}
|
|
96
|
+
get(base, path, auth = true) {
|
|
97
|
+
return this.request(base, path, { method: "GET" }, auth);
|
|
98
|
+
}
|
|
99
|
+
post(base, path, body, auth = true) {
|
|
100
|
+
return this.request(base, path, { method: "POST", body: JSON.stringify(body) }, auth);
|
|
101
|
+
}
|
|
102
|
+
// ── reads ──
|
|
103
|
+
getUser() {
|
|
104
|
+
return this.get(this.api, "/user");
|
|
105
|
+
}
|
|
106
|
+
/** Portfolio summary; 'full' adds the multi-asset + perps breakdown. */
|
|
107
|
+
getPortfolio(view) {
|
|
108
|
+
const q = view === "full" ? "?view=full" : "";
|
|
109
|
+
return this.get(this.api, `/portfolio/summary${q}`);
|
|
110
|
+
}
|
|
111
|
+
getBalance() {
|
|
112
|
+
return this.get(this.api, "/balances/me");
|
|
113
|
+
}
|
|
114
|
+
getGoldPrice() {
|
|
115
|
+
return this.get(this.api, "/prices/gold", false);
|
|
116
|
+
}
|
|
117
|
+
getCryptoPrices() {
|
|
118
|
+
return this.get(this.api, "/prices/crypto", false);
|
|
119
|
+
}
|
|
120
|
+
/** Local units per 1 USD, for display-currency conversion. */
|
|
121
|
+
getFiatRate(currency) {
|
|
122
|
+
return this.get(this.api, `/prices/fiat/${encodeURIComponent(currency.toUpperCase())}`, false);
|
|
123
|
+
}
|
|
124
|
+
getTxHistory(limit = 20) {
|
|
125
|
+
return this.get(this.api, `/tx/history?limit=${limit}`);
|
|
126
|
+
}
|
|
127
|
+
getTxStatus(txHash) {
|
|
128
|
+
return this.get(this.api, `/tx/status/${txHash}`);
|
|
129
|
+
}
|
|
130
|
+
// ── gold buy / sell (session-key, agent-signed) ──
|
|
131
|
+
/** Swap cash → gold (buy) or gold → cash (sell), via the DeFi session. */
|
|
132
|
+
swap(b) {
|
|
133
|
+
return this.post(this.api, "/tx/swap", b);
|
|
134
|
+
}
|
|
135
|
+
// ── extra pools the /portfolio/summary endpoint doesn't include ──
|
|
136
|
+
// The combined `portfolio` view folds these in so a user sees their WHOLE net
|
|
137
|
+
// worth (prediction balance + open bets + staked/earn) from the terminal.
|
|
138
|
+
/** Prediction deposit-wallet address + deployed status. */
|
|
139
|
+
polymarketDepositWallet() {
|
|
140
|
+
return this.get(this.api, "/polymarket/deposit-wallet");
|
|
141
|
+
}
|
|
142
|
+
/** Open prediction positions + portfolio totals (incl. spendable cashUsd). */
|
|
143
|
+
polymarketPositions() {
|
|
144
|
+
return this.get(this.api, "/polymarket/positions");
|
|
145
|
+
}
|
|
146
|
+
/** The user's earn (vault) position for an asset symbol. */
|
|
147
|
+
getEarnPosition(asset) {
|
|
148
|
+
return this.get(this.api, `/protocol/vault-position?asset=${encodeURIComponent(asset)}`);
|
|
149
|
+
}
|
|
150
|
+
// ── fiat / account (money side) ──
|
|
151
|
+
kycStatus() {
|
|
152
|
+
return this.get(this.fiat, "/kyc/status");
|
|
153
|
+
}
|
|
154
|
+
getAccounts() {
|
|
155
|
+
return this.get(this.fiat, "/accounts");
|
|
156
|
+
}
|
|
157
|
+
}
|