Skip to content

Commit a0ce95f

Browse files
committed
Merge branch 'develop' into feature/#2533-sablier-and-transfer-template
2 parents bb66eae + 09aa306 commit a0ce95f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+2471
-361
lines changed

.dev.vars.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Minutes to cache token balances for address
2+
BALANCES_CACHE_INTERVAL_MINUTES="1"
3+
# Minutes to give Moralis to index new addresses
4+
BALANCES_MORALIS_INDEX_DELAY_MINUTES="0"
5+
# Moralis API key for fetching DAO treasury balances
6+
MORALIS_API_KEY="local api key"

.env

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ SENTRY_AUTH_TOKEN=""
1616
VITE_APP_NAME="Decent"
1717

1818
# Hotjar Site ID. This should be parseable to an integer.
19-
VITE_APP_HOTJAR_SITE_ID=""
19+
# VITE_APP_HOTJAR_SITE_ID=""
2020

2121
# Hotjar Version. Should be retrieved from the Hotjar dashboard. This should be parseable to an integer.
22-
VITE_APP_HOTJAR_VERSION=""
22+
# VITE_APP_HOTJAR_VERSION=""
2323

2424
# API key for Amplitude analytics
2525
VITE_APP_AMPLITUDE_API_KEY=""
@@ -55,6 +55,9 @@ VITE_APP_SITE_URL="https://app.dev.decentdao.org"
5555
# WalletConnect Cloud Project ID
5656
VITE_APP_WALLET_CONNECT_PROJECT_ID=""
5757

58+
# Use legacy Netlify balances backend
59+
VITE_APP_USE_LEGACY_BACKEND=""
60+
5861
# FEATURE FLAGS (Must equal "ON")
59-
VITE_APP_FLAG_DEVELOPMENT_MODE=""
60-
VITE_APP_FLAG_DEMO_MODE=""
62+
VITE_APP_FLAG_DEV=""
63+
VITE_APP_FLAG_DEMO=""

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ yarn-error.log*
3838

3939
# Local Netlify folder
4040
/.netlify
41+
42+
# Wrangler
43+
/.wrangler
44+
.dev.vars

README.md

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,103 @@ Install the dependencies
2020
$ npm install
2121
```
2222

23-
Running development environment (without `Netlify` functions)
23+
Running development environment
2424

2525
```shell
2626
$ npm run dev
2727
```
2828

29-
Running development environment (with `Netlify` functions)
30-
31-
```shell
32-
$ npm run dev:netlify
33-
```
34-
3529
### Netlify functions
3630

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

4135
- Treasury page
4236
- Payments feature
4337

38+
### Cloudflare Pages functions
39+
40+
We're using Cloudflare Pages functions for retrieving various off-chain data.
41+
Currently it's being used to fetch abstract `address`'s ERC-20, ERC-721 and DeFi balances through `Moralis`.
42+
It is crucial to have Cloudflare Pages functions running locally to work with anything related to DAO treasury, for instance
43+
44+
- Treasury page
45+
- Payments feature
46+
47+
### Environment Variables
48+
49+
The application uses two sets of environment variables:
50+
51+
1. **Functions Environment Variables** (`.dev.vars`)
52+
53+
- Copy `.dev.vars.example` to `.dev.vars` for local development
54+
- Contains variables needed for Cloudflare Pages Functions (e.g., Moralis API key)
55+
- In production, these need to be manually configured as "secrets" in the Cloudflare Dashboard
56+
57+
2. **Application Environment Variables** (`.env.local`)
58+
- Copy `.env` to `.env.local` for local development
59+
- Contains Vite-injected variables for the React application
60+
- In production, these also need to be manually configured as "secrets" in the Cloudflare Dashboard
61+
62+
## Feature flags
63+
64+
### Setup
65+
66+
Start with adding a new Feature Flag to the app. In https://github.com/decentdao/decent-interface/src/helpers/featureFlags.ts, Add a flag.
67+
68+
```typescript
69+
export const FEATURE_FLAGS = [
70+
'flag_dev',
71+
'flag_demo',
72+
'flag_yelling', // <-- new flag
73+
] as const;
74+
```
75+
76+
### Usage
77+
78+
In consumer of the flag, use the convenience function
79+
80+
```typescript
81+
import { isFeatureEnabled } from '@/helpers/featureFlags';
82+
83+
if (isFeatureEnabled('flag_yelling')) {
84+
// code here
85+
}
86+
```
87+
88+
### Injecting flags via your environment
89+
90+
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>`.
91+
92+
```shell
93+
VITE_APP_FLAG_YELLING="ON"
94+
```
95+
96+
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:
97+
98+
```shell
99+
http://localhost:3000/?flag_yelling=on
100+
```
101+
102+
### Testing
103+
104+
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:
105+
106+
```
107+
https://app.dev.decentdao.org?flag_yelling=on
108+
```
109+
110+
From then, the flag holds the value from the URL param until app is refreshed
111+
112+
### Deployment and after
113+
114+
Deployment can ship with the flag turned off in .env file.
115+
116+
Change the value in .env file after the feature is completed and thouroughly tested.
117+
118+
Once code under the feature flag has been proven reliable, remove the feature flag and dead code from code base.
119+
44120
## Subgraph
45121

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

