@xpr-agents/openclaw 0.3.1 → 0.4.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 (57) hide show
  1. package/README.md +51 -10
  2. package/openclaw.plugin.json +15 -1
  3. package/package.json +7 -4
  4. package/skills/code-sandbox/SKILL.md +30 -0
  5. package/skills/code-sandbox/dist/index.js +188 -0
  6. package/skills/code-sandbox/skill.json +13 -0
  7. package/skills/code-sandbox/src/index.ts +212 -0
  8. package/skills/creative/SKILL.md +32 -0
  9. package/skills/creative/dist/index.js +667 -0
  10. package/skills/creative/skill.json +13 -0
  11. package/skills/creative/src/index.ts +679 -0
  12. package/skills/defi/SKILL.md +123 -0
  13. package/skills/defi/dist/index.js +1745 -0
  14. package/skills/defi/skill.json +44 -0
  15. package/skills/defi/src/index.ts +1788 -0
  16. package/skills/defi/test-read.mjs +281 -0
  17. package/skills/governance/SKILL.md +69 -0
  18. package/skills/governance/dist/index.js +632 -0
  19. package/skills/governance/skill.json +21 -0
  20. package/skills/governance/src/index.ts +656 -0
  21. package/skills/governance/test-read.mjs +176 -0
  22. package/skills/lending/SKILL.md +63 -0
  23. package/skills/lending/dist/index.js +1039 -0
  24. package/skills/lending/skill.json +29 -0
  25. package/skills/lending/src/index.ts +1105 -0
  26. package/skills/lending/test-read.mjs +156 -0
  27. package/skills/nft/SKILL.md +95 -0
  28. package/skills/nft/dist/index.js +1520 -0
  29. package/skills/nft/skill.json +37 -0
  30. package/skills/nft/src/index.ts +1539 -0
  31. package/skills/shellbook/SKILL.md +59 -0
  32. package/skills/shellbook/dist/index.js +381 -0
  33. package/skills/shellbook/skill.json +29 -0
  34. package/skills/shellbook/src/index.ts +391 -0
  35. package/skills/shellbook/tsconfig.json +14 -0
  36. package/skills/smart-contracts/SKILL.md +128 -0
  37. package/skills/smart-contracts/dist/index.js +1225 -0
  38. package/skills/smart-contracts/skill.json +25 -0
  39. package/skills/smart-contracts/src/index.ts +1327 -0
  40. package/skills/smart-contracts/tsconfig.json +14 -0
  41. package/skills/structured-data/SKILL.md +36 -0
  42. package/skills/structured-data/dist/index.js +501 -0
  43. package/skills/structured-data/skill.json +13 -0
  44. package/skills/structured-data/src/index.ts +597 -0
  45. package/skills/tax/SKILL.md +109 -0
  46. package/skills/tax/dist/index.js +1749 -0
  47. package/skills/tax/skill.json +20 -0
  48. package/skills/tax/src/index.ts +1985 -0
  49. package/skills/web-scraping/SKILL.md +29 -0
  50. package/skills/web-scraping/dist/index.js +311 -0
  51. package/skills/web-scraping/skill.json +13 -0
  52. package/skills/web-scraping/src/index.ts +371 -0
  53. package/skills/xmd/SKILL.md +52 -0
  54. package/skills/xmd/dist/index.js +596 -0
  55. package/skills/xmd/skill.json +22 -0
  56. package/skills/xmd/src/index.ts +635 -0
  57. package/skills/xmd/test-read.mjs +178 -0
