Skip to content

Commit 7e7bd73

Browse files
Jpatchingclaude
andcommitted
Fix 9 data accuracy bugs, add CI/CD pipeline, death classifier tests
Priority 1 (wrong scores): - Deploy velocity uses dated tokens only, not total (inflated penalty) - Wallet route now caps dead token lifespan at 7 days (parity with deployer) - cluster_total_dead uses deadCount consistently (not adjustedDead) - Death classifier lifespan_hours capped at 7d (fixes quick-dump rule) Priority 2 (misleading data): - tokens_assumed_dead deprecated (set to 0), tokens_unverified is canonical - Death classification limit raised from 20 to 50 tokens Priority 3 (tests): - New death-classifier.test.ts (8 tests: lifespan, rug detection, natural, boundary) - Extended data-validation.test.ts (velocity, lifespan cap, cluster consistency) Infrastructure: - GitHub Actions CI/CD: test + build on PR, deploy backend+frontend on push to main - Updated .gitignore for db files, .next, .vercel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c6c4e13 commit 7e7bd73

14 files changed

Lines changed: 735 additions & 197 deletions

File tree

.github/workflows/ci.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: CI/CD
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
# ── Test & Build ──
11+
test-backend:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-node@v4
16+
with:
17+
node-version: 22
18+
cache: npm
19+
cache-dependency-path: backend/package-lock.json
20+
- run: npm ci
21+
working-directory: backend
22+
- run: npm test
23+
working-directory: backend
24+
- run: npm run build
25+
working-directory: backend
26+
27+
build-frontend:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
- uses: actions/setup-node@v4
32+
with:
33+
node-version: 22
34+
cache: npm
35+
cache-dependency-path: web/package-lock.json
36+
- run: npm ci
37+
working-directory: web
38+
- run: npm run build
39+
working-directory: web
40+
env:
41+
NEXT_PUBLIC_API_URL: https://api.daybreakscan.com
42+
43+
# ── Deploy (only on push to main, after tests pass) ──
44+
deploy-backend:
45+
needs: [test-backend, build-frontend]
46+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
47+
runs-on: ubuntu-latest
48+
steps:
49+
- name: Deploy to VPS
50+
uses: appleboy/ssh-action@v1
51+
with:
52+
host: ${{ secrets.VPS_HOST }}
53+
username: ${{ secrets.VPS_USER }}
54+
key: ${{ secrets.VPS_SSH_KEY }}
55+
script: |
56+
source ~/.nvm/nvm.sh
57+
cd ~/projects/daybreak
58+
git pull origin main
59+
cd backend
60+
npm ci --production=false
61+
npm run build
62+
pm2 restart daybreak-api
63+
64+
deploy-frontend:
65+
needs: [test-backend, build-frontend]
66+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
67+
runs-on: ubuntu-latest
68+
steps:
69+
- uses: actions/checkout@v4
70+
- uses: actions/setup-node@v4
71+
with:
72+
node-version: 22
73+
cache: npm
74+
cache-dependency-path: web/package-lock.json
75+
- run: npm ci
76+
working-directory: web
77+
- name: Deploy to Vercel
78+
run: npx vercel --prod --yes --token "$VERCEL_TOKEN"
79+
working-directory: web
80+
env:
81+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
82+
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
83+
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ backend/node_modules/
1515
backend/dist/
1616
web/node_modules/
1717
web/dist/
18+
web/.next/
19+
web/.vercel/
20+
daybreak.db
21+
*.db

backend/src/__tests__/data-validation.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,70 @@ describe('Data Accuracy: Death rate calculation', () => {
221221
});
222222
});
223223