57133
## Deployment Notes
58134

59-
The "dev" and "prod" environments of this app are currently deployed via `Netlify`.
60-
61-
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).
135+
This app is deployed on Cloudflare Pages with the following configuration:
62136

63-
- dev: https://app.dev.decentdao.org
64-
- prod: https://app.decentdao.org
137+
- Production deployment (tracking `main` branch): https://app.new.decentdao.org
138+
- All other branches get preview deployments at: https://branch-name.decent-interface.pages.dev

functions/balances/balanceCache.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Context } from 'hono';
2+
import type { Address } from 'viem';
3+
import { DefiBalance, NFTBalance, TokenBalance } from '../../src/types/daoTreasury';
4+
import { withCache } from '../shared/kvCache';
5+
import { Var, type Env } from '../types';
6+
7+
type BalanceMap = {
8+
tokens: TokenBalance[];
9+
nfts: NFTBalance[];
10+
defi: DefiBalance[];
11+
};
12+
13+
export async function withBalanceCache<T extends keyof BalanceMap>(
14+
c: Context<{ Bindings: Env; Variables: Var }>,
15+
storeName: T,
16+
fetchFromMoralis: (scope: { chain: string; address: Address }) => Promise<BalanceMap[T]>,
17+
) {
18+
const { address, network } = c.var;
19+
const storeKey = `${storeName}-${network}-${address}`;
20+
21+
try {
22+
const cacheTimeSeconds = parseInt(c.env.BALANCES_CACHE_INTERVAL_MINUTES) * 60;
23+
const indexingDelaySeconds = parseInt(c.env.BALANCES_MORALIS_INDEX_DELAY_MINUTES) * 60;
24+
25+
const data = await withCache<BalanceMap[T]>({
26+
store: c.env.balances,
27+
key: storeKey,
28+
namespace: storeName,
29+
options: {
30+
cacheTimeSeconds,
31+
indexingDelaySeconds,
32+
},
33+
fetch: async () => {
34+
try {
35+
return await fetchFromMoralis({ chain: network, address });
36+
} catch (e) {
37+
console.error(`Error fetching from Moralis: ${e}`);
38+
throw new Error('Failed to fetch from Moralis');
39+
}
40+
},
41+
});
42+
43+
return { data };
44+
} catch (e) {
45+
console.error(e);
46+
return { error: 'Unexpected error while fetching balances' };
47+
}
48+
}

functions/balances/index.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Hono } from 'hono';
2+
import { TokenBalance } from '../../src/types/daoTreasury';
3+
import { fetchMoralis } from '../shared/moralisApi';
4+
import { DefiResponse, NFTResponse, TokenResponse } from '../shared/moralisTypes';
5+
import type { Env } from '../types';
6+
import { withBalanceCache } from './balanceCache';
7+
import { getParams } from './middleware';
8+
import {
9+
transformDefiResponse,
10+
transformNFTResponse,
11+
transformTokenResponse,
12+
} from './transformers';
13+
14+
const endpoints = {
15+
tokens: {
16+
moralisPath: (address: string) => `/wallets/${address}/tokens`,
17+
transform: transformTokenResponse,
18+
postProcess: (data: TokenBalance[]) => data.filter(token => token.balance !== '0'),
19+
fetch: async ({ chain, address }: { chain: string; address: string }, c: { env: Env }) => {
20+
const result = await fetchMoralis<TokenResponse>({
21+
endpoint: endpoints.tokens.moralisPath(address),
22+
chain,
23+
apiKey: c.env.MORALIS_API_KEY,
24+
});
25+
const transformed = result.map(endpoints.tokens.transform);
26+
return endpoints.tokens.postProcess(transformed);
27+
},
28+
},
29+
nfts: {
30+
moralisPath: (address: string) => `/${address}/nft`,
31+
transform: transformNFTResponse,
32+
params: {
33+
format: 'decimal',
34+
media_items: 'true',
35+
normalizeMetadata: 'true',
36+
},
37+
fetch: async ({ chain, address }: { chain: string; address: string }, c: { env: Env }) => {
38+
const result = await fetchMoralis<NFTResponse>({
39+
endpoint: endpoints.nfts.moralisPath(address),
40+
chain,
41+
apiKey: c.env.MORALIS_API_KEY,
42+
params: endpoints.nfts.params,
43+
});
44+
return result.map(endpoints.nfts.transform);
45+
},
46+
},
47+
defi: {
48+
moralisPath: (address: string) => `/wallets/${address}/defi/positions`,
49+
transform: transformDefiResponse,
50+
fetch: async ({ chain, address }: { chain: string; address: string }, c: { env: Env }) => {
51+
const result = await fetchMoralis<DefiResponse>({
52+
endpoint: endpoints.defi.moralisPath(address),
53+
chain,
54+
apiKey: c.env.MORALIS_API_KEY,
55+
});
56+
return result.map(endpoints.defi.transform);
57+
},
58+
},
59+
} as const;
60+
61+
type BalanceType = keyof typeof endpoints;
62+
const ALL_BALANCE_TYPES: BalanceType[] = ['tokens', 'nfts', 'defi'];
63+
64+
export const router = new Hono<{ Bindings: Env }>().use('*', getParams).get('/', async c => {
65+
const { address, network } = c.var;
66+
const flavors = c.req.queries('flavor') as BalanceType[] | undefined;
67+
const requestedTypes = flavors?.filter(t => ALL_BALANCE_TYPES.includes(t)) ?? ALL_BALANCE_TYPES;
68+
69+
const results = await Promise.all(
70+
requestedTypes.map(async type => {
71+
const result = await withBalanceCache(c, type, () =>
72+
endpoints[type].fetch({ chain: network, address }, c),
73+
);
74+
return [type, result] as const;
75+
}),
76+
);
77+
78+
const response = Object.fromEntries(results);
79+
if (results.some(([, result]) => 'error' in result)) {
80+
return c.json(response, 503);
81+
}
82+
return c.json(response);
83+
});

