Skip to content

Commit 9dbddd6

Browse files
authored
Merge pull request #143 from MeshJS/feature/get-TVL
Feature/get-TVL
2 parents bbbfa3a + 148e431 commit 9dbddd6

File tree

9 files changed

+1054
-0
lines changed

9 files changed

+1054
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Daily Balance Snapshots
2+
3+
# This workflow takes daily snapshots of wallet balances and stores them in the database.
4+
# API requests require SNAPSHOT_AUTH_TOKEN secret to be set in GitHub repository settings.
5+
6+
on:
7+
#schedule:
8+
# Run at midnight UTC every day
9+
#- cron: '0 0 * * *'
10+
# Allow manual triggering for testing
11+
workflow_dispatch:
12+
13+
jobs:
14+
snapshot-balances:
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v4
20+
21+
- name: Setup Node.js
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: '18'
25+
cache: 'npm'
26+
27+
- name: Install dependencies
28+
run: npm ci
29+
30+
- name: Run balance snapshots
31+
run: node scripts/balance-snapshots.js
32+
env:
33+
API_BASE_URL: "https://multisig.meshjs.dev"
34+
SNAPSHOT_AUTH_TOKEN: ${{ secrets.SNAPSHOT_AUTH_TOKEN }}
35+
BATCH_SIZE: 3
36+
DELAY_BETWEEN_REQUESTS: 3
37+
DELAY_BETWEEN_BATCHES: 15
38+
MAX_RETRIES: 3
39+
REQUEST_TIMEOUT: 30
40+
41+
- name: Notify on failure
42+
if: failure()
43+
run: |
44+
echo "❌ Daily balance snapshot job failed"
45+
# Optional: Send failure notification
46+
# curl -X POST -H 'Content-type: application/json' \
47+
# --data "{\"text\":\"❌ Daily balance snapshots failed. Check the GitHub Actions logs.\"}" \
48+
# ${{ secrets.DISCORD_WEBHOOK_URL }}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- CreateTable
2+
CREATE TABLE "BalanceSnapshot" (
3+
"id" TEXT NOT NULL,
4+
"walletId" TEXT NOT NULL,
5+
"walletName" TEXT NOT NULL,
6+
"address" TEXT NOT NULL,
7+
"adaBalance" DECIMAL(65,30) NOT NULL,
8+
"assetBalances" JSONB NOT NULL,
9+
"isArchived" BOOLEAN NOT NULL,
10+
"snapshotDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
11+
12+
CONSTRAINT "BalanceSnapshot_pkey" PRIMARY KEY ("id")
13+
);
14+
15+
-- CreateIndex
16+
CREATE INDEX "BalanceSnapshot_snapshotDate_idx" ON "BalanceSnapshot"("snapshotDate");
17+
18+
-- CreateIndex
19+
CREATE INDEX "BalanceSnapshot_walletId_idx" ON "BalanceSnapshot"("walletId");

prisma/schema.prisma

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,14 @@ model Ballot {
9797
type Int
9898
createdAt DateTime @default(now())
9999
}
100+
101+
model BalanceSnapshot {
102+
id String @id @default(cuid())
103+
walletId String
104+
walletName String
105+
address String
106+
adaBalance Decimal
107+
assetBalances Json
108+
isArchived Boolean
109+
snapshotDate DateTime @default(now())
110+
}

