Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to use the Zapper API to loading token balances #3

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
44 changes: 13 additions & 31 deletions bin/get-balances.ts
Expand Up @@ -2,41 +2,23 @@

import assert from 'node:assert';

import ethers from 'ethers';

import { LunchMoneyEthereumWalletConnection, createEthereumWalletClient } from '../src/main.js';
import { LunchMoneyEthereumWalletConnection, createZapperAPIClient } from '../src/main.js';

const requireEnv = (key: string): string => {
const value = process.env[key];
assert(value, `No value provided for required environment variable ${key}.`);
return value;
};

const apiKey = requireEnv('LM_ETHERSCAN_API_KEY');
const walletAddress = requireEnv('LM_ETHEREUM_WALLET_ADDRESS');

const provider = ethers.providers.getDefaultProvider('homestead', {
etherscan: apiKey,
// TODO: Get these other keys for redundancy and performance
// infura: YOUR_INFURA_PROJECT_ID,
// Or if using a project secret:
// infura: {
// projectId: YOUR_INFURA_PROJECT_ID,
// projectSecret: YOUR_INFURA_PROJECT_SECRET,
// },
// alchemy: YOUR_ALCHEMY_API_KEY,
// pocket: YOUR_POCKET_APPLICATION_KEY
// Or if using an application secret key:
// pocket: {
// applicationId: ,
// applicationSecretKey:
// }
});

const client = createEthereumWalletClient(provider);

const resp = await LunchMoneyEthereumWalletConnection.getBalances({ walletAddress }, { client });

for (const { asset, amount } of resp.balances) {
console.log(`${asset}: ${amount}`);
}
const ETH_ADDRESS = requireEnv('ETH_ADDRESS');

(async function () {
console.log(
await LunchMoneyEthereumWalletConnection.getBalances(
{
walletAddress: ETH_ADDRESS,
},
{},
),
);
})();
7 changes: 0 additions & 7 deletions bin/refresh-token-list.sh

This file was deleted.

4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -44,6 +44,7 @@
},
"devDependencies": {
"@types/chai": "^4",
"@types/eventsource": "^1.1.7",
"@types/mocha": "^9.0.0",
"@types/node": "^16.6.1",
"@types/sinon": "^10.0.2",
Expand All @@ -70,8 +71,7 @@
]
},
"dependencies": {
"@mycrypto/eth-scan": "^3.4.4",
"ethers": "^5.4.6",
"eventsource": "^1.1.0",
"mem": "^8.1.1"
}
}
95 changes: 73 additions & 22 deletions src/client.ts
@@ -1,32 +1,83 @@
import * as ethscan from '@mycrypto/eth-scan';
import * as ethers from 'ethers';
import mem from 'mem';
import EventSource from 'eventsource';
import { encode } from 'querystring';

import tokenList1inch from '../fixtures/1inch.json';
const ZAPPER_FI_API_URL = 'https://api.zapper.fi/v1/balances';
// This API key is public and shared with all users. This API is publicly
// available, free of charge. See here for details:
// https://docs.zapper.fi/zapper-api/endpoints
const ZAPPER_FI_API_KEY = '96e0cc51-a62e-42ca-acee-910ea7d2a241';

