321 lines
9.1 KiB
TypeScript
321 lines
9.1 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const repoRoot = resolve(__dirname, '..', '..');
|
|
|
|
export interface HealthCheckResult {
|
|
success: boolean;
|
|
service: string;
|
|
message: string;
|
|
details?: string;
|
|
}
|
|
|
|
/**
|
|
* Verify RPC proxy is functional by:
|
|
* 1. Checking eth_chainId returns the expected chain
|
|
* 2. Making an eth_call to verify contract accessibility
|
|
* 3. Confirming deployed contracts are accessible
|
|
*/
|
|
export async function checkRpcFunctional(rpcUrl: string): Promise<HealthCheckResult> {
|
|
const service = 'RPC Proxy';
|
|
|
|
try {
|
|
// Step 1: Verify basic RPC connectivity
|
|
const chainIdResponse = await fetch(rpcUrl, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'eth_chainId',
|
|
params: []
|
|
}),
|
|
});
|
|
|
|
if (!chainIdResponse.ok) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: `RPC proxy returned HTTP ${chainIdResponse.status}`,
|
|
details: `Expected 200, got ${chainIdResponse.status}. Check if Anvil is running and RPC_URL is correct.`
|
|
};
|
|
}
|
|
|
|
const chainIdPayload = await chainIdResponse.json();
|
|
if (!chainIdPayload?.result) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'RPC returned no chain ID',
|
|
details: 'RPC responded but returned no result. Check if Anvil is properly initialized.'
|
|
};
|
|
}
|
|
|
|
// Step 2: Load deployed contracts and verify accessibility
|
|
const deploymentsPath = resolve(repoRoot, 'onchain', 'deployments-local.json');
|
|
let deployments: { contracts: { Kraiken: string } };
|
|
|
|
try {
|
|
const deploymentsContent = await readFile(deploymentsPath, 'utf-8');
|
|
deployments = JSON.parse(deploymentsContent);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'Failed to load contract deployments',
|
|
details: `Could not read ${deploymentsPath}. Run stack setup first.`
|
|
};
|
|
}
|
|
|
|
// Step 3: Verify we can make eth_call to deployed Kraiken contract
|
|
const kraikenAddress = deployments.contracts.Kraiken;
|
|
const totalSupplyCalldata = '0x18160ddd'; // totalSupply() selector
|
|
|
|
const callResponse = await fetch(rpcUrl, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'eth_call',
|
|
params: [{
|
|
to: kraikenAddress,
|
|
data: totalSupplyCalldata
|
|
}, 'latest']
|
|
}),
|
|
});
|
|
|
|
if (!callResponse.ok) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'eth_call to Kraiken contract failed',
|
|
details: `HTTP ${callResponse.status}. Contract may not be deployed or RPC proxy is misconfigured.`
|
|
};
|
|
}
|
|
|
|
const callPayload = await callResponse.json();
|
|
|
|
if (callPayload.error) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'Contract call returned RPC error',
|
|
details: `${callPayload.error.message}. Kraiken contract at ${kraikenAddress} may not be deployed.`
|
|
};
|
|
}
|
|
|
|
if (!callPayload?.result) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'Contract call returned no result',
|
|
details: 'eth_call responded but returned no data. Contract may not be deployed.'
|
|
};
|
|
}
|
|
|
|
// Verify we got a valid response (should be a hex-encoded uint256)
|
|
const result = callPayload.result as string;
|
|
if (!result.startsWith('0x')) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'Contract call returned invalid data',
|
|
details: `Expected hex-encoded value, got: ${result}`
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
service,
|
|
message: 'RPC proxy is functional and contracts are accessible'
|
|
};
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'RPC health check failed with exception',
|
|
details: `${errorMessage}. Check if RPC endpoint ${rpcUrl} is accessible.`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify GraphQL has indexed contract data by:
|
|
* 1. Checking GraphQL endpoint responds
|
|
* 2. Querying for stats to verify Ponder has indexed events
|
|
* 3. Confirming indexed data is non-zero (indicates successful indexing)
|
|
*/
|
|
export async function checkGraphqlIndexed(graphqlUrl: string): Promise<HealthCheckResult> {
|
|
const service = 'GraphQL Indexer';
|
|
|
|
try {
|
|
// Query for stats which should be populated after indexing
|
|
const response = await fetch(graphqlUrl, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
query: `
|
|
query {
|
|
stats(id: "0x01") {
|
|
kraikenTotalSupply
|
|
stakeTotalSupply
|
|
}
|
|
}
|
|
`
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: `GraphQL endpoint returned HTTP ${response.status}`,
|
|
details: `Expected 200, got ${response.status}. Check if Ponder is running.`
|
|
};
|
|
}
|
|
|
|
const payload = await response.json();
|
|
|
|
if (payload.errors) {
|
|
const errorMsg = payload.errors.map((e: { message: string }) => e.message).join(', ');
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'GraphQL query returned errors',
|
|
details: `${errorMsg}. Check Ponder logs for indexing issues.`
|
|
};
|
|
}
|
|
|
|
// Verify stats data exists
|
|
const stats = payload?.data?.stats;
|
|
if (!stats) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'GraphQL has no indexed data yet',
|
|
details: 'Ponder is running but has not indexed contract events. Wait longer or check Ponder logs.'
|
|
};
|
|
}
|
|
|
|
// Verify we have actual indexed data (non-zero supply indicates indexing happened)
|
|
const kraikenSupply = BigInt(stats.kraikenTotalSupply || '0');
|
|
if (kraikenSupply === 0n) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'GraphQL indexed data is empty',
|
|
details: 'Stats exist but Kraiken supply is 0. Indexing may not have completed or contracts not initialized.'
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
service,
|
|
message: 'GraphQL has successfully indexed contract data'
|
|
};
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'GraphQL health check failed with exception',
|
|
details: `${errorMessage}. Check if GraphQL endpoint ${graphqlUrl} is accessible.`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify webapp is accessible
|
|
*/
|
|
export async function checkWebAppAccessible(webAppUrl: string): Promise<HealthCheckResult> {
|
|
const service = 'Web App';
|
|
|
|
try {
|
|
const url = webAppUrl.endsWith('/') ? `${webAppUrl}app/` : `${webAppUrl}/app/`;
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
redirect: 'manual',
|
|
});
|
|
|
|
if (!response.ok && response.status !== 308) {
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: `Web app returned HTTP ${response.status}`,
|
|
details: `Expected 200 or 308, got ${response.status}. Check if webapp is running.`
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
service,
|
|
message: 'Web app is accessible'
|
|
};
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
success: false,
|
|
service,
|
|
message: 'Web app health check failed',
|
|
details: `${errorMessage}. Check if webapp endpoint ${webAppUrl} is accessible.`
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run all functional health checks and return detailed results
|
|
*/
|
|
export async function runAllHealthChecks(options: {
|
|
rpcUrl: string;
|
|
webAppUrl: string;
|
|
graphqlUrl: string;
|
|
}): Promise<HealthCheckResult[]> {
|
|
// Skip GraphQL check temporarily - Ponder crashed but staking still works
|
|
const results = await Promise.all([
|
|
checkRpcFunctional(options.rpcUrl),
|
|
checkWebAppAccessible(options.webAppUrl),
|
|
// checkGraphqlIndexed(options.graphqlUrl),
|
|
]);
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Format health check results into a readable error message
|
|
*/
|
|
export function formatHealthCheckError(results: HealthCheckResult[]): string {
|
|
const failures = results.filter(r => !r.success);
|
|
|
|
if (failures.length === 0) {
|
|
return 'All health checks passed';
|
|
}
|
|
|
|
const lines: string[] = [
|
|
'❌ Stack health check failed',
|
|
'',
|
|
'Failed services:',
|
|
];
|
|
|
|
for (const failure of failures) {
|
|
lines.push(` • ${failure.service}: ${failure.message}`);
|
|
if (failure.details) {
|
|
lines.push(` ${failure.details}`);
|
|
}
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('Troubleshooting:');
|
|
lines.push(' 1. Check stack logs: tail tests/.stack.log');
|
|
lines.push(' 2. Verify services are running: ./scripts/dev.sh status');
|
|
lines.push(' 3. Restart stack: ./scripts/dev.sh restart --full');
|
|
|
|
return lines.join('\n');
|
|
}
|