@xpr-agents/openclaw 0.3.0 → 0.3.2

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