@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,224 @@
|
|
|
1
|
+
import { buildContext, emit, ui, formatPortfolioView, computeNetWorthUsd, formatMoney, formatAgentFunds, resolveDisplayPrefs, displayDescriptor, fetchPusdBalanceUsd, DEFAULT_PREFS, } from "../../lib/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Combined worth in one place: INVESTMENTS (money side — gold, cash, bitcoin,
|
|
4
|
+
* ethereum, perps, borrowed PLUS the prediction balance + open bets and earn/
|
|
5
|
+
* staked positions the backend summary can't see) and SPENDING (agent side —
|
|
6
|
+
* live-service funds + their status). Each side is fetched independently: if one
|
|
7
|
+
* isn't connected or briefly fails, the other still shows.
|
|
8
|
+
*/
|
|
9
|
+
/** The earn vaults to probe for staked value, with their display labels. */
|
|
10
|
+
const EARN_PROBE = [
|
|
11
|
+
{ symbol: "USDT", label: "Cash", priced: "stable" },
|
|
12
|
+
{ symbol: "USDC", label: "USDC", priced: "stable" },
|
|
13
|
+
{ symbol: "WETH", label: "Ethereum", priced: "eth" },
|
|
14
|
+
];
|
|
15
|
+
// Independent deadlines per pool so a slow probe can't stall the whole view.
|
|
16
|
+
// Predictions gets more headroom: in the fallback path it does a positions call
|
|
17
|
+
// plus a deposit-wallet lookup plus an on-chain pUSD read against public RPCs,
|
|
18
|
+
// and trimming it too early was hiding deposited-but-unbet balances entirely.
|
|
19
|
+
const PREDICTION_DEADLINE_MS = 9_000;
|
|
20
|
+
const EARN_DEADLINE_MS = 5_000;
|
|
21
|
+
function settledValue(r) {
|
|
22
|
+
return r.status === "fulfilled" ? r.value : null;
|
|
23
|
+
}
|
|
24
|
+
/** Resolve `p`, but give up with `fallback` after `ms` so a slow probe can't stall the view. */
|
|
25
|
+
function withDeadline(p, ms, fallback) {
|
|
26
|
+
return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(fallback), ms))]);
|
|
27
|
+
}
|
|
28
|
+
/** Build display prefs from the user's profile + FX rate. USD defaults on any failure. */
|
|
29
|
+
async function loadDisplayPrefs(ctx) {
|
|
30
|
+
try {
|
|
31
|
+
const user = (await ctx.money.getUser());
|
|
32
|
+
const currency = typeof user?.currency === "string" && user.currency.trim()
|
|
33
|
+
? user.currency.trim().toUpperCase()
|
|
34
|
+
: "USD";
|
|
35
|
+
let rate = 1;
|
|
36
|
+
if (currency !== "USD") {
|
|
37
|
+
try {
|
|
38
|
+
const fx = await ctx.money.getFiatRate(currency);
|
|
39
|
+
if (fx && typeof fx.price === "number" && fx.price > 0)
|
|
40
|
+
rate = fx.price;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* FX unavailable → render USD */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return resolveDisplayPrefs(user, rate);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return DEFAULT_PREFS;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Live ETH/USD price for valuing a WETH earn position. null when unavailable. */
|
|
53
|
+
async function ethUsdPrice(ctx) {
|
|
54
|
+
try {
|
|
55
|
+
const arr = (await ctx.money.getCryptoPrices());
|
|
56
|
+
const found = Array.isArray(arr) ? arr.find((e) => e?.currency === "ETH") : undefined;
|
|
57
|
+
return typeof found?.price === "number" && found.price > 0 ? found.price : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Prediction available cash + open-bet value. Best-effort; {} on any failure.
|
|
65
|
+
*
|
|
66
|
+
* Available cash comes from the SAME source the predictions screen uses: the
|
|
67
|
+
* backend `/polymarket/positions` `cashUsd` field (an on-chain pUSD read done
|
|
68
|
+
* server-side via a reliable RPC). For older backends that don't return it we
|
|
69
|
+
* fall back to reading the deposit wallet's pUSD balance directly from a public
|
|
70
|
+
* Polygon RPC. Both the positions call and the deposit-wallet lookup run in
|
|
71
|
+
* parallel so the fallback adds no latency in the common case.
|
|
72
|
+
*/
|
|
73
|
+
export async function gatherPredictions(ctx) {
|
|
74
|
+
const [positionsR, walletR] = await Promise.allSettled([
|
|
75
|
+
ctx.money.polymarketPositions(),
|
|
76
|
+
ctx.money.polymarketDepositWallet(),
|
|
77
|
+
]);
|
|
78
|
+
const extras = {};
|
|
79
|
+
const positions = settledValue(positionsR);
|
|
80
|
+
// Open-bet value (only when there are live positions).
|
|
81
|
+
if (positions && positions.valueUsd > 0) {
|
|
82
|
+
extras.predictionPositionsUsd = positions.valueUsd;
|
|
83
|
+
extras.predictionPnlUsd = positions.pnlUsd;
|
|
84
|
+
}
|
|
85
|
+
// Available-to-bet cash: prefer the backend's server-side value (matches the
|
|
86
|
+
// predictions screen), else read the deposit wallet's pUSD balance directly.
|
|
87
|
+
if (positions && typeof positions.cashUsd === "number") {
|
|
88
|
+
extras.predictionAvailableUsd = positions.cashUsd;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const wallet = settledValue(walletR);
|
|
92
|
+
if (wallet?.address) {
|
|
93
|
+
const bal = await fetchPusdBalanceUsd(wallet.address);
|
|
94
|
+
if (typeof bal === "number")
|
|
95
|
+
extras.predictionAvailableUsd = bal;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return extras;
|
|
99
|
+
}
|
|
100
|
+
/** Earn (staked) positions across the vaults, valued in USD. Best-effort; [] on any failure. */
|
|
101
|
+
async function gatherEarn(ctx) {
|
|
102
|
+
const [ethPriceR, ...earnRs] = await Promise.allSettled([
|
|
103
|
+
ethUsdPrice(ctx),
|
|
104
|
+
...EARN_PROBE.map((p) => ctx.money.getEarnPosition(p.symbol)),
|
|
105
|
+
]);
|
|
106
|
+
const ethPrice = settledValue(ethPriceR);
|
|
107
|
+
const earn = [];
|
|
108
|
+
earnRs.forEach((r, i) => {
|
|
109
|
+
const p = settledValue(r);
|
|
110
|
+
if (!p)
|
|
111
|
+
return;
|
|
112
|
+
const probe = EARN_PROBE[i];
|
|
113
|
+
const amount = Number(p.assetsFormatted);
|
|
114
|
+
if (!Number.isFinite(amount) || amount <= 0)
|
|
115
|
+
return;
|
|
116
|
+
const price = probe.priced === "eth" ? (typeof ethPrice === "number" ? ethPrice : null) : 1;
|
|
117
|
+
if (price == null || price <= 0)
|
|
118
|
+
return; // can't value WETH without a price → skip
|
|
119
|
+
const earnedTok = p.earnedFormatted != null ? Number(p.earnedFormatted) : null;
|
|
120
|
+
earn.push({
|
|
121
|
+
label: probe.label,
|
|
122
|
+
valueUsd: amount * price,
|
|
123
|
+
earnedUsd: earnedTok != null && Number.isFinite(earnedTok) ? earnedTok * price : null,
|
|
124
|
+
apy: typeof p.netApy === "number" ? p.netApy : null,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
return earn;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Gather the money pools the backend summary can't see — the on-chain prediction
|
|
131
|
+
* balance + open bets, and earn (staked) positions. Both run concurrently under
|
|
132
|
+
* independent deadlines: every probe is best-effort, a failure or slow backend
|
|
133
|
+
* silently omits that line, and neither can stall the command.
|
|
134
|
+
*/
|
|
135
|
+
async function gatherExtras(ctx) {
|
|
136
|
+
const [pred, earn] = await Promise.all([
|
|
137
|
+
withDeadline(gatherPredictions(ctx), PREDICTION_DEADLINE_MS, {}),
|
|
138
|
+
withDeadline(gatherEarn(ctx), EARN_DEADLINE_MS, []),
|
|
139
|
+
]);
|
|
140
|
+
return earn.length ? { ...pred, earn } : pred;
|
|
141
|
+
}
|
|
142
|
+
async function gather(cmd) {
|
|
143
|
+
const ctx = buildContext(cmd);
|
|
144
|
+
const [prefsR, moneyR, extrasR, agentR] = await Promise.allSettled([
|
|
145
|
+
ctx.money.connected ? loadDisplayPrefs(ctx) : Promise.resolve(DEFAULT_PREFS),
|
|
146
|
+
ctx.money.connected
|
|
147
|
+
? ctx.money.getPortfolio("full")
|
|
148
|
+
: Promise.resolve(null),
|
|
149
|
+
ctx.money.connected ? gatherExtras(ctx) : Promise.resolve({}),
|
|
150
|
+
ctx.agent.connected
|
|
151
|
+
? ctx.agent.balance()
|
|
152
|
+
: Promise.resolve(null),
|
|
153
|
+
]);
|
|
154
|
+
return {
|
|
155
|
+
ctx,
|
|
156
|
+
prefs: prefsR.status === "fulfilled" ? prefsR.value : DEFAULT_PREFS,
|
|
157
|
+
money: moneyR.status === "fulfilled" ? moneyR.value : null,
|
|
158
|
+
extras: extrasR.status === "fulfilled" ? extrasR.value : {},
|
|
159
|
+
moneyFailed: moneyR.status === "rejected",
|
|
160
|
+
agent: agentR.status === "fulfilled" ? agentR.value : null,
|
|
161
|
+
agentFailed: agentR.status === "rejected",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Indent every line of a block by two spaces. */
|
|
165
|
+
function indent(block) {
|
|
166
|
+
return block
|
|
167
|
+
.split("\n")
|
|
168
|
+
.map((l) => (l ? ` ${l}` : l))
|
|
169
|
+
.join("\n");
|
|
170
|
+
}
|
|
171
|
+
export function registerPortfolio(program) {
|
|
172
|
+
const run = async (cmd) => {
|
|
173
|
+
const g = await gather(cmd);
|
|
174
|
+
const { ctx, prefs, money, extras, moneyFailed, agent, agentFailed } = g;
|
|
175
|
+
emit(ctx, {
|
|
176
|
+
investments: money
|
|
177
|
+
? {
|
|
178
|
+
...money,
|
|
179
|
+
// One canonical net worth across ALL pools (holdings + perps + earn +
|
|
180
|
+
// predictions − debt). The backend sub-totals exclude predictions+earn;
|
|
181
|
+
// this is the honest figure programmatic consumers should read.
|
|
182
|
+
netWorthUsd: computeNetWorthUsd(money, extras),
|
|
183
|
+
netWorthDisplay: formatMoney(computeNetWorthUsd(money, extras), prefs),
|
|
184
|
+
extras,
|
|
185
|
+
display: displayDescriptor(prefs),
|
|
186
|
+
}
|
|
187
|
+
: null,
|
|
188
|
+
spending: agent,
|
|
189
|
+
}, () => {
|
|
190
|
+
if (!ctx.money.connected && !ctx.agent.connected)
|
|
191
|
+
return "Not connected yet. Run `grant login`.";
|
|
192
|
+
const lines = [ui.title("Your Grant Cash"), ""];
|
|
193
|
+
// ── Investments (money side) ──
|
|
194
|
+
lines.push(ui.title("Investments"));
|
|
195
|
+
if (!ctx.money.connected) {
|
|
196
|
+
lines.push(ui.dim(" Not connected."));
|
|
197
|
+
}
|
|
198
|
+
else if (money) {
|
|
199
|
+
lines.push(indent(formatPortfolioView(money, prefs, extras)));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
lines.push(ui.amber(` ${moneyFailed ? "Temporarily unavailable." : "—"}`));
|
|
203
|
+
}
|
|
204
|
+
lines.push("");
|
|
205
|
+
// ── Spending (agent side) ──
|
|
206
|
+
lines.push(ui.title("Spending") + ui.dim(" — live pay-per-use services"));
|
|
207
|
+
if (!ctx.agent.connected) {
|
|
208
|
+
lines.push(ui.dim(" Not connected."));
|
|
209
|
+
}
|
|
210
|
+
else if (agent) {
|
|
211
|
+
lines.push(formatAgentFunds(agent));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
lines.push(ui.amber(` ${agentFailed ? "Temporarily unavailable." : "—"}`));
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
program
|
|
220
|
+
.command("portfolio")
|
|
221
|
+
.alias("balance")
|
|
222
|
+
.description("Your combined worth — investments (gold, cash, bitcoin, ethereum, perps, predictions, earn) and agent spending money")
|
|
223
|
+
.action((_opts, cmd) => run(cmd));
|
|
224
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { isJsonMode, printError } from "../lib/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Load `.env` from the PACKAGE ROOT only (never the cwd), so an installed CLI
|
|
8
|
+
* can't pick up a stray `.env` in whatever folder it's run from and silently
|
|
9
|
+
* repoint at the wrong backend. Already-exported shell vars win.
|
|
10
|
+
*/
|
|
11
|
+
function loadDotEnv(path) {
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = readFileSync(path, "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (const line of raw.split("\n")) {
|
|
20
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
21
|
+
if (!m)
|
|
22
|
+
continue;
|
|
23
|
+
const key = m[1];
|
|
24
|
+
if (process.env[key] !== undefined)
|
|
25
|
+
continue;
|
|
26
|
+
let val = m[2].trim();
|
|
27
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
28
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
29
|
+
// Quoted value: keep everything inside the quotes verbatim (a `#` here is data).
|
|
30
|
+
val = val.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// Unquoted value: strip a trailing inline comment (whitespace + `#…`), the
|
|
34
|
+
// dotenv convention. Prevents `KEY=http://x # note` from capturing the note.
|
|
35
|
+
val = val.replace(/\s+#.*$/, "").trim();
|
|
36
|
+
}
|
|
37
|
+
process.env[key] = val;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
loadDotEnv(fileURLToPath(new URL("../../.env", import.meta.url)));
|
|
41
|
+
import { registerAuth } from "./commands/auth.js";
|
|
42
|
+
import { registerPortfolio } from "./commands/portfolio.js";
|
|
43
|
+
import { registerMoney } from "./commands/money.js";
|
|
44
|
+
import { registerAgent } from "./commands/agent.js";
|
|
45
|
+
import { registerMeta } from "./commands/meta.js";
|
|
46
|
+
// ── full investing surface ported from the standalone Perfolio CLI ──
|
|
47
|
+
import { registerTrade } from "./perfolio-commands/trade.js";
|
|
48
|
+
import { registerMarket } from "./perfolio-commands/market.js";
|
|
49
|
+
import { registerDiscover } from "./perfolio-commands/discover.js";
|
|
50
|
+
import { registerBorrow } from "./perfolio-commands/borrow.js";
|
|
51
|
+
import { registerEarn } from "./perfolio-commands/earn.js";
|
|
52
|
+
import { registerHyperliquid } from "./perfolio-commands/hyperliquid.js";
|
|
53
|
+
import { registerPolymarket } from "./perfolio-commands/polymarket.js";
|
|
54
|
+
import { registerAccount } from "./perfolio-commands/account.js";
|
|
55
|
+
import { registerTx } from "./perfolio-commands/tx.js";
|
|
56
|
+
import { registerSession } from "./perfolio-commands/session.js";
|
|
57
|
+
import { registerLoans } from "./perfolio-commands/loans.js";
|
|
58
|
+
let version = "0.0.0";
|
|
59
|
+
try {
|
|
60
|
+
version =
|
|
61
|
+
JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version ??
|
|
62
|
+
version;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* fall back */
|
|
66
|
+
}
|
|
67
|
+
const program = new Command();
|
|
68
|
+
program
|
|
69
|
+
.name("grant")
|
|
70
|
+
.description("Grant Cash — one CLI for your money (gold, cash, bitcoin, ethereum, perps, prediction markets, borrow, earn) and your agent (pay-per-use services).")
|
|
71
|
+
.version(version)
|
|
72
|
+
.option("--json", "machine-readable JSON output")
|
|
73
|
+
.option("--creds-file <path>", "override ~/.grant-cash/credentials.json");
|
|
74
|
+
registerAuth(program);
|
|
75
|
+
registerPortfolio(program);
|
|
76
|
+
registerMoney(program);
|
|
77
|
+
registerAgent(program);
|
|
78
|
+
// ── investing surface (gold, cash, bitcoin, ethereum, perps, predictions, …) ──
|
|
79
|
+
registerTrade(program);
|
|
80
|
+
registerMarket(program);
|
|
81
|
+
registerDiscover(program);
|
|
82
|
+
registerBorrow(program);
|
|
83
|
+
registerEarn(program);
|
|
84
|
+
registerHyperliquid(program);
|
|
85
|
+
registerPolymarket(program);
|
|
86
|
+
registerLoans(program);
|
|
87
|
+
registerAccount(program);
|
|
88
|
+
registerTx(program);
|
|
89
|
+
registerSession(program);
|
|
90
|
+
registerMeta(program);
|
|
91
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
92
|
+
const json = isJsonMode(Boolean(program.opts().json));
|
|
93
|
+
process.exit(printError(json, err));
|
|
94
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { PerfolioClient, AssetResolver, baseUrls, resolveCredsPath, loadCredentials, saveCredentials, openBrowser, waitForSign, resolveDisplayPrefs, DEFAULT_PREFS, resolveCashToUsd, resolveCashTargetToAssetQty, } from "../lib/index.js";
|
|
3
|
+
import { waitForTx } from "../lib/verify.js";
|
|
4
|
+
/** PerfolioClient needs a `widget` base; derive it from the API origin if unset. */
|
|
5
|
+
function widgetUrl(api) {
|
|
6
|
+
return process.env.GRANTCASH_WIDGET_URL || api.replace(/\/api\/?$/, "/widget");
|
|
7
|
+
}
|
|
8
|
+
export function buildCtx(cmd) {
|
|
9
|
+
const opts = cmd.optsWithGlobals();
|
|
10
|
+
const credsPath = resolveCredsPath(opts.credsFile);
|
|
11
|
+
const creds = loadCredentials(credsPath);
|
|
12
|
+
const urls = baseUrls();
|
|
13
|
+
const client = new PerfolioClient({
|
|
14
|
+
urls: { api: urls.api, fiat: urls.fiat, app: urls.app, widget: widgetUrl(urls.api) },
|
|
15
|
+
token: creds.money?.token,
|
|
16
|
+
refreshToken: creds.money?.refreshToken,
|
|
17
|
+
// Persist rotated tokens into the `money` slot so a silent refresh survives
|
|
18
|
+
// across invocations without disturbing the agent credential.
|
|
19
|
+
onRefresh: (token, refreshToken, expiresAt) => saveCredentials({ ...creds, money: { ...(creds.money ?? {}), token, refreshToken, expiresAt } }, credsPath),
|
|
20
|
+
});
|
|
21
|
+
return { client, creds, credsPath, json: Boolean(opts.json) };
|
|
22
|
+
}
|
|
23
|
+
export function requireAuth(ctx) {
|
|
24
|
+
if (!ctx.creds.money?.token) {
|
|
25
|
+
fail("Not connected. Run `grant login` first.", ctx.json);
|
|
26
|
+
}
|
|
27
|
+
return ctx.creds;
|
|
28
|
+
}
|
|
29
|
+
/** Load assets once and build a resolver. */
|
|
30
|
+
export async function resolver(ctx) {
|
|
31
|
+
const assets = await ctx.client.getAssets();
|
|
32
|
+
return new AssetResolver(assets);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a user-entered CASH amount into a USD decimal string for the backend.
|
|
36
|
+
* A bare number is interpreted in the user's display currency; an explicit
|
|
37
|
+
* currency prefix ("$10", "₹50", "100 EUR") always wins. Non-USD amounts are
|
|
38
|
+
* converted via the live FX rate; refuses (throws) if that rate is unavailable.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Force a bare numeric amount to USD by prefixing `$`. An amount the user already
|
|
42
|
+
* qualified with a currency ($/₹/€/ISO code) is left untouched — an explicit
|
|
43
|
+
* currency always wins (same rule money-input enforces). So `--usd` + "50" → "$50",
|
|
44
|
+
* but `--usd` + "₹50" stays ₹50.
|
|
45
|
+
*/
|
|
46
|
+
export function forceUsdAmount(raw) {
|
|
47
|
+
const s = String(raw).trim();
|
|
48
|
+
return /^\d[\d,]*(\.\d+)?$/.test(s) ? `$${s}` : s;
|
|
49
|
+
}
|
|
50
|
+
export async function resolveCash(ctx, raw, decimals, opts) {
|
|
51
|
+
const prefs = await loadDisplayPrefs(ctx);
|
|
52
|
+
const amount = opts?.usd ? forceUsdAmount(raw) : raw;
|
|
53
|
+
return resolveCashToUsd(amount, prefs, decimals, async (ccy) => {
|
|
54
|
+
const fx = await ctx.client.getFiatRate(ccy);
|
|
55
|
+
return fx.price;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Live USD unit price for a spot asset, used to size a cash-target sell:
|
|
60
|
+
* gold → gold spot; btc/eth → /prices/crypto; stable → 1:1. Throws if the
|
|
61
|
+
* price can't be resolved (caller refuses to guess a quantity).
|
|
62
|
+
*/
|
|
63
|
+
export async function assetUsdPrice(ctx, asset) {
|
|
64
|
+
if (asset.class === "stable")
|
|
65
|
+
return 1;
|
|
66
|
+
if (asset.class === "gold") {
|
|
67
|
+
const g = (await ctx.client.getGoldPrice());
|
|
68
|
+
const p = typeof g?.price === "number" ? g.price : NaN;
|
|
69
|
+
if (!(p > 0))
|
|
70
|
+
throw new Error("Could not get the live gold price to size this sale.");
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
if (asset.class === "btc" || asset.class === "eth") {
|
|
74
|
+
const wanted = asset.class === "btc" ? "BTC" : "ETH";
|
|
75
|
+
const arr = (await ctx.client.getCryptoPrices());
|
|
76
|
+
const found = Array.isArray(arr) ? arr.find((e) => e?.currency === wanted) : undefined;
|
|
77
|
+
const p = typeof found?.price === "number" ? found.price : NaN;
|
|
78
|
+
if (!(p > 0))
|
|
79
|
+
throw new Error(`Could not get the live ${asset.friendly} price to size this sale.`);
|
|
80
|
+
return p;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`Don't know how to price "${asset.friendly}" for a cash-target sale.`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a cash-denominated SELL/CONVERT target ("sell ₹100 of gold") into a
|
|
86
|
+
* quantity of the asset to sell. Refuses (throws) if either the FX rate or the
|
|
87
|
+
* asset price is unavailable — never guesses a money quantity.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveCashTargetQty(ctx, asset, cashRaw) {
|
|
90
|
+
const [prefs, unitPrice] = await Promise.all([loadDisplayPrefs(ctx), assetUsdPrice(ctx, asset)]);
|
|
91
|
+
return resolveCashTargetToAssetQty(cashRaw, prefs, asset.decimals, async (ccy) => {
|
|
92
|
+
const fx = await ctx.client.getFiatRate(ccy);
|
|
93
|
+
return fx.price;
|
|
94
|
+
}, unitPrice);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the user's money-display preferences (currency, USD-vs-local, gold
|
|
98
|
+
* unit, experience mode) plus the live FX rate. Any failure falls back to safe
|
|
99
|
+
* USD defaults rather than breaking a read command.
|
|
100
|
+
*/
|
|
101
|
+
export async function loadDisplayPrefs(ctx) {
|
|
102
|
+
try {
|
|
103
|
+
const user = await ctx.client.getUser();
|
|
104
|
+
const currency = typeof user?.currency === "string" && user.currency.trim()
|
|
105
|
+
? user.currency.trim().toUpperCase()
|
|
106
|
+
: "USD";
|
|
107
|
+
let rate = 1;
|
|
108
|
+
if (currency !== "USD") {
|
|
109
|
+
try {
|
|
110
|
+
const fx = await ctx.client.getFiatRate(currency);
|
|
111
|
+
if (fx && typeof fx.price === "number" && fx.price > 0)
|
|
112
|
+
rate = fx.price;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* FX unavailable → render USD */
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return resolveDisplayPrefs(user, rate);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return DEFAULT_PREFS;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function out(ctx, human, data) {
|
|
125
|
+
if (ctx.json)
|
|
126
|
+
console.log(JSON.stringify(data, null, 2));
|
|
127
|
+
else
|
|
128
|
+
console.log(human);
|
|
129
|
+
}
|
|
130
|
+
export function fail(msg, json) {
|
|
131
|
+
if (json)
|
|
132
|
+
console.error(JSON.stringify({ success: false, error: msg }));
|
|
133
|
+
else
|
|
134
|
+
console.error(pc.red(`✖ ${msg}`));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Report the outcome of a write. With `wait` (the default), poll the tx to a
|
|
139
|
+
* terminal state and report success/failure honestly — never "submitted". A
|
|
140
|
+
* failed tx exits non-zero; a timeout is reported as still-pending.
|
|
141
|
+
*/
|
|
142
|
+
export async function reportTx(ctx, res, wait, label) {
|
|
143
|
+
const txHash = res?.txHash;
|
|
144
|
+
const tail = txHash ? ` (tx ${txHash})` : "";
|
|
145
|
+
if (!wait) {
|
|
146
|
+
out(ctx, `Submitted: ${label}${tail}.\nNot yet confirmed — check with \`grant tx status${txHash ? ` ${txHash}` : ""}\`.`, { submitted: true, confirmed: false, status: res?.status ?? "submitted", txHash });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const r = await waitForTx(ctx.client, txHash);
|
|
150
|
+
if (r.status === "success") {
|
|
151
|
+
out(ctx, `✓ ${label} confirmed${tail}.`, { success: true, confirmed: true, status: "success", txHash });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (r.status === "failed") {
|
|
155
|
+
fail(`${label} failed on-chain${tail}.`, ctx.json);
|
|
156
|
+
}
|
|
157
|
+
out(ctx, `${label}: still pending after waiting${tail}. Re-check with \`grant tx status${txHash ? ` ${txHash}` : ""}\`.`, { submitted: true, confirmed: false, status: r.status, txHash });
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Browser-sign hand-off for user-signed operations (withdrawal, gift, HL agent
|
|
161
|
+
* approval). The CLI prepared a self-describing sign task; here we hand it to
|
|
162
|
+
* the relay, open the browser for the user to sign + execute, then (for on-chain
|
|
163
|
+
* ops) confirm the resulting tx. The agent never holds a key — the user's Privy
|
|
164
|
+
* wallet signs.
|
|
165
|
+
*/
|
|
166
|
+
export async function runSignFlow(ctx, opts) {
|
|
167
|
+
const start = await ctx.client.signStart(opts.task);
|
|
168
|
+
openBrowser(start.approveUrl);
|
|
169
|
+
if (ctx.json) {
|
|
170
|
+
out(ctx, "", { status: "awaiting_browser", approveUrl: start.approveUrl });
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
console.log(`\nApprove this ${opts.label} in your browser (sign with your wallet):\n\n ${start.approveUrl}\n\nWaiting for you to approve…`);
|
|
174
|
+
}
|
|
175
|
+
const r = await waitForSign(baseUrls().api, start.sid, start.pollInterval, start.expiresIn * 1000);
|
|
176
|
+
if (r.status !== "complete") {
|
|
177
|
+
fail(r.status === "denied" ? `${opts.label} was declined in the browser.` : `${opts.label} timed out. Try again.`, ctx.json);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (opts.confirmTx === false) {
|
|
181
|
+
out(ctx, `✓ ${opts.label} approved.`, { success: true, ...r.result });
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
const tail = r.result.txHash ? ` (tx ${r.result.txHash})` : "";
|
|
185
|
+
if (!ctx.json)
|
|
186
|
+
console.log(`\nSigned ✓${tail}. Confirming on-chain…`);
|
|
187
|
+
await reportTx(ctx, { txHash: r.result.txHash, status: r.result.status }, true, opts.label);
|
|
188
|
+
return true;
|
|
189
|
+
}
|