@@ -0,0 +1,1985 @@
1
+ /**
2
+ * Tax Skill — Crypto tax reporting for XPR Network
3
+ *
4
+ * All tools are read-only (query APIs + calculate).
5
+ * Region system: pass `region` param (default "NZ") to any tool.
6
+ * Adding a new region = adding an entry to REGIONS.
7
+ */
8
+
9
+ // ── Types ────────────────────────────────────────
10
+
11
+ interface ToolDef {
12
+ name: string;
13
+ description: string;
14
+ parameters: { type: 'object'; required?: string[]; properties: Record<string, unknown> };
15
+ handler: (params: any) => Promise<unknown>;
16
+ }
17
+
18
+ interface SkillApi {
19
+ registerTool(tool: ToolDef): void;
20
+ getConfig(): Record<string, unknown>;
21
+ }
22
+
23
+ // ── Region Configuration ─────────────────────────
24
+
25
+ interface RegionConfig {
26
+ name: string;
27
+ code: string;
28
+ currency: string;
29
+ tax_year: { start_month: number; start_day: number };
30
+ cost_basis_methods: string[];
31
+ has_capital_gains: boolean;
32
+ brackets: Array<{ limit: number; rate: number }>;
33
+ disclaimer: string;
34
+ }
35
+
36
+ const REGIONS: Record<string, RegionConfig> = {
37
+ NZ: {
38
+ name: 'New Zealand',
39
+ code: 'NZ',
40
+ currency: 'NZD',
41
+ tax_year: { start_month: 4, start_day: 1 },
42
+ cost_basis_methods: ['fifo', 'average'],
43
+ has_capital_gains: false,
44
+ brackets: [
45
+ { limit: 14000, rate: 0.105 },
46
+ { limit: 48000, rate: 0.175 },
47
+ { limit: 70000, rate: 0.30 },
48
+ { limit: 180000, rate: 0.33 },
49
+ { limit: Infinity, rate: 0.39 },
50
+ ],
51
+ disclaimer: 'Estimate only. Consult a NZ tax professional. IRD requires 7 years of records.',
52
+ },
53
+ US: {
54
+ name: 'United States',
55
+ code: 'US',
56
+ currency: 'USD',
57
+ tax_year: { start_month: 1, start_day: 1 },
58
+ cost_basis_methods: ['fifo', 'average'],
59
+ has_capital_gains: true,
60
+ brackets: [
61
+ { limit: 11600, rate: 0.10 },
62
+ { limit: 47150, rate: 0.12 },
63
+ { limit: 100525, rate: 0.22 },
64
+ { limit: 191950, rate: 0.24 },
65
+ { limit: 243725, rate: 0.32 },
66
+ { limit: 609350, rate: 0.35 },
67
+ { limit: Infinity, rate: 0.37 },
68
+ ],
69
+ disclaimer: 'Estimate only — uses 2024 Single filer federal brackets. Does not include state taxes, NIIT (3.8%), or long-term capital gains rates. Short-term gains (<1 year hold) are taxed as ordinary income. Consult a US CPA or tax professional. IRS requires records for 3+ years.',
70
+ },
71
+ };
72
+
73
+ function getRegion(code?: string): RegionConfig {
74
+ const key = (code || 'NZ').toUpperCase();
75
+ const region = REGIONS[key];
76
+ if (!region) {
77
+ throw new Error(`Unsupported region "${key}". Supported: ${Object.keys(REGIONS).join(', ')}`);
78
+ }
79
+ return region;
80
+ }
81
+
82
+ // ── Tax Year Helpers ─────────────────────────────
83
+
84
+ function getTaxYearDates(taxYear: number, region: RegionConfig): { start: string; end: string } {
85
+ const { start_month, start_day } = region.tax_year;
86
+ // Tax year "2025" in NZ = Apr 1, 2024 – Mar 31, 2025
87
+ const startYear = start_month > 1 ? taxYear - 1 : taxYear;
88
+ const endYear = start_month > 1 ? taxYear : taxYear + 1;
89
+ const endMonth = start_month - 1 || 12;
90
+ const endDay = new Date(endYear, endMonth, 0).getDate(); // last day of end month
91
+
92
+ const start = `${startYear}-${String(start_month).padStart(2, '0')}-${String(start_day).padStart(2, '0')}T00:00:00.000Z`;
93
+ const end = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(endDay).padStart(2, '0')}T23:59:59.999Z`;
94
+ return { start, end };
95
+ }
96
+
97
+ // ── HTTP Helpers ─────────────────────────────────
98
+
99
+ const HTTP_TIMEOUT = 20000;
100
+
101
+ async function httpGet(url: string, headers?: Record<string, string>): Promise<any> {
102
+ const controller = new AbortController();
103
+ const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT);
104
+ try {
105
+ const resp = await fetch(url, { signal: controller.signal, headers });
106
+ if (!resp.ok) {
107
+ const text = await resp.text().catch(() => '');
108
+ throw new Error(`HTTP GET ${url} failed (${resp.status}): ${text.slice(0, 200)}`);
109
+ }
110
+ return resp;
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
116
+ async function httpGetJson(url: string, headers?: Record<string, string>): Promise<any> {
117
+ const resp = await httpGet(url, headers);
118
+ return resp.json();
119
+ }
120
+
121
+ async function httpGetText(url: string): Promise<string> {
122
+ const resp = await httpGet(url);
123
+ return resp.text();
124
+ }
125
+
126
+ function sleep(ms: number): Promise<void> {
127
+ return new Promise(resolve => setTimeout(resolve, ms));
128
+ }
129
+
130
+ // ── CoinGecko ────────────────────────────────────
131
+
132
+ // Auto-detect API key tier: Pro key starts with "CG-", Demo key otherwise
133
+ function getCoinGeckoConfig(): { baseUrl: string; headers: Record<string, string>; hasKey: boolean } {
134
+ const apiKey = process.env.COINGECKO_API_KEY || '';
135
+ if (!apiKey) {
136
+ return { baseUrl: 'https://api.coingecko.com/api/v3', headers: {}, hasKey: false };
137
+ }
138
+ if (apiKey.startsWith('CG-')) {
139
+ // Pro API key
140
+ return {
141
+ baseUrl: 'https://pro-api.coingecko.com/api/v3',
142
+ headers: { 'x-cg-pro-api-key': apiKey },
143
+ hasKey: true,
144
+ };
145
+ }
146
+ // Demo API key (free tier with key)
147
+ return {
148
+ baseUrl: 'https://api.coingecko.com/api/v3',
149
+ headers: { 'x-cg-demo-api-key': apiKey },
150
+ hasKey: true,
151
+ };
152
+ }
153
+
154
+ const COINGECKO_BASE = getCoinGeckoConfig().baseUrl;
155
+
156
+ const TOKEN_TO_COINGECKO: Record<string, string> = {
157
+ XPR: 'proton',
158
+ XBTC: 'bitcoin',
159
+ XETH: 'ethereum',
160
+ XDOGE: 'dogecoin',
161
+ METAL: 'metal-blockchain',
162
+ XUSDC: 'usd-coin',
163
+ XMD: 'usd-coin',
164
+ XXRP: 'ripple',
165
+ XLTC: 'litecoin',
166
+ XHBAR: 'hedera-hashgraph',
167
+ LOAN: 'proton-loan',
168
+ SLOAN: 'proton-loan',
169
+ };
170
+
171
+ const STABLECOINS = new Set(['XUSDC', 'XMD', 'USDT']);
172
+
173
+ // CoinGecko fetch with API key headers
174
+ async function cgFetch(path: string): Promise<any> {
175
+ const cg = getCoinGeckoConfig();
176
+ return httpGetJson(`${cg.baseUrl}${path}`, cg.headers);
177
+ }
178
+
179
+ // Persistent rate cache: "SYMBOL:YYYY-MM-DD" → rate
180
+ // Historical prices are immutable — once fetched they never change.
181
+ // Stored as JSON file so rates survive container restarts.
182
+ import * as fs from 'fs';
183
+ import * as path from 'path';
184
+
185
+ const RATE_CACHE_FILE = process.env.RATE_CACHE_PATH || path.join(process.cwd(), 'data', 'rate-cache.json');
186
+ const rateCache = new Map<string, number>();
187
+ let rateCacheDirty = false;
188
+
189
+ function loadRateCache(): void {
190
+ try {
191
+ if (fs.existsSync(RATE_CACHE_FILE)) {
192
+ const data = JSON.parse(fs.readFileSync(RATE_CACHE_FILE, 'utf-8'));
193
+ for (const [k, v] of Object.entries(data)) {
194
+ if (typeof v === 'number' && v > 0) rateCache.set(k, v);
195
+ }
196
+ console.log(`[tax] Loaded ${rateCache.size} cached rates from ${RATE_CACHE_FILE}`);
197
+ }
198
+ } catch { /* start fresh */ }
199
+ }
200
+
201
+ function saveRateCache(): void {
202
+ if (!rateCacheDirty) return;
203
+ try {
204
+ const dir = path.dirname(RATE_CACHE_FILE);
205
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
206
+ const obj: Record<string, number> = {};
207
+ for (const [k, v] of rateCache) obj[k] = v;
208
+ fs.writeFileSync(RATE_CACHE_FILE, JSON.stringify(obj));
209
+ rateCacheDirty = false;
210
+ } catch (err) {
211
+ console.error(`[tax] Failed to save rate cache:`, err);
212
+ }
213
+ }
214
+
215
+ function cacheRate(key: string, rate: number): void {
216
+ // Only cache historical date rates (not "current")
217
+ if (key.includes(':current') || rate <= 0) return;
218
+ rateCache.set(key, rate);
219
+ rateCacheDirty = true;
220
+ }
221
+
222
+ // Load on startup
223
+ loadRateCache();
224
+
225
+ // ── CSV Parser ───────────────────────────────────
226
+
227
+ function parseCSV(csv: string): Array<Record<string, string>> {
228
+ const lines = csv.trim().split('\n');
229
+ if (lines.length < 2) return [];
230
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
231
+ const rows: Array<Record<string, string>> = [];
232
+ for (let i = 1; i < lines.length; i++) {
233
+ const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
234
+ const row: Record<string, string> = {};
235
+ for (let j = 0; j < headers.length; j++) {
236
+ row[headers[j]] = values[j] || '';
237
+ }
238
+ rows.push(row);
239
+ }
240
+ return rows;
241
+ }
242
+
243
+ // ── Transfer Categorization ──────────────────────
244
+
245
+ interface CategorizedTransfer {
246
+ category: string;
247
+ from: string;
248
+ to: string;
249
+ amount: number;
250
+ symbol: string;
251
+ memo: string;
252
+ timestamp: string;
253
+ tx_id: string;
254
+ direction: 'incoming' | 'outgoing';
255
+ }
256
+
257
+ function categorizeTransfer(
258
+ account: string,
259
+ from: string,
260
+ to: string,
261
+ amount: number,
262
+ symbol: string,
263
+ memo: string,
264
+ ): string {
265
+ const memoLower = (memo || '').toLowerCase();
266
+ const isIncoming = to === account;
267
+ const counterparty = isIncoming ? from : to;
268
+
269
+ // Staking rewards (block production + community fund)
270
+ if (isIncoming && (from === 'eosio' || from === 'eosio.vpay' || from === 'eosio.bpay' || from === 'cfund.proton')) {
271
+ return 'staking_reward';
272
+ }
273
+
274
+ // Lending (lending.loan)
275
+ if (counterparty === 'lending.loan') {
276
+ if (isIncoming) {
277
+ if (memoLower.includes('interest') || memoLower.includes('reward') || memoLower.includes('yield')) {
278
+ return 'lending_interest';
279
+ }
280
+ return 'lending_withdrawal';
281
+ }
282
+ return 'lending_deposit';
283
+ }
284
+
285
+ // Swaps (proton.swaps)
286
+ if (counterparty === 'proton.swaps') {
287
+ return isIncoming ? 'swap_withdrawal' : 'swap_deposit';
288
+ }
289
+
290
+ // Long staking XPR (longstaking contract)
291
+ if (counterparty === 'longstaking') {
292
+ return isIncoming ? 'long_unstake' : 'long_stake';
293
+ }
294
+
295
+ // LOAN staking (lock.token + yield.farms)
296
+ if (counterparty === 'lock.token' || counterparty === 'yield.farms') {
297
+ return isIncoming ? 'loan_unstake' : 'loan_stake';
298
+ }
299
+
300
+ // DEX (Metal X)
301
+ if (counterparty === 'dex' || counterparty === 'metalx') {
302
+ return isIncoming ? 'dex_withdrawal' : 'dex_deposit';
303
+ }
304
+
305
+ // NFT marketplace
306
+ if (counterparty === 'atomicmarket') {
307
+ return isIncoming ? 'nft_sale' : 'nft_purchase';
308
+ }
309
+
310
+ // Agent escrow
311
+ if (counterparty === 'agentescrow') {
312
+ return 'escrow';
313
+ }
314
+
315
+ // Burned tokens — disposal at zero value = realized loss
316
+ if (to === 'eosio.null') {
317
+ return 'burn';
318
+ }
319
+
320
+ return 'transfer';
321
+ }
322
+
323
+ // ── Gains Calculation ────────────────────────────
324
+
325
+ interface TradeEvent {
326
+ type: 'trade';
327
+ date: string;
328
+ buy_amount: number;
329
+ buy_currency: string;
330
+ sell_amount: number;
331
+ sell_currency: string;
332
+ fee?: number;
333
+ fee_currency?: string;
334
+ tx_id?: string;
335
+ }
336
+
337
+ interface TransferEvent {
338
+ type: 'transfer';
339
+ date: string;
340
+ category: string;
341
+ amount: number;
342
+ symbol: string;
343
+ direction: 'incoming' | 'outgoing';
344
+ tx_id?: string;
345
+ }
346
+
347
+ interface Disposal {
348
+ date: string;
349
+ asset: string;
350
+ amount: number;
351
+ proceeds_local: number;
352
+ cost_basis_local: number;
353
+ gain_loss_local: number;
354
+ method: string;
355
+ tx_id?: string;
356
+ }
357
+
358
+ interface IncomeEvent {
359
+ date: string;
360
+ category: string;
361
+ asset: string;
362
+ amount: number;
363
+ value_local: number;
364
+ tx_id?: string;
365
+ }
366
+
367
+ interface GainsResult {
368
+ disposals: Disposal[];
369
+ income_events: IncomeEvent[];
370
+ summary: {
371
+ total_proceeds: number;
372
+ total_cost_basis: number;
373
+ total_gains: number;
374
+ total_losses: number;
375
+ net_gain_loss: number;
376
+ total_income: number;
377
+ grand_total_taxable: number;
378
+ };
379
+ remaining_lots: Record<string, Array<{ amount: number; cost_per_unit: number; date: string }>>;
380
+ method: string;
381
+ currency: string;
382
+ }
383
+
384
+ type RateMap = Record<string, number>; // "SYMBOL:YYYY-MM-DD" → local currency rate
385
+
386
+ function dateKey(date: string): string {
387
+ return date.slice(0, 10); // YYYY-MM-DD
388
+ }
389
+
390
+ function getRate(rates: RateMap, symbol: string, date: string): number {
391
+ const key = `${symbol}:${dateKey(date)}`;
392
+ return rates[key] || rates[`${symbol}:current`] || 0;
393
+ }
394
+
395
+ // Income categories: these incoming transfers are taxable income
396
+ const INCOME_CATEGORIES = new Set([
397
+ 'staking_reward', 'lending_interest', 'nft_sale',
398
+ ]);
399
+
400
+ // Long staking is special: only the EXCESS over what was staked is income.
401
+ // We track deposits and only count the surplus on unstake.
402
+ // Same for loan staking.
403
+ const LONG_STAKE_INCOME = new Set(['long_unstake', 'loan_unstake']);
404
+
405
+ // DeFi movements: not taxable events (moving between own wallets/protocols)
406
+ const DEFI_MOVE_CATEGORIES = new Set([
407
+ 'lending_deposit', 'lending_withdrawal',
408
+ 'swap_deposit', 'swap_withdrawal',
409
+ 'long_stake', 'long_unstake',
410
+ 'loan_stake', 'loan_unstake',
411
+ 'dex_deposit', 'dex_withdrawal',
412
+ 'escrow',
413
+ ]);
414
+
415
+ function calculateGainsFIFO(
416
+ trades: TradeEvent[],
417
+ transfers: TransferEvent[],
418
+ rates: RateMap,
419
+ currency: string,
420
+ ): GainsResult {
421
+ // Lot queues: asset → [{ amount, cost_per_unit, date }]
422
+ const lots: Record<string, Array<{ amount: number; cost_per_unit: number; date: string }>> = {};
423
+ const disposals: Disposal[] = [];
424
+ const incomeEvents: IncomeEvent[] = [];
425
+
426
+ // Track long staking / loan staking deposits per symbol to compute excess on unstake
427
+ const stakeDeposits: Record<string, number> = {}; // "long:SYMBOL" or "loan:SYMBOL" → total staked
428
+
429
+ function addLot(asset: string, amount: number, costPerUnit: number, date: string): void {
430
+ if (!lots[asset]) lots[asset] = [];
431
+ lots[asset].push({ amount, cost_per_unit: costPerUnit, date });
432
+ }
433
+
434
+ function consumeLots(asset: string, amount: number): number {
435
+ const queue = lots[asset] || [];
436
+ let remaining = amount;
437
+ let totalCost = 0;
438
+ while (remaining > 0 && queue.length > 0) {
439
+ const lot = queue[0];
440
+ if (lot.amount <= remaining) {
441
+ totalCost += lot.amount * lot.cost_per_unit;
442
+ remaining -= lot.amount;
443
+ queue.shift();
444
+ } else {
445
+ totalCost += remaining * lot.cost_per_unit;
446
+ lot.amount -= remaining;
447
+ remaining = 0;
448
+ }
449
+ }
450
+ return totalCost;
451
+ }
452
+
453
+ // Merge and sort all events chronologically
454
+ const events: Array<{ date: string; event: TradeEvent | TransferEvent }> = [
455
+ ...trades.map(t => ({ date: t.date, event: t })),
456
+ ...transfers.map(t => ({ date: t.date, event: t })),
457
+ ];
458
+ events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
459
+
460
+ let totalProceeds = 0;
461
+ let totalCostBasis = 0;
462
+ let totalGains = 0;
463
+ let totalLosses = 0;
464
+ let totalIncome = 0;
465
+
466
+ for (const { event } of events) {
467
+ if (event.type === 'trade') {
468
+ const trade = event as TradeEvent;
469
+ const buyRate = getRate(rates, trade.buy_currency, trade.date);
470
+ const sellRate = getRate(rates, trade.sell_currency, trade.date);
471
+
472
+ // Disposal of sell currency
473
+ const proceeds = trade.sell_amount * sellRate;
474
+ const costBasis = consumeLots(trade.sell_currency, trade.sell_amount);
475
+ const gainLoss = proceeds - costBasis;
476
+
477
+ disposals.push({
478
+ date: trade.date,
479
+ asset: trade.sell_currency,
480
+ amount: trade.sell_amount,
481
+ proceeds_local: proceeds,
482
+ cost_basis_local: costBasis,
483
+ gain_loss_local: gainLoss,
484
+ method: 'fifo',
485
+ tx_id: trade.tx_id,
486
+ });
487
+
488
+ totalProceeds += proceeds;
489
+ totalCostBasis += costBasis;
490
+ if (gainLoss > 0) totalGains += gainLoss;
491
+ else totalLosses += Math.abs(gainLoss);
492
+
493
+ // Acquisition of buy currency
494
+ addLot(trade.buy_currency, trade.buy_amount, buyRate, trade.date);
495
+
496
+ } else {
497
+ const xfer = event as TransferEvent;
498
+ const rate = getRate(rates, xfer.symbol, xfer.date);
499
+
500
+ if (xfer.direction === 'incoming') {
501
+ // Income categories: record as income + create cost basis lot
502
+ if (INCOME_CATEGORIES.has(xfer.category)) {
503
+ const value = xfer.amount * rate;
504
+ incomeEvents.push({
505
+ date: xfer.date,
506
+ category: xfer.category,
507
+ asset: xfer.symbol,
508
+ amount: xfer.amount,
509
+ value_local: value,
510
+ tx_id: xfer.tx_id,
511
+ });
512
+ totalIncome += value;
513
+ addLot(xfer.symbol, xfer.amount, rate, xfer.date);
514
+
515
+ } else if (LONG_STAKE_INCOME.has(xfer.category)) {
516
+ // Long staking / loan staking unstake — only the EXCESS over deposits is income
517
+ // (e.g. stake 100 XPR, unstake 150 XPR → income of 50 XPR)
518
+ const stakeKey = xfer.category === 'long_unstake' ? `long:${xfer.symbol}` : `loan:${xfer.symbol}`;
519
+ const deposited = stakeDeposits[stakeKey] || 0;
520
+ const excess = Math.max(0, xfer.amount - deposited);
521
+
522
+ // Reduce tracked deposits by the principal portion returned
523
+ stakeDeposits[stakeKey] = Math.max(0, deposited - (xfer.amount - excess));
524
+
525
+ if (excess > 0) {
526
+ const value = excess * rate;
527
+ incomeEvents.push({
528
+ date: xfer.date,
529
+ category: xfer.category === 'long_unstake' ? 'long_staking_reward' : 'loan_staking_reward',
530
+ asset: xfer.symbol,
531
+ amount: excess,
532
+ value_local: value,
533
+ tx_id: xfer.tx_id,
534
+ });
535
+ totalIncome += value;
536
+ }
537
+ // Full amount returns as cost basis (principal at original cost, excess at current rate)
538
+ addLot(xfer.symbol, xfer.amount, rate, xfer.date);
539
+
540
+ } else if (!DEFI_MOVE_CATEGORIES.has(xfer.category)) {
541
+ // Regular incoming transfer — cost basis acquisition
542
+ addLot(xfer.symbol, xfer.amount, rate, xfer.date);
543
+ }
544
+ // DeFi moves (deposit/withdrawal) are not taxable events
545
+ } else {
546
+ // Outgoing transfer
547
+
548
+ // Track long staking / loan staking deposits for excess calculation
549
+ if (xfer.category === 'long_stake') {
550
+ const key = `long:${xfer.symbol}`;
551
+ stakeDeposits[key] = (stakeDeposits[key] || 0) + xfer.amount;
552
+ } else if (xfer.category === 'loan_stake') {
553
+ const key = `loan:${xfer.symbol}`;
554
+ stakeDeposits[key] = (stakeDeposits[key] || 0) + xfer.amount;
555
+ }
556
+
557
+ // Burn = disposal at zero proceeds (realized loss)
558
+ if (xfer.category === 'burn') {
559
+ const costBasis = consumeLots(xfer.symbol, xfer.amount);
560
+ disposals.push({
561
+ date: xfer.date,
562
+ asset: xfer.symbol,
563
+ amount: xfer.amount,
564
+ proceeds_local: 0,
565
+ cost_basis_local: costBasis,
566
+ gain_loss_local: -costBasis,
567
+ method: 'fifo',
568
+ tx_id: xfer.tx_id,
569
+ });
570
+ totalCostBasis += costBasis;
571
+ totalLosses += costBasis;
572
+ } else if (!DEFI_MOVE_CATEGORIES.has(xfer.category)) {
573
+ // Disposal (sending to someone else)
574
+ const proceeds = xfer.amount * rate;
575
+ const costBasis = consumeLots(xfer.symbol, xfer.amount);
576
+ const gainLoss = proceeds - costBasis;
577
+
578
+ disposals.push({
579
+ date: xfer.date,
580
+ asset: xfer.symbol,
581
+ amount: xfer.amount,
582
+ proceeds_local: proceeds,
583
+ cost_basis_local: costBasis,
584
+ gain_loss_local: gainLoss,
585
+ method: 'fifo',
586
+ tx_id: xfer.tx_id,
587
+ });
588
+
589
+ totalProceeds += proceeds;
590
+ totalCostBasis += costBasis;
591
+ if (gainLoss > 0) totalGains += gainLoss;
592
+ else totalLosses += Math.abs(gainLoss);
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ return {
599
+ disposals,
600
+ income_events: incomeEvents,
601
+ summary: {
602
+ total_proceeds: round2(totalProceeds),
603
+ total_cost_basis: round2(totalCostBasis),
604
+ total_gains: round2(totalGains),
605
+ total_losses: round2(totalLosses),
606
+ net_gain_loss: round2(totalGains - totalLosses),
607
+ total_income: round2(totalIncome),
608
+ grand_total_taxable: round2(totalGains - totalLosses + totalIncome),
609
+ },
610
+ remaining_lots: lots,
611
+ method: 'fifo',
612
+ currency,
613
+ };
614
+ }
615
+
616
+ function calculateGainsAverage(
617
+ trades: TradeEvent[],
618
+ transfers: TransferEvent[],
619
+ rates: RateMap,
620
+ currency: string,
621
+ ): GainsResult {
622
+ // Average cost per asset
623
+ const holdings: Record<string, { total_amount: number; total_cost: number }> = {};
624
+ const disposals: Disposal[] = [];
625
+ const incomeEvents: IncomeEvent[] = [];
626
+
627
+ // Track long staking / loan staking deposits per symbol to compute excess on unstake
628
+ const stakeDeposits: Record<string, number> = {};
629
+
630
+ function addHolding(asset: string, amount: number, cost: number): void {
631
+ if (!holdings[asset]) holdings[asset] = { total_amount: 0, total_cost: 0 };
632
+ holdings[asset].total_amount += amount;
633
+ holdings[asset].total_cost += cost;
634
+ }
635
+
636
+ function avgCostPerUnit(asset: string): number {
637
+ const h = holdings[asset];
638
+ if (!h || h.total_amount <= 0) return 0;
639
+ return h.total_cost / h.total_amount;
640
+ }
641
+
642
+ function consumeAvg(asset: string, amount: number): number {
643
+ const h = holdings[asset];
644
+ if (!h || h.total_amount <= 0) return 0;
645
+ const costPerUnit = h.total_cost / h.total_amount;
646
+ const consumed = Math.min(amount, h.total_amount);
647
+ const cost = consumed * costPerUnit;
648
+ h.total_amount -= consumed;
649
+ h.total_cost -= cost;
650
+ return cost;
651
+ }
652
+
653
+ const events: Array<{ date: string; event: TradeEvent | TransferEvent }> = [
654
+ ...trades.map(t => ({ date: t.date, event: t })),
655
+ ...transfers.map(t => ({ date: t.date, event: t })),
656
+ ];
657
+ events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
658
+
659
+ let totalProceeds = 0;
660
+ let totalCostBasis = 0;
661
+ let totalGains = 0;
662
+ let totalLosses = 0;
663
+ let totalIncome = 0;
664
+
665
+ for (const { event } of events) {
666
+ if (event.type === 'trade') {
667
+ const trade = event as TradeEvent;
668
+ const buyRate = getRate(rates, trade.buy_currency, trade.date);
669
+ const sellRate = getRate(rates, trade.sell_currency, trade.date);
670
+
671
+ const proceeds = trade.sell_amount * sellRate;
672
+ const costBasis = consumeAvg(trade.sell_currency, trade.sell_amount);
673
+ const gainLoss = proceeds - costBasis;
674
+
675
+ disposals.push({
676
+ date: trade.date,
677
+ asset: trade.sell_currency,
678
+ amount: trade.sell_amount,
679
+ proceeds_local: proceeds,
680
+ cost_basis_local: costBasis,
681
+ gain_loss_local: gainLoss,
682
+ method: 'average',
683
+ tx_id: trade.tx_id,
684
+ });
685
+
686
+ totalProceeds += proceeds;
687
+ totalCostBasis += costBasis;
688
+ if (gainLoss > 0) totalGains += gainLoss;
689
+ else totalLosses += Math.abs(gainLoss);
690
+
691
+ addHolding(trade.buy_currency, trade.buy_amount, trade.buy_amount * buyRate);
692
+
693
+ } else {
694
+ const xfer = event as TransferEvent;
695
+ const rate = getRate(rates, xfer.symbol, xfer.date);
696
+
697
+ if (xfer.direction === 'incoming') {
698
+ if (INCOME_CATEGORIES.has(xfer.category)) {
699
+ const value = xfer.amount * rate;
700
+ incomeEvents.push({
701
+ date: xfer.date,
702
+ category: xfer.category,
703
+ asset: xfer.symbol,
704
+ amount: xfer.amount,
705
+ value_local: value,
706
+ tx_id: xfer.tx_id,
707
+ });
708
+ totalIncome += value;
709
+ addHolding(xfer.symbol, xfer.amount, value);
710
+
711
+ } else if (LONG_STAKE_INCOME.has(xfer.category)) {
712
+ // Long staking / loan staking unstake — only the EXCESS over deposits is income
713
+ const stakeKey = xfer.category === 'long_unstake' ? `long:${xfer.symbol}` : `loan:${xfer.symbol}`;
714
+ const deposited = stakeDeposits[stakeKey] || 0;
715
+ const excess = Math.max(0, xfer.amount - deposited);
716
+ stakeDeposits[stakeKey] = Math.max(0, deposited - (xfer.amount - excess));
717
+
718
+ if (excess > 0) {
719
+ const value = excess * rate;
720
+ incomeEvents.push({
721
+ date: xfer.date,
722
+ category: xfer.category === 'long_unstake' ? 'long_staking_reward' : 'loan_staking_reward',
723
+ asset: xfer.symbol,
724
+ amount: excess,
725
+ value_local: value,
726
+ tx_id: xfer.tx_id,
727
+ });
728
+ totalIncome += value;
729
+ }
730
+ addHolding(xfer.symbol, xfer.amount, xfer.amount * rate);
731
+
732
+ } else if (!DEFI_MOVE_CATEGORIES.has(xfer.category)) {
733
+ addHolding(xfer.symbol, xfer.amount, xfer.amount * rate);
734
+ }
735
+ } else {
736
+ // Track staking deposits
737
+ if (xfer.category === 'long_stake') {
738
+ stakeDeposits[`long:${xfer.symbol}`] = (stakeDeposits[`long:${xfer.symbol}`] || 0) + xfer.amount;
739
+ } else if (xfer.category === 'loan_stake') {
740
+ stakeDeposits[`loan:${xfer.symbol}`] = (stakeDeposits[`loan:${xfer.symbol}`] || 0) + xfer.amount;
741
+ }
742
+
743
+ // Burn = disposal at zero proceeds (realized loss)
744
+ if (xfer.category === 'burn') {
745
+ const costBasis = consumeAvg(xfer.symbol, xfer.amount);
746
+ disposals.push({
747
+ date: xfer.date,
748
+ asset: xfer.symbol,
749
+ amount: xfer.amount,
750
+ proceeds_local: 0,
751
+ cost_basis_local: costBasis,
752
+ gain_loss_local: -costBasis,
753
+ method: 'average',
754
+ tx_id: xfer.tx_id,
755
+ });
756
+ totalCostBasis += costBasis;
757
+ totalLosses += costBasis;
758
+ } else if (!DEFI_MOVE_CATEGORIES.has(xfer.category)) {
759
+ const proceeds = xfer.amount * rate;
760
+ const costBasis = consumeAvg(xfer.symbol, xfer.amount);
761
+ const gainLoss = proceeds - costBasis;
762
+
763
+ disposals.push({
764
+ date: xfer.date,
765
+ asset: xfer.symbol,
766
+ amount: xfer.amount,
767
+ proceeds_local: proceeds,
768
+ cost_basis_local: costBasis,
769
+ gain_loss_local: gainLoss,
770
+ method: 'average',
771
+ tx_id: xfer.tx_id,
772
+ });
773
+
774
+ totalProceeds += proceeds;
775
+ totalCostBasis += costBasis;
776
+ if (gainLoss > 0) totalGains += gainLoss;
777
+ else totalLosses += Math.abs(gainLoss);
778
+ }
779
+ }
780
+ }
781
+ }
782
+
783
+ // Convert remaining holdings to lot-like format
784
+ const remainingLots: Record<string, Array<{ amount: number; cost_per_unit: number; date: string }>> = {};
785
+ for (const [asset, h] of Object.entries(holdings)) {
786
+ if (h.total_amount > 0) {
787
+ remainingLots[asset] = [{ amount: h.total_amount, cost_per_unit: avgCostPerUnit(asset), date: 'average' }];
788
+ }
789
+ }
790
+
791
+ return {
792
+ disposals,
793
+ income_events: incomeEvents,
794
+ summary: {
795
+ total_proceeds: round2(totalProceeds),
796
+ total_cost_basis: round2(totalCostBasis),
797
+ total_gains: round2(totalGains),
798
+ total_losses: round2(totalLosses),
799
+ net_gain_loss: round2(totalGains - totalLosses),
800
+ total_income: round2(totalIncome),
801
+ grand_total_taxable: round2(totalGains - totalLosses + totalIncome),
802
+ },
803
+ remaining_lots: remainingLots,
804
+ method: 'average',
805
+ currency,
806
+ };
807
+ }
808
+
809
+ function round2(n: number): number {
810
+ return Math.round(n * 100) / 100;
811
+ }
812
+
813
+ // ── Tax Bracket Calculation ──────────────────────
814
+
815
+ function calculateTax(taxableIncome: number, region: RegionConfig): Array<{ bracket: string; income: number; rate: number; tax: number }> {
816
+ const result: Array<{ bracket: string; income: number; rate: number; tax: number }> = [];
817
+ let remaining = taxableIncome;
818
+ let prevLimit = 0;
819
+
820
+ for (const { limit, rate } of region.brackets) {
821
+ if (remaining <= 0) break;
822
+ const bracketSize = limit === Infinity ? remaining : Math.min(remaining, limit - prevLimit);
823
+ if (bracketSize <= 0) {
824
+ prevLimit = limit;
825
+ continue;
826
+ }
827
+ const tax = bracketSize * rate;
828
+ const bracketLabel = limit === Infinity
829
+ ? `$${prevLimit.toLocaleString()}+`
830
+ : `$${prevLimit.toLocaleString()} – $${limit.toLocaleString()}`;
831
+ result.push({ bracket: bracketLabel, income: round2(bracketSize), rate, tax: round2(tax) });
832
+ remaining -= bracketSize;
833
+ prevLimit = limit;
834
+ }
835
+
836
+ return result;
837
+ }
838
+
839
+ // ── Balance Markdown Formatter ───────────────────
840
+
841
+ function formatBalancesMarkdown(balances: any): string {
842
+ if (!balances || balances.error) {
843
+ return `*Data unavailable${balances?.error ? `: ${balances.error}` : ''}*`;
844
+ }
845
+
846
+ // Handle both grouped format { liquid: [], staked: [] } and flat API response { balances: [] }
847
+ let grouped: Record<string, any[]>;
848
+ if (balances.liquid || balances.staked || balances.lending || balances.yield_farm) {
849
+ grouped = balances;
850
+ } else if (Array.isArray(balances.balances)) {
851
+ grouped = { liquid: [], staked: [], lending: [], yield_farm: [] };
852
+ for (const item of balances.balances) {
853
+ const t = (item.type || 'liquid').toLowerCase().replace(/ /g, '_');
854
+ const bucket = grouped[t] || (grouped[t] = []);
855
+ bucket.push(item);
856
+ }
857
+ } else {
858
+ return '*No balances found*';
859
+ }
860
+
861
+ const lines: string[] = [];
862
+
863
+ const formatGroup = (name: string, items: any[]) => {
864
+ if (!Array.isArray(items) || items.length === 0) return;
865
+ lines.push(`**${name}:**`);
866
+ lines.push('');
867
+ lines.push('| Token | Amount |');
868
+ lines.push('|-------|-------:|');
869
+ for (const item of items) {
870
+ if (typeof item === 'string') {
871
+ lines.push(`| ${item.split(' ')[1] || '?'} | ${item} |`);
872
+ } else {
873
+ const sym = item.currency || item.symbol || '?';
874
+ const display = item.display || item.amount || item.quantity || item.balance || '?';
875
+ lines.push(`| ${sym} | ${display} |`);
876
+ }
877
+ }
878
+ lines.push('');
879
+ };
880
+
881
+ formatGroup('Liquid', grouped.liquid);
882
+ formatGroup('Staked', grouped.staked);
883
+ formatGroup('Lending', grouped.lending);
884
+ formatGroup('Yield Farm', grouped.yield_farm);
885
+
886
+ return lines.length > 0 ? lines.join('\n') : '*No balances found*';
887
+ }
888
+
889
+ // ── Skill Entry Point ────────────────────────────
890
+
891
+ export default function taxSkill(api: SkillApi): void {
892
+ const SALTANT_BASE = 'https://api-xprnetwork-main.saltant.io';
893
+ const METALX_TAX_BASE = 'https://dex.api.mainnet.metalx.com';
894
+
895
+ // ════════════════════════════════════════════════
896
+ // 1. tax_get_balances
897
+ // ════════════════════════════════════════════════
898
+
899
+ api.registerTool({
900
+ name: 'tax_get_balances',
901
+ description: 'Get token balances at a specific date (or now). Returns liquid, staked, lending (with underlying), and yield farm balances. Uses mainnet Saltant historical balance API.',
902
+ parameters: {
903
+ type: 'object',
904
+ required: ['account'],
905
+ properties: {
906
+ account: { type: 'string', description: 'XPR Network account name' },
907
+ date: { type: 'string', description: 'ISO 8601 date for historical snapshot (default: now). E.g. "2025-03-31T23:59:59Z"' },
908
+ },
909
+ },
910
+ handler: async ({ account, date }: { account: string; date?: string }) => {
911
+ if (!account || typeof account !== 'string') {
912
+ return { error: 'account parameter is required' };
913
+ }
914
+
915
+ try {
916
+ let url = `${SALTANT_BASE}/v2/state/get_balance?account=${encodeURIComponent(account)}`;
917
+ if (date) {
918
+ url += `&datetime=${encodeURIComponent(date)}`;
919
+ }
920
+
921
+ const data = await httpGetJson(url);
922
+
923
+ // API returns { balances: [{ type, symbol, amount, ... }] } — group by type
924
+ const rawBalances: any[] = data.balances || data.liquid || [];
925
+ const balances: Record<string, any[]> = {
926
+ liquid: [],
927
+ staked: [],
928
+ lending: [],
929
+ yield_farm: [],
930
+ };
931
+
932
+ const tokenSet = new Set<string>();
933
+ for (const item of rawBalances) {
934
+ const balType = (item.type || 'liquid').toLowerCase().replace(/ /g, '_');
935
+ const sym = item.currency || item.symbol || '';
936
+ if (sym) tokenSet.add(sym);
937
+ const bucket = balances[balType] || (balances[balType] = []);
938
+ bucket.push(item);
939
+ }
940
+
941
+ return {
942
+ account,
943
+ date: date || new Date().toISOString(),
944
+ balances,
945
+ tokens_found: tokenSet.size,
946
+ token_list: [...tokenSet].sort(),
947
+ };
948
+ } catch (err: any) {
949
+ return { error: `Failed to fetch balances: ${err.message}` };
950
+ }
951
+ },
952
+ });
953
+
954
+ // ════════════════════════════════════════════════
955
+ // 2. tax_get_dex_trades
956
+ // ════════════════════════════════════════════════
957
+
958
+ api.registerTool({
959
+ name: 'tax_get_dex_trades',
960
+ description: 'Get Metal X DEX trading history for an account. Returns all trades with buy/sell amounts, currencies, fees, and dates. Date filtering is client-side on the full export.',
961
+ parameters: {
962
+ type: 'object',
963
+ required: ['account'],
964
+ properties: {
965
+ account: { type: 'string', description: 'XPR Network account name' },
966
+ start_date: { type: 'string', description: 'Filter trades after this ISO date (inclusive)' },
967
+ end_date: { type: 'string', description: 'Filter trades before this ISO date (inclusive)' },
968
+ },
969
+ },
970
+ handler: async ({ account, start_date, end_date }: {
971
+ account: string; start_date?: string; end_date?: string;
972
+ }) => {
973
+ if (!account || typeof account !== 'string') {
974
+ return { error: 'account parameter is required' };
975
+ }
976
+
977
+ try {
978
+ const url = `${METALX_TAX_BASE}/dex/v1/tax/user?account=${encodeURIComponent(account)}`;
979
+ const csvText = await httpGetText(url);
980
+
981
+ if (!csvText || csvText.trim().length === 0) {
982
+ return { trades: [], total: 0, note: 'No trading history found' };
983
+ }
984
+
985
+ const rows = parseCSV(csvText);
986
+ if (rows.length === 0) {
987
+ return { trades: [], total: 0, note: 'CSV parsed but no data rows found' };
988
+ }
989
+
990
+ // Map CSV columns to trade objects
991
+ // Expected columns: Type, Buy Amount, Buy Currency, Sell Amount, Sell Currency, Fee, Fee Currency, Date, Tx-ID
992
+ // Filter to only "Trade" rows — Withdrawal/Income/Deposit are handled by tax_get_transfers
993
+ let trades = rows
994
+ .filter(row => (row['Type'] || row['type'] || '').toLowerCase() === 'trade')
995
+ .map(row => ({
996
+ type: row['Type'] || row['type'] || '',
997
+ buy_amount: parseFloat(row['Buy Amount'] || row['buy_amount'] || '0'),
998
+ buy_currency: row['Buy Currency'] || row['buy_currency'] || '',
999
+ sell_amount: parseFloat(row['Sell Amount'] || row['sell_amount'] || '0'),
1000
+ sell_currency: row['Sell Currency'] || row['sell_currency'] || '',
1001
+ fee: parseFloat(row['Fee'] || row['fee'] || '0'),
1002
+ fee_currency: row['Fee Currency'] || row['fee_currency'] || '',
1003
+ date: row['Date'] || row['date'] || '',
1004
+ tx_id: row['Tx-ID'] || row['TxId'] || row['txid'] || row['tx_id'] || '',
1005
+ }));
1006
+
1007
+ // Client-side date filtering
1008
+ if (start_date) {
1009
+ const startMs = new Date(start_date).getTime();
1010
+ trades = trades.filter(t => new Date(t.date).getTime() >= startMs);
1011
+ }
1012
+ if (end_date) {
1013
+ const endMs = new Date(end_date).getTime();
1014
+ trades = trades.filter(t => new Date(t.date).getTime() <= endMs);
1015
+ }
1016
+
1017
+ // Volume summary
1018
+ const volumeByCurrency: Record<string, number> = {};
1019
+ for (const t of trades) {
1020
+ if (t.sell_currency) {
1021
+ volumeByCurrency[t.sell_currency] = (volumeByCurrency[t.sell_currency] || 0) + t.sell_amount;
1022
+ }
1023
+ }
1024
+
1025
+ return {
1026
+ trades,
1027
+ total: trades.length,
1028
+ volume_by_currency: volumeByCurrency,
1029
+ date_range: trades.length > 0
1030
+ ? { earliest: trades[0].date, latest: trades[trades.length - 1].date }
1031
+ : null,
1032
+ };
1033
+ } catch (err: any) {
1034
+ return { error: `Failed to fetch DEX trades: ${err.message}` };
1035
+ }
1036
+ },
1037
+ });
1038
+
1039
+ // ════════════════════════════════════════════════
1040
+ // 3. tax_get_transfers
1041
+ // ════════════════════════════════════════════════
1042
+
1043
+ api.registerTool({
1044
+ name: 'tax_get_transfers',
1045
+ description: 'Get on-chain transfer history with automatic categorization. Categories: staking_reward, lending_deposit/withdrawal/interest, swap_deposit/withdrawal, long_stake/unstake, loan_stake/unstake, dex_deposit/withdrawal, nft_sale/purchase, burn, escrow, transfer. Paginated from Hyperion.',
1046
+ parameters: {
1047
+ type: 'object',
1048
+ required: ['account'],
1049
+ properties: {
1050
+ account: { type: 'string', description: 'XPR Network account name' },
1051
+ start_date: { type: 'string', description: 'Filter after this ISO date' },
1052
+ end_date: { type: 'string', description: 'Filter before this ISO date' },
1053
+ max_results: { type: 'number', description: 'Max transfers to return (default 1000, max 5000)' },
1054
+ },
1055
+ },
1056
+ handler: async ({ account, start_date, end_date, max_results }: {
1057
+ account: string; start_date?: string; end_date?: string; max_results?: number;
1058
+ }) => {
1059
+ if (!account || typeof account !== 'string') {
1060
+ return { error: 'account parameter is required' };
1061
+ }
1062
+
1063
+ const limit = Math.min(max_results || 1000, 5000);
1064
+ const pageSize = 100;
1065
+ const allTransfers: CategorizedTransfer[] = [];
1066
+
1067
+ try {
1068
+ let skip = 0;
1069
+ let hasMore = true;
1070
+
1071
+ while (hasMore && allTransfers.length < limit) {
1072
+ let url = `${SALTANT_BASE}/v2/history/get_actions?account=${encodeURIComponent(account)}&act.name=transfer&limit=100&sort=asc&skip=${skip}`;
1073
+ if (start_date) url += `&after=${encodeURIComponent(start_date)}`;
1074
+ if (end_date) url += `&before=${encodeURIComponent(end_date)}`;
1075
+
1076
+ const data = await httpGetJson(url);
1077
+ const actions: any[] = data.actions || [];
1078
+
1079
+ if (actions.length === 0) {
1080
+ hasMore = false;
1081
+ break;
1082
+ }
1083
+
1084
+ for (const action of actions) {
1085
+ if (allTransfers.length >= limit) break;
1086
+
1087
+ const act = action.act?.data || {};
1088
+ const from = act.from || '';
1089
+ const to = act.to || '';
1090
+ const memo = act.memo || '';
1091
+
1092
+ // Parse amount: "100.0000 XPR" → { amount: 100, symbol: "XPR" }
1093
+ const quantityStr = act.quantity || '0 UNKNOWN';
1094
+ const parts = quantityStr.split(' ');
1095
+ const amount = parseFloat(parts[0]) || 0;
1096
+ const symbol = parts[1] || 'UNKNOWN';
1097
+
1098
+ if (amount === 0) continue;
1099
+
1100
+ const category = categorizeTransfer(account, from, to, amount, symbol, memo);
1101
+ const direction: 'incoming' | 'outgoing' = to === account ? 'incoming' : 'outgoing';
1102
+ const timestamp = action['@timestamp'] || action.timestamp || '';
1103
+
1104
+ allTransfers.push({
1105
+ category,
1106
+ from,
1107
+ to,
1108
+ amount,
1109
+ symbol,
1110
+ memo,
1111
+ timestamp,
1112
+ tx_id: action.trx_id || '',
1113
+ direction,
1114
+ });
1115
+ }
1116
+
1117
+ skip += actions.length;
1118
+ if (actions.length < pageSize) hasMore = false;
1119
+ }
1120
+
1121
+ // Summary by category
1122
+ const byCategory: Record<string, number> = {};
1123
+ const bySymbol: Record<string, { incoming: number; outgoing: number }> = {};
1124
+ for (const t of allTransfers) {
1125
+ byCategory[t.category] = (byCategory[t.category] || 0) + 1;
1126
+ if (!bySymbol[t.symbol]) bySymbol[t.symbol] = { incoming: 0, outgoing: 0 };
1127
+ bySymbol[t.symbol][t.direction] += t.amount;
1128
+ }
1129
+
1130
+ return {
1131
+ transfers: allTransfers,
1132
+ total: allTransfers.length,
1133
+ summary_by_category: byCategory,
1134
+ summary_by_symbol: bySymbol,
1135
+ truncated: allTransfers.length >= limit,
1136
+ };
1137
+ } catch (err: any) {
1138
+ return { error: `Failed to fetch transfers: ${err.message}` };
1139
+ }
1140
+ },
1141
+ });
1142
+
1143
+ // ════════════════════════════════════════════════
1144
+ // 4. tax_get_rates
1145
+ // ════════════════════════════════════════════════
1146
+
1147
+ api.registerTool({
1148
+ name: 'tax_get_rates',
1149
+ description: 'Get local currency conversion rates for crypto tokens. Uses CoinGecko for major tokens, forex for stablecoins. Supports current and historical rates. Returns a map of "SYMBOL:date" → rate.',
1150
+ parameters: {
1151
+ type: 'object',
1152
+ required: ['symbols'],
1153
+ properties: {
1154
+ symbols: {
1155
+ type: 'array',
1156
+ description: 'Array of token symbols to get rates for, e.g. ["XPR", "XUSDC", "XBTC"]',
1157
+ },
1158
+ date: { type: 'string', description: 'ISO date for historical rate (default: current). E.g. "2025-03-31"' },
1159
+ region: { type: 'string', description: 'Region code: "NZ" (default, NZD) or "US" (USD)' },
1160
+ },
1161
+ },
1162
+ handler: async ({ symbols, date, region }: {
1163
+ symbols: string[]; date?: string; region?: string;
1164
+ }) => {
1165
+ if (!Array.isArray(symbols) || symbols.length === 0) {
1166
+ return { error: 'symbols must be a non-empty array of token symbols' };
1167
+ }
1168
+
1169
+ const regionConfig = getRegion(region);
1170
+ const currency = regionConfig.currency.toLowerCase();
1171
+ const rates: Record<string, number> = {};
1172
+ const errors: string[] = [];
1173
+
1174
+ // Separate stablecoins from others
1175
+ const stableSymbols = symbols.filter(s => STABLECOINS.has(s.toUpperCase()));
1176
+ const cryptoSymbols = symbols.filter(s => !STABLECOINS.has(s.toUpperCase()));
1177
+
1178
+ // Get USD → local forex rate upfront (needed for stablecoins AND USD fallback)
1179
+ let forexRate = 1;
1180
+ if (currency !== 'usd') {
1181
+ try {
1182
+ const forexData = await cgFetch(`/simple/price?ids=usd-coin&vs_currencies=${currency}`);
1183
+ forexRate = forexData['usd-coin']?.[currency] || 1;
1184
+ } catch (err: any) {
1185
+ errors.push(`Forex rate error: ${err.message}`);
1186
+ }
1187
+ }
1188
+
1189
+ // 1. Handle stablecoins via forex rate (USD → local)
1190
+ if (stableSymbols.length > 0) {
1191
+ const dk = date ? dateKey(date) : 'current';
1192
+ for (const sym of stableSymbols) {
1193
+ const key = `${sym.toUpperCase()}:${dk}`;
1194
+ rates[key] = forexRate;
1195
+ }
1196
+ }
1197
+
1198
+ // 2. Handle crypto tokens via CoinGecko
1199
+ if (cryptoSymbols.length > 0) {
1200
+ if (date) {
1201
+ // Historical: one request per token (CoinGecko /coins/{id}/history)
1202
+ const d = new Date(date);
1203
+ const ddMmYyyy = `${String(d.getUTCDate()).padStart(2, '0')}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${d.getUTCFullYear()}`;
1204
+
1205
+ for (const sym of cryptoSymbols) {
1206
+ const upper = sym.toUpperCase();
1207
+ const cacheKey = `${upper}:${dateKey(date)}`;
1208
+
1209
+ if (rateCache.has(cacheKey)) {
1210
+ rates[cacheKey] = rateCache.get(cacheKey)!;
1211
+ continue;
1212
+ }
1213
+
1214
+ const cgId = TOKEN_TO_COINGECKO[upper];
1215
+ if (!cgId) {
1216
+ errors.push(`No CoinGecko mapping for ${upper}`);
1217
+ continue;
1218
+ }
1219
+
1220
+ try {
1221
+ const histData = await cgFetch(`/coins/${cgId}/history?date=${ddMmYyyy}`);
1222
+ // Prefer local currency price; if unavailable, use USD × forex rate
1223
+ let price = histData?.market_data?.current_price?.[currency] || 0;
1224
+ if (!price) {
1225
+ const usdPrice = histData?.market_data?.current_price?.usd || 0;
1226
+ price = usdPrice * forexRate;
1227
+ }
1228
+ rates[cacheKey] = price;
1229
+ cacheRate(cacheKey, price);
1230
+ } catch (err: any) {
1231
+ errors.push(`CoinGecko history error for ${upper}: ${err.message}`);
1232
+ }
1233
+
1234
+ await sleep(getCoinGeckoConfig().hasKey ? 100 : 200); // Rate limit (faster with key)
1235
+ }
1236
+ } else {
1237
+ // Current: batch request
1238
+ const cgIds = cryptoSymbols
1239
+ .map(s => TOKEN_TO_COINGECKO[s.toUpperCase()])
1240
+ .filter(Boolean);
1241
+
1242
+ if (cgIds.length > 0) {
1243
+ try {
1244
+ const batchData = await cgFetch(`/simple/price?ids=${cgIds.join(',')}&vs_currencies=${currency},usd`);
1245
+
1246
+ for (const sym of cryptoSymbols) {
1247
+ const upper = sym.toUpperCase();
1248
+ const cgId = TOKEN_TO_COINGECKO[upper];
1249
+ if (!cgId) continue;
1250
+ // Prefer local currency; if unavailable, use USD × forex rate
1251
+ let price = batchData[cgId]?.[currency] || 0;
1252
+ if (!price) {
1253
+ price = (batchData[cgId]?.usd || 0) * forexRate;
1254
+ }
1255
+ rates[`${upper}:current`] = price;
1256
+ }
1257
+ } catch (err: any) {
1258
+ errors.push(`CoinGecko batch error: ${err.message}`);
1259
+ }
1260
+ }
1261
+
1262
+ // Map symbols without CoinGecko IDs
1263
+ for (const sym of cryptoSymbols) {
1264
+ const upper = sym.toUpperCase();
1265
+ if (!TOKEN_TO_COINGECKO[upper] && !rates[`${upper}:current`]) {
1266
+ errors.push(`No CoinGecko mapping for ${upper} — price unavailable`);
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ saveRateCache();
1273
+
1274
+ return {
1275
+ rates,
1276
+ currency: regionConfig.currency,
1277
+ date: date || 'current',
1278
+ errors: errors.length > 0 ? errors : undefined,
1279
+ };
1280
+ },
1281
+ });
1282
+
1283
+ // ════════════════════════════════════════════════
1284
+ // 5. tax_calculate_gains
1285
+ // ════════════════════════════════════════════════
1286
+
1287
+ api.registerTool({
1288
+ name: 'tax_calculate_gains',
1289
+ description: 'Calculate taxable gains/losses using FIFO or Average Cost method. Takes pre-fetched trades, transfers, and rates. Returns disposals, income events, and summary with total taxable income.',
1290
+ parameters: {
1291
+ type: 'object',
1292
+ required: ['trades', 'transfers', 'rates'],
1293
+ properties: {
1294
+ trades: {
1295
+ type: 'array',
1296
+ description: 'Array of DEX trades from tax_get_dex_trades',
1297
+ },
1298
+ transfers: {
1299
+ type: 'array',
1300
+ description: 'Array of categorized transfers from tax_get_transfers',
1301
+ },
1302
+ rates: {
1303
+ type: 'object',
1304
+ description: 'Rate map from tax_get_rates: {"SYMBOL:YYYY-MM-DD": rate}',
1305
+ },
1306
+ method: { type: 'string', description: '"fifo" (default) or "average"' },
1307
+ region: { type: 'string', description: 'Region code: "NZ" (default) or "US"' },
1308
+ },
1309
+ },
1310
+ handler: async ({ trades, transfers, rates, method, region }: {
1311
+ trades: any[]; transfers: any[]; rates: Record<string, number>;
1312
+ method?: string; region?: string;
1313
+ }) => {
1314
+ const regionConfig = getRegion(region);
1315
+ const costMethod = (method || 'fifo').toLowerCase();
1316
+
1317
+ if (!regionConfig.cost_basis_methods.includes(costMethod)) {
1318
+ return { error: `Method "${costMethod}" not supported for ${regionConfig.code}. Supported: ${regionConfig.cost_basis_methods.join(', ')}` };
1319
+ }
1320
+
1321
+ // Normalize trade objects
1322
+ const tradeEvents: TradeEvent[] = (trades || []).map((t: any) => ({
1323
+ type: 'trade' as const,
1324
+ date: t.date || '',
1325
+ buy_amount: parseFloat(t.buy_amount) || 0,
1326
+ buy_currency: t.buy_currency || '',
1327
+ sell_amount: parseFloat(t.sell_amount) || 0,
1328
+ sell_currency: t.sell_currency || '',
1329
+ fee: parseFloat(t.fee) || 0,
1330
+ fee_currency: t.fee_currency || '',
1331
+ tx_id: t.tx_id || '',
1332
+ }));
1333
+
1334
+ // Normalize transfer objects
1335
+ const transferEvents: TransferEvent[] = (transfers || []).map((t: any) => ({
1336
+ type: 'transfer' as const,
1337
+ date: t.timestamp || t.date || '',
1338
+ category: t.category || 'transfer',
1339
+ amount: parseFloat(t.amount) || 0,
1340
+ symbol: t.symbol || '',
1341
+ direction: t.direction || 'incoming',
1342
+ tx_id: t.tx_id || '',
1343
+ }));
1344
+
1345
+ const result = costMethod === 'average'
1346
+ ? calculateGainsAverage(tradeEvents, transferEvents, rates, regionConfig.currency)
1347
+ : calculateGainsFIFO(tradeEvents, transferEvents, rates, regionConfig.currency);
1348
+
1349
+ return result;
1350
+ },
1351
+ });
1352
+
1353
+ // ════════════════════════════════════════════════
1354
+ // 6. tax_generate_report
1355
+ // ════════════════════════════════════════════════
1356
+
1357
+ api.registerTool({
1358
+ name: 'tax_generate_report',
1359
+ description: 'Generate a full crypto tax report. Orchestrates all tax tools: fetches balances, trades, transfers, rates, calculates gains, and estimates tax by bracket. Can accept pre-computed data to skip API calls.',
1360
+ parameters: {
1361
+ type: 'object',
1362
+ required: ['account', 'tax_year'],
1363
+ properties: {
1364
+ account: { type: 'string', description: 'XPR Network account name' },
1365
+ tax_year: { type: 'number', description: 'Tax year number. NZ: 2025 = Apr 2024–Mar 2025. US: 2024 = Jan 2024–Dec 2024' },
1366
+ method: { type: 'string', description: '"fifo" (default) or "average"' },
1367
+ region: { type: 'string', description: 'Region code: "NZ" (default) or "US"' },
1368
+ balances_opening: { type: 'object', description: 'Pre-computed opening balances (skip API call)' },
1369
+ balances_closing: { type: 'object', description: 'Pre-computed closing balances (skip API call)' },
1370
+ trades: { type: 'array', description: 'Pre-computed trades array (skip API call)' },
1371
+ transfers: { type: 'array', description: 'Pre-computed transfers array (skip API call)' },
1372
+ },
1373
+ },
1374
+ handler: async ({ account, tax_year, method, region, balances_opening, balances_closing, trades, transfers }: {
1375
+ account: string;
1376
+ tax_year: number;
1377
+ method?: string;
1378
+ region?: string;
1379
+ balances_opening?: any;
1380
+ balances_closing?: any;
1381
+ trades?: any[];
1382
+ transfers?: any[];
1383
+ }) => {
1384
+ if (!account || typeof account !== 'string') {
1385
+ return { error: 'account parameter is required' };
1386
+ }
1387
+ if (!tax_year || typeof tax_year !== 'number') {
1388
+ return { error: 'tax_year parameter is required (e.g. 2025)' };
1389
+ }
1390
+
1391
+ const regionConfig = getRegion(region);
1392
+ const costMethod = (method || 'fifo').toLowerCase();
1393
+ const { start, end } = getTaxYearDates(tax_year, regionConfig);
1394
+ const currency = regionConfig.currency.toLowerCase();
1395
+ const steps: string[] = [];
1396
+
1397
+ try {
1398
+ // Step 1: Fetch opening balances
1399
+ let openingBalances = balances_opening;
1400
+ if (!openingBalances) {
1401
+ steps.push('Fetching opening balances...');
1402
+ try {
1403
+ let url = `${SALTANT_BASE}/v2/state/get_balance?account=${encodeURIComponent(account)}&datetime=${encodeURIComponent(start)}`;
1404
+ openingBalances = await httpGetJson(url);
1405
+ } catch (err: any) {
1406
+ openingBalances = { error: err.message };
1407
+ }
1408
+ }
1409
+
1410
+ // Step 2: Fetch closing balances
1411
+ let closingBalances = balances_closing;
1412
+ if (!closingBalances) {
1413
+ steps.push('Fetching closing balances...');
1414
+ try {
1415
+ let url = `${SALTANT_BASE}/v2/state/get_balance?account=${encodeURIComponent(account)}&datetime=${encodeURIComponent(end)}`;
1416
+ closingBalances = await httpGetJson(url);
1417
+ } catch (err: any) {
1418
+ closingBalances = { error: err.message };
1419
+ }
1420
+ }
1421
+
1422
+ // Step 3: Fetch DEX trades
1423
+ let tradeData = trades;
1424
+ if (!tradeData) {
1425
+ steps.push('Fetching DEX trades...');
1426
+ try {
1427
+ const url = `${METALX_TAX_BASE}/dex/v1/tax/user?account=${encodeURIComponent(account)}`;
1428
+ const csvText = await httpGetText(url);
1429
+ const rows = parseCSV(csvText);
1430
+
1431
+ tradeData = rows
1432
+ .filter(row => (row['Type'] || row['type'] || '').toLowerCase() === 'trade')
1433
+ .map(row => ({
1434
+ type: row['Type'] || row['type'] || '',
1435
+ buy_amount: parseFloat(row['Buy Amount'] || row['buy_amount'] || '0'),
1436
+ buy_currency: row['Buy Currency'] || row['buy_currency'] || '',
1437
+ sell_amount: parseFloat(row['Sell Amount'] || row['sell_amount'] || '0'),
1438
+ sell_currency: row['Sell Currency'] || row['sell_currency'] || '',
1439
+ fee: parseFloat(row['Fee'] || row['fee'] || '0'),
1440
+ fee_currency: row['Fee Currency'] || row['fee_currency'] || '',
1441
+ date: row['Date'] || row['date'] || '',
1442
+ tx_id: row['Tx-ID'] || row['TxId'] || row['txid'] || row['tx_id'] || '',
1443
+ }));
1444
+
1445
+ // Filter to tax year
1446
+ const startMs = new Date(start).getTime();
1447
+ const endMs = new Date(end).getTime();
1448
+ tradeData = tradeData.filter(t => {
1449
+ const ms = new Date(t.date).getTime();
1450
+ return ms >= startMs && ms <= endMs;
1451
+ });
1452
+ } catch (err: any) {
1453
+ tradeData = [];
1454
+ steps.push(`DEX trades error: ${err.message}`);
1455
+ }
1456
+ }
1457
+
1458
+ // Step 4: Fetch transfers
1459
+ let transferData = transfers;
1460
+ if (!transferData) {
1461
+ steps.push('Fetching transfers...');
1462
+ try {
1463
+ const allTransfers: CategorizedTransfer[] = [];
1464
+ let skip = 0;
1465
+ let hasMore = true;
1466
+ const maxTransfers = 5000;
1467
+ const pageSize = 100;
1468
+
1469
+ while (hasMore && allTransfers.length < maxTransfers) {
1470
+ let url = `${SALTANT_BASE}/v2/history/get_actions?account=${encodeURIComponent(account)}&act.name=transfer&limit=100&sort=asc&skip=${skip}`;
1471
+ url += `&after=${encodeURIComponent(start)}&before=${encodeURIComponent(end)}`;
1472
+
1473
+ const data = await httpGetJson(url);
1474
+ const actions: any[] = data.actions || [];
1475
+
1476
+ if (actions.length === 0) { hasMore = false; break; }
1477
+
1478
+ for (const action of actions) {
1479
+ if (allTransfers.length >= maxTransfers) break;
1480
+ const act = action.act?.data || {};
1481
+ const from = act.from || '';
1482
+ const to = act.to || '';
1483
+ const memo = act.memo || '';
1484
+ const quantityStr = act.quantity || '0 UNKNOWN';
1485
+ const parts = quantityStr.split(' ');
1486
+ const amount = parseFloat(parts[0]) || 0;
1487
+ const symbol = parts[1] || 'UNKNOWN';
1488
+ if (amount === 0) continue;
1489
+
1490
+ const category = categorizeTransfer(account, from, to, amount, symbol, memo);
1491
+ const direction: 'incoming' | 'outgoing' = to === account ? 'incoming' : 'outgoing';
1492
+
1493
+ allTransfers.push({
1494
+ category, from, to, amount, symbol, memo,
1495
+ timestamp: action['@timestamp'] || action.timestamp || '',
1496
+ tx_id: action.trx_id || '',
1497
+ direction,
1498
+ });
1499
+ }
1500
+
1501
+ skip += actions.length;
1502
+ if (actions.length < pageSize) hasMore = false;
1503
+ }
1504
+
1505
+ transferData = allTransfers;
1506
+ } catch (err: any) {
1507
+ transferData = [];
1508
+ steps.push(`Transfers error: ${err.message}`);
1509
+ }
1510
+ }
1511
+
1512
+ // Step 5: Build conversion rates
1513
+ // Strategy: DEX trades give us direct price ratios (primary source),
1514
+ // stablecoins use forex rate, CoinGecko as fallback for recent dates only
1515
+ steps.push('Building conversion rates...');
1516
+ const rates: Record<string, number> = {};
1517
+ const allSymbols = new Set<string>();
1518
+ const uniqueDates = new Set<string>();
1519
+
1520
+ for (const t of (tradeData || [])) {
1521
+ if (t.buy_currency) { allSymbols.add(t.buy_currency); uniqueDates.add(dateKey(t.date)); }
1522
+ if (t.sell_currency) { allSymbols.add(t.sell_currency); uniqueDates.add(dateKey(t.date)); }
1523
+ }
1524
+ for (const t of (transferData || []) as CategorizedTransfer[]) {
1525
+ if (t.symbol) { allSymbols.add(t.symbol); uniqueDates.add(dateKey(t.timestamp)); }
1526
+ }
1527
+
1528
+ // Pre-populate rates from persistent cache (historical prices are immutable)
1529
+ let cacheHits = 0;
1530
+ for (const sym of allSymbols) {
1531
+ const upper = sym.toUpperCase();
1532
+ for (const d of uniqueDates) {
1533
+ const key = `${upper}:${d}`;
1534
+ const cached = rateCache.get(key);
1535
+ if (cached && cached > 0) {
1536
+ rates[key] = cached;
1537
+ cacheHits++;
1538
+ }
1539
+ }
1540
+ }
1541
+ if (cacheHits > 0) steps.push(`Loaded ${cacheHits} rates from cache`);
1542
+
1543
+ // Get USD→local forex rate (stablecoins and XMD are pegged to USD)
1544
+ let forexRate = 1;
1545
+ if (currency !== 'usd') {
1546
+ try {
1547
+ const forexData = await cgFetch(`/simple/price?ids=usd-coin&vs_currencies=${currency}`);
1548
+ forexRate = forexData['usd-coin']?.[currency] || 1;
1549
+ } catch { /* use 1 */ }
1550
+ }
1551
+
1552
+ // Set stablecoin/XMD rates for all dates (they're pegged to USD)
1553
+ for (const sym of allSymbols) {
1554
+ const upper = sym.toUpperCase();
1555
+ if (STABLECOINS.has(upper) || upper === 'XMD') {
1556
+ for (const d of uniqueDates) {
1557
+ rates[`${upper}:${d}`] = forexRate;
1558
+ }
1559
+ rates[`${upper}:current`] = forexRate;
1560
+ }
1561
+ }
1562
+
1563
+ // Derive token rates from DEX trades (primary source — no API limits)
1564
+ // Most trades are TOKEN→XMD, so rate = buy_xmd / sell_token * forexRate
1565
+ const dexRatesByDate: Record<string, number> = {}; // "SYMBOL:date" → local rate
1566
+ for (const t of (tradeData || [])) {
1567
+ if (!t.date || !t.sell_currency || !t.buy_currency) continue;
1568
+ const d = dateKey(t.date);
1569
+
1570
+ // TOKEN → XMD: sell token, buy XMD → token price = (buy_xmd / sell_token) * forex
1571
+ if ((t.buy_currency === 'XMD' || STABLECOINS.has(t.buy_currency.toUpperCase())) && t.sell_amount > 0 && t.buy_amount > 0) {
1572
+ const tokenRate = (t.buy_amount / t.sell_amount) * forexRate;
1573
+ const key = `${t.sell_currency.toUpperCase()}:${d}`;
1574
+ // Use last trade of the day (overwrites earlier)
1575
+ dexRatesByDate[key] = tokenRate;
1576
+ }
1577
+ // XMD → TOKEN: sell XMD, buy token → token price = (sell_xmd / buy_token) * forex
1578
+ if ((t.sell_currency === 'XMD' || STABLECOINS.has(t.sell_currency.toUpperCase())) && t.buy_amount > 0 && t.sell_amount > 0) {
1579
+ const tokenRate = (t.sell_amount / t.buy_amount) * forexRate;
1580
+ const key = `${t.buy_currency.toUpperCase()}:${d}`;
1581
+ dexRatesByDate[key] = tokenRate;
1582
+ }
1583
+ }
1584
+
1585
+ // Apply DEX-derived rates
1586
+ for (const [key, rate] of Object.entries(dexRatesByDate)) {
1587
+ if (!rates[key]) {
1588
+ rates[key] = rate;
1589
+ cacheRate(key, rate);
1590
+ }
1591
+ }
1592
+
1593
+ // Fill gaps: for dates without a DEX trade, use nearest available DEX rate
1594
+ const symbolsNeedingRates = [...allSymbols].filter(s => {
1595
+ const upper = s.toUpperCase();
1596
+ return !STABLECOINS.has(upper) && upper !== 'XMD';
1597
+ });
1598
+ const sortedDates = [...uniqueDates].sort();
1599
+
1600
+ for (const sym of symbolsNeedingRates) {
1601
+ const upper = sym.toUpperCase();
1602
+ let lastKnownRate = 0;
1603
+
1604
+ for (const d of sortedDates) {
1605
+ const key = `${upper}:${d}`;
1606
+ if (rates[key] && rates[key] > 0) {
1607
+ lastKnownRate = rates[key];
1608
+ } else if (lastKnownRate > 0) {
1609
+ // Forward-fill from last known rate
1610
+ rates[key] = lastKnownRate;
1611
+ }
1612
+ }
1613
+ }
1614
+
1615
+ // Fallback: CoinGecko for dates where we still have no rate
1616
+ // With API key: no date limit, higher rate limits, more fetches allowed
1617
+ // Without key: limited to 365 days, max 30 fetches
1618
+ const cgConfig = getCoinGeckoConfig();
1619
+ const now = Date.now();
1620
+ const oneYearMs = 365 * 24 * 60 * 60 * 1000;
1621
+ let cgFetches = 0;
1622
+ const MAX_CG_FETCHES = cgConfig.hasKey ? 2000 : 30;
1623
+ const CG_DELAY = cgConfig.hasKey ? 100 : 200;
1624
+
1625
+ for (const d of sortedDates) {
1626
+ const dateMs = new Date(d + 'T00:00:00Z').getTime();
1627
+ if (isNaN(dateMs)) continue;
1628
+ // Without API key, skip dates beyond 365 days (CoinGecko free limit)
1629
+ if (!cgConfig.hasKey && (now - dateMs) > oneYearMs) continue;
1630
+
1631
+ for (const sym of symbolsNeedingRates) {
1632
+ const upper = sym.toUpperCase();
1633
+ const key = `${upper}:${d}`;
1634
+ if (rates[key] && rates[key] > 0) continue;
1635
+ if (cgFetches >= MAX_CG_FETCHES) continue;
1636
+
1637
+ const cgId = TOKEN_TO_COINGECKO[upper];
1638
+ if (!cgId) continue;
1639
+
1640
+ const dateObj = new Date(d + 'T00:00:00Z');
1641
+ const ddMmYyyy = `${String(dateObj.getUTCDate()).padStart(2, '0')}-${String(dateObj.getUTCMonth() + 1).padStart(2, '0')}-${dateObj.getUTCFullYear()}`;
1642
+
1643
+ try {
1644
+ const histData = await cgFetch(`/coins/${cgId}/history?date=${ddMmYyyy}`);
1645
+ // Prefer local currency; if unavailable, use USD × forex rate
1646
+ let price = histData?.market_data?.current_price?.[currency] || 0;
1647
+ if (!price) {
1648
+ const usdPrice = histData?.market_data?.current_price?.usd || 0;
1649
+ price = usdPrice * forexRate;
1650
+ }
1651
+ if (price > 0) {
1652
+ rates[key] = price;
1653
+ cacheRate(key, price);
1654
+ }
1655
+ cgFetches++;
1656
+ } catch { /* skip */ }
1657
+
1658
+ await sleep(CG_DELAY);
1659
+ }
1660
+ }
1661
+
1662
+ // Forward-fill again after CoinGecko to cover remaining gaps
1663
+ for (const sym of symbolsNeedingRates) {
1664
+ const upper = sym.toUpperCase();
1665
+ let lastKnownRate = 0;
1666
+ for (const d of sortedDates) {
1667
+ const key = `${upper}:${d}`;
1668
+ if (rates[key] && rates[key] > 0) {
1669
+ lastKnownRate = rates[key];
1670
+ } else if (lastKnownRate > 0) {
1671
+ rates[key] = lastKnownRate;
1672
+ }
1673
+ }
1674
+ // Also backward-fill: if the first few dates had no rate but later ones do
1675
+ let firstKnownRate = 0;
1676
+ for (let i = sortedDates.length - 1; i >= 0; i--) {
1677
+ const d = sortedDates[i];
1678
+ const key = `${upper}:${d}`;
1679
+ if (rates[key] && rates[key] > 0) {
1680
+ firstKnownRate = rates[key];
1681
+ } else if (firstKnownRate > 0) {
1682
+ rates[key] = firstKnownRate;
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ // Final fallback: fetch current rate for any symbols still missing
1688
+ const missingSymbols = symbolsNeedingRates.filter(sym => {
1689
+ const upper = sym.toUpperCase();
1690
+ return sortedDates.some(d => !rates[`${upper}:${d}`] || rates[`${upper}:${d}`] === 0);
1691
+ });
1692
+ if (missingSymbols.length > 0) {
1693
+ const cgIds = missingSymbols.map(s => TOKEN_TO_COINGECKO[s.toUpperCase()]).filter(Boolean);
1694
+ if (cgIds.length > 0) {
1695
+ try {
1696
+ const currentData = await cgFetch(`/simple/price?ids=${cgIds.join(',')}&vs_currencies=${currency},usd`);
1697
+ for (const sym of missingSymbols) {
1698
+ const upper = sym.toUpperCase();
1699
+ const cgId = TOKEN_TO_COINGECKO[upper];
1700
+ if (!cgId || !currentData[cgId]) continue;
1701
+ let price = currentData[cgId][currency] || 0;
1702
+ if (!price) {
1703
+ price = (currentData[cgId].usd || 0) * forexRate;
1704
+ }
1705
+ if (price > 0) {
1706
+ // Apply to all dates that still have no rate
1707
+ for (const d of sortedDates) {
1708
+ const key = `${upper}:${d}`;
1709
+ if (!rates[key] || rates[key] === 0) {
1710
+ rates[key] = price;
1711
+ }
1712
+ }
1713
+ rates[`${upper}:current`] = price;
1714
+ }
1715
+ }
1716
+ } catch { /* skip */ }
1717
+ }
1718
+ }
1719
+
1720
+ // Persist rate cache to disk
1721
+ saveRateCache();
1722
+
1723
+ // Step 6: Calculate gains
1724
+ steps.push('Calculating gains...');
1725
+ const tradeEvents: TradeEvent[] = (tradeData || []).map((t: any) => ({
1726
+ type: 'trade' as const,
1727
+ date: t.date || '',
1728
+ buy_amount: parseFloat(t.buy_amount) || 0,
1729
+ buy_currency: t.buy_currency || '',
1730
+ sell_amount: parseFloat(t.sell_amount) || 0,
1731
+ sell_currency: t.sell_currency || '',
1732
+ fee: parseFloat(t.fee) || 0,
1733
+ fee_currency: t.fee_currency || '',
1734
+ tx_id: t.tx_id || '',
1735
+ }));
1736
+
1737
+ const transferEvents: TransferEvent[] = (transferData || []).map((t: any) => ({
1738
+ type: 'transfer' as const,
1739
+ date: t.timestamp || t.date || '',
1740
+ category: t.category || 'transfer',
1741
+ amount: parseFloat(t.amount) || 0,
1742
+ symbol: t.symbol || '',
1743
+ direction: t.direction || 'incoming',
1744
+ tx_id: t.tx_id || '',
1745
+ }));
1746
+
1747
+ const gains = costMethod === 'average'
1748
+ ? calculateGainsAverage(tradeEvents, transferEvents, rates, regionConfig.currency)
1749
+ : calculateGainsFIFO(tradeEvents, transferEvents, rates, regionConfig.currency);
1750
+
1751
+ // Step 7: Estimate tax
1752
+ const taxableIncome = gains.summary.grand_total_taxable;
1753
+ const taxBrackets = calculateTax(Math.max(0, taxableIncome), regionConfig);
1754
+ const estimatedTax = taxBrackets.reduce((sum, b) => sum + b.tax, 0);
1755
+
1756
+ // Build CSV exports
1757
+ const disposalCsv = [
1758
+ 'Date,Asset,Amount,Proceeds,Cost Basis,Gain/Loss,Method,TX ID',
1759
+ ...gains.disposals.map(d =>
1760
+ `${d.date},${d.asset},${d.amount},${d.proceeds_local.toFixed(2)},${d.cost_basis_local.toFixed(2)},${d.gain_loss_local.toFixed(2)},${d.method},${d.tx_id || ''}`
1761
+ ),
1762
+ ].join('\n');
1763
+
1764
+ const incomeCsv = [
1765
+ 'Date,Category,Asset,Amount,Value,TX ID',
1766
+ ...gains.income_events.map(e =>
1767
+ `${e.date},${e.category},${e.asset},${e.amount},${e.value_local.toFixed(2)},${e.tx_id || ''}`
1768
+ ),
1769
+ ].join('\n');
1770
+
1771
+ // Transfer summary by category
1772
+ const transferSummary: Record<string, number> = {};
1773
+ for (const t of (transferData || []) as CategorizedTransfer[]) {
1774
+ transferSummary[t.category] = (transferSummary[t.category] || 0) + 1;
1775
+ }
1776
+
1777
+ // Income by category
1778
+ const incomeByCategory = gains.income_events.reduce((acc: Record<string, number>, e) => {
1779
+ acc[e.category] = (acc[e.category] || 0) + e.value_local;
1780
+ return acc;
1781
+ }, {});
1782
+
1783
+ const effectiveRate = taxableIncome > 0
1784
+ ? `${((estimatedTax / taxableIncome) * 100).toFixed(2)}%`
1785
+ : '0%';
1786
+
1787
+ // ── Build formatted markdown report ──
1788
+ const CUR = regionConfig.currency;
1789
+ const fmt = (n: number) => `$${n.toLocaleString('en-NZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
1790
+ const startLabel = start.slice(0, 10);
1791
+ const endLabel = end.slice(0, 10);
1792
+
1793
+ const md: string[] = [];
1794
+ md.push(`# Crypto Tax Report — ${regionConfig.name}`);
1795
+ md.push('');
1796
+ md.push(`**Account:** \`${account}\``);
1797
+ md.push(`**Tax Year:** ${tax_year} (${startLabel} to ${endLabel})`);
1798
+ md.push(`**Currency:** ${CUR}`);
1799
+ md.push(`**Cost Basis Method:** ${costMethod.toUpperCase()}`);
1800
+ md.push(`**Generated:** ${new Date().toISOString().slice(0, 10)}`);
1801
+ md.push('');
1802
+
1803
+ // Balance sheets
1804
+ md.push('---');
1805
+ md.push('');
1806
+ md.push('## Balance Snapshots');
1807
+ md.push('');
1808
+ md.push(`### Opening Balances (${startLabel})`);
1809
+ md.push('');
1810
+ md.push(formatBalancesMarkdown(openingBalances));
1811
+ md.push('');
1812
+ md.push(`### Closing Balances (${endLabel})`);
1813
+ md.push('');
1814
+ md.push(formatBalancesMarkdown(closingBalances));
1815
+ md.push('');
1816
+
1817
+ // Activity summary
1818
+ md.push('---');
1819
+ md.push('');
1820
+ md.push('## Activity Summary');
1821
+ md.push('');
1822
+ md.push(`| Metric | Count |`);
1823
+ md.push(`|--------|-------|`);
1824
+ md.push(`| DEX Trades | ${(tradeData || []).length} |`);
1825
+ md.push(`| On-chain Transfers | ${(transferData || []).length} |`);
1826
+ md.push('');
1827
+
1828
+ if (Object.keys(transferSummary).length > 0) {
1829
+ md.push('**Transfers by Category:**');
1830
+ md.push('');
1831
+ md.push('| Category | Count |');
1832
+ md.push('|----------|-------|');
1833
+ for (const [cat, count] of Object.entries(transferSummary).sort((a, b) => (b[1] as number) - (a[1] as number))) {
1834
+ md.push(`| ${cat} | ${count} |`);
1835
+ }
1836
+ md.push('');
1837
+ }
1838
+
1839
+ // Trading summary
1840
+ md.push('---');
1841
+ md.push('');
1842
+ md.push('## Trading Summary');
1843
+ md.push('');
1844
+ md.push(`| | ${CUR} |`);
1845
+ md.push(`|---|---:|`);
1846
+ md.push(`| Total Proceeds | ${fmt(gains.summary.total_proceeds)} |`);
1847
+ md.push(`| Total Cost Basis | ${fmt(gains.summary.total_cost_basis)} |`);
1848
+ md.push(`| **Net Gain/Loss** | **${fmt(gains.summary.net_gain_loss)}** |`);
1849
+ md.push(`| Disposals | ${gains.disposals.length} |`);
1850
+ md.push('');
1851
+
1852
+ // Top disposals (max 20)
1853
+ if (gains.disposals.length > 0) {
1854
+ md.push('### Disposals');
1855
+ md.push('');
1856
+ md.push('| Date | Asset | Amount | Proceeds | Cost Basis | Gain/Loss |');
1857
+ md.push('|------|-------|-------:|--------:|---------:|----------:|');
1858
+ const topDisposals = gains.disposals.slice(0, 20);
1859
+ for (const d of topDisposals) {
1860
+ md.push(`| ${d.date.slice(0, 10)} | ${d.asset} | ${d.amount} | ${fmt(d.proceeds_local)} | ${fmt(d.cost_basis_local)} | ${fmt(d.gain_loss_local)} |`);
1861
+ }
1862
+ if (gains.disposals.length > 20) {
1863
+ md.push(`| ... | *${gains.disposals.length - 20} more* | | | | |`);
1864
+ }
1865
+ md.push('');
1866
+ }
1867
+
1868
+ // Income summary
1869
+ md.push('---');
1870
+ md.push('');
1871
+ md.push('## Income Summary');
1872
+ md.push('');
1873
+ md.push(`| Category | ${CUR} |`);
1874
+ md.push(`|----------|---:|`);
1875
+ for (const [cat, val] of Object.entries(incomeByCategory).sort((a, b) => (b[1] as number) - (a[1] as number))) {
1876
+ md.push(`| ${cat} | ${fmt(val as number)} |`);
1877
+ }
1878
+ md.push(`| **Total Income** | **${fmt(gains.summary.total_income)}** |`);
1879
+ md.push('');
1880
+
1881
+ // Top income events (max 20)
1882
+ if (gains.income_events.length > 0) {
1883
+ md.push('### Income Events');
1884
+ md.push('');
1885
+ md.push('| Date | Category | Asset | Amount | Value |');
1886
+ md.push('|------|----------|-------|-------:|------:|');
1887
+ const topIncome = gains.income_events.slice(0, 20);
1888
+ for (const e of topIncome) {
1889
+ md.push(`| ${e.date.slice(0, 10)} | ${e.category} | ${e.asset} | ${e.amount} | ${fmt(e.value_local)} |`);
1890
+ }
1891
+ if (gains.income_events.length > 20) {
1892
+ md.push(`| ... | *${gains.income_events.length - 20} more* | | | |`);
1893
+ }
1894
+ md.push('');
1895
+ }
1896
+
1897
+ // Tax estimate
1898
+ md.push('---');
1899
+ md.push('');
1900
+ md.push('## Estimated Tax');
1901
+ md.push('');
1902
+ md.push(`| | ${CUR} |`);
1903
+ md.push(`|---|---:|`);
1904
+ md.push(`| Net Trading Gain/Loss | ${fmt(gains.summary.net_gain_loss)} |`);
1905
+ md.push(`| Total Income | ${fmt(gains.summary.total_income)} |`);
1906
+ md.push(`| **Total Taxable** | **${fmt(gains.summary.grand_total_taxable)}** |`);
1907
+ md.push('');
1908
+
1909
+ if (taxBrackets.length > 0) {
1910
+ md.push('**Tax Brackets:**');
1911
+ md.push('');
1912
+ md.push('| Bracket | Income | Rate | Tax |');
1913
+ md.push('|---------|-------:|-----:|----:|');
1914
+ for (const b of taxBrackets) {
1915
+ md.push(`| ${b.bracket} | ${fmt(b.income)} | ${(b.rate * 100).toFixed(1)}% | ${fmt(b.tax)} |`);
1916
+ }
1917
+ md.push(`| **Total** | | | **${fmt(round2(estimatedTax))}** |`);
1918
+ md.push(`| **Effective Rate** | | **${effectiveRate}** | |`);
1919
+ md.push('');
1920
+ }
1921
+
1922
+ if (!regionConfig.has_capital_gains) {
1923
+ md.push('> No separate capital gains tax in ' + regionConfig.name + ' — all crypto gains are treated as income.');
1924
+ md.push('');
1925
+ }
1926
+
1927
+ // Disclaimer
1928
+ md.push('---');
1929
+ md.push('');
1930
+ md.push(`**Disclaimer:** ${regionConfig.disclaimer}`);
1931
+ md.push('');
1932
+
1933
+ const reportMarkdown = md.join('\n');
1934
+
1935
+ return {
1936
+ report: {
1937
+ account,
1938
+ tax_year: tax_year,
1939
+ region: regionConfig.code,
1940
+ currency: regionConfig.currency,
1941
+ period: { start, end },
1942
+ method: costMethod,
1943
+ },
1944
+ opening_balances: openingBalances,
1945
+ closing_balances: closingBalances,
1946
+ activity: {
1947
+ dex_trades: (tradeData || []).length,
1948
+ transfers: (transferData || []).length,
1949
+ transfer_categories: transferSummary,
1950
+ },
1951
+ trading_summary: {
1952
+ total_proceeds: gains.summary.total_proceeds,
1953
+ total_cost_basis: gains.summary.total_cost_basis,
1954
+ net_gain_loss: gains.summary.net_gain_loss,
1955
+ total_disposals: gains.disposals.length,
1956
+ },
1957
+ income_summary: {
1958
+ total_income: gains.summary.total_income,
1959
+ by_category: incomeByCategory,
1960
+ total_events: gains.income_events.length,
1961
+ },
1962
+ tax_estimate: {
1963
+ total_taxable_income: gains.summary.grand_total_taxable,
1964
+ brackets: taxBrackets,
1965
+ estimated_tax: round2(estimatedTax),
1966
+ effective_rate: effectiveRate,
1967
+ note: regionConfig.has_capital_gains
1968
+ ? 'Capital gains tax applies in this region'
1969
+ : 'No separate capital gains tax — all gains treated as income',
1970
+ },
1971
+ report_markdown: reportMarkdown,
1972
+ csv_exports: {
1973
+ disposals: disposalCsv,
1974
+ income: incomeCsv,
1975
+ },
1976
+ remaining_cost_basis: gains.remaining_lots,
1977
+ steps_completed: steps,
1978
+ disclaimer: regionConfig.disclaimer,
1979
+ };
1980
+ } catch (err: any) {
1981
+ return { error: `Report generation failed: ${err.message}`, steps_completed: steps };
1982
+ }
1983
+ },
1984
+ });
1985
+ }