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