@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,272 @@
1
+ import { buildCtx, requireAuth, out, fail, runSignFlow, resolveCash } from '../money-helpers.js';
2
+ import { assertDecimal, describeCashResolution, baseUrls, openBrowser } from '../../lib/index.js';
3
+ import { isKycApproved, isKycSubmitted, classifyKyc } from '../../lib/kyc-status.js';
4
+ /** Decide whether deposit details can be shown, or what's blocking. */
5
+ export function decideDepositGate(status, accounts) {
6
+ const statusStr = typeof status === 'string' ? status : 'unknown';
7
+ if (!isKycApproved(status))
8
+ return { ok: false, reason: 'KYC_REQUIRED', status: statusStr };
9
+ if (mapDepositAccounts(accounts).length === 0)
10
+ return { ok: false, reason: 'PROVISIONING', status: statusStr };
11
+ return { ok: true, status: statusStr };
12
+ }
13
+ /**
14
+ * A real fiat deposit target = a non-empty IBAN or account number. Crypto accounts
15
+ * (USDT_ETH/USDC_ETH) come back with a bankDetails object full of EMPTY strings, so a
16
+ * mere "object exists / has keys" check is not enough — test the actual wire target.
17
+ */
18
+ export function hasDepositDetails(a) {
19
+ const b = a.bankDetails;
20
+ if (!b)
21
+ return false;
22
+ return ((b.iban ?? b.accountNumber ?? '').trim().length > 0);
23
+ }
24
+ /** Keep only accounts you can actually wire money to (skips crypto accounts), optionally filtered by currency. */
25
+ export function mapDepositAccounts(accounts, currency) {
26
+ const want = currency?.trim().toLowerCase();
27
+ return accounts
28
+ .filter(hasDepositDetails)
29
+ .filter((a) => !want || a.currency.toLowerCase() === want);
30
+ }
31
+ /** Render a single account's bank-transfer details, tolerating both field-name variants. */
32
+ export function formatBankDetails(a) {
33
+ const b = a.bankDetails ?? {};
34
+ const holder = b.accountHolderName ?? b.beneficiaryName;
35
+ const account = b.iban ?? b.accountNumber;
36
+ const swift = b.swiftCode ?? b.swift;
37
+ const lines = [
38
+ ` ${a.currency} account`,
39
+ holder ? ` Account holder : ${holder}` : '',
40
+ b.bankName ? ` Bank : ${b.bankName}` : '',
41
+ account ? ` IBAN / Account : ${account}` : '',
42
+ swift ? ` SWIFT / BIC : ${swift}` : '',
43
+ b.bankAddress ? ` Bank address : ${b.bankAddress}` : '',
44
+ ].filter(Boolean);
45
+ return lines.join('\n');
46
+ }
47
+ /** Poll a KYC status fetcher until approved or a hard timeout. Never blocks past timeoutMs. */
48
+ export async function gracePollKyc(getStatus, opts) {
49
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
50
+ const now = opts.now ?? Date.now;
51
+ const start = now();
52
+ let last = classifyKyc(undefined);
53
+ for (;;) {
54
+ last = classifyKyc(await getStatus());
55
+ if (last.approved)
56
+ return last;
57
+ if (now() - start >= opts.timeoutMs)
58
+ return last;
59
+ await sleep(opts.intervalMs);
60
+ }
61
+ }
62
+ export function registerAccount(program) {
63
+ const kyc = program.command('kyc').description('KYC verification');
64
+ kyc.command('status').action(async (_o, cmd) => {
65
+ const ctx = buildCtx(cmd);
66
+ requireAuth(ctx);
67
+ const data = await ctx.client.kycStatus();
68
+ if (ctx.json) {
69
+ out(ctx, '', data);
70
+ return;
71
+ }
72
+ const { status, approved } = classifyKyc(data);
73
+ const human = approved
74
+ ? '✓ Identity verified — you can deposit cash and cash out to your bank.'
75
+ : isKycSubmitted(status)
76
+ ? `Identity verification is under review (${status}). Check back shortly.`
77
+ : `Identity not verified yet (${status}). Run \`grant kyc start\` to begin.`;
78
+ out(ctx, human, data);
79
+ });
80
+ kyc.command('start')
81
+ .description('Begin identity verification (opens the KYC screen in your browser)')
82
+ .option('--no-open', 'do not open the browser, just print the URL')
83
+ .action(async (opts, cmd) => {
84
+ const ctx = buildCtx(cmd);
85
+ const creds = requireAuth(ctx);
86
+ const current = classifyKyc(await ctx.client.kycStatus().catch(() => null));
87
+ if (current.approved) {
88
+ out(ctx, '✓ KYC already approved. Run `grant fiat deposit` to see your bank details.', { success: true, status: current.status, approved: true });
89
+ return;
90
+ }
91
+ const url = `${baseUrls().app}/kyc`;
92
+ // Only ever hand http(s) URLs to the OS opener (PERFOLIO_APP_URL is an env knob).
93
+ if (!/^https?:\/\//i.test(url))
94
+ fail(`Refusing to open a non-http(s) URL: ${url}`, ctx.json);
95
+ if (opts.open === false) {
96
+ out(ctx, `Open this to verify your identity:\n\n ${url}\n`, { status: current.status, kycUrl: url, approved: false });
97
+ return;
98
+ }
99
+ openBrowser(url);
100
+ // Identity binding: the browser session is whoever logs in there — tell the
101
+ // user which Perfolio account this CLI is bound to so they verify the right one.
102
+ if (!ctx.json)
103
+ console.log(`\nVerifying KYC for ${creds.money?.email}.\nOpening KYC in your browser:\n\n ${url}\n\nLog in as ${creds.money?.email}, complete the steps, then we'll check status…`);
104
+ const r = await gracePollKyc(() => ctx.client.kycStatus(), { timeoutMs: 75_000, intervalMs: 5_000 });
105
+ if (r.approved) {
106
+ out(ctx, '✓ KYC approved. Run `grant fiat deposit` to see your bank details.', { success: true, status: r.status, approved: true });
107
+ }
108
+ else {
109
+ out(ctx, `Current status: ${r.status}. Verification can take a while — run \`grant kyc status\` later to check.`, { success: true, status: r.status, approved: false, kycUrl: url });
110
+ }
111
+ });
112
+ const fiat = program.command('fiat').description('Fiat (cash-out) info');
113
+ fiat.command('quote').requiredOption('--amount <cash>').requiredOption('--currency <code>')
114
+ .action(async (opts, cmd) => {
115
+ const ctx = buildCtx(cmd);
116
+ requireAuth(ctx);
117
+ // --amount is the cash you want to cash out (USDT ≈ USD on the wire), entered in
118
+ // the user's currency by default; --currency is the destination fiat for the quote.
119
+ const m = await resolveCash(ctx, opts.amount, 6);
120
+ const data = await ctx.client.fiatQuote({ sourceAmount: m.usdString, sourceCurrencyCode: 'USDT_ETH', destinationCurrencyCode: opts.currency });
121
+ out(ctx, JSON.stringify(data, null, 2), data);
122
+ });
123
+ fiat.command('deposit')
124
+ .description('Show your bank-transfer details for depositing cash')
125
+ .option('--currency <code>', 'filter to a single currency (e.g. AED)')
126
+ .action(async (opts, cmd) => {
127
+ const ctx = buildCtx(cmd);
128
+ requireAuth(ctx);
129
+ // Don't swallow fetch failures into a misleading "KYC not complete" message —
130
+ // let network/auth errors bubble to the top-level handler (like `kyc status`).
131
+ const status = classifyKyc(await ctx.client.kycStatus()).status;
132
+ const accounts = await ctx.client.getAccounts();
133
+ const gate = decideDepositGate(status, accounts);
134
+ if (!gate.ok && gate.reason === 'KYC_REQUIRED') {
135
+ if (ctx.json) {
136
+ out(ctx, '', { success: false, error: 'KYC_REQUIRED', data: { status: gate.status, next: 'kyc start' } });
137
+ return;
138
+ }
139
+ fail(`KYC not complete (status: ${gate.status}). Run: grant kyc start`, false);
140
+ }
141
+ if (!gate.ok && gate.reason === 'PROVISIONING') {
142
+ out(ctx, 'Your account is approved — bank details are being provisioned. Check back in a few minutes.', { success: true, kycApproved: true, accounts: [] });
143
+ return;
144
+ }
145
+ const list = mapDepositAccounts(accounts, opts.currency);
146
+ if (list.length === 0) {
147
+ out(ctx, `No bank account found${opts.currency ? ` for ${opts.currency}` : ''}.`, { success: true, kycApproved: true, accounts: [] });
148
+ return;
149
+ }
150
+ const human = [
151
+ 'Wire funds to your Perfolio account using the details below.',
152
+ 'Reference any incoming transfer with your registered name.\n',
153
+ ...list.map(formatBankDetails),
154
+ ].join('\n');
155
+ out(ctx, human, { success: true, kycApproved: true, accounts: list });
156
+ });
157
+ program.command('beneficiaries').description('List payout bank accounts')
158
+ .action(async (_o, cmd) => {
159
+ const ctx = buildCtx(cmd);
160
+ requireAuth(ctx);
161
+ const data = await ctx.client.beneficiaries();
162
+ out(ctx, JSON.stringify(data, null, 2), data);
163
+ });
164
+ const settings = program.command('settings').description('Account preferences');
165
+ settings.command('set')
166
+ .option('--currency <code>', 'local currency code (e.g. USD, INR, AED, EUR)')
167
+ .option('--country <code>')
168
+ .option('--display <usd|local>', 'show amounts in USD or your local currency')
169
+ .option('--gold-unit <oz|g>')
170
+ .option('--mode <simple|advanced>', 'experience mode: simple (plain language) or advanced (crypto-native)')
171
+ .action(async (opts, cmd) => {
172
+ const ctx = buildCtx(cmd);
173
+ requireAuth(ctx);
174
+ // Validate enum-ish flags locally so a typo fails fast with a clear message
175
+ // (the backend also validates, but a 400 round-trip is a worse UX).
176
+ if (opts.display && !['usd', 'local'].includes(String(opts.display).toLowerCase())) {
177
+ fail('Invalid --display. Use "usd" or "local".', ctx.json);
178
+ }
179
+ if (opts.goldUnit && !['oz', 'g'].includes(String(opts.goldUnit).toLowerCase())) {
180
+ fail('Invalid --gold-unit. Use "oz" or "g".', ctx.json);
181
+ }
182
+ if (opts.mode && !['simple', 'advanced'].includes(String(opts.mode).toLowerCase())) {
183
+ fail('Invalid --mode. Use "simple" or "advanced".', ctx.json);
184
+ }
185
+ const body = {};
186
+ if (opts.currency)
187
+ body.currency = String(opts.currency).toUpperCase();
188
+ if (opts.country)
189
+ body.country = String(opts.country).toUpperCase();
190
+ if (opts.display)
191
+ body.displayCurrency = String(opts.display).toLowerCase() === 'local' ? 'local' : 'USD';
192
+ if (opts.goldUnit)
193
+ body.goldDisplayUnit = String(opts.goldUnit).toLowerCase();
194
+ if (opts.mode)
195
+ body.experienceMode = String(opts.mode).toLowerCase();
196
+ if (Object.keys(body).length === 0) {
197
+ fail('Nothing to update. Pass at least one of --currency --country --display --gold-unit --mode.', ctx.json);
198
+ }
199
+ const data = await ctx.client.updateSettings(body);
200
+ out(ctx, 'Settings updated.', data);
201
+ });
202
+ program.command('gifts').description('Gifts sent and received')
203
+ .action(async (_o, cmd) => {
204
+ const ctx = buildCtx(cmd);
205
+ requireAuth(ctx);
206
+ const [sent, received] = await Promise.all([ctx.client.giftsSent(), ctx.client.giftsReceived()]);
207
+ out(ctx, JSON.stringify({ sent, received }, null, 2), { sent, received });
208
+ });
209
+ // ── user-signed: withdraw cash to an external wallet (browser approval) ──
210
+ program.command('withdraw').description('Withdraw cash to an external wallet address (opens the browser to approve)')
211
+ .requiredOption('--to <address>', 'destination wallet address (0x…)')
212
+ .requiredOption('--amount <cash>', 'amount of cash to withdraw')
213
+ .option('--token <symbol>', 'token to withdraw', 'USDT')
214
+ .action(async (opts, cmd) => {
215
+ const ctx = buildCtx(cmd);
216
+ requireAuth(ctx);
217
+ if (!/^0x[a-fA-F0-9]{40}$/.test(opts.to))
218
+ fail('Invalid destination address. Expected a 0x… wallet address.', ctx.json);
219
+ const assets = await ctx.client.getAssets();
220
+ const sym = String(opts.token).toUpperCase();
221
+ const tok = assets.find((a) => a.symbol === sym);
222
+ if (!tok)
223
+ fail(`Unknown token "${opts.token}".`, ctx.json);
224
+ // Cash withdrawals (USDT/USDC) are fiat — interpret in the user's currency and
225
+ // convert to USD. Withdrawing a non-stable token treats the amount as a quantity.
226
+ let amount;
227
+ let label;
228
+ if (tok.class === 'stable') {
229
+ const m = await resolveCash(ctx, opts.amount, tok.decimals);
230
+ amount = m.usdString;
231
+ label = `Withdraw ${describeCashResolution(m)} (${sym}) to ${opts.to}`;
232
+ }
233
+ else {
234
+ amount = assertDecimal(opts.amount, tok.decimals);
235
+ label = `Withdraw ${amount} ${sym} to ${opts.to}`;
236
+ }
237
+ const prepared = await ctx.client.prepareWithdrawal({ token: tok.address, recipient: opts.to, amount });
238
+ await runSignFlow(ctx, {
239
+ label,
240
+ task: {
241
+ type: 'withdrawal', signMode: 'personal', signatureFormat: 'hex',
242
+ payload: prepared.payloadToSign, executePath: '/tx/execute',
243
+ executeBody: { quoteId: prepared.quoteId }, preview: prepared.preview,
244
+ },
245
+ });
246
+ });
247
+ // ── user-signed: send a gold gift (browser approval) ──
248
+ const gift = program.command('gift').description('Send a gold gift');
249
+ gift.command('send').description('Send gold to someone by email or phone (opens the browser to approve)')
250
+ .requiredOption('--to <email|phone>', 'recipient email or phone number')
251
+ .requiredOption('--amount <gold>', 'amount of gold to send')
252
+ .option('--message <text>', 'optional message')
253
+ .action(async (opts, cmd) => {
254
+ const ctx = buildCtx(cmd);
255
+ requireAuth(ctx);
256
+ const isEmail = opts.to.includes('@');
257
+ const amountXaut = assertDecimal(opts.amount, 6); // XAUT has 6 decimals
258
+ const prepared = await ctx.client.prepareGiftSend({
259
+ ...(isEmail ? { recipientEmail: opts.to } : { recipientPhone: opts.to }),
260
+ amountXaut,
261
+ message: opts.message,
262
+ });
263
+ await runSignFlow(ctx, {
264
+ label: `Gift ${opts.amount} gold to ${opts.to}`,
265
+ task: {
266
+ type: 'gift', signMode: 'personal', signatureFormat: 'hex',
267
+ payload: prepared.payloadToSign, executePath: '/gift/execute-send',
268
+ executeBody: { quoteId: prepared.quoteId }, preview: prepared.preview,
269
+ },
270
+ });
271
+ });
272
+ }
@@ -0,0 +1,75 @@
1
+ import { buildCtx, requireAuth, resolver, reportTx, resolveCash } from '../money-helpers.js';
2
+ import { assertDecimal, describeCashResolution } from '../../lib/index.js';
3
+ const CASH = 'USDT';
4
+ const CASH_DECIMALS = 6;
5
+ export function registerBorrow(program) {
6
+ // Run a write then report its on-chain outcome (wait-by-default via reportTx).
7
+ const exec = async (ctx, fn, wait, label) => {
8
+ const res = (await fn());
9
+ await reportTx(ctx, res, wait, label);
10
+ };
11
+ program.command('borrow').description('Borrow cash against an asset')
12
+ .requiredOption('--against <asset>').requiredOption('--amount <cash>')
13
+ .option('--no-wait', 'return immediately without waiting for confirmation')
14
+ .action(async (opts, cmd) => {
15
+ const ctx = buildCtx(cmd);
16
+ requireAuth(ctx);
17
+ const r = await resolver(ctx);
18
+ const collateral = r.resolve(opts.against).symbol;
19
+ const m = await resolveCash(ctx, opts.amount, CASH_DECIMALS);
20
+ await exec(ctx, () => ctx.client.borrow({ collateral, loan: CASH, amount: m.usdString }), opts.wait !== false, `Borrow ${describeCashResolution(m)} against ${opts.against}`);
21
+ });
22
+ program.command('repay').description('Repay borrowed cash')
23
+ .requiredOption('--against <asset>').requiredOption('--amount <cash>')
24
+ .option('--no-wait', 'return immediately without waiting for confirmation')
25
+ .action(async (opts, cmd) => {
26
+ const ctx = buildCtx(cmd);
27
+ requireAuth(ctx);
28
+ const r = await resolver(ctx);
29
+ const collateral = r.resolve(opts.against).symbol;
30
+ const m = await resolveCash(ctx, opts.amount, CASH_DECIMALS);
31
+ await exec(ctx, () => ctx.client.repay({ collateral, loan: CASH, amount: m.usdString }), opts.wait !== false, `Repay ${describeCashResolution(m)} against ${opts.against}`);
32
+ });
33
+ program.command('add-collateral').description('Add collateral')
34
+ .requiredOption('--asset <asset>').requiredOption('--amount <n>')
35
+ .option('--no-wait', 'return immediately without waiting for confirmation')
36
+ .action(async (opts, cmd) => {
37
+ const ctx = buildCtx(cmd);
38
+ requireAuth(ctx);
39
+ const r = await resolver(ctx);
40
+ const a = r.resolve(opts.asset);
41
+ await exec(ctx, () => ctx.client.supply({ collateral: a.symbol, amount: assertDecimal(opts.amount, a.decimals) }), opts.wait !== false, `Add ${opts.asset} collateral`);
42
+ });
43
+ program.command('remove-collateral').description('Remove collateral')
44
+ .requiredOption('--asset <asset>').requiredOption('--amount <n>')
45
+ .option('--no-wait', 'return immediately without waiting for confirmation')
46
+ .action(async (opts, cmd) => {
47
+ const ctx = buildCtx(cmd);
48
+ requireAuth(ctx);
49
+ const r = await resolver(ctx);
50
+ const a = r.resolve(opts.asset);
51
+ await exec(ctx, () => ctx.client.withdrawCollateral({ collateral: a.symbol, amount: assertDecimal(opts.amount, a.decimals) }), opts.wait !== false, `Remove ${opts.asset} collateral`);
52
+ });
53
+ program.command('leverage').description('Add collateral and borrow cash in one step')
54
+ .requiredOption('--asset <asset>').requiredOption('--deposit <n>').requiredOption('--borrow <cash>')
55
+ .option('--no-wait', 'return immediately without waiting for confirmation')
56
+ .action(async (opts, cmd) => {
57
+ const ctx = buildCtx(cmd);
58
+ requireAuth(ctx);
59
+ const r = await resolver(ctx);
60
+ const a = r.resolve(opts.asset);
61
+ // --deposit is a quantity of the collateral asset; --borrow is cash (currency-aware).
62
+ const borrow = await resolveCash(ctx, opts.borrow, CASH_DECIMALS);
63
+ await exec(ctx, () => ctx.client.supplyAndBorrow({ collateral: a.symbol, loan: CASH, collateralAmount: assertDecimal(opts.deposit, a.decimals), borrowAmount: borrow.usdString }), opts.wait !== false, `Leverage ${opts.asset} (borrow ${describeCashResolution(borrow)})`);
64
+ });
65
+ program.command('close').description('Close a loan (repay all + withdraw collateral)')
66
+ .requiredOption('--against <asset>')
67
+ .option('--no-wait', 'return immediately without waiting for confirmation')
68
+ .action(async (opts, cmd) => {
69
+ const ctx = buildCtx(cmd);
70
+ requireAuth(ctx);
71
+ const r = await resolver(ctx);
72
+ const collateral = r.resolve(opts.against).symbol;
73
+ await exec(ctx, () => ctx.client.closePosition({ collateral, loan: CASH }), opts.wait !== false, `Close ${opts.against} loan`);
74
+ });
75
+ }
@@ -0,0 +1,30 @@
1
+ import { buildCtx, resolver, out } from '../money-helpers.js';
2
+ import { FRIENDLY_TERMS } from '../../lib/index.js';
3
+ export function registerDiscover(program) {
4
+ program.command('assets').description('List assets you can hold (gold, cash, bitcoin, ethereum)')
5
+ .action(async (_o, cmd) => {
6
+ const ctx = buildCtx(cmd);
7
+ const r = await resolver(ctx);
8
+ const resolved = [];
9
+ const lines = [];
10
+ for (const t of FRIENDLY_TERMS) {
11
+ try {
12
+ const a = r.resolve(t);
13
+ resolved.push(a);
14
+ lines.push(`• ${t} (${a.symbol})`);
15
+ }
16
+ catch { /* not in registry yet */ }
17
+ }
18
+ out(ctx, lines.join('\n') || 'No assets available.', resolved);
19
+ });
20
+ program.command('markets').description('Where you can borrow cash against your assets')
21
+ .action(async (_o, cmd) => {
22
+ const ctx = buildCtx(cmd);
23
+ const [markets, r] = await Promise.all([ctx.client.getMarkets(), resolver(ctx)]);
24
+ // v1 surfaces only canonical assets — hide variant collaterals (cbBTC, wstETH)
25
+ // whose raw symbols have no friendly word yet.
26
+ const shown = markets.filter((m) => r.isFriendly(m.collateral));
27
+ const human = shown.map((m) => `• Borrow cash against ${r.label(m.collateral)} — up to ${m.safeMaxLtvPercent}% LTV (max ${m.lltvPercent}%)`).join('\n');
28
+ out(ctx, human || 'No markets available.', shown);
29
+ });
30
+ }
@@ -0,0 +1,193 @@
1
+ import { buildCtx, requireAuth, reportTx, out, fail, resolveCash } from '../money-helpers.js';
2
+ import { assertDecimal, describeCashResolution, } from '../../lib/index.js';
3
+ /**
4
+ * Whether an earn asset is a stablecoin (cash). Stable amounts are MONEY — entered
5
+ * in the user's currency and FX-resolved to USD (so "100" = ₹100 for an INR user,
6
+ * consistent with buy/borrow). Non-stable assets (WETH) keep asset-QUANTITY input
7
+ * (0.5 WETH = 0.5 WETH), like selling an asset by quantity. Exported for testing.
8
+ */
9
+ export function isStableEarnAsset(symbol) {
10
+ return symbol.toUpperCase() === 'USDT' || symbol.toUpperCase() === 'USDC';
11
+ }
12
+ /**
13
+ * Earn — deposit cash/crypto into Morpho Vault V2 to earn yield, and withdraw.
14
+ *
15
+ * Runs on the same DeFi session grant as borrow/swap (rev-19 folded the vault
16
+ * actions in), so it needs no separate session install — if the grant is stale
17
+ * or missing the user is told to reconnect, exactly like borrow.
18
+ *
19
+ * Asset accepts friendly words (cash → USDT, ethereum → WETH) AND raw symbols
20
+ * (USDT / USDC / WETH) — the friendly resolver only knows the canonical-per-class
21
+ * symbol, so earn (which has two stablecoin vaults) resolves symbols directly.
22
+ */
23
+ const FRIENDLY_TO_SYMBOL = {
24
+ cash: 'USDT',
25
+ usdt: 'USDT',
26
+ usdc: 'USDC',
27
+ ethereum: 'WETH',
28
+ eth: 'WETH',
29
+ weth: 'WETH',
30
+ };
31
+ async function resolveEarnAsset(ctx, input) {
32
+ const assets = (await ctx.client.getAssets());
33
+ const wanted = (FRIENDLY_TO_SYMBOL[input.trim().toLowerCase()] ?? input.trim()).toUpperCase();
34
+ const match = assets.find((a) => a.symbol.toUpperCase() === wanted);
35
+ if (!match) {
36
+ fail(`I don't recognize "${input}" for earn. Try: cash (USDT), USDC, or ethereum (WETH).`, ctx.json);
37
+ }
38
+ return { symbol: match.symbol, decimals: match.decimals };
39
+ }
40
+ function pct(n) {
41
+ return n == null ? '—' : `${(n * 100).toFixed(2)}%`;
42
+ }
43
+ function usd(n) {
44
+ return n == null ? '—' : `$${n.toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
45
+ }
46
+ /** Already-percent number (e.g. earnedPct 1.2) → "1.20%". */
47
+ function pctRaw(n) {
48
+ return n == null ? '—' : `${n.toFixed(2)}%`;
49
+ }
50
+ export function registerEarn(program) {
51
+ const earn = program.command('earn').description('Earn yield on cash & crypto via Morpho vaults');
52
+ earn
53
+ .command('list')
54
+ .description('List earn vaults with current APY and size')
55
+ .option('--asset <asset>', 'filter to one asset (cash, USDC, ethereum)')
56
+ .action(async (opts, cmd) => {
57
+ const ctx = buildCtx(cmd);
58
+ const assetArg = opts.asset
59
+ ? (await resolveEarnAsset(ctx, opts.asset)).symbol
60
+ : undefined;
61
+ const { vaults } = await ctx.client.getVaults(assetArg);
62
+ if (ctx.json) {
63
+ out(ctx, '', { vaults });
64
+ return;
65
+ }
66
+ if (!vaults.length) {
67
+ out(ctx, 'No earn vaults available.', { vaults });
68
+ return;
69
+ }
70
+ const lines = vaults.map((v) => {
71
+ const feeStr = v.performanceFee != null ? `${(v.performanceFee * 100).toFixed(0)}%` : '—';
72
+ return (`• ${v.asset ?? v.symbol} — ${v.name} · curated by ${v.curator}\n` +
73
+ ` Net APY: ${pct(v.netApy)}${v.apy != null && v.apy !== v.netApy ? ` (gross ${pct(v.apy)})` : ''}\n` +
74
+ ` Vault size: ${usd(v.tvlUsd)} · Available now: ${usd(v.liquidityUsd)}\n` +
75
+ ` Perf. fee: ${feeStr} · Share price: ${v.sharePrice != null ? v.sharePrice.toFixed(4) : '—'}`);
76
+ });
77
+ out(ctx, `Earn vaults — deposit to earn yield (APY is variable):\n\n${lines.join('\n\n')}\n\n` +
78
+ `Deposit with: grant earn deposit --asset <cash|usdc|ethereum> --amount <n>`, { vaults });
79
+ });
80
+ earn
81
+ .command('position')
82
+ .description('Your earn position for an asset — current value, principal, and earnings')
83
+ .requiredOption('--asset <asset>', 'cash, USDC, or ethereum')
84
+ .action(async (opts, cmd) => {
85
+ const ctx = buildCtx(cmd);
86
+ requireAuth(ctx);
87
+ const a = await resolveEarnAsset(ctx, opts.asset);
88
+ const p = (await ctx.client.getEarnPosition(a.symbol));
89
+ // JSON: emit the full payload (exact strings — assetsFormatted, earnedFormatted, raw wei).
90
+ if (ctx.json) {
91
+ out(ctx, '', p);
92
+ return;
93
+ }
94
+ // Human: a fuller card (mirrors the borrow position detail) — value +
95
+ // earnings to full precision (formatUnits is exact; a 0.000001 gain shows,
96
+ // never rounded), plus live vault context (APY, size, liquidity, fee).
97
+ const lines = [
98
+ `${a.symbol} earn position`,
99
+ ` Vault: ${p.vaultName} · curated by ${p.curator}`,
100
+ ` Value: ${p.assetsFormatted} ${a.symbol}`,
101
+ ];
102
+ if (p.earnedFormatted != null && p.principalFormatted != null) {
103
+ lines.push(` Deposited: ${p.principalFormatted} ${a.symbol}`);
104
+ lines.push(` Earned: +${p.earnedFormatted} ${a.symbol}${p.earnedPct != null ? ` (+${pctRaw(p.earnedPct)})` : ''}`);
105
+ }
106
+ else {
107
+ lines.push(' Earned: — (no Perfolio deposit on record to compare against)');
108
+ }
109
+ lines.push(` Net APY: ${pct(p.netApy)}${p.apy != null && p.apy !== p.netApy ? ` (gross ${pct(p.apy)})` : ''}`);
110
+ lines.push(` Shares: ${p.shares} (${p.shareDecimals} dec)`);
111
+ if (p.sharePrice != null)
112
+ lines.push(` Share price: ${p.sharePrice.toFixed(6)} ${a.symbol}/share`);
113
+ lines.push(` Vault size: ${usd(p.tvlUsd)} · Available now: ${usd(p.liquidityUsd)}` +
114
+ (p.performanceFee != null ? ` · Perf. fee: ${(p.performanceFee * 100).toFixed(0)}%` : ''));
115
+ out(ctx, lines.join('\n'), p);
116
+ });
117
+ // `deposit` IS the "create / add to a position" command — a single call.
118
+ // (`earn position` is just a read to view it later, not a required second step.)
119
+ earn
120
+ .command('deposit')
121
+ .alias('open')
122
+ .description('Create or add to an earn position (deposit an asset into its vault)')
123
+ .requiredOption('--asset <asset>', 'cash, USDC, or ethereum')
124
+ .requiredOption('--amount <n>', 'cash to stake (your currency) for cash/USDC; a WETH quantity for ethereum')
125
+ .option('--no-wait', 'return immediately without waiting for confirmation')
126
+ .action(async (opts, cmd) => {
127
+ const ctx = buildCtx(cmd);
128
+ requireAuth(ctx);
129
+ const a = await resolveEarnAsset(ctx, opts.asset);
130
+ // Stable (cash/USDC) deposits are MONEY → interpret in the user's currency and
131
+ // convert to USD. Ethereum (WETH) is an asset QUANTITY → pass through.
132
+ let amount;
133
+ let label;
134
+ if (isStableEarnAsset(a.symbol)) {
135
+ const m = await resolveCash(ctx, opts.amount, a.decimals);
136
+ amount = m.usdString;
137
+ label = `Deposit ${describeCashResolution(m)} into earn`;
138
+ }
139
+ else {
140
+ amount = assertDecimal(opts.amount, a.decimals);
141
+ label = `Deposit ${amount} ${a.symbol} into earn`;
142
+ }
143
+ const res = await ctx.client.earnDeposit({ asset: a.symbol, amount });
144
+ await reportTx(ctx, res, opts.wait !== false, label);
145
+ // One command = create + show the resulting position. Only when we waited
146
+ // for confirmation (human mode); --json callers read the tx result + can
147
+ // call `earn position` themselves. Best-effort — never fail the deposit.
148
+ if (opts.wait !== false && !ctx.json) {
149
+ try {
150
+ const p = (await ctx.client.getEarnPosition(a.symbol));
151
+ const earnedTail = p.earnedFormatted != null ? `, earned so far +${p.earnedFormatted} ${a.symbol}` : '';
152
+ console.log(`Your ${a.symbol} earn position is now ${p.assetsFormatted} ${a.symbol} in ${p.vaultName} ` +
153
+ `(APY ${pct(p.netApy)}${earnedTail}).`);
154
+ }
155
+ catch {
156
+ /* position read is a courtesy — the deposit already reported its outcome */
157
+ }
158
+ }
159
+ });
160
+ earn
161
+ .command('withdraw')
162
+ .description('Withdraw from an earn vault')
163
+ .requiredOption('--asset <asset>', 'cash, USDC, or ethereum')
164
+ .option('--amount <n>', 'cash to withdraw (your currency) for cash/USDC; a WETH quantity for ethereum (omit + use --all for a full exit)')
165
+ .option('--all', 'withdraw the entire position')
166
+ .option('--no-wait', 'return immediately without waiting for confirmation')
167
+ .action(async (opts, cmd) => {
168
+ const ctx = buildCtx(cmd);
169
+ requireAuth(ctx);
170
+ const a = await resolveEarnAsset(ctx, opts.asset);
171
+ if (!opts.all && !opts.amount) {
172
+ fail('Provide --amount <n> or --all.', ctx.json);
173
+ }
174
+ let body;
175
+ let label;
176
+ if (opts.all) {
177
+ body = { asset: a.symbol, all: true };
178
+ label = `Withdraw all ${a.symbol} from earn`;
179
+ }
180
+ else if (isStableEarnAsset(a.symbol)) {
181
+ const m = await resolveCash(ctx, opts.amount, a.decimals);
182
+ body = { asset: a.symbol, amount: m.usdString };
183
+ label = `Withdraw ${describeCashResolution(m)} from earn`;
184
+ }
185
+ else {
186
+ const amount = assertDecimal(opts.amount, a.decimals);
187
+ body = { asset: a.symbol, amount };
188
+ label = `Withdraw ${amount} ${a.symbol} from earn`;
189
+ }
190
+ const res = await ctx.client.earnWithdraw(body);
191
+ await reportTx(ctx, res, opts.wait !== false, label);
192
+ });
193
+ }