Skip to content

Commit

Permalink
Implement Cloudflare Pages Functions API only
Browse files Browse the repository at this point in the history
  • Loading branch information
adamgall committed Jan 14, 2025
1 parent 947fdf8 commit a63ec58
Show file tree
Hide file tree
Showing 18 changed files with 3,945 additions and 108 deletions.
6 changes: 6 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Minutes to cache token balances for address
BALANCES_CACHE_INTERVAL_MINUTES="1"
# Minutes to give Moralis to index new addresses
BALANCES_MORALIS_INDEX_DELAY_MINUTES="0"
# Moralis API key for fetching DAO treasury balances
MORALIS_API_KEY="local api key"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ yarn-error.log*

# Local Netlify folder
/.netlify

# Wrangler
/.wrangler
.dev.vars
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,26 @@ It is crucial to have `Netlify` functions running locally to work with anything
- Treasury page
- Payments feature

### Cloudflare Pages functions

We're using Cloudflare Pages functions for retrieving various off-chain data.
Currently it's being used to fetch abstract `address`'s ERC-20, ERC-721 and DeFi balances through `Moralis`.
It is crucial to have Cloudflare Pages functions running locally to work with anything related to DAO treasury, for instance

- Treasury page
- Payments feature

### Environment Variables

The application uses one set of environment variables:
The application uses two sets of environment variables:

1. **Functions Environment Variables** (`.dev.vars`)

- Copy `.dev.vars.example` to `.dev.vars` for local development
- Contains variables needed for Cloudflare Pages Functions (e.g., Moralis API key)
- In production, these need to be manually configured as "secrets" in the Cloudflare Dashboard

1. **Application Environment Variables** (`.env.local`)
2. **Application Environment Variables** (`.env.local`)
- Copy `.env` to `.env.local` for local development
- Contains Vite-injected variables for the React application
- In production, these also need to be manually configured as "secrets" in the Cloudflare Dashboard
Expand Down
48 changes: 48 additions & 0 deletions functions/balances/balanceCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Context } from 'hono';
import type { Address } from 'viem';
import { DefiBalance, NFTBalance, TokenBalance } from '../../src/types/daoTreasury';
import { withCache } from '../shared/kvCache';
import { Var, type Env } from '../types';

type BalanceMap = {
tokens: TokenBalance[];
nfts: NFTBalance[];
defi: DefiBalance[];
};

export async function withBalanceCache<T extends keyof BalanceMap>(
c: Context<{ Bindings: Env; Variables: Var }>,
storeName: T,
fetchFromMoralis: (scope: { chain: string; address: Address }) => Promise<BalanceMap[T]>,
) {
const { address, network } = c.var;
const storeKey = `${storeName}-${network}-${address}`;

try {
const cacheTimeSeconds = parseInt(c.env.BALANCES_CACHE_INTERVAL_MINUTES) * 60;
const indexingDelaySeconds = parseInt(c.env.BALANCES_MORALIS_INDEX_DELAY_MINUTES) * 60;

const data = await withCache<BalanceMap[T]>({
store: c.env.balances,
key: storeKey,
namespace: storeName,
options: {
cacheTimeSeconds,
indexingDelaySeconds,
},
fetch: async () => {
try {
return await fetchFromMoralis({ chain: network, address });
} catch (e) {
console.error(`Error fetching from Moralis: ${e}`);
throw new Error('Failed to fetch from Moralis');
}
},
});

return { data };
} catch (e) {
console.error(e);
return { error: 'Unexpected error while fetching balances' };
}
}
83 changes: 83 additions & 0 deletions functions/balances/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { TokenBalance } from '../../src/types/daoTreasury';
import { fetchMoralis } from '../shared/moralisApi';
import { DefiResponse, NFTResponse, TokenResponse } from '../shared/moralisTypes';
import type { Env } from '../types';
import { withBalanceCache } from './balanceCache';
import { getParams } from './middleware';
import {
transformDefiResponse,
transformNFTResponse,
transformTokenResponse,
} from './transformers';

const endpoints = {
tokens: {
moralisPath: (address: string) => `/wallets/${address}/tokens`,
transform: transformTokenResponse,
postProcess: (data: TokenBalance[]) => data.filter(token => token.balance !== '0'),
fetch: async ({ chain, address }: { chain: string; address: string }, c: { env: Env }) => {
const result = await fetchMoralis<TokenResponse>({
endpoint: endpoints.tokens.moralisPath(address),
chain,
apiKey: c.env.MORALIS_API_KEY,
});
const transformed = result.map(endpoints.tokens.transform);
return endpoints.tokens.postProcess(transformed);
},
},
nfts: {
moralisPath: (address: string) => `/${address}/nft`,
transform: transformNFTResponse,
params: {
format: 'decimal',
media_items: 'true',
normalizeMetadata: 'true',
},
fetch: async ({ chain, address }: { chain: string; address: string }, c: { env: Env }) => {
const result = await fetchMoralis<NFTResponse>({
endpoint: endpoints.nfts.moralisPath(address),
chain,
apiKey: c.env.MORALIS_API_KEY,
params: endpoints.nfts.params,
});
return result.map(endpoints.nfts.transform);
},
},
defi: {
moralisPath: (address: string) => `/wallets/${address}/defi/positions`,
transform: transformDefiResponse,
fetch: async ({ chain, address }: { chain: string; address: string }, c: { env: Env }) => {
const result = await fetchMoralis<DefiResponse>({
endpoint: endpoints.defi.moralisPath(address),
chain,
apiKey: c.env.MORALIS_API_KEY,
});
return result.map(endpoints.defi.transform);
},
},
} as const;

type BalanceType = keyof typeof endpoints;
const ALL_BALANCE_TYPES: BalanceType[] = ['tokens', 'nfts', 'defi'];

export const router = new Hono<{ Bindings: Env }>().use('*', getParams).get('/', async c => {
const { address, network } = c.var;
const flavors = c.req.queries('flavor') as BalanceType[] | undefined;
const requestedTypes = flavors?.filter(t => ALL_BALANCE_TYPES.includes(t)) ?? ALL_BALANCE_TYPES;

const results = await Promise.all(
requestedTypes.map(async type => {
const result = await withBalanceCache(c, type, () =>
endpoints[type].fetch({ chain: network, address }, c),
);
return [type, result] as const;
}),
);

const response = Object.fromEntries(results);
if (results.some(([, result]) => 'error' in result)) {
return c.json(response, 503);
}
return c.json(response);
});
27 changes: 27 additions & 0 deletions functions/balances/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { createMiddleware } from 'hono/factory';
import { isAddress } from 'viem';
import { moralisSupportedChainIds } from '../../src/providers/NetworkConfig/useNetworkConfigStore';
import type { Env, Var } from '../types';

export const getParams = createMiddleware<{ Bindings: Env; Variables: Var }>(async (c, next) => {
const address = c.req.query('address');
if (!address) {
return c.json({ error: 'Address is required' }, 400);
}
if (!isAddress(address)) {
return c.json({ error: 'Provided address is not a valid address' }, 400);
}
c.set('address', address);

const network = c.req.query('network');
if (!network) {
return c.json({ error: 'Network is required' }, 400);
}
const chainId = parseInt(network);
if (!moralisSupportedChainIds.includes(chainId)) {
return c.json({ error: 'Requested network is not supported' }, 400);
}
c.set('network', network);

await next();
});
36 changes: 36 additions & 0 deletions functions/balances/transformers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { DefiBalance, NFTBalance, TokenBalance } from '../../src/types/daoTreasury';
import { DefiResponse, NFTResponse, TokenResponse } from '../shared/moralisTypes';

export function transformTokenResponse(token: TokenResponse): TokenBalance {
return {
...token,
tokenAddress: token.token_address,
verifiedContract: token.verified_contract,
balanceFormatted: token.balance_formatted,
nativeToken: token.native_token,
portfolioPercentage: token.portfolio_percentage,
logo: token.logo,
thumbnail: token.thumbnail,
usdValue: token.usd_value,
possibleSpam: token.possible_spam,
};
}

export function transformNFTResponse(nft: NFTResponse): NFTBalance {
return {
...nft,
tokenAddress: nft.token_address,
tokenId: nft.token_id,
possibleSpam: !!nft.possible_spam,
media: nft.media,
metadata: nft.metadata ? JSON.parse(nft.metadata) : undefined,
tokenUri: nft.token_uri,
name: nft.name || undefined,
symbol: nft.symbol || undefined,
amount: nft.amount ? parseInt(nft.amount) : undefined,
};
}

export function transformDefiResponse(defi: DefiResponse): DefiBalance {
return defi;
}
8 changes: 8 additions & 0 deletions functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Hono } from 'hono';
import { router as balancesRouter } from './balances';
import { type Env } from './types';

const app = new Hono<{ Bindings: Env }>().basePath('/api').route('/balances', balancesRouter);

export type AppType = typeof app;
export default app;
Loading

0 comments on commit a63ec58

Please sign in to comment.