@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.
- package/README.md +51 -10
- package/openclaw.plugin.json +15 -1
- package/package.json +7 -4
- package/skills/code-sandbox/SKILL.md +30 -0
- package/skills/code-sandbox/dist/index.js +188 -0
- package/skills/code-sandbox/skill.json +13 -0
- package/skills/code-sandbox/src/index.ts +212 -0
- package/skills/creative/SKILL.md +32 -0
- package/skills/creative/dist/index.js +667 -0
- package/skills/creative/skill.json +13 -0
- package/skills/creative/src/index.ts +679 -0
- package/skills/defi/SKILL.md +123 -0
- package/skills/defi/dist/index.js +1745 -0
- package/skills/defi/skill.json +44 -0
- package/skills/defi/src/index.ts +1788 -0
- package/skills/defi/test-read.mjs +281 -0
- package/skills/governance/SKILL.md +69 -0
- package/skills/governance/dist/index.js +632 -0
- package/skills/governance/skill.json +21 -0
- package/skills/governance/src/index.ts +656 -0
- package/skills/governance/test-read.mjs +176 -0
- package/skills/lending/SKILL.md +63 -0
- package/skills/lending/dist/index.js +1039 -0
- package/skills/lending/skill.json +29 -0
- package/skills/lending/src/index.ts +1105 -0
- package/skills/lending/test-read.mjs +156 -0
- package/skills/nft/SKILL.md +95 -0
- package/skills/nft/dist/index.js +1520 -0
- package/skills/nft/skill.json +37 -0
- package/skills/nft/src/index.ts +1539 -0
- package/skills/shellbook/SKILL.md +59 -0
- package/skills/shellbook/dist/index.js +381 -0
- package/skills/shellbook/skill.json +29 -0
- package/skills/shellbook/src/index.ts +391 -0
- package/skills/shellbook/tsconfig.json +14 -0
- package/skills/smart-contracts/SKILL.md +128 -0
- package/skills/smart-contracts/dist/index.js +1225 -0
- package/skills/smart-contracts/skill.json +25 -0
- package/skills/smart-contracts/src/index.ts +1327 -0
- package/skills/smart-contracts/tsconfig.json +14 -0
- package/skills/structured-data/SKILL.md +36 -0
- package/skills/structured-data/dist/index.js +501 -0
- package/skills/structured-data/skill.json +13 -0
- package/skills/structured-data/src/index.ts +597 -0
- package/skills/tax/SKILL.md +109 -0
- package/skills/tax/dist/index.js +1749 -0
- package/skills/tax/skill.json +20 -0
- package/skills/tax/src/index.ts +1985 -0
- package/skills/web-scraping/SKILL.md +29 -0
- package/skills/web-scraping/dist/index.js +311 -0
- package/skills/web-scraping/skill.json +13 -0
- package/skills/web-scraping/src/index.ts +371 -0
- package/skills/xmd/SKILL.md +52 -0
- package/skills/xmd/dist/index.js +596 -0
- package/skills/xmd/skill.json +22 -0
- package/skills/xmd/src/index.ts +635 -0
- 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
|
+
}
|