functions/balances/middleware.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createMiddleware } from 'hono/factory';
2+
import { isAddress } from 'viem';
3+
import { moralisSupportedChainIds } from '../../src/providers/NetworkConfig/useNetworkConfigStore';
4+
import type { Env, Var } from '../types';
5+
6+
export const getParams = createMiddleware<{ Bindings: Env; Variables: Var }>(async (c, next) => {
7+
const address = c.req.query('address');
8+
if (!address) {
9+
return c.json({ error: 'Address is required' }, 400);
10+
}
11+
if (!isAddress(address)) {
12+
return c.json({ error: 'Provided address is not a valid address' }, 400);
13+
}
14+
c.set('address', address);
15+
16+
const network = c.req.query('network');
17+
if (!network) {
18+
return c.json({ error: 'Network is required' }, 400);
19+
}
20+
const chainId = parseInt(network);
21+
if (!moralisSupportedChainIds.includes(chainId)) {
22+
return c.json({ error: 'Requested network is not supported' }, 400);
23+
}
24+
c.set('network', network);
25+
26+
await next();
27+
});

functions/balances/transformers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { DefiBalance, NFTBalance, TokenBalance } from '../../src/types/daoTreasury';
2+
import { DefiResponse, NFTResponse, TokenResponse } from '../shared/moralisTypes';
3+
4+
export function transformTokenResponse(token: TokenResponse): TokenBalance {
5+
return {
6+
...token,
7+
tokenAddress: token.token_address,
8+
verifiedContract: token.verified_contract,
9+
balanceFormatted: token.balance_formatted,
10+
nativeToken: token.native_token,
11+
portfolioPercentage: token.portfolio_percentage,
12+
logo: token.logo,
13+
thumbnail: token.thumbnail,
14+
usdValue: token.usd_value,
15+
possibleSpam: token.possible_spam,
16+
};
17+
}
18+
19+
export function transformNFTResponse(nft: NFTResponse): NFTBalance {
20+
return {
21+
...nft,
22+
tokenAddress: nft.token_address,
23+
tokenId: nft.token_id,
24+
possibleSpam: !!nft.possible_spam,
25+
media: nft.media,
26+
metadata: nft.metadata ? JSON.parse(nft.metadata) : undefined,
27+
tokenUri: nft.token_uri,
28+
name: nft.name || undefined,
29+
symbol: nft.symbol || undefined,
30+
amount: nft.amount ? parseInt(nft.amount) : undefined,
31+
};
32+
}
33+
34+
export function transformDefiResponse(defi: DefiResponse): DefiBalance {
35+
return defi;
36+
}

functions/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Hono } from 'hono';
2+
import { router as balancesRouter } from './balances';
3+
import { type Env } from './types';
4+
5+
const app = new Hono<{ Bindings: Env }>().basePath('/api').route('/balances', balancesRouter);
6+
7+
export type AppType = typeof app;
8+
export default app;

0 commit comments

Comments
 (0)