scripts/balance-snapshots.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Balance Snapshots Script (JavaScript version)
5+
*
6+
* This script fetches wallet balances and stores them as snapshots in the database.
7+
* It can be run locally for testing or by GitHub Actions for automated snapshots.
8+
*
9+
* Usage:
10+
* node scripts/balance-snapshots.js
11+
* SNAPSHOT_AUTH_TOKEN=your_token node scripts/balance-snapshots.js
12+
*
13+
* Environment Variables:
14+
* - API_BASE_URL: Base URL for the API (default: http://localhost:3000)
15+
* - SNAPSHOT_AUTH_TOKEN: Authentication token for API requests
16+
* - BATCH_SIZE: Number of wallets to process per batch (default: 3)
17+
* - DELAY_BETWEEN_REQUESTS: Delay between requests in seconds (default: 3)
18+
* - DELAY_BETWEEN_BATCHES: Delay between batches in seconds (default: 15)
19+
* - MAX_RETRIES: Maximum retries for failed requests (default: 3)
20+
* - REQUEST_TIMEOUT: Request timeout in seconds (default: 30)
21+
*/
22+
23+
class BalanceSnapshotService {
24+
constructor() {
25+
this.config = this.loadConfig();
26+
this.results = {
27+
walletsFound: 0,
28+
processedWallets: 0,
29+
failedWallets: 0,
30+
totalAdaBalance: 0,
31+
snapshotsStored: 0,
32+
executionTime: 0,
33+
};
34+
}
35+
36+
loadConfig() {
37+
const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
38+
const authToken = process.env.SNAPSHOT_AUTH_TOKEN;
39+
40+
if (!authToken) {
41+
throw new Error('SNAPSHOT_AUTH_TOKEN environment variable is required');
42+
}
43+
44+
return {
45+
apiBaseUrl,
46+
authToken,
47+
batchSize: parseInt(process.env.BATCH_SIZE || '3'),
48+
delayBetweenRequests: parseInt(process.env.DELAY_BETWEEN_REQUESTS || '3'),
49+
delayBetweenBatches: parseInt(process.env.DELAY_BETWEEN_BATCHES || '15'),
50+
maxRetries: parseInt(process.env.MAX_RETRIES || '3'),
51+
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || '30'),
52+
};
53+
}
54+
55+
async makeRequest(/** @type {string} */ url, /** @type {RequestInit} */ options = {}) {
56+
const controller = new AbortController();
57+
const timeoutId = setTimeout(() => controller.abort(), this.config.requestTimeout * 1000);
58+
59+
try {
60+
const response = await fetch(url, {
61+
...options,
62+
headers: {
63+
'Authorization': `Bearer ${this.config.authToken}`,
64+
'Content-Type': 'application/json',
65+
...(options.headers || {}),
66+
},
67+
signal: controller.signal,
68+
});
69+
70+
clearTimeout(timeoutId);
71+
72+
if (!response.ok) {
73+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
74+
}
75+
76+
const data = await response.json();
77+
return { data, status: response.status };
78+
} catch (error) {
79+
clearTimeout(timeoutId);
80+
throw error;
81+
}
82+
}
83+
84+
async delay(/** @type {number} */ seconds) {
85+
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
86+
}
87+
88+
async fetchWallets() {
89+
console.log('📋 Fetching all wallets...');
90+
91+
const { data } = await this.makeRequest(
92+
`${this.config.apiBaseUrl}/api/v1/aggregatedBalances/wallets`
93+
);
94+
95+
console.log(`✅ Found ${data.walletCount} wallets`);
96+
this.results.walletsFound = data.walletCount;
97+
98+
if (data.walletCount === 0) {
99+
console.log('ℹ️ No wallets found, skipping snapshot process');
100+
return [];
101+
}
102+
103+
return data.wallets;
104+
}
105+
106+
async fetchWalletBalance(/** @type {any} */ wallet) {
107+
const params = new URLSearchParams({
108+
walletId: wallet.walletId,
109+
walletName: wallet.walletName,
110+
signersAddresses: JSON.stringify(wallet.signersAddresses),
111+
numRequiredSigners: wallet.numRequiredSigners.toString(),
112+
type: wallet.type,
113+
stakeCredentialHash: wallet.stakeCredentialHash || '',
114+
isArchived: wallet.isArchived.toString(),
115+
network: wallet.network.toString(),
116+
});
117+
118+
const url = `${this.config.apiBaseUrl}/api/v1/aggregatedBalances/balance?${params}`;
119+
120+
for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
121+
try {
122+
const { data } = await this.makeRequest(url);
123+
console.log(` ✅ Balance: ${data.walletBalance.adaBalance} ADA`);
124+
return data.walletBalance;
125+
} catch (error) {
126+
const isLastAttempt = attempt === this.config.maxRetries;
127+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
128+
129+
if (errorMessage.includes('429')) {
130+
// Rate limited - wait longer before retry
131+
const retryDelay = this.config.delayBetweenRequests * attempt * 2;
132+
console.log(` ⚠️ Rate limited (429). Waiting ${retryDelay}s before retry ${attempt}/${this.config.maxRetries}`);
133+
await this.delay(retryDelay);
134+
} else {
135+
console.log(` ❌ Failed to fetch balance for wallet ${wallet.walletId}: ${errorMessage}`);
136+
if (isLastAttempt) {
137+
return null;
138+
}
139+
}
140+
}
141+
}
142+
143+
return null;
144+
}
145+
146+
async processWalletsInBatches(/** @type {any[]} */ wallets) {
147+
console.log(`💰 Fetching balances for ${wallets.length} wallets with rate limiting...`);
148+
console.log(`📊 Configuration: batch_size=${this.config.batchSize}, request_delay=${this.config.delayBetweenRequests}s, batch_delay=${this.config.delayBetweenBatches}s`);
149+
150+
const walletBalances = [];
151+
const totalBatches = Math.ceil(wallets.length / this.config.batchSize);
152+
153+
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) {
154+
const batchStart = batchIndex * this.config.batchSize;
155+
const batchEnd = Math.min(batchStart + this.config.batchSize, wallets.length);
156+
const batchWallets = wallets.slice(batchStart, batchEnd);
157+
158+
console.log(`📦 Processing batch ${batchIndex + 1}/${totalBatches}: wallets ${batchStart + 1}-${batchEnd}`);
159+
160+
for (let i = 0; i < batchWallets.length; i++) {
161+
const wallet = batchWallets[i];
162+
if (!wallet) continue;
163+
164+
console.log(` Processing wallet: ${wallet.walletName} (${wallet.walletId})`);
165+
166+
const walletBalance = await this.fetchWalletBalance(wallet);
167+
168+
if (walletBalance) {
169+
walletBalances.push(walletBalance);
170+
this.results.totalAdaBalance += walletBalance.adaBalance;
171+
this.results.processedWallets++;
172+
} else {
173+
this.results.failedWallets++;
174+
}
175+
176+
// Delay between requests within a batch (except for the last request)
177+
if (i < batchWallets.length - 1) {
178+
await this.delay(this.config.delayBetweenRequests);
179+
}
180+
}
181+
182+
// Delay between batches (except for the last batch)
183+
if (batchIndex < totalBatches - 1) {
184+
console.log(` ⏳ Waiting ${this.config.delayBetweenBatches}s before next batch...`);
185+
await this.delay(this.config.delayBetweenBatches);
186+
}
187+
}
188+
189+
console.log(`📊 Balance fetching completed. Failed wallets: ${this.results.failedWallets}`);
190+
console.log(`✅ Successfully processed: ${walletBalances.length} wallets`);
191+
192+
return walletBalances;
193+
}
194+
195+
async storeSnapshots(/** @type {any[]} */ walletBalances) {
196+
console.log('💾 Storing balance snapshots...');
197+
198+
const { data } = await this.makeRequest(
199+
`${this.config.apiBaseUrl}/api/v1/aggregatedBalances/snapshots`,
200+
{
201+
method: 'POST',
202+
body: JSON.stringify({ walletBalances }),
203+
}
204+
);
205+
206+
this.results.snapshotsStored = data.snapshotsStored;
207+
console.log(`✅ Successfully stored ${data.snapshotsStored} balance snapshots`);
208+
}
209+
210+
async run() {
211+
const startTime = Date.now();
212+
213+
try {
214+
console.log('🔄 Starting daily balance snapshot process...');
215+
216+
// Step 1: Fetch all wallets
217+
const wallets = await this.fetchWallets();
218+
219+
if (wallets.length === 0) {
220+
console.log('ℹ️ No wallets to process');
221+
return this.results;
222+
}
223+
224+
// Step 2: Process wallets in batches
225+
const walletBalances = await this.processWalletsInBatches(wallets);
226+
227+
// Step 3: Store snapshots
228+
if (walletBalances.length > 0) {
229+
await this.storeSnapshots(walletBalances);
230+
}
231+
232+
// Calculate execution time
233+
this.results.executionTime = Math.round((Date.now() - startTime) / 1000);
234+
235+
// Final summary
236+
console.log('\n🎉 Balance snapshot process completed successfully!');
237+
console.log(`📊 Summary:`);
238+
console.log(` • Wallets found: ${this.results.walletsFound}`);
239+
console.log(` • Processed: ${this.results.processedWallets}`);
240+
console.log(` • Failed: ${this.results.failedWallets}`);
241+
console.log(` • Snapshots stored: ${this.results.snapshotsStored}`);
242+
console.log(` • Total TVL: ${Math.round(this.results.totalAdaBalance * 100) / 100} ADA`);
243+
console.log(` • Execution time: ${this.results.executionTime}s`);
244+
245+
return this.results;
246+
} catch (error) {
247+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
248+
console.error('❌ Balance snapshot process failed:', errorMessage);
249+
throw error;
250+
}
251+
}
252+
}
253+
254+
// Main execution
255+
async function main() {
256+
try {
257+
const service = new BalanceSnapshotService();
258+
await service.run();
259+
process.exit(0);
260+
} catch (error) {
261+
console.error('❌ Script execution failed:', error);
262+
process.exit(1);
263+
}
264+
}
265+
266+
// Export for use in other modules
267+
export { BalanceSnapshotService };
268+
269+
// Run if this file is executed directly
270+
if (import.meta.url === `file://${process.argv[1]}`) {
271+
main();
272+
}

0 commit comments

Comments
 (0)