export interface EthereumWalletClient {
getWeiBalance(walletAddress: string): Promise<bigint>;
getTokensBalance(walletAddress: string, tokenContractAddresses: string[]): Promise<ethscan.BalanceMap<bigint>>;
export interface ZapperAPIClient {
getTokenBalances(walletAddresses: string[]): Promise<ZapperTokenBalancesResponse[]>;
}

export const createEthereumWalletClient = (provider: ethers.providers.BaseProvider): EthereumWalletClient => ({
async getWeiBalance(walletAddress) {
return (await provider.getBalance(walletAddress)).toBigInt();
},
async getTokensBalance(walletAddress, tokenContractAddresses) {
return ethscan.getTokensBalance(provider, walletAddress, tokenContractAddresses);
export const createZapperAPIClient = (): ZapperAPIClient => ({
async getTokenBalances(walletAddresses) {
const qs = encode({
'addresses[]': walletAddresses.join(','),
api_key: ZAPPER_FI_API_KEY,
});

const es = new EventSource(ZAPPER_FI_API_URL + '?' + qs);

let balances: ZapperTokenBalancesResponse[] = [];

es.addEventListener('balance', (ev) => {
const data: ZapperTokenBalancesResponse = JSON.parse(ev.data);
balances = balances.concat(data);
});

return new Promise((resolve, reject) => {
es.addEventListener('end', () => {
es.close();
resolve(balances);
});

es.addEventListener('error', (ev) => {
es.close();
reject(ev);
});
});
},
});

interface Token {
export interface ZapperTokenBalancesResponse {
network: string;
appId: string;
balances: Record<string, ZapperBalance>;
}

interface ZapperBalance {
products: ZapperProduct[];
meta: ZapperMeta[];
}

interface ZapperMeta {
label: string;
value: number;
type: string;
}

interface ZapperProduct {
label: string;
assets: ZapperAsset[];
}

interface ZapperAsset {
type: string;
balanceUSD: number;
tokens: ZapperToken[];
}

interface ZapperToken {
type: string;
network: string;
address: string;
chainId: number;
name: string;
symbol: string;
decimals: number;
logoURI: string;
symbol: string;
price: number;
hide: boolean;
balance: number;
balanceRaw: string;
balanceUSD: number;
}

export const loadTokenList = mem(async (): Promise<Token[]> => {
return tokenList1inch.tokens;
});
53 changes: 29 additions & 24 deletions src/main.ts
Expand Up @@ -4,15 +4,14 @@ import {
LunchMoneyCryptoConnectionConfig,
} from './types.js';

import { loadTokenList, EthereumWalletClient } from './client.js';
import * as ethers from 'ethers';
import { createZapperAPIClient, ZapperAPIClient } from './client.js';

export { LunchMoneyCryptoConnection } from './types.js';
export { createEthereumWalletClient, EthereumWalletClient } from './client.js';
export { createZapperAPIClient, ZapperAPIClient } from './client.js';

/** The minimum balance (in wei) that a token should have in order to be
/** The minimum balance (in USD) that a token should have in order to be
* considered for returning as a balance. */
const NEGLIGIBLE_BALANCE_THRESHOLD = 1000;
const NEGLIGIBLE_BALANCE_THRESHOLD = 0.01;

interface LunchMoneyEthereumWalletConnectionConfig extends LunchMoneyCryptoConnectionConfig {
/** The unique ID of the user's wallet address on the blockchain. */
Expand All @@ -21,7 +20,7 @@ interface LunchMoneyEthereumWalletConnectionConfig extends LunchMoneyCryptoConne
}

interface LunchMoneyEthereumWalletConnectionContext extends LunchMoneyCryptoConnectionContext {
client: EthereumWalletClient;
client?: ZapperAPIClient;
}

export const LunchMoneyEthereumWalletConnection: LunchMoneyCryptoConnection<
Expand All @@ -31,24 +30,30 @@ export const LunchMoneyEthereumWalletConnection: LunchMoneyCryptoConnection<
async initiate(config, context) {
return this.getBalances(config, context);
},
async getBalances({ walletAddress, negligibleBalanceThreshold = NEGLIGIBLE_BALANCE_THRESHOLD }, { client }) {
const weiBalance = await client.getWeiBalance(walletAddress);

const tokenList = await loadTokenList();
const map = await client.getTokensBalance(
walletAddress,
tokenList.map((t) => t.address),
);

const balances = tokenList
.map((t) => ({
asset: t.symbol,
amount: map[t.address] ?? 0,
}))
.concat({ asset: 'ETH', amount: weiBalance })
.filter((b) => b.amount > negligibleBalanceThreshold)
.map(({ asset, amount }) => ({ asset, amount: ethers.utils.formatEther(amount) }))
.sort((a, b) => a.asset.localeCompare(b.asset));
async getBalances(
{ walletAddress, negligibleBalanceThreshold = NEGLIGIBLE_BALANCE_THRESHOLD },
{ client = createZapperAPIClient() },
) {
// For some reason the address that is returned in the response is
// lowercased.
const walletAddressIndex = walletAddress.toLowerCase();

const res = await client.getTokenBalances([walletAddress]);

const balances = res
.flatMap((x) =>
x.balances[walletAddressIndex].products.flatMap((p) =>
p.assets.flatMap((a) =>
a.tokens.map((t) => ({
asset: t.symbol,
amount: t.balance,
amountInUSD: t.balanceUSD,
})),
),
),
)
.filter((t) => t.amountInUSD > negligibleBalanceThreshold)
.map((t) => ({ ...t, amount: t.amount.toString(), amountInUSD: t.amountInUSD.toString() }));

return {
providerName: 'wallet_ethereum',
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Expand Up @@ -17,6 +17,7 @@ export type LunchMoneyCryptoConnectionContext = {};
export interface CryptoBalance {
asset: string;
amount: string;
amountInUSD: string;
}

export const providerNames = ['coinbase', 'coinbase_pro', 'kraken', 'binance', 'wallet_ethereum'] as const;
Expand Down
68 changes: 51 additions & 17 deletions test/main.spec.ts
Expand Up @@ -2,6 +2,7 @@ import { assert } from 'chai';
import sinon from 'sinon';

import { LunchMoneyEthereumWalletConnection as underTest } from '../src/main.js';
import { ZapperTokenBalancesResponse } from '../src/client.js';

describe('LunchMoneyEthereumWalletConnection', () => {
const dummyConfig = {
Expand All @@ -10,18 +11,51 @@ describe('LunchMoneyEthereumWalletConnection', () => {
};

const mockClient = {
getWeiBalance: sinon.stub(),
getTokensBalance: sinon.stub(),
getTokenBalances: sinon.stub<[], Promise<ZapperTokenBalancesResponse[]>>(),
};
const dummyContext = {
client: mockClient,
};

const makeBalances = (balances: { asset: string; amountInUSD: number; amount: number }[]) => [
{
network: 'ethereum',
appId: 'tokens',
balances: {
[dummyConfig.walletAddress]: {
meta: [],
products: [
{
label: 'Tokens',
assets: [
{
balanceUSD: 0,
type: 'tokens',
tokens: balances.map((t) => ({
address: '0xbar',
type: 'token',
balanceUSD: t.amountInUSD,
balance: t.amount,
balanceRaw: '',
decimals: 9,
hide: false,
network: 'ethereum',
price: 1337,
symbol: t.asset,
})),
},
],
},
],
},
},
},
];

describe('getBalances', () => {
describe('when the wallet has an ETH amount less than the neglible balance threshold', () => {
it('does not output the ETH balance amount', async () => {
mockClient.getWeiBalance.resolves(50);
mockClient.getTokensBalance.resolves({});
mockClient.getTokenBalances.resolves(makeBalances([{ asset: 'ETH', amount: 0, amountInUSD: 0 }]));

const response = await underTest.getBalances(dummyConfig, dummyContext);

Expand All @@ -34,22 +68,20 @@ describe('LunchMoneyEthereumWalletConnection', () => {

describe('when the wallet has an ETH amount more than the neglible balance threshold', () => {
it('outputs the ETH balance amount', async () => {
mockClient.getWeiBalance.resolves(1000);
mockClient.getTokensBalance.resolves({});
mockClient.getTokenBalances.resolves(makeBalances([{ asset: 'ETH', amount: 1, amountInUSD: 150 }]));

const response = await underTest.getBalances(dummyConfig, dummyContext);

assert.deepEqual(response, {
providerName: 'wallet_ethereum',
balances: [{ asset: 'ETH', amount: '0.000000000000001' }],
balances: [{ asset: 'ETH', amount: '1', amountInUSD: '150' }],
});
});
});

describe('when the wallet contains no tokens', () => {
it('outputs nothing', async () => {
mockClient.getWeiBalance.resolves(0);
mockClient.getTokensBalance.resolves({});
mockClient.getTokenBalances.resolves([]);

const response = await underTest.getBalances(dummyConfig, dummyContext);

Expand All @@ -62,19 +94,21 @@ describe('LunchMoneyEthereumWalletConnection', () => {

describe('when the wallet contains tokens', () => {
it('outputs the tokens which have balances above the negligible balance threshold', async () => {
mockClient.getWeiBalance.resolves(50);
mockClient.getTokensBalance.resolves({
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 1000, // USDC
'0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2': 10, // MKR
'0xa1d65E8fB6e87b60FECCBc582F7f97804B725521': 10000, // DXD
});
mockClient.getTokenBalances.resolves(
makeBalances([
{ asset: 'ETH', amount: 0, amountInUSD: 200 },
{ asset: 'USDC', amount: 0, amountInUSD: 300 },
{ asset: 'WBTC', amount: 0, amountInUSD: 250 },
]),
);

const response = await underTest.getBalances(dummyConfig, dummyContext);

assert.strictEqual(response.providerName, 'wallet_ethereum');
assert.sameDeepMembers(response.balances, [
{ asset: 'USDC', amount: '0.000000000000001' },
{ asset: 'DXD', amount: '0.00000000000001' },
{ asset: 'ETH', amount: '0', amountInUSD: '200' },
{ asset: 'USDC', amount: '0', amountInUSD: '300' },
{ asset: 'WBTC', amount: '0', amountInUSD: '250' },
]);
});
});
Expand Down