@lunch-money/ethereum-to-lunch-money
Version:
A wrapper around Ethereum APIs for enabling Lunch Money to gather information about a user's wallet.
446 lines • 23.4 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadTokenList = exports.createEthereumWalletClient = exports.MoralisProvider = exports.EtherscanProvider = exports.debug = void 0;
const ethscan = __importStar(require("@mycrypto/eth-scan"));
const ethers_1 = require("ethers");
const _1inch_json_1 = __importDefault(require("../fixtures/1inch.json"));
// Use node-fetch for Node.js compatibility
const node_fetch_1 = __importDefault(require("node-fetch"));
/**
* Debug function that logs to console.log if DEBUG_ETHEREUM environment variable is set
*/
const debug = (...args) => {
if (process.env.DEBUG_ETHEREUM) {
const timestamp = new Date().toISOString();
console.log(`[DEBUG_ETHEREUM] [${timestamp}]`, ...args);
}
};
exports.debug = debug;
class EtherscanProvider {
constructor(apiKey, fetchFn = node_fetch_1.default) {
this.baseUrl = 'https://api.etherscan.io/v2/api';
this.apiKey = apiKey;
this.fetchFn = fetchFn;
}
discoverTokensForWallet(address, chainId) {
return __awaiter(this, void 0, void 0, function* () {
const targetChainId = chainId ? Number(chainId) : 1;
const pageSize = process.env.ETHERSCAN_PAGE_SIZE ? parseInt(process.env.ETHERSCAN_PAGE_SIZE) : 1000;
const uniqueTokens = new Set();
const obscuredAddress = `0x..${address.slice(-6)}`;
let page = 1;
let totalEvents = 0;
while (true) {
const url = `${this.baseUrl}?chainid=${targetChainId}&module=account&action=tokentx&address=${address}&startblock=0&endblock=99999999&sort=desc&page=${page}&offset=${pageSize}&apikey=${this.apiKey}`;
let data;
try {
const response = yield this.fetchFn(url);
data = (yield response.json());
}
catch (error) {
const msg = error instanceof Error ? error.message.replace(/apikey=[^&\s]+/gi, 'apikey=[redacted]') : String(error);
throw new Error(`Etherscan API network error on page ${page} for ${obscuredAddress}: ${msg}`);
}
if (data.status === '0' && data.message === 'No transactions found') {
break;
}
if (data.status === '0' && data.message === 'NOTOK') {
throw new Error(`Etherscan V2 API error: ${data.result}`);
}
if (data.status !== '1') {
throw new Error(`Etherscan V2 API error: ${data.message}`);
}
totalEvents += data.result.length;
for (const tx of data.result) {
uniqueTokens.add(tx.contractAddress);
}
if (data.result.length < pageSize) {
break;
}
// Etherscan enforces a hard maximum of 10 pages: page 11 always returns
// "Result window is too large" regardless of offset size. This is not
// configurable — it applies even with ETHERSCAN_PAGE_SIZE=5 (50 records total).
// Wallets with more than 10 * pageSize transfer events will have older
// token history truncated here; Moralis (primary provider) does not have this limit.
if (page >= 10) {
(0, exports.debug)(`Etherscan: reached 10-page hard limit for ${obscuredAddress} — token discovery may be incomplete for high-activity wallets`);
break;
}
// Stay under Etherscan's free tier limit of 3 calls/sec
yield new Promise((resolve) => setTimeout(resolve, 400));
page++;
(0, exports.debug)(`Etherscan: fetching page ${page} for ${obscuredAddress}`);
}
(0, exports.debug)(`Etherscan: fetched ${totalEvents} transfer events across ${page} page(s), discovered ${uniqueTokens.size} unique tokens for ${obscuredAddress}`);
return Array.from(uniqueTokens);
});
}
}
exports.EtherscanProvider = EtherscanProvider;
class MoralisProvider {
constructor(apiKey) {
this.baseUrl = 'https://deep-index.moralis.io/api/v2';
this.apiKey = apiKey;
}
discoverTokensForWallet(address, chainId) {
return __awaiter(this, void 0, void 0, function* () {
// Map chainId to Moralis chain format (only supports specific enum values)
let chainFormat;
if (chainId) {
switch (Number(chainId)) {
case 1:
chainFormat = 'eth';
break; // Ethereum mainnet
case 137:
chainFormat = 'polygon';
break; // Polygon
case 56:
chainFormat = 'bsc';
break; // BSC
case 42161:
chainFormat = 'arbitrum';
break; // Arbitrum
case 10:
chainFormat = 'optimism';
break; // Optimism
case 43114:
chainFormat = 'avalanche';
break; // Avalanche
case 250:
chainFormat = 'fantom';
break; // Fantom
default:
chainFormat = 'eth';
break; // Default to Ethereum
}
}
else {
chainFormat = 'eth'; // Default to Ethereum mainnet
}
const response = yield (0, node_fetch_1.default)(`${this.baseUrl}/${address}/erc20?chain=${chainFormat}`, {
headers: {
'X-API-Key': this.apiKey,
},
});
if (!response.ok) {
const errorData = yield response.json().catch(() => ({}));
if (errorData.message && errorData.message.includes('over 2000 tokens')) {
throw new Error(`Moralis API error: Wallet has too many tokens (>2000) - ${errorData.message}`);
}
throw new Error(`Moralis API error: ${response.status} ${response.statusText}`);
}
const data = yield response.json();
// Extract unique token contract addresses from balance data
const uniqueTokens = new Set();
for (const item of data) {
if (item.token_address && item.balance !== '0') {
uniqueTokens.add(item.token_address);
}
}
return Array.from(uniqueTokens);
});
}
}
exports.MoralisProvider = MoralisProvider;
const createEthereumWalletClient = (serviceProviderInfo = [], walletAPIProviderInfo = [], getTokensBalanceFn = ethscan.getTokensBalance) => {
let providers = [];
let etherscanProvider = null;
let moralisProvider = null;
try {
// Initialize the custom provider(s) for ethscan to use the ethers client correctly.
// A custom ethscan provider implementation is needed to map `call` to `send` for ethscan to use the ethers client correctly.
// This is a temporary solution until the ethscan library is updated to support ethers v6.
if (serviceProviderInfo.length) {
providers = Object.freeze(serviceProviderInfo.map((providerInfo) => {
if (providerInfo.type === 'Service Provider' && providerInfo.provider) {
return Object.assign(Object.assign({}, providerInfo), { customProvider: {
send(method, params) {
var _a;
// Type pulled from: https://github.com/MyCryptoHQ/eth-scan/blob/master/src/providers/provider.ts#L32
const typedParams = params;
return (_a = providerInfo.provider) === null || _a === void 0 ? void 0 : _a.call({
to: typedParams[0].to,
data: typedParams[0].data,
});
},
} });
}
else {
throw new Error(`Invalid service provider: ${providerInfo.name} passed to createEthereumWalletClient`);
}
}));
// Initialize any wallet API providers
for (const walletKeyInfo of walletAPIProviderInfo) {
if (walletKeyInfo.type === 'API Provider') {
if (walletKeyInfo.name === 'Etherscan' && walletKeyInfo.apiKey) {
etherscanProvider = new EtherscanProvider(walletKeyInfo.apiKey);
}
else if (walletKeyInfo.name === 'Moralis' && walletKeyInfo.apiKey) {
moralisProvider = new MoralisProvider(walletKeyInfo.apiKey);
}
}
else {
throw new Error(`Invalid wallet API provider: ${walletKeyInfo.name} passed to createEthereumWalletClient`);
}
}
}
else {
// Create a wallet using the quorum of free service providers
const publicProvider = ethers_1.ethers.getDefaultProvider();
providers = Object.freeze([
...providers,
{
type: 'Service Provider',
name: 'Public Provider',
provider: publicProvider,
status: 'SUCCESS',
customProvider: {
send(method, params) {
const typedParams = params;
return publicProvider === null || publicProvider === void 0 ? void 0 : publicProvider.call({ to: typedParams[0].to, data: typedParams[0].data });
},
},
},
]);
}
}
catch (error) {
throw new Error(`Error creating Ethereum wallet client: ${error instanceof Error ? error.message : String(error)}`);
}
const internalGetBalances = (client, walletAddress, negligibleBalanceThreshold, provider, customProvider, obscuredWalletAddress) => __awaiter(void 0, void 0, void 0, function* () {
let timeoutId;
const timeoutDuration = process.env.ETHEREUM_BALANCE_TIMEOUT_MSECS
? parseInt(process.env.ETHEREUM_BALANCE_TIMEOUT_MSECS)
: 60000;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`Ethereum connector getBalances timed out after ${timeoutDuration} milliseconds.`));
}, timeoutDuration);
});
const result = yield Promise.race([
(() => __awaiter(void 0, void 0, void 0, function* () {
try {
const weiBalance = yield client.getWeiBalance(walletAddress, provider);
const chainId = yield client.getChainId(provider);
let filteredTokens = [];
try {
let discoveredTokens = [];
if (moralisProvider || etherscanProvider) {
discoveredTokens = yield client.discoverTokensHybrid(walletAddress, chainId);
if (discoveredTokens.length > 0) {
filteredTokens = yield (0, exports.loadTokenList)().then((tokens) => tokens.filter((t) => discoveredTokens.includes(t.address) && BigInt(t.chainId) === BigInt(chainId)));
(0, exports.debug)(`Wallet ${obscuredWalletAddress}: Filtered to ${filteredTokens.length} tokens on chain ${chainId}`);
}
else {
(0, exports.debug)(`Wallet ${obscuredWalletAddress}: No tokens discovered, using empty token list`);
}
}
else {
(0, exports.debug)(`Wallet ${obscuredWalletAddress}: No discovery APIs available, using full token list`);
filteredTokens = (yield (0, exports.loadTokenList)()).filter((t) => BigInt(t.chainId) === BigInt(chainId));
}
}
catch (error) {
(0, exports.debug)(`Wallet ${obscuredWalletAddress}: Token discovery failed, falling back to full token list:`, error);
filteredTokens = (yield (0, exports.loadTokenList)()).filter((t) => BigInt(t.chainId) === BigInt(chainId));
}
(0, exports.debug)(`Wallet ${obscuredWalletAddress}: Checking balances for ETH and ${filteredTokens.length} other tokens`);
const map = yield getTokensBalanceFn(customProvider, walletAddress, filteredTokens.map((t) => t.address));
return { weiBalance, chainId, map, filteredTokens };
}
finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}))(),
timeout,
]);
const { weiBalance, chainId, map, filteredTokens } = result;
(0, exports.debug)('ethers.getTokensBalance returned for wallet address:', obscuredWalletAddress);
const balances = Object.entries(map)
.map(([address, balance]) => {
const token = filteredTokens.find((t) => t.address === address);
if (!token) {
throw new Error(`Token ${address} not found in discovered token list for chainId ${chainId}`);
}
return {
asset: token.symbol,
undivisedAmount: balance,
decimals: token.decimals,
};
})
.concat({ asset: 'ETH', undivisedAmount: weiBalance, decimals: 18 })
.map(({ asset, undivisedAmount, decimals }) => ({ asset, amount: ethers_1.ethers.formatUnits(undivisedAmount, decimals) }))
.filter((b) => ethers_1.ethers.parseUnits(b.amount, 18) > negligibleBalanceThreshold)
.map((b) => ({ asset: b.asset, amount: String(b.amount) }))
.sort((a, b) => a.asset.localeCompare(b.asset));
(0, exports.debug)(`Returning from getBalances for ${obscuredWalletAddress}:`, balances);
const balanceResult = {
providerName: 'wallet_ethereum',
balances,
};
return balanceResult;
});
return {
getChainId(provider) {
return __awaiter(this, void 0, void 0, function* () {
return (yield provider.getNetwork()).chainId;
});
},
getWeiBalance(walletAddress, provider) {
return __awaiter(this, void 0, void 0, function* () {
return yield provider.getBalance(walletAddress);
});
},
getBalances(walletAddress, negligibleBalanceThreshold) {
return __awaiter(this, void 0, void 0, function* () {
const obscuredWalletAddress = `0x..${walletAddress.slice(-6)}`;
(0, exports.debug)('getBalances called for wallet address:', obscuredWalletAddress);
let result = {
providerName: 'wallet_ethereum',
balances: [],
};
if (providers.length === 0) {
throw new Error('No Ethereum providers available');
}
let providerIndex = 0;
let provider = providers[0].provider;
let customProvider = providers[0].customProvider;
let providerName = providers[0].name;
(0, exports.debug)(`Attempting lookup using primary provider: ${providerName}`);
let balanceFound = false;
while (!balanceFound) {
try {
result = yield internalGetBalances(this, walletAddress, negligibleBalanceThreshold, provider, customProvider, obscuredWalletAddress);
balanceFound = true;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
(0, exports.debug)(`Error getting balances using provider ${providerName}: ${errorMessage}`);
if (errorMessage.includes('unconfigured name') ||
errorMessage.includes('bad address checksum') ||
errorMessage.includes('invalid ENS name')) {
throw new Error(`Invalid wallet address. Account needs to be relinked.`);
}
else if (providerIndex === providers.length - 1) {
throw error;
}
providerIndex = providerIndex + 1;
providerName = providers[providerIndex].name;
provider = providers[providerIndex].provider;
customProvider = providers[providerIndex].customProvider;
(0, exports.debug)(`Retrying lookup using provider: ${providerName}`);
}
}
return result;
});
},
getTokensBalance(walletAddress, tokenContractAddresses, providerInfo) {
return __awaiter(this, void 0, void 0, function* () {
if (providerInfo.customProvider) {
return getTokensBalanceFn(providerInfo.customProvider, walletAddress, tokenContractAddresses);
}
else {
throw new Error(`No ethscan compatible provider found for ${providerInfo.name}`);
}
});
},
discoverTokensHybrid(walletAddress, chainId) {
return __awaiter(this, void 0, void 0, function* () {
const allTokens = new Set();
let discoverySucceeded = false;
// 1. Moralis Discovery (PRIMARY)
if (moralisProvider) {
try {
const moralisTokens = yield moralisProvider.discoverTokensForWallet(walletAddress, chainId);
(0, exports.debug)(`Moralis: Discovered ${moralisTokens.length} tokens`);
moralisTokens.forEach((t) => allTokens.add(t));
discoverySucceeded = true;
}
catch (error) {
(0, exports.debug)(`Moralis: Failed - ${error instanceof Error ? error.message : String(error)}`);
// 2. Etherscan Discovery (FALLBACK ONLY)
if (etherscanProvider) {
try {
const etherscanTokens = yield etherscanProvider.discoverTokensForWallet(walletAddress, chainId);
(0, exports.debug)(`Etherscan (fallback): Discovered ${etherscanTokens.length} tokens`);
etherscanTokens.forEach((t) => allTokens.add(t));
discoverySucceeded = true;
}
catch (fallbackError) {
(0, exports.debug)(`Etherscan (fallback): Failed - ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
}
}
else {
(0, exports.debug)('Etherscan: No API key provided for fallback');
}
}
}
else {
(0, exports.debug)('Moralis: No API key provided');
// 3. Etherscan Discovery (PRIMARY if no Moralis)
if (etherscanProvider) {
try {
const etherscanTokens = yield etherscanProvider.discoverTokensForWallet(walletAddress, chainId);
(0, exports.debug)(`Etherscan: Discovered ${etherscanTokens.length} tokens`);
etherscanTokens.forEach((t) => allTokens.add(t));
discoverySucceeded = true;
}
catch (error) {
(0, exports.debug)(`Etherscan: Failed - ${error instanceof Error ? error.message : String(error)}`);
}
}
else {
(0, exports.debug)('Etherscan: No API key provided');
}
}
if (!discoverySucceeded) {
throw new Error('All token discovery providers failed');
}
return Array.from(allTokens);
});
},
};
};
exports.createEthereumWalletClient = createEthereumWalletClient;
const loadTokenList = () => __awaiter(void 0, void 0, void 0, function* () {
return _1inch_json_1.default.tokens;
});
exports.loadTokenList = loadTokenList;
//# sourceMappingURL=client.js.map