224+
describe('Data Accuracy: Deploy velocity only counts dated tokens', () => {
225+
it('velocity uses dated token count, not total token count', () => {
226+
// Simulate: 2 dated tokens over 10 days + 5 undated tokens
227+
const creationDates = [
228+
'2026-02-01T00:00:00Z',
229+
'2026-02-11T00:00:00Z',
230+
];
231+
const totalTokens = 7; // includes 5 undated
232+
233+
// Old (wrong): totalTokens / daySpan = 7 / 10 = 0.7
234+
const daySpan = (new Date(creationDates[1]).getTime() - new Date(creationDates[0]).getTime()) / 86400000;
235+
const wrongVelocity = Math.round((totalTokens / Math.max(1, daySpan)) * 100) / 100;
236+
const correctVelocity = Math.round((creationDates.length / Math.max(1, daySpan)) * 100) / 100;
237+
238+
expect(correctVelocity).toBe(0.2);
239+
expect(wrongVelocity).toBe(0.7);
240+
expect(correctVelocity).not.toBe(wrongVelocity);
241+
});
242+
243+
it('velocity with all tokens dated is unchanged', () => {
244+
const creationDates = [
245+
'2026-02-01T00:00:00Z',
246+
'2026-02-06T00:00:00Z',
247+
'2026-02-11T00:00:00Z',
248+
];
249+
const daySpan = (new Date(creationDates[2]).getTime() - new Date(creationDates[0]).getTime()) / 86400000;
250+
const velocity = Math.round((creationDates.length / Math.max(1, daySpan)) * 100) / 100;
251+
expect(velocity).toBe(0.3);
252+
});
253+
});
254+
255+
describe('Data Accuracy: Lifespan cap consistency between routes', () => {
256+
it('dead token lifespan is capped at 7 days', () => {
257+
const created = new Date('2025-01-01T00:00:00Z').getTime();
258+
const isAlive = false;
259+
let days = (Date.now() - created) / (1000 * 60 * 60 * 24);
260+
// Apply cap (same logic in both deployer.ts and wallet.ts)
261+
if (!isAlive && days > 7) days = 7;
262+
expect(days).toBe(7);
263+
});
264+
265+
it('alive token lifespan is NOT capped', () => {
266+
const created = new Date('2025-01-01T00:00:00Z').getTime();
267+
const isAlive = true;
268+
let days = (Date.now() - created) / (1000 * 60 * 60 * 24);
269+
if (!isAlive && days > 7) days = 7;
270+
expect(days).toBeGreaterThan(7);
271+
});
272+
273+
it('cluster_total_dead uses deadCount consistently', () => {
274+
// Simulating: 10 tokens, 5 verified dead, 3 unverified
275+
const deadCount = 5;
276+
const unknownCount = 3;
277+
const adjustedDead = deadCount + unknownCount; // old wrong value
278+
279+
// Both initial and post-cluster should use deadCount (not adjustedDead)
280+
const initialValue = deadCount;
281+
const postClusterValue = deadCount;
282+
283+
expect(initialValue).toBe(postClusterValue);
284+
expect(initialValue).not.toBe(adjustedDead);
285+
});
286+
});
287+
224288
describe('Data Accuracy: Verdict thresholds', () => {
225289
const noRisk: RiskPenalties = {
226290
mintAuthorityActive: false,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
3+
// Mock helius before importing death-classifier
4+
vi.mock('../services/helius', () => ({
5+
getEnhancedTransactions: vi.fn().mockResolvedValue([]),
6+
checkDeployerHoldings: vi.fn().mockResolvedValue(null),
7+
checkMintAuthority: vi.fn().mockResolvedValue(null),
8+
findFundingSource: vi.fn().mockResolvedValue(null),
9+
DEX_PROGRAM_IDS: new Set(['675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8']),
10+
}));
11+
12+
import { classifyDeaths } from '../services/death-classifier';
13+
import { checkMintAuthority, checkDeployerHoldings, getEnhancedTransactions } from '../services/helius';
14+
15+
const DEPLOYER = 'DeployerWallet111111111111111111111111111111';
16+
const FUNDER = 'FunderWallet1111111111111111111111111111111';
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
describe('Death Classifier: lifespan_hours correctness', () => {
23+
it('caps lifespan_hours at 168 (7 days) for old dead tokens', async () => {
24+
const sixMonthsAgo = new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString();
25+
const results = await classifyDeaths(
26+
DEPLOYER,
27+
[{ address: 'Token111111111111111111111111111111111111111', liquidity: 500, created_at: sixMonthsAgo }],
28+
null,
29+
);
30+
const classification = results.get('Token111111111111111111111111111111111111111');
31+
expect(classification).toBeDefined();
32+
expect(classification!.evidence.lifespan_hours).toBeLessThanOrEqual(168);
33+
});
34+
35+
it('preserves short lifespan for recent tokens', async () => {
36+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
37+
const results = await classifyDeaths(
38+
DEPLOYER,
39+
[{ address: 'Token222222222222222222222222222222222222222', liquidity: 500, created_at: twoHoursAgo }],
40+
null,
41+
);
42+
const classification = results.get('Token222222222222222222222222222222222222222');
43+
expect(classification).toBeDefined();
44+
expect(classification!.evidence.lifespan_hours).toBeLessThanOrEqual(3); // ~2h with rounding
45+
});
46+
});
47+
48+
describe('Death Classifier: likely_rug detection', () => {
49+
it('classifies as likely_rug when deployer sold and had real buyers', async () => {
50+
const tokenAddr = 'Token333333333333333333333333333333333333333';
51+
vi.mocked(checkMintAuthority).mockResolvedValue({
52+
mintAuthority: null,
53+
freezeAuthority: null,
54+
supply: 1000000000,
55+
decimals: 9,
56+
});
57+
vi.mocked(checkDeployerHoldings).mockResolvedValue(0); // deployer sold everything
58+
59+
const results = await classifyDeaths(
60+
DEPLOYER,
61+
[{ address: tokenAddr, liquidity: 500, created_at: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString() }],
62+
null,
63+
);
64+
const c = results.get(tokenAddr);
65+
expect(c).toBeDefined();
66+
expect(c!.type).toBe('likely_rug');
67+
expect(c!.evidence.deployer_sold).toBe(true);
68+
});
69+
70+
it('classifies quick dump within 48h as likely_rug', async () => {
71+
const tokenAddr = 'Token444444444444444444444444444444444444444';
72+
vi.mocked(checkMintAuthority).mockResolvedValue({
73+
mintAuthority: null,
74+
freezeAuthority: null,
75+
supply: 1000000000,
76+
decimals: 9,
77+
});
78+
vi.mocked(checkDeployerHoldings).mockResolvedValue(0); // deployer sold
79+
80+
// Token created 6 hours ago — should still trigger quick dump after lifespan fix
81+
const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
82+
const results = await classifyDeaths(
83+
DEPLOYER,
84+
[{ address: tokenAddr, liquidity: 10, created_at: sixHoursAgo }], // low liquidity but deployer sold
85+
null,
86+
);
87+
const c = results.get(tokenAddr);
88+
expect(c).toBeDefined();
89+
expect(c!.type).toBe('likely_rug');
90+
expect(c!.evidence.lifespan_hours).toBeLessThan(48);
91+
});
92+
});
93+
94+
describe('Death Classifier: natural death', () => {
95+
it('classifies as natural when no real buyers and deployer still holds', async () => {
96+
const tokenAddr = 'Token555555555555555555555555555555555555555';
97+
vi.mocked(checkMintAuthority).mockResolvedValue({
98+
mintAuthority: null,
99+
freezeAuthority: null,
100+
supply: 1000000000,
101+
decimals: 9,
102+
});
103+
vi.mocked(checkDeployerHoldings).mockResolvedValue(50); // deployer still holds 50%
104+
105+
const results = await classifyDeaths(
106+
DEPLOYER,
107+
[{ address: tokenAddr, liquidity: 10, created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() }],
108+
null,
109+
);
110+
const c = results.get(tokenAddr);
111+
expect(c).toBeDefined();
112+
expect(c!.type).toBe('natural');
113+
expect(c!.evidence.deployer_sold).toBe(false);
114+
});
115+
116+
it('classifies tokens with no DexScreener data as natural (never got traction)', async () => {
117+
const tokenAddr = 'Token666666666666666666666666666666666666666';
118+
const results = await classifyDeaths(
119+
DEPLOYER,
120+
[{ address: tokenAddr, liquidity: 0, created_at: null }],
121+
null,
122+
);
123+
const c = results.get(tokenAddr);
124+
expect(c).toBeDefined();
125+
expect(c!.type).toBe('natural');
126+
});
127+
});
128+
129+
describe('Death Classifier: token at 48h boundary', () => {
130+
it('token at exactly 48h with deployer sold = likely_rug (boundary)', async () => {
131+
const tokenAddr = 'Token777777777777777777777777777777777777777';
132+
vi.mocked(checkMintAuthority).mockResolvedValue({
133+
mintAuthority: null,
134+
freezeAuthority: null,
135+
supply: 1000000000,
136+
decimals: 9,
137+
});
138+
vi.mocked(checkDeployerHoldings).mockResolvedValue(0);
139+
140+
// Exactly 47h ago — should still be under 48h after rounding
141+
const created = new Date(Date.now() - 47 * 60 * 60 * 1000).toISOString();
142+
const results = await classifyDeaths(
143+
DEPLOYER,
144+
[{ address: tokenAddr, liquidity: 10, created_at: created }],
145+
null,
146+
);
147+
const c = results.get(tokenAddr);
148+
expect(c).toBeDefined();
149+
expect(c!.type).toBe('likely_rug');
150+
expect(c!.evidence.lifespan_hours).toBeLessThan(48);
151+
});
152+
});
153+
154+
describe('Death Classifier: classification limit', () => {
155+
it('classifies up to 50 tokens (not just 20)', async () => {
156+
const deadTokens = Array.from({ length: 60 }, (_, i) => ({
157+
address: `Token${String(i).padStart(39, '0')}AA`,
158+
liquidity: 100 + i,
159+
created_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
160+
}));
161+
162+
const results = await classifyDeaths(DEPLOYER, deadTokens, null);
163+
// All 60 should get some classification (50 classifiable + 10 overflow as natural)
164+
expect(results.size).toBe(60);
165+
// The top 50 by liquidity should have been processed (even if they end up as natural/unverified)
166+
// The bottom 10 should be natural (no DexScreener classification needed)
167+
});
168+
});

0 commit comments

Comments
 (0)