@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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money INPUT resolution — the safety-critical counterpart to currency.ts
|
|
3
|
+
* (which handles money OUTPUT/display).
|
|
4
|
+
*
|
|
5
|
+
* The backend operates entirely in USD (the "cash" stablecoin ≈ 1 USD, and all
|
|
6
|
+
* trade/borrow/withdraw amounts are USD-denominated decimal strings). But a user
|
|
7
|
+
* thinks in their own currency: an INR user who says "buy ₹50 of gold" means ₹50,
|
|
8
|
+
* NOT $50. Sending the raw number through would overspend by the FX rate (~83×).
|
|
9
|
+
*
|
|
10
|
+
* This module resolves a user-entered amount into a USD decimal string for the
|
|
11
|
+
* backend, with two rules:
|
|
12
|
+
* 1. A bare number is interpreted in the currency the user is *viewing*
|
|
13
|
+
* (their display currency) — local if they view local, else USD.
|
|
14
|
+
* 2. An explicit currency on the amount ("$10", "100 EUR", "₹50") always wins.
|
|
15
|
+
*
|
|
16
|
+
* Hard safety property: if the amount is in a non-USD currency and we cannot get
|
|
17
|
+
* an authoritative FX rate, we THROW rather than guess — a wrong-currency money
|
|
18
|
+
* transaction is never acceptable.
|
|
19
|
+
*/
|
|
20
|
+
import { assertDecimal } from './amounts.js';
|
|
21
|
+
import { usesLocalCurrency } from './currency.js';
|
|
22
|
+
const SYMBOL_TO_CURRENCY = {
|
|
23
|
+
$: 'USD',
|
|
24
|
+
'₹': 'INR',
|
|
25
|
+
'€': 'EUR',
|
|
26
|
+
'£': 'GBP',
|
|
27
|
+
'¥': 'JPY',
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Parse a money input that may carry an explicit currency:
|
|
31
|
+
* "50" "$50" "₹50" "€10.5" "50 INR" "50inr" "usd 10" "$1,234.56"
|
|
32
|
+
* Returns the numeric amount and the explicit currency (uppercased) if present.
|
|
33
|
+
* Throws on anything that isn't a positive number.
|
|
34
|
+
*/
|
|
35
|
+
export function parseMoneyInput(raw) {
|
|
36
|
+
if (raw == null)
|
|
37
|
+
throw new Error('Amount is required.');
|
|
38
|
+
let s = String(raw).trim();
|
|
39
|
+
if (!s)
|
|
40
|
+
throw new Error('Amount is required.');
|
|
41
|
+
s = s.replace(/,/g, ''); // thousands separators
|
|
42
|
+
let currency;
|
|
43
|
+
// Leading symbol ($, ₹, €, …)
|
|
44
|
+
const lead = s[0];
|
|
45
|
+
if (SYMBOL_TO_CURRENCY[lead]) {
|
|
46
|
+
currency = SYMBOL_TO_CURRENCY[lead];
|
|
47
|
+
s = s.slice(1).trim();
|
|
48
|
+
}
|
|
49
|
+
// Leading ISO code: "USD 50" / "usd50"
|
|
50
|
+
let m = s.match(/^([A-Za-z]{3})\s*([\d.].*)$/);
|
|
51
|
+
if (m) {
|
|
52
|
+
currency = currency ?? m[1].toUpperCase();
|
|
53
|
+
s = m[2].trim();
|
|
54
|
+
}
|
|
55
|
+
// Trailing ISO code: "50 USD" / "50usd"
|
|
56
|
+
m = s.match(/^([\d.]+)\s*([A-Za-z]{3})$/);
|
|
57
|
+
if (m) {
|
|
58
|
+
currency = currency ?? m[2].toUpperCase();
|
|
59
|
+
s = m[1].trim();
|
|
60
|
+
}
|
|
61
|
+
if (!/^\d+(\.\d+)?$/.test(s))
|
|
62
|
+
throw new Error(`Invalid amount: "${raw}"`);
|
|
63
|
+
const amount = Number(s);
|
|
64
|
+
if (!Number.isFinite(amount) || amount <= 0) {
|
|
65
|
+
throw new Error(`Amount must be greater than 0: "${raw}"`);
|
|
66
|
+
}
|
|
67
|
+
return currency ? { amount, currency } : { amount };
|
|
68
|
+
}
|
|
69
|
+
/** Round a number to at most `decimals` places, returning a trimmed decimal string. */
|
|
70
|
+
function roundToDecimalString(n, decimals) {
|
|
71
|
+
const fixed = n.toFixed(Math.max(0, decimals));
|
|
72
|
+
// Trim trailing zeros and a dangling dot: "0.602410" → "0.60241", "50.00" → "50".
|
|
73
|
+
const trimmed = decimals > 0 ? fixed.replace(/\.?0+$/, '') : fixed;
|
|
74
|
+
return trimmed === '' || trimmed === '-0' ? '0' : trimmed;
|
|
75
|
+
}
|
|
76
|
+
/** The currency a bare (unitless) amount is assumed to be in — matches what the user sees. */
|
|
77
|
+
export function defaultInputCurrency(prefs) {
|
|
78
|
+
return usesLocalCurrency(prefs) ? prefs.currency.toUpperCase() : 'USD';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a user-entered cash amount into a USD decimal string for the backend.
|
|
82
|
+
*
|
|
83
|
+
* `fetchRate(currency)` must return local-units-per-USD for that currency. For any
|
|
84
|
+
* non-USD source currency we ALWAYS fetch an authoritative rate (we never trust a
|
|
85
|
+
* possibly-stale `prefs.rate`, which falls back to 1 when FX is unavailable — and a
|
|
86
|
+
* rate of 1 would silently treat ₹50 as $50). If the rate can't be obtained, we throw.
|
|
87
|
+
*/
|
|
88
|
+
export async function resolveCashToUsd(raw, prefs, decimals, fetchRate) {
|
|
89
|
+
const parsed = parseMoneyInput(raw);
|
|
90
|
+
const sourceCurrency = (parsed.currency ?? defaultInputCurrency(prefs)).toUpperCase();
|
|
91
|
+
if (sourceCurrency === 'USD') {
|
|
92
|
+
const usdString = assertDecimal(roundToDecimalString(parsed.amount, decimals), decimals);
|
|
93
|
+
return {
|
|
94
|
+
usd: Number(usdString),
|
|
95
|
+
usdString,
|
|
96
|
+
sourceAmount: parsed.amount,
|
|
97
|
+
sourceCurrency: 'USD',
|
|
98
|
+
rate: 1,
|
|
99
|
+
converted: false,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
let rate;
|
|
103
|
+
try {
|
|
104
|
+
rate = await fetchRate(sourceCurrency);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(`Couldn't get the ${sourceCurrency} exchange rate to convert your amount — ` +
|
|
108
|
+
`refusing to risk a wrong-currency transaction. Try again, or specify the amount in USD (e.g. "$10").`);
|
|
109
|
+
}
|
|
110
|
+
if (!Number.isFinite(rate) || rate <= 0) {
|
|
111
|
+
throw new Error(`Invalid ${sourceCurrency} exchange rate; refusing to guess. Specify the amount in USD (e.g. "$10").`);
|
|
112
|
+
}
|
|
113
|
+
const usd = parsed.amount / rate;
|
|
114
|
+
const usdString = roundToDecimalString(usd, decimals);
|
|
115
|
+
if (Number(usdString) <= 0) {
|
|
116
|
+
throw new Error(`${parsed.amount} ${sourceCurrency} is too small to transact (≈ $0 at the current rate).`);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
usd: Number(usdString),
|
|
120
|
+
usdString: assertDecimal(usdString, decimals),
|
|
121
|
+
sourceAmount: parsed.amount,
|
|
122
|
+
sourceCurrency,
|
|
123
|
+
rate,
|
|
124
|
+
converted: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function fmtCurrency(amount, currency) {
|
|
128
|
+
try {
|
|
129
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return `${amount} ${currency}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Human description of a resolved cash amount — shows the source amount and its
|
|
137
|
+
* USD equivalent when a conversion happened, so the user can see exactly what
|
|
138
|
+
* will be transacted (e.g. "₹50.00 (≈ $0.60)").
|
|
139
|
+
*/
|
|
140
|
+
export function describeCashResolution(m) {
|
|
141
|
+
if (!m.converted)
|
|
142
|
+
return fmtCurrency(m.usd, 'USD');
|
|
143
|
+
return `${fmtCurrency(m.sourceAmount, m.sourceCurrency)} (≈ ${fmtCurrency(m.usd, 'USD')})`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a cash-denominated SELL target into a quantity of the asset to sell.
|
|
147
|
+
*
|
|
148
|
+
* "Sell ₹100 of gold" means: realize ~₹100 of cash by selling gold. Swaps are
|
|
149
|
+
* exact-input (you specify how much to sell), so we size the sale from the live
|
|
150
|
+
* USD unit price: `quantity = targetUSD / unitPriceUsd`. The result is therefore
|
|
151
|
+
* an approximation of the cash proceeds (slippage/fees move the exact figure) —
|
|
152
|
+
* hence "~₹100 worth", which is what the user means.
|
|
153
|
+
*
|
|
154
|
+
* `unitPriceUsd` is the live USD price per asset unit (gold price/oz for XAUT,
|
|
155
|
+
* BTC price for WBTC, ETH price for WETH, 1 for stables). If it isn't available
|
|
156
|
+
* (≤ 0) we throw rather than guess — a mis-sized money sale is never acceptable.
|
|
157
|
+
*/
|
|
158
|
+
export async function resolveCashTargetToAssetQty(raw, prefs, decimals, fetchRate, unitPriceUsd) {
|
|
159
|
+
// Reuse the cash→USD resolution (handles bare-number default currency + explicit
|
|
160
|
+
// prefixes + refuse-when-no-FX). We pass a high precision here (USD target is an
|
|
161
|
+
// intermediate, not the on-wire amount), then size the asset quantity from it.
|
|
162
|
+
const m = await resolveCashToUsd(raw, prefs, 8, fetchRate);
|
|
163
|
+
if (!Number.isFinite(unitPriceUsd) || unitPriceUsd <= 0) {
|
|
164
|
+
throw new Error('No live price available to size this sale — refusing to guess. Try again in a moment.');
|
|
165
|
+
}
|
|
166
|
+
const quantity = roundToDecimalString(m.usd / unitPriceUsd, decimals);
|
|
167
|
+
if (Number(quantity) <= 0) {
|
|
168
|
+
throw new Error(`${fmtCurrency(m.sourceAmount, m.sourceCurrency)} is too small to sell (≈ 0 units at the current price).`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...m,
|
|
172
|
+
quantity: assertDecimal(quantity, decimals),
|
|
173
|
+
quantityNum: Number(quantity),
|
|
174
|
+
unitPriceUsd,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { friendlyError } from "./errors.js";
|
|
3
|
+
export function isJsonMode(flag) {
|
|
4
|
+
return Boolean(flag) || !process.stdout.isTTY;
|
|
5
|
+
}
|
|
6
|
+
export function emit(ctx, data, human) {
|
|
7
|
+
if (ctx.json) {
|
|
8
|
+
process.stdout.write(JSON.stringify({ ok: true, ...data }) + "\n");
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
process.stdout.write(human() + "\n");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Print a failure in the active mode and return the process exit code. */
|
|
15
|
+
export function printError(json, err) {
|
|
16
|
+
const e = (err ?? {});
|
|
17
|
+
const message = friendlyError(e.status, e.code) ??
|
|
18
|
+
e.message ??
|
|
19
|
+
(e.code
|
|
20
|
+
? `Request failed: ${e.code}`
|
|
21
|
+
: `Request failed${e.status ? ` (HTTP ${e.status})` : ""}.`);
|
|
22
|
+
if (json) {
|
|
23
|
+
process.stderr.write(JSON.stringify({
|
|
24
|
+
ok: false,
|
|
25
|
+
error: {
|
|
26
|
+
code: e.code ?? "ERROR",
|
|
27
|
+
message,
|
|
28
|
+
recoverable: e.recoverable ?? false,
|
|
29
|
+
},
|
|
30
|
+
}) + "\n");
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
process.stderr.write(pc.red(`✖ ${message}`) + "\n");
|
|
34
|
+
}
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
/* Small pretty helpers (human mode only). */
|
|
38
|
+
export const ui = {
|
|
39
|
+
title: (s) => pc.bold(s),
|
|
40
|
+
dim: (s) => pc.dim(s),
|
|
41
|
+
green: (s) => pc.green(s),
|
|
42
|
+
amber: (s) => pc.yellow(s),
|
|
43
|
+
red: (s) => pc.red(s),
|
|
44
|
+
label: (s) => pc.dim(s),
|
|
45
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a deposit wallet's prediction-balance (pUSD) on Polygon — client-side,
|
|
3
|
+
* with no backend change and no web3 dependency.
|
|
4
|
+
*
|
|
5
|
+
* The prediction balance lives as pUSD (Polymarket USD: a standard ERC-20 on
|
|
6
|
+
* Polygon, 6 decimals, USDC-backed) in the user's Polymarket deposit wallet.
|
|
7
|
+
* The Perfolio backend only exposes the wallet ADDRESS, not its balance, and we
|
|
8
|
+
* are not allowed to change the backend — so we read the balance directly from a
|
|
9
|
+
* public Polygon RPC with a single `eth_call` to `balanceOf`, encoded/decoded by
|
|
10
|
+
* hand (no viem). The CLI ships only `commander` + `picocolors`; this keeps it
|
|
11
|
+
* that way.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is best-effort: a balance read must NEVER break a command, so
|
|
14
|
+
* `fetchPusdBalanceUsd` returns `null` on any failure rather than throwing.
|
|
15
|
+
*/
|
|
16
|
+
/** pUSD (Polymarket USD) — ERC-20 on Polygon mainnet, 6 decimals. */
|
|
17
|
+
export const PUSD_ADDRESS = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB';
|
|
18
|
+
export const PUSD_DECIMALS = 6;
|
|
19
|
+
/** ERC-20 `balanceOf(address)` selector = first 4 bytes of keccak256("balanceOf(address)"). */
|
|
20
|
+
const BALANCE_OF_SELECTOR = '0x70a08231';
|
|
21
|
+
/**
|
|
22
|
+
* Free, no-key public Polygon RPCs, tried in order until one answers — a single
|
|
23
|
+
* public endpoint can start gating requests without notice (polygon-rpc.com did),
|
|
24
|
+
* so we fall through a small list for resilience. Override with PERFOLIO_POLYGON_RPC
|
|
25
|
+
* (a single endpoint) to pin your own.
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_POLYGON_RPCS = [
|
|
28
|
+
'https://polygon-bor-rpc.publicnode.com',
|
|
29
|
+
'https://polygon.drpc.org',
|
|
30
|
+
'https://1rpc.io/matic',
|
|
31
|
+
];
|
|
32
|
+
/** Per-request timeout — a balance read is a courtesy, never worth a long hang. */
|
|
33
|
+
const RPC_TIMEOUT_MS = 5_000;
|
|
34
|
+
/**
|
|
35
|
+
* ABI-encode `balanceOf(address)` calldata: the 4-byte selector followed by the
|
|
36
|
+
* address left-padded to a 32-byte word. Throws on a malformed address so the
|
|
37
|
+
* caller (which swallows failures) reports "unavailable" rather than querying junk.
|
|
38
|
+
*/
|
|
39
|
+
export function encodeBalanceOf(address) {
|
|
40
|
+
const a = address.trim().toLowerCase();
|
|
41
|
+
if (!/^0x[0-9a-f]{40}$/.test(a))
|
|
42
|
+
throw new Error(`Invalid address: ${address}`);
|
|
43
|
+
return BALANCE_OF_SELECTOR + a.slice(2).padStart(64, '0');
|
|
44
|
+
}
|
|
45
|
+
/** Decode a 32-byte hex word (an eth_call uint256 result) to a bigint. `0x`/empty → 0n. */
|
|
46
|
+
export function decodeUint(hex) {
|
|
47
|
+
if (typeof hex !== 'string')
|
|
48
|
+
return 0n;
|
|
49
|
+
const h = hex.trim();
|
|
50
|
+
if (!/^0x[0-9a-fA-F]*$/.test(h) || h === '0x')
|
|
51
|
+
return 0n;
|
|
52
|
+
return BigInt(h);
|
|
53
|
+
}
|
|
54
|
+
/** Convert a base-unit bigint to a decimal number given the token's decimals. */
|
|
55
|
+
export function baseUnitsToNumber(units, decimals) {
|
|
56
|
+
if (units === 0n)
|
|
57
|
+
return 0;
|
|
58
|
+
const divisor = 10 ** decimals;
|
|
59
|
+
// 6-decimal balances are tiny relative to Number's safe integer range, so a
|
|
60
|
+
// direct Number() conversion is exact enough for display money.
|
|
61
|
+
return Number(units) / divisor;
|
|
62
|
+
}
|
|
63
|
+
/** One `eth_call` to a single RPC endpoint. Returns USD, or `null` on any failure. */
|
|
64
|
+
async function callBalanceOf(rpcUrl, data, f) {
|
|
65
|
+
const ctrl = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => ctrl.abort(), RPC_TIMEOUT_MS);
|
|
67
|
+
try {
|
|
68
|
+
const res = await f(rpcUrl, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
jsonrpc: '2.0',
|
|
73
|
+
id: 1,
|
|
74
|
+
method: 'eth_call',
|
|
75
|
+
params: [{ to: PUSD_ADDRESS, data }, 'latest'],
|
|
76
|
+
}),
|
|
77
|
+
signal: ctrl.signal,
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok)
|
|
80
|
+
return null;
|
|
81
|
+
const json = (await res.json());
|
|
82
|
+
if (json.error || typeof json.result !== 'string')
|
|
83
|
+
return null;
|
|
84
|
+
return baseUnitsToNumber(decodeUint(json.result), PUSD_DECIMALS);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read the pUSD balance (in USD; 1 pUSD ≈ $1) for a Polygon address via an
|
|
95
|
+
* `eth_call` to `balanceOf`. Returns the USD amount, or `null` on ANY failure —
|
|
96
|
+
* a balance read must never throw or break the command that called it.
|
|
97
|
+
*
|
|
98
|
+
* Endpoint selection: an explicit `opts.rpcUrl` or `PERFOLIO_POLYGON_RPC` pins a
|
|
99
|
+
* single endpoint; otherwise we try the public defaults in order and return the
|
|
100
|
+
* first that answers (so one endpoint going dark doesn't kill the feature).
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchPusdBalanceUsd(address, opts = {}) {
|
|
103
|
+
let data;
|
|
104
|
+
try {
|
|
105
|
+
data = encodeBalanceOf(address);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const f = opts.fetchImpl ?? fetch;
|
|
111
|
+
const pinned = opts.rpcUrl || process.env.PERFOLIO_POLYGON_RPC;
|
|
112
|
+
const endpoints = pinned ? [pinned] : DEFAULT_POLYGON_RPCS;
|
|
113
|
+
// Two passes over the endpoint list: an intermittent blip can briefly take out
|
|
114
|
+
// every public RPC at once, and a balance read should survive that rather than
|
|
115
|
+
// reporting "unavailable" for a perfectly funded account.
|
|
116
|
+
const PASSES = 2;
|
|
117
|
+
for (let pass = 0; pass < PASSES; pass++) {
|
|
118
|
+
for (const rpcUrl of endpoints) {
|
|
119
|
+
const usd = await callBalanceOf(rpcUrl, data, f);
|
|
120
|
+
if (usd != null)
|
|
121
|
+
return usd;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friendly multi-asset portfolio rendering for the MONEY (investment) side —
|
|
3
|
+
* ported from the standalone Perfolio CLI so the merged Grant Cash CLI shows the
|
|
4
|
+
* same rich breakdown.
|
|
5
|
+
*
|
|
6
|
+
* Consumes the `GET /api/portfolio/summary?view=full` shape. When the optional
|
|
7
|
+
* `multiAsset` block is present it renders every spot asset (gold, cash, bitcoin,
|
|
8
|
+
* ethereum, …) plus the Hyperliquid perps bucket and its per-category breakdown.
|
|
9
|
+
* When it is absent (gold-only response) it degrades to a gold + cash summary so
|
|
10
|
+
* the command never shows raw JSON.
|
|
11
|
+
*
|
|
12
|
+
* All money is rendered through `formatMoney`, which applies the user's display
|
|
13
|
+
* currency. Callers pass USD values straight through — never pre-convert.
|
|
14
|
+
*/
|
|
15
|
+
import { formatMoney, DEFAULT_PREFS } from "./currency.js";
|
|
16
|
+
/** Pad a label to a fixed width for aligned columns (plain-text terminals). */
|
|
17
|
+
function pad(label, width) {
|
|
18
|
+
return label.length >= width ? label : label + " ".repeat(width - label.length);
|
|
19
|
+
}
|
|
20
|
+
function pct(n) {
|
|
21
|
+
return `${Number(n.toFixed(1))}%`;
|
|
22
|
+
}
|
|
23
|
+
function signed(amountUsd, prefs) {
|
|
24
|
+
const sign = amountUsd >= 0 ? "+" : "-";
|
|
25
|
+
return `${sign}${formatMoney(Math.abs(amountUsd), prefs)}`;
|
|
26
|
+
}
|
|
27
|
+
function earnTotalUsd(extras) {
|
|
28
|
+
return (extras?.earn ?? []).reduce((s, e) => s + (e.valueUsd || 0), 0);
|
|
29
|
+
}
|
|
30
|
+
function predictionTotalUsd(extras) {
|
|
31
|
+
return (extras?.predictionAvailableUsd ?? 0) + (extras?.predictionPositionsUsd ?? 0);
|
|
32
|
+
}
|
|
33
|
+
function hasExtras(extras) {
|
|
34
|
+
return earnTotalUsd(extras) > 0 || predictionTotalUsd(extras) > 0;
|
|
35
|
+
}
|
|
36
|
+
export function formatPortfolioView(summary, prefs = DEFAULT_PREFS, extras) {
|
|
37
|
+
if (hasExtras(extras))
|
|
38
|
+
return formatWithExtras(summary, prefs, extras);
|
|
39
|
+
const ma = summary.multiAsset;
|
|
40
|
+
const lines = [];
|
|
41
|
+
// ── Gold-only fallback (no multiAsset block) ──
|
|
42
|
+
if (!ma) {
|
|
43
|
+
const c = summary.current;
|
|
44
|
+
lines.push(`Net worth: ${formatMoney(c.netWorthUsd, prefs)}`);
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push("Holdings");
|
|
47
|
+
lines.push(` ${pad("Gold", 12)} ${formatMoney(c.goldValueUsd, prefs)}`);
|
|
48
|
+
lines.push(` ${pad("Cash", 12)} ${formatMoney(c.usdtBalance, prefs)}`);
|
|
49
|
+
if (c.totalDebtUsd > 0) {
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push(`Borrowed: ${formatMoney(c.totalDebtUsd, prefs)}`);
|
|
52
|
+
}
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
// ── Full multi-asset view ──
|
|
56
|
+
lines.push(`Net worth: ${formatMoney(ma.netWorthUsd, prefs)}`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("Allocation");
|
|
59
|
+
const labelWidth = Math.max(8, ...ma.holdings.map((h) => h.label.length), ma.perps ? "Perps".length : 0);
|
|
60
|
+
for (const h of ma.holdings) {
|
|
61
|
+
lines.push(` ${pad(h.label, labelWidth)} ${pad(formatMoney(h.valueUsd, prefs), 14)} ${pct(h.allocationPct)}`);
|
|
62
|
+
}
|
|
63
|
+
if (ma.perps) {
|
|
64
|
+
const p = ma.perps;
|
|
65
|
+
lines.push(` ${pad("Perps", labelWidth)} ${pad(formatMoney(p.equityUsd, prefs), 14)} ${pct(p.allocationPct)}`);
|
|
66
|
+
if (p.byCategory.length > 0) {
|
|
67
|
+
lines.push("");
|
|
68
|
+
lines.push("Perps detail");
|
|
69
|
+
for (const cat of p.byCategory) {
|
|
70
|
+
const plural = cat.positions === 1 ? "position" : "positions";
|
|
71
|
+
lines.push(` ${pad(cat.label, labelWidth)} ${pad(formatMoney(cat.valueUsd, prefs), 14)} ` +
|
|
72
|
+
`(PnL ${signed(cat.unrealizedPnlUsd, prefs)}, ${cat.positions} ${plural})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Loan context, if any.
|
|
77
|
+
if (summary.current.totalDebtUsd > 0) {
|
|
78
|
+
lines.push("");
|
|
79
|
+
lines.push(`Borrowed: ${formatMoney(summary.current.totalDebtUsd, prefs)}`);
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The single, honest net worth (USD) including ALL pools: holdings (or gold+cash),
|
|
85
|
+
* perps equity, earning, and predictions — minus debt.
|
|
86
|
+
*
|
|
87
|
+
* The backend exposes `multiAsset.totalUsd` / `multiAsset.netWorthUsd` /
|
|
88
|
+
* `current.netWorthUsd`, but ALL of them EXCLUDE predictions and earn — which
|
|
89
|
+
* caused a real ~$190 under-report. This is the one number a programmatic consumer
|
|
90
|
+
* should trust, and it's emitted as `netWorthUsd` in `portfolio --json`.
|
|
91
|
+
*/
|
|
92
|
+
export function computeNetWorthUsd(summary, extras) {
|
|
93
|
+
const ma = summary.multiAsset;
|
|
94
|
+
let gross = 0;
|
|
95
|
+
if (ma) {
|
|
96
|
+
gross += ma.holdings.reduce((s, h) => s + h.valueUsd, 0);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
gross += summary.current.goldValueUsd + summary.current.usdtBalance;
|
|
100
|
+
}
|
|
101
|
+
if (ma?.perps)
|
|
102
|
+
gross += ma.perps.equityUsd;
|
|
103
|
+
gross += earnTotalUsd(extras);
|
|
104
|
+
gross += predictionTotalUsd(extras);
|
|
105
|
+
return gross - summary.current.totalDebtUsd;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Net-worth view INCLUSIVE of the extra pools (earning + predictions). Used
|
|
109
|
+
* whenever the user has staked funds or a prediction balance; otherwise the
|
|
110
|
+
* plain (backend-only) view above is rendered verbatim.
|
|
111
|
+
*/
|
|
112
|
+
function formatWithExtras(summary, prefs, extras) {
|
|
113
|
+
const ma = summary.multiAsset;
|
|
114
|
+
const lines = [];
|
|
115
|
+
const rows = [];
|
|
116
|
+
if (ma) {
|
|
117
|
+
for (const h of ma.holdings)
|
|
118
|
+
rows.push({ label: h.label, valueUsd: h.valueUsd });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
rows.push({ label: "Gold", valueUsd: summary.current.goldValueUsd });
|
|
122
|
+
rows.push({ label: "Cash", valueUsd: summary.current.usdtBalance });
|
|
123
|
+
}
|
|
124
|
+
const perps = ma?.perps ?? null;
|
|
125
|
+
if (perps)
|
|
126
|
+
rows.push({ label: "Perps", valueUsd: perps.equityUsd });
|
|
127
|
+
const earnTotal = earnTotalUsd(extras);
|
|
128
|
+
if (earnTotal > 0)
|
|
129
|
+
rows.push({ label: "Earning", valueUsd: earnTotal });
|
|
130
|
+
const predTotal = predictionTotalUsd(extras);
|
|
131
|
+
if (predTotal > 0)
|
|
132
|
+
rows.push({ label: "Predictions", valueUsd: predTotal });
|
|
133
|
+
const gross = rows.reduce((s, r) => s + r.valueUsd, 0);
|
|
134
|
+
const debt = summary.current.totalDebtUsd;
|
|
135
|
+
// Use the shared computation so the displayed Net worth and the JSON `netWorthUsd`
|
|
136
|
+
// are always identical.
|
|
137
|
+
const netWorth = computeNetWorthUsd(summary, extras);
|
|
138
|
+
lines.push(`Net worth: ${formatMoney(netWorth, prefs)}`);
|
|
139
|
+
lines.push("");
|
|
140
|
+
lines.push("Allocation");
|
|
141
|
+
const labelWidth = Math.max(8, ...rows.map((r) => r.label.length));
|
|
142
|
+
for (const r of rows) {
|
|
143
|
+
const allocation = gross > 0 ? (r.valueUsd / gross) * 100 : 0;
|
|
144
|
+
lines.push(` ${pad(r.label, labelWidth)} ${pad(formatMoney(r.valueUsd, prefs), 14)} ${pct(allocation)}`);
|
|
145
|
+
}
|
|
146
|
+
if (perps && perps.byCategory.length > 0) {
|
|
147
|
+
lines.push("");
|
|
148
|
+
lines.push("Perps detail");
|
|
149
|
+
for (const cat of perps.byCategory) {
|
|
150
|
+
const plural = cat.positions === 1 ? "position" : "positions";
|
|
151
|
+
lines.push(` ${pad(cat.label, labelWidth)} ${pad(formatMoney(cat.valueUsd, prefs), 14)} ` +
|
|
152
|
+
`(PnL ${signed(cat.unrealizedPnlUsd, prefs)}, ${cat.positions} ${plural})`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const earn = (extras.earn ?? []).filter((e) => (e.valueUsd || 0) > 0);
|
|
156
|
+
if (earn.length > 0) {
|
|
157
|
+
lines.push("");
|
|
158
|
+
lines.push("Earning detail");
|
|
159
|
+
for (const e of earn) {
|
|
160
|
+
const bits = [];
|
|
161
|
+
if (e.earnedUsd != null && e.earnedUsd > 0)
|
|
162
|
+
bits.push(`+${formatMoney(e.earnedUsd, prefs)} earned`);
|
|
163
|
+
if (e.apy != null)
|
|
164
|
+
bits.push(`${(e.apy * 100).toFixed(1)}% APY`);
|
|
165
|
+
const tail = bits.length ? ` (${bits.join(", ")})` : "";
|
|
166
|
+
lines.push(` ${pad(e.label, labelWidth)} ${pad(formatMoney(e.valueUsd, prefs), 14)}${tail}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (predTotal > 0) {
|
|
170
|
+
lines.push("");
|
|
171
|
+
lines.push("Predictions detail");
|
|
172
|
+
const avail = extras.predictionAvailableUsd ?? 0;
|
|
173
|
+
const inBets = extras.predictionPositionsUsd ?? 0;
|
|
174
|
+
lines.push(` ${pad("Available to bet", labelWidth)} ${formatMoney(avail, prefs)}`);
|
|
175
|
+
if (inBets > 0) {
|
|
176
|
+
const pnlTail = extras.predictionPnlUsd != null ? ` (PnL ${signed(extras.predictionPnlUsd, prefs)})` : "";
|
|
177
|
+
lines.push(` ${pad("In open bets", labelWidth)} ${formatMoney(inBets, prefs)}${pnlTail}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (debt > 0) {
|
|
181
|
+
lines.push("");
|
|
182
|
+
lines.push(`Borrowed: ${formatMoney(debt, prefs)}`);
|
|
183
|
+
}
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
function usdcMinorPlain(minor) {
|
|
187
|
+
if (minor === undefined || minor === null)
|
|
188
|
+
return "—";
|
|
189
|
+
const n = Number(minor) / 1e6;
|
|
190
|
+
return `$${n.toFixed(n !== 0 && Math.abs(n) < 0.01 ? 4 : 2)}`;
|
|
191
|
+
}
|
|
192
|
+
/** Render the agent (spending) funds block as indented lines (no section title). */
|
|
193
|
+
export function formatAgentFunds(bal) {
|
|
194
|
+
const status = bal.status;
|
|
195
|
+
if (status === "not_set_up" || status === "no_active_session") {
|
|
196
|
+
return [
|
|
197
|
+
" Not set up to spend yet.",
|
|
198
|
+
" Approve spending limits with `grant login`, or fund the address below.",
|
|
199
|
+
bal.address ? ` Address: ${bal.address}` : "",
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join("\n");
|
|
203
|
+
}
|
|
204
|
+
if (status === "credit_exhausted") {
|
|
205
|
+
return [
|
|
206
|
+
` Sign-up credit used up — balance ${usdcMinorPlain(bal.totalMinor)}`,
|
|
207
|
+
" Add funds to keep spending (`grant fetch`/`grant transfer` need a balance).",
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
// active. A funded on-chain session carries live USDC/USDT; a grant-only user
|
|
211
|
+
// carries the chain-agnostic sign-up credit total in minor units.
|
|
212
|
+
if (bal.USDC !== undefined || bal.USDT !== undefined) {
|
|
213
|
+
const lines = [
|
|
214
|
+
` Spendable: ${usdcMinorPlain(bal.USDC)} USDC + ${usdcMinorPlain(bal.USDT)} USDT`,
|
|
215
|
+
];
|
|
216
|
+
if (bal.address)
|
|
217
|
+
lines.push(` Address: ${bal.address}`);
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
const lines = [` Spendable: ${usdcMinorPlain(bal.totalMinor)} (sign-up credit)`];
|
|
221
|
+
if (bal.address)
|
|
222
|
+
lines.push(` Address: ${bal.address}`);
|
|
223
|
+
return lines.join("\n");
|
|
224
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export function newSid() { return randomUUID(); }
|
|
3
|
+
export async function pollRelay(widgetBase, sid, opts = {}) {
|
|
4
|
+
const f = opts.fetchImpl ?? fetch;
|
|
5
|
+
const interval = opts.intervalMs ?? 3000;
|
|
6
|
+
const timeout = opts.timeoutMs ?? 120000;
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
while (Date.now() - start < timeout) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await f(`${widgetBase}/relay/${sid}`);
|
|
11
|
+
const json = (await res.json());
|
|
12
|
+
if (json.data)
|
|
13
|
+
return json.data;
|
|
14
|
+
}
|
|
15
|
+
catch { /* keep polling */ }
|
|
16
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
package/dist/lib/sign.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-sign poll helper for user-signed operations (withdrawal, gift).
|
|
3
|
+
*
|
|
4
|
+
* The CLI prepares the operation and hands the payload to the sign relay
|
|
5
|
+
* (via PerfolioClient.signStart), opens the browser, then polls here until the
|
|
6
|
+
* user signs + executes in the browser. Poll is public (the sid is the secret).
|
|
7
|
+
*/
|
|
8
|
+
export async function pollSign(api, sid, f = fetch) {
|
|
9
|
+
const res = await f(`${api}/cli/sign/poll`, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12
|
+
body: JSON.stringify({ sid }),
|
|
13
|
+
});
|
|
14
|
+
const j = (await res.json().catch(() => ({})));
|
|
15
|
+
if (!res.ok || !j.success || !j.data)
|
|
16
|
+
return { status: 'expired' };
|
|
17
|
+
return j.data;
|
|
18
|
+
}
|
|
19
|
+
/** Poll until the browser signs (complete), the user cancels (denied), or it expires. */
|
|
20
|
+
export async function waitForSign(api, sid, intervalMs, timeoutMs, f = fetch) {
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
while (Date.now() - start < timeoutMs) {
|
|
23
|
+
const r = await pollSign(api, sid, f);
|
|
24
|
+
if (r.status !== 'pending')
|
|
25
|
+
return r;
|
|
26
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
27
|
+
}
|
|
28
|
+
return { status: 'expired' };
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Terminal backend states. (Source: backend TxStatus = submitted|processing|executing|success|failed.) */
|
|
2
|
+
const TERMINAL = new Set(["success", "failed"]);
|
|
3
|
+
/**
|
|
4
|
+
* After a session-key write (buy/sell), never trust "submitted". Poll the
|
|
5
|
+
* authoritative per-tx status endpoint for THIS exact txHash until it reaches
|
|
6
|
+
* success/failed or we time out. MEE/cross-chain settlement can take a while, so
|
|
7
|
+
* the timeout is generous. Ported from the standalone Perfolio CLI so the merged
|
|
8
|
+
* CLI reports honestly — never a premature "✓ done".
|
|
9
|
+
*/
|
|
10
|
+
export async function waitForTx(money, txHash, opts = {}) {
|
|
11
|
+
if (!txHash)
|
|
12
|
+
return { status: "timeout" };
|
|
13
|
+
const timeout = opts.timeoutMs ?? 180_000; // 3 min — MEE/cross-chain headroom
|
|
14
|
+
const interval = opts.intervalMs ?? 4_000;
|
|
15
|
+
// A just-submitted tx isn't queryable for a beat (the DB row commits slightly
|
|
16
|
+
// after the submit response). Wait once up front so the FIRST poll doesn't 404.
|
|
17
|
+
const initialDelay = opts.initialDelayMs ?? 2_500;
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
let last;
|
|
20
|
+
if (initialDelay > 0)
|
|
21
|
+
await new Promise((r) => setTimeout(r, initialDelay));
|
|
22
|
+
while (Date.now() - start < timeout) {
|
|
23
|
+
try {
|
|
24
|
+
last = await money.getTxStatus(txHash);
|
|
25
|
+
if (last && TERMINAL.has(last.status)) {
|
|
26
|
+
return { status: last.status, txHash, receipt: last.receipt };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// 404 right after submit (row not indexed yet) or a transient blip — keep polling.
|
|
31
|
+
}
|
|
32
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
33
|
+
}
|
|
34
|
+
return { status: last?.status === "failed" ? "failed" : "timeout", txHash, receipt: last?.receipt };
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the merged Grant Cash CLI.
|
|
3
|
+
*
|
|
4
|
+
* Two engines, one identity (one Privy app underneath): the MONEY side
|
|
5
|
+
* (Perfolio backend — gold, portfolio, cash) authenticates with a Bearer
|
|
6
|
+
* device token (`pfk_`), and the AGENT side (Agent-mode backend — pay-per-use
|
|
7
|
+
* services) authenticates with an `X-API-Key` (`grant_live_`). Both credentials
|
|
8
|
+
* live in one file so a single `grant login` connects everything.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|