diff --git a/src/plugins/arbitrum_one/ZenmoneyManifest.xml b/src/plugins/arbitrum_one/ZenmoneyManifest.xml new file mode 100755 index 000000000..b6d5d5a24 --- /dev/null +++ b/src/plugins/arbitrum_one/ZenmoneyManifest.xml @@ -0,0 +1,16 @@ + + + arbitrum_one + 0 + + A plugin that retrieves balances and transactions on the Arbitrum One network using Arbiscan and Etherscan compatible API services. + Input your wallet's address and API key from the API provider. + + 1.0 + 1 + true + + index.js + preferences.xml + + diff --git a/src/plugins/arbitrum_one/__tests__/api.test.ts b/src/plugins/arbitrum_one/__tests__/api.test.ts new file mode 100644 index 000000000..26c525d33 --- /dev/null +++ b/src/plugins/arbitrum_one/__tests__/api.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, jest } from '@jest/globals' +import { ArbitrumOneApi } from '../api' + +type FetchMock = jest.MockedFunction + +describe('ArbitrumOneApi.getBalance', () => { + it('возвращает корректный баланс (объект result)', async () => { + const mockFetch = jest.fn() as FetchMock + global.fetch = mockFetch + + mockFetch.mockResolvedValue({ + json: async () => ({ + status: '1', + result: { balance: '1000000000000000000' } + }) + } as any) + + const api = new ArbitrumOneApi('TEST_KEY') + const res = await api.getBalance('0x123') + + expect(res.balance).toBe('1000000000000000000') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('возвращает корректный баланс (строка result)', async () => { + const mockFetch = jest.fn() as FetchMock + global.fetch = mockFetch + + mockFetch.mockResolvedValue({ + json: async () => ({ + status: '1', + result: '2000000000000000000' + }) + } as any) + + const api = new ArbitrumOneApi('TEST_KEY') + const res = await api.getBalance('0x123') + + expect(res.balance).toBe('2000000000000000000') + }) +}) + +describe('ArbitrumOneApi.getBlockNumberByTimestamp', () => { + it('возвращает число блока', async () => { + const mockFetch = jest.fn() as FetchMock + global.fetch = mockFetch + + mockFetch.mockResolvedValue({ + json: async () => ({ + status: '1', + result: '123456' + }) + } as any) + + const api = new ArbitrumOneApi('TEST_KEY') + const block = await api.getBlockNumberByTimestamp(1700000000) + + expect(block).toBe(123456) + }) +}) diff --git a/src/plugins/arbitrum_one/__tests__/convertBalances.test.ts b/src/plugins/arbitrum_one/__tests__/convertBalances.test.ts new file mode 100644 index 000000000..c2f5fd045 --- /dev/null +++ b/src/plugins/arbitrum_one/__tests__/convertBalances.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from '@jest/globals' +import { convertBalances } from '../converter' +import { SUPPORTED_TOKENS } from '../supportedTokens' + +describe('convertBalances', () => { + it('конвертирует нативный баланс ETH', () => { + const native = { balance: '1000000000000000000' } // 1 ETH + const tokens: any[] = [] + + const res = convertBalances(native as any, tokens) + + expect(res[0].id).toBe('arbitrum-one-main') + expect(res[0].instrument).toBe('µETH') + expect(res[0].balance).toBe(1000000) + }) + + it('конвертирует поддерживаемый токен', () => { + const tokenMeta = SUPPORTED_TOKENS[0] + const native = { balance: '0' } + const tokens = [ + { + contract: tokenMeta.contract, + balance: String(10 * 10 ** tokenMeta.decimals), + symbol: tokenMeta.symbol + } + ] + + const res = convertBalances(native as any, tokens as any) + + const tokenAcc = res.find((r) => r.instrument === tokenMeta.symbol) + expect(tokenAcc).toBeDefined() + expect(tokenAcc?.balance).toBe(10) + }) +}) diff --git a/src/plugins/arbitrum_one/__tests__/convertTransactions.test.ts b/src/plugins/arbitrum_one/__tests__/convertTransactions.test.ts new file mode 100644 index 000000000..7d72f9e48 --- /dev/null +++ b/src/plugins/arbitrum_one/__tests__/convertTransactions.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from '@jest/globals' +import { convertTransactions } from '../converter' +import { SUPPORTED_TOKENS } from '../supportedTokens' + +describe('convertTransactions', () => { + it('конвертирует исходящую ETH транзакцию', () => { + const nativeTxs = [ + { + hash: '0x1', + from: '0xaaa', + to: '0xbbb', + value: '1000000000000000000', // 1 ETH + gasUsed: '21000', + gasPrice: '1000000000', + timeStamp: '1700000000' + } + ] + + const res = convertTransactions(nativeTxs as any, [], '0xaaa') + + expect(res).toHaveLength(1) + const tx = res[0] + expect(tx.movements[0].sum).toBe(-1000000) + expect(tx.movements[0].fee).toBeGreaterThan(0) + expect(tx.merchant.fullTitle).toBe('0xbbb') + }) + + it('конвертирует входящую ETH транзакцию', () => { + const nativeTxs = [ + { + hash: '0x2', + from: '0xccc', + to: '0xddd', + value: '2000000000000000000', // 2 ETH + gasUsed: '21000', + gasPrice: '1000000000', + timeStamp: '1700000001' + } + ] + + const res = convertTransactions(nativeTxs as any, [], '0xddd') + + expect(res).toHaveLength(1) + const tx = res[0] + expect(tx.movements[0].sum).toBe(2000000) + expect(tx.movements[0].fee).toBe(0) + expect(tx.merchant.fullTitle).toBe('0xccc') + }) + + it('конвертирует ERC20 транзакцию', () => { + const tokenMeta = SUPPORTED_TOKENS[0] + + const nativeTxs: any[] = [] + const tokenTxs = [ + { + hash: '0x3', + from: '0xaaa', + to: '0xbbb', + contract: tokenMeta.contract, + value: String(5 * 10 ** tokenMeta.decimals), + timeStamp: '1700000002' + } + ] + + const res = convertTransactions(nativeTxs as any, tokenTxs as any, '0xaaa') + + expect(res).toHaveLength(1) + const tx = res[0] + expect(tx.movements[0].sum).toBe(-5) + expect(tx.movements[0].account.id).toBe( + `arbitrum-one-${tokenMeta.symbol.toLowerCase()}` + ) + }) +}) diff --git a/src/plugins/arbitrum_one/__tests__/merchants.test.ts b/src/plugins/arbitrum_one/__tests__/merchants.test.ts new file mode 100644 index 000000000..1870bd5d5 --- /dev/null +++ b/src/plugins/arbitrum_one/__tests__/merchants.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from '@jest/globals' +import { normalizeMerchant, KNOWN_CONTRACTS } from '../merchants' + +describe('normalizeMerchant', () => { + it('возвращает Unknown для пустого адреса', () => { + expect(normalizeMerchant('', '0x0')).toBe('Unknown') + }) + + it('распознаёт Self', () => { + expect(normalizeMerchant('0xAbC', '0xabc')).toBe('Self') + }) + + it('распознаёт известный контракт', () => { + const addr = Object.keys(KNOWN_CONTRACTS)[0] + expect(normalizeMerchant(addr, '0x0')).toBe(KNOWN_CONTRACTS[addr]) + }) + + it('форматирует неизвестный контракт', () => { + const addr = '0x1111111111111111111111111111111111111111' + const res = normalizeMerchant(addr, '0x0') + expect(res.startsWith('Contract ')).toBe(true) + }) + + it('возвращает исходный addr для не‑контрактов', () => { + expect(normalizeMerchant('Some Name', '0x0')).toBe('Some Name') + }) +}) diff --git a/src/plugins/arbitrum_one/__tests__/queue.test.ts b/src/plugins/arbitrum_one/__tests__/queue.test.ts new file mode 100644 index 000000000..5bc0af8fb --- /dev/null +++ b/src/plugins/arbitrum_one/__tests__/queue.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, jest } from '@jest/globals' +import { ArbitrumOneApi } from '../api' + +type FetchMock = jest.MockedFunction + +describe('ArbitrumOneApi очередь', () => { + it('выполняет задачи последовательно через публичные методы', async () => { + const mockFetch = jest.fn() as FetchMock + global.fetch = mockFetch + + // First API call + mockFetch.mockResolvedValueOnce({ + json: async () => ({ + status: '1', + result: { balance: '100' } + }) + } as any) + + // Second API call + mockFetch.mockResolvedValueOnce({ + json: async () => ({ + status: '1', + result: { balance: '200' } + }) + } as any) + + const api = new ArbitrumOneApi('TEST') + + // Start two requests in a row — they should execute sequentially + const p1 = api.getBalance('0x111') + const p2 = api.getBalance('0x222') + + const r1 = await p1 + const r2 = await p2 + + expect(r1.balance).toBe('100') + expect(r2.balance).toBe('200') + + // Check that fetch was called twice + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Check order of calls + expect(mockFetch.mock.calls[0][0]).toContain('0x111') + expect(mockFetch.mock.calls[1][0]).toContain('0x222') + }) +}) diff --git a/src/plugins/arbitrum_one/api.ts b/src/plugins/arbitrum_one/api.ts new file mode 100644 index 000000000..34fb14c63 --- /dev/null +++ b/src/plugins/arbitrum_one/api.ts @@ -0,0 +1,189 @@ +import { + ArbiscanResponse, + BalanceResponse, + TokenBalance, + Transaction, + TokenTransfer +} from './types' + +import { SUPPORTED_TOKENS } from './supportedTokens' + +const API_URL = 'https://api.etherscan.io/v2/api' +const CHAIN_ID = 42161 +const PAGE_SIZE = 100 + +async function delay (ms: number): Promise { + return await new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class ArbitrumOneApi { + private readonly apiKey: string + + private readonly queue: Array<() => Promise> = [] + private isProcessing = false + private readonly MIN_DELAY = 350 + + constructor (apiKey: string) { + this.apiKey = apiKey + } + + private async enqueue(task: () => Promise): Promise { + return await new Promise((resolve, reject) => { + this.queue.push(async (): Promise => { + try { + const result = await task() + resolve(result) + } catch (err) { + reject(err) + } + }) + + void this.processQueue() + }) + } + + private async processQueue (): Promise { + if (this.isProcessing) return + this.isProcessing = true + + while (this.queue.length > 0) { + const job = this.queue.shift() + if (job == null) continue + + await job() + await delay(this.MIN_DELAY) + } + + this.isProcessing = false + } + + private async call(params: Record): Promise { + return await this.enqueue(async () => { + const url = new URL(API_URL) + + url.searchParams.set('chainid', String(CHAIN_ID)) + url.searchParams.set('apikey', this.apiKey) + + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, String(value)) + } + + const response = await fetch(url.toString()) + const json = (await response.json()) as ArbiscanResponse + + if (json.status !== '1') { + throw new Error(json.message ?? 'Etherscan API error') + } + + return json.result + }) + } + + // Normalization for all cases + async getBalance (address: string): Promise { + const res = await this.call({ + module: 'account', + action: 'balance', + address, + tag: 'latest' + }) + + // Etherscan v2 sometimes returns a string instead of an object + if (typeof res === 'string') { + return { balance: res } + } + + if (typeof res === 'object' && res.balance != null) { + return { balance: res.balance } + } + + return { balance: '0' } + } + + // Get startblocknumber + async getBlockNumberByTimestamp (ts: number): Promise { + const result = await this.call({ + module: 'block', + action: 'getblocknobytime', + timestamp: ts, + closest: 'before' + }) + + return Number(result) + } + + async getTokenBalances (address: string): Promise { + const result = await Promise.all( + SUPPORTED_TOKENS.map(async (token) => { + const balance = await this.call({ + module: 'account', + action: 'tokenbalance', + contractaddress: token.contract, + address, + tag: 'latest' + }) + + return { + contract: token.contract, + balance: String(balance), + symbol: token.symbol + } + }) + ) + + return result + } + + async getTransactions (address: string, fromDate: Date): Promise { + return await this.fetchTxPages({ + action: 'txlist', + address, + fromDate + }) + } + + async getTokenTransfers (address: string, fromDate: Date): Promise { + return await this.fetchTxPages({ + action: 'tokentx', + address, + fromDate + }) + } + + private async fetchTxPages(opts: { + action: string + address: string + fromDate: Date + }): Promise { + let page = 1 + const all: T[] = [] + + const startTimestamp = Math.floor(opts.fromDate.getTime() / 1000) + const startBlock = await this.getBlockNumberByTimestamp(startTimestamp) + + while (true) { + const result = await this.call({ + module: 'account', + action: opts.action, + address: opts.address, + page, + offset: PAGE_SIZE, + sort: 'asc', + startblock: startBlock, + endblock: 9999999999 + }) + + const normalized = result.map((tx) => ({ + ...tx, + contract: tx.contractAddress + })) + + all.push(...normalized) + + if (result.length < PAGE_SIZE) break + page++ + } + + return all + } +} diff --git a/src/plugins/arbitrum_one/converter.ts b/src/plugins/arbitrum_one/converter.ts new file mode 100644 index 000000000..606599b3b --- /dev/null +++ b/src/plugins/arbitrum_one/converter.ts @@ -0,0 +1,168 @@ +import { BalanceResponse, TokenBalance, Transaction, TokenTransfer } from './types' + +// ----------- +// Tokens +// ----------- +import { SUPPORTED_TOKENS } from './supportedTokens' + +// ------ +// Merchants +// ------ +import { normalizeMerchant } from './merchants' + +// Helper to coerce various numeric types (string, number, bigint) to Number safely +function toNumeric (value: any): number { + if (value == null) return 0 + if (typeof value === 'number') return value + if (typeof value === 'string') { + const v = value.trim() + if (v === '') return 0 + const n = Number(v) + return isFinite(n) ? n : 0 + } + if (typeof value === 'bigint') { + try { + return Number(value.toString()) + } catch (err) { + return 0 + } + } + if (typeof value === 'object' && typeof value.toString === 'function') { + const n = Number(value.toString()) + return isFinite(n) ? n : 0 + } + return 0 +} + +// ------------------------- +// ACCOUNTS +// ------------------------- + +export function convertBalances (native: BalanceResponse, tokenBalances: TokenBalance[]): any[] { + const result = [] + + // ETH → µETH + const ethRaw = toNumeric(native.balance) + // Convert wei to micro-ETH (µETH): 1 ETH = 1e18 wei = 1e6 µETH => divide wei by 1e12 + const ethAmount = isFinite(ethRaw) ? ethRaw / 1e12 : 0 + + result.push({ + id: 'arbitrum-one-main', + type: 'checking', + title: 'Arbitrum One (ETH)', + instrument: 'µETH', + balance: ethAmount, // ZenMoney interprets this as µETH + syncIds: ['arbitrum-one-main'] + }) + + // Tokens + for (const tb of tokenBalances) { + const token = SUPPORTED_TOKENS.find( + (t) => t.contract.toLowerCase() === tb.contract.toLowerCase() + ) + + if (token == null) continue + + const raw = toNumeric(tb.balance) + const amount = isFinite(raw) ? raw / 10 ** token.decimals : 0 + + result.push({ + id: `arbitrum-one-${token.symbol.toLowerCase()}`, + type: 'checking', + title: `Arbitrum One (${token.symbol})`, + instrument: token.symbol, + balance: amount, + syncIds: [`arbitrum-one-${token.symbol.toLowerCase()}`] + }) + } + + return result +} + +// ------------------------- +// TRANSACTIONS +// ------------------------- + +export function convertTransactions ( + nativeTxs: Transaction[], + tokenTxs: TokenTransfer[], + address: string +): any[] { + const result = [] + const addr = address.toLowerCase() + + // ETH transactions + for (const tx of nativeTxs) { + const value = toNumeric(tx.value) / 1e12 + const fee = (toNumeric(tx.gasUsed) * toNumeric(tx.gasPrice)) / 1e12 + + // skip completely empty transactions (neither value nor fee) + if ((value === 0 || !isFinite(value)) && (fee === 0 || !isFinite(fee))) { + continue + } + + const from = tx.from.toLowerCase() + const isOutgoing = from === addr + const sign = isOutgoing ? -1 : 1 + + result.push({ + hold: null, + date: new Date(Number(tx.timeStamp) * 1000), + movements: [ + { + id: tx.hash, + account: { id: 'arbitrum-one-main' }, + invoice: null, + sum: sign * value, + fee: isOutgoing ? fee : 0 + } + ], + merchant: { + fullTitle: normalizeMerchant(isOutgoing ? tx.to : tx.from, address), + mcc: null, + location: null + }, + comment: null + }) + } + + // ERC20 transactions + for (const tx of tokenTxs) { + const token = SUPPORTED_TOKENS.find( + (t) => t.contract.toLowerCase() === tx.contract.toLowerCase() + ) + + if (token == null) continue + + const value = toNumeric(tx.value) / (10 ** token.decimals) + if (value === 0) continue + + const from = tx.from.toLowerCase() + // const to = tx.to.toLowerCase() + + const isOutgoing = from === addr + const sign = isOutgoing ? -1 : 1 + + result.push({ + hold: null, + date: new Date(Number(tx.timeStamp) * 1000), + movements: [ + { + id: `${tx.hash}-${token.symbol}`, + account: { id: `arbitrum-one-${token.symbol.toLowerCase()}` }, + invoice: null, + sum: sign * value, + fee: 0 + } + ], + merchant: { + fullTitle: isOutgoing ? tx.to : tx.from, + mcc: null, + location: null + }, + comment: null + }) + } + + return result +} diff --git a/src/plugins/arbitrum_one/index.ts b/src/plugins/arbitrum_one/index.ts new file mode 100644 index 000000000..ba6ec3359 --- /dev/null +++ b/src/plugins/arbitrum_one/index.ts @@ -0,0 +1 @@ +export { scrape } from './plugin' diff --git a/src/plugins/arbitrum_one/merchants.ts b/src/plugins/arbitrum_one/merchants.ts new file mode 100644 index 000000000..7929680f9 --- /dev/null +++ b/src/plugins/arbitrum_one/merchants.ts @@ -0,0 +1,36 @@ +export const KNOWN_CONTRACTS: Record = { + // DEX + '0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45': 'Uniswap V3 Router', + '0x1b02da8cb0d097eb8d57a175b88c7d8b47997506': 'SushiSwap Router', + '0x2df1adb252afb77fe762586fa6c116857f163df7': 'PancakeSwap Router', + + // Bridges + '0x0000000000000000000000000000000000000064': 'Arbitrum Bridge', + '0x3c2269811836af69497e5f486a85d7316753cf62': 'LayerZero Endpoint', + '0x8731d54e9d02c286767d56ac03e8037c07e01e98': 'Stargate Router', + + // Lending / Perps + '0x5f3b5dfeb7b28cdbd7faba78963ee202a494e2a2': 'Aave Lending Pool', + '0x32df62dc3aed2cd6224193052ce665dc18165841': 'Radiant Capital', + '0x489ee077994b6658eafa855c308275ead8097c4a': 'GMX Exchange', + + // System + '0x0000000000000000000000000000000000000000': 'System' +} + +export function normalizeMerchant (addr: string, user: string): string { + if (addr == null || addr === '') return 'Unknown' + + const a = addr.toLowerCase() + const u = user.toLowerCase() + + if (a === u) return 'Self' + + if (KNOWN_CONTRACTS[a] != null) return KNOWN_CONTRACTS[a] + + if (a.startsWith('0x') && a.length === 42) { + return `Contract ${addr.slice(0, 6)}…${addr.slice(-4)}` + } + + return addr +} diff --git a/src/plugins/arbitrum_one/plugin.ts b/src/plugins/arbitrum_one/plugin.ts new file mode 100644 index 000000000..f570413d6 --- /dev/null +++ b/src/plugins/arbitrum_one/plugin.ts @@ -0,0 +1,60 @@ +import type { ScrapeFunc } from '../../types/zenmoney' +import { ArbitrumOneApi } from './api' +import { convertBalances, convertTransactions } from './converter' +import type { Preferences } from './types' + +export const scrape: ScrapeFunc = async ({ preferences, fromDate }) => { + const apiKey = preferences.apiKey + + // Split the addresses string by comma + const raw = preferences.account + + // raw can be a string or string[] + const addresses = Array.isArray(raw) + ? raw + : raw.split(',').map(a => a.trim()) + + const normalized = addresses + .map((a: string) => a.toLowerCase()) + .filter(a => a.length > 0) + + const api = new ArbitrumOneApi(apiKey) + + const allAccounts: any[] = [] + const allTransactions: any[] = [] + + for (const address of normalized) { + // Balances + const [nativeBalance, tokenBalances] = await Promise.all([ + api.getBalance(address), + api.getTokenBalances(address) + ]) + + const accounts = convertBalances(nativeBalance, tokenBalances) + allAccounts.push(...accounts) + + // Transactions + const [nativeTxs, tokenTxs] = await Promise.all([ + api.getTransactions(address, fromDate), + api.getTokenTransfers(address, fromDate) + ]) + + const fromTs = fromDate.getTime() + + const filteredNative = nativeTxs.filter( + tx => Number(tx.timeStamp) * 1000 >= fromTs + ) + + const filteredToken = tokenTxs.filter( + tx => Number(tx.timeStamp) * 1000 >= fromTs + ) + + const transactions = convertTransactions(filteredNative, filteredToken, address) + allTransactions.push(...transactions) + } + + return { + accounts: allAccounts, + transactions: allTransactions + } +} diff --git a/src/plugins/arbitrum_one/preferences.xml b/src/plugins/arbitrum_one/preferences.xml new file mode 100755 index 000000000..84a09f262 --- /dev/null +++ b/src/plugins/arbitrum_one/preferences.xml @@ -0,0 +1,35 @@ + + + + + + diff --git a/src/plugins/arbitrum_one/supportedTokens.ts b/src/plugins/arbitrum_one/supportedTokens.ts new file mode 100644 index 000000000..889a66f74 --- /dev/null +++ b/src/plugins/arbitrum_one/supportedTokens.ts @@ -0,0 +1,12 @@ +export const SUPPORTED_TOKENS = [ + { + symbol: 'USDT', + decimals: 6, + contract: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9' + }, + { + symbol: 'USDC', + decimals: 6, + contract: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' + } +] diff --git a/src/plugins/arbitrum_one/types.ts b/src/plugins/arbitrum_one/types.ts new file mode 100644 index 000000000..7fdfab50d --- /dev/null +++ b/src/plugins/arbitrum_one/types.ts @@ -0,0 +1,42 @@ +export interface ArbiscanResponse { + status: string + message: string + result: T +} + +export interface BalanceResponse { + balance: string +} + +export interface TokenBalance { + contract: string + balance: string + symbol: string +} + +export interface Transaction { + hash: string + timeStamp: string + from: string + to: string + value: string + gasUsed: string + gasPrice: string + contractAddress?: string + contract?: string +} + +export interface TokenTransfer { + hash: string + timeStamp: string + from: string + to: string + value: string + contractAddress: string + contract: string +} + +export interface Preferences { + apiKey: string + account: string | string[] +}