Skip to content

Commit

Permalink
Merge branch 'develop' into feature/#2533-sablier-and-transfer-template
Browse files Browse the repository at this point in the history
  • Loading branch information
adamgall committed Jan 16, 2025
2 parents bb66eae + 09aa306 commit a0ce95f
Show file tree
Hide file tree
Showing 60 changed files with 2,471 additions and 361 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"
11 changes: 7 additions & 4 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ SENTRY_AUTH_TOKEN=""
VITE_APP_NAME="Decent"

# Hotjar Site ID. This should be parseable to an integer.
VITE_APP_HOTJAR_SITE_ID=""
# VITE_APP_HOTJAR_SITE_ID=""

# Hotjar Version. Should be retrieved from the Hotjar dashboard. This should be parseable to an integer.
VITE_APP_HOTJAR_VERSION=""
# VITE_APP_HOTJAR_VERSION=""

# API key for Amplitude analytics
VITE_APP_AMPLITUDE_API_KEY=""
Expand Down Expand Up @@ -55,6 +55,9 @@ VITE_APP_SITE_URL="https://app.dev.decentdao.org"
# WalletConnect Cloud Project ID
VITE_APP_WALLET_CONNECT_PROJECT_ID=""

# Use legacy Netlify balances backend
VITE_APP_USE_LEGACY_BACKEND=""

# FEATURE FLAGS (Must equal "ON")
VITE_APP_FLAG_DEVELOPMENT_MODE=""
VITE_APP_FLAG_DEMO_MODE=""
VITE_APP_FLAG_DEV=""
VITE_APP_FLAG_DEMO=""
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
100 changes: 87 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,103 @@ Install the dependencies
$ npm install
```

Running development environment (without `Netlify` functions)
Running development environment

```shell
$ npm run dev
```

Running development environment (with `Netlify` functions)

```shell
$ npm run dev:netlify
```

### Netlify functions

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

- 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 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

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

## Feature flags

### Setup

Start with adding a new Feature Flag to the app. In https://github.com/decentdao/decent-interface/src/helpers/featureFlags.ts, Add a flag.

```typescript
export const FEATURE_FLAGS = [
'flag_dev',
'flag_demo',
'flag_yelling', // <-- new flag
] as const;
```

### Usage

In consumer of the flag, use the convenience function

```typescript
import { isFeatureEnabled } from '@/helpers/featureFlags';

if (isFeatureEnabled('flag_yelling')) {
// code here
}
```

### Injecting flags via your environment

During development, add a flag environment variable in your (local) .env(.local) file. It must be a string value of "ON" or "OFF". The syntax of the environment variable is `VITE_APP_<FLAG_NAME>`.

```shell
VITE_APP_FLAG_YELLING="ON"
```

You can also set the flag in the URL with a query param. Notice how the `VITE_APP_` prefix is omitted and the flag name in the query param matches the name you gave it in code:

```shell
http://localhost:3000/?flag_yelling=on
```

### Testing

Override the flag value by adding query params to the URL. Notice how the `VITE_APP_` prefix is omitted and the flag name is in lowercase:

```
https://app.dev.decentdao.org?flag_yelling=on
```

From then, the flag holds the value from the URL param until app is refreshed

### Deployment and after

Deployment can ship with the flag turned off in .env file.

Change the value in .env file after the feature is completed and thouroughly tested.

Once code under the feature flag has been proven reliable, remove the feature flag and dead code from code base.

## Subgraph

We're using `Subgraph` to index certain "metadata" events to simplify data fetching from application site.
Expand All @@ -56,9 +132,7 @@ $ npm run graphql:build

## Deployment Notes

The "dev" and "prod" environments of this app are currently deployed via `Netlify`.

The "dev" environment tracks the `develop` [branch](https://github.com/decentdao/decent-interface/tree/develop), and the "prod" environment tracks the `main` [branch](https://github.com/decentdao/decent-interface/tree/main).
This app is deployed on Cloudflare Pages with the following configuration:

- dev: https://app.dev.decentdao.org
- prod: https://app.decentdao.org
- Production deployment (tracking `main` branch): https://app.new.decentdao.org
- All other branches get preview deployments at: https://branch-name.decent-interface.pages.dev
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 a0ce95f

Please sign in to comment.