initial commit

This commit is contained in:
Shreerang Kale 2025-08-04 14:19:45 +05:30
commit 2b140e16df
11 changed files with 2339 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Dependencies
node_modules/
# Build output
dist/
# Environment variables
.env
.env.local
.env.production
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Temporary folders
tmp/
temp/
./generated

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# Build stage
FROM node:22.17.0-alpine3.22 AS builder
RUN apk --update --no-cache add git python3 alpine-sdk jq curl bash
WORKDIR /app
COPY . .
RUN echo "Building mock-lockdrop-watcher" && \
yarn && yarn build
# Production stage
FROM node:22.17.0-alpine3.22
RUN apk --update --no-cache add git python3 alpine-sdk jq curl bash
WORKDIR /app
# Copy package files
COPY package.json yarn.lock ./
# Install only production dependencies
RUN yarn install --production --frozen-lockfile
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "mock-lockdrop-watcher",
"version": "1.0.0",
"private": true,
"description": "Mock GraphQL server for lockdrop watcher testing",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"postbuild": "cp src/schema.graphql dist/schema.graphql",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"generate-participants": "node dist/participant-generator.js"
},
"dependencies": {
"apollo-server-express": "^3.12.1",
"express": "^4.19.2",
"graphql": "^16.9.0",
"@cosmjs/crypto": "^0.33.1",
"@cosmjs/encoding": "^0.33.1",
"ethers": "^6.13.4",
"urbit-ob": "^5.0.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.14.10",
"ts-node": "^10.9.2",
"typescript": "^5.5.3"
}
}

148
src/data-generator.ts Normal file
View File

@ -0,0 +1,148 @@
import * as fs from 'fs';
interface VerifiedParticipant {
attestation: {
payload: {
address: string;
msg: string;
payload: {
address: string;
msg: string;
owned_points: {
galaxy: string;
stars: string[];
};
};
};
signatures: string[];
};
role: string;
}
interface BlockData {
hash: string;
number: number;
timestamp: number;
}
interface PointLockedEvent {
__typename: "PointLockedEvent";
azimuth_id: string;
point: string;
lock_period: number;
}
interface LockdropClosedEvent {
__typename: "LockdropClosedEvent";
ok: boolean;
}
interface EventInRange {
block: BlockData;
event: PointLockedEvent | LockdropClosedEvent;
}
interface GeneratedData {
data: {
eventsInRange: EventInRange[];
};
}
function generateMockBlock(): BlockData {
const sixMonthsAgo = Math.floor(Date.now() / 1000) - (6 * 30 * 24 * 60 * 60);
const now = Math.floor(Date.now() / 1000);
const randomTimestamp = Math.floor(Math.random() * (now - sixMonthsAgo)) + sixMonthsAgo;
const randomBlockNumber = Math.floor(Math.random() * 1000000) + 22000000;
return {
hash: `0x${Math.random().toString(16).substr(2, 64)}`,
number: randomBlockNumber,
timestamp: randomTimestamp,
};
}
function generateRandomLockPeriod(): number {
return Math.floor(Math.random() * 5) + 1; // 1 to 5 years
}
export function generateDataFromParticipants(verifiedParticipantsPath: string): GeneratedData {
const participantsData = JSON.parse(fs.readFileSync(verifiedParticipantsPath, 'utf8')) as VerifiedParticipant[];
const events: EventInRange[] = [];
let latestBlock: BlockData | null = null;
let hasGalaxyWith5Years = false;
let hasStarWith5Years = false;
// Generate PointLockedEvent for each point in each participant
for (const participant of participantsData) {
const azimuthId = participant.attestation.payload.address;
const ownedPoints = participant.attestation.payload.payload.owned_points;
// Collect all points (galaxy + stars)
const allPoints: { point: string; isGalaxy: boolean }[] = [];
if (ownedPoints.galaxy && ownedPoints.galaxy !== '-') {
allPoints.push({ point: ownedPoints.galaxy, isGalaxy: true });
}
ownedPoints.stars.forEach(star => {
allPoints.push({ point: star, isGalaxy: false });
});
// Generate random block for this participant (all points from same participant use same block)
const block = generateMockBlock();
// Track the latest block
if (!latestBlock || block.timestamp > latestBlock.timestamp) {
latestBlock = block;
}
// Generate events for each point
for (const { point, isGalaxy } of allPoints) {
let lockPeriod = generateRandomLockPeriod();
// Ensure we have at least one galaxy and one star with 5-year lock period
if (isGalaxy && !hasGalaxyWith5Years) {
lockPeriod = 5;
hasGalaxyWith5Years = true;
}
if (!isGalaxy && !hasStarWith5Years) {
lockPeriod = 5;
hasStarWith5Years = true;
}
const event: EventInRange = {
block,
event: {
__typename: "PointLockedEvent",
azimuth_id: azimuthId,
point,
lock_period: lockPeriod,
},
};
events.push(event);
}
}
// Add a LockdropClosedEvent using the latest block from PointLockedEvents
if (latestBlock) {
events.push({
block: latestBlock,
event: {
__typename: "LockdropClosedEvent",
ok: true,
},
});
}
return {
data: {
eventsInRange: events,
},
};
}
export function saveGeneratedData(data: GeneratedData, outputPath: string): void {
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2));
}

29
src/index.ts Normal file
View File

@ -0,0 +1,29 @@
import express from 'express';
import { ApolloServer } from 'apollo-server-express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { resolvers } from './resolvers';
async function startServer() {
const typeDefs = readFileSync(join(__dirname, 'schema.graphql'), 'utf8');
const server = new ApolloServer({
typeDefs,
resolvers,
});
await server.start();
const app = express();
server.applyMiddleware({ app: app as any });
const PORT = process.env.PORT || 6000;
app.listen(PORT, () => {
console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}`);
});
}
startServer().catch(error => {
console.error('Error starting server:', error);
});

View File

@ -0,0 +1,413 @@
import { Wallet } from 'ethers';
import { patp } from 'urbit-ob';
import * as fs from 'fs';
import * as path from 'path';
import { Secp256k1, sha256, Random, ripemd160 } from '@cosmjs/crypto';
import { toBech32 } from '@cosmjs/encoding';
const MAX_GALAXIES = 256;
const MAX_STARS = 65280;
// Generate real Cosmos address with zenith prefix using standard derivation
async function generateCosmosAddress(): Promise<{ address: string; privateKey: string }> {
const privkeyBytes = Random.getBytes(32);
const keypair = await Secp256k1.makeKeypair(privkeyBytes);
const pubkey = Secp256k1.compressPubkey(keypair.pubkey);
// Standard Cosmos address derivation: RIPEMD160(SHA256(compressed_pubkey))
const sha256Hash = sha256(pubkey);
const addressBytes = ripemd160(sha256Hash);
const address = toBech32('zenith', addressBytes);
return {
address,
privateKey: Buffer.from(keypair.privkey).toString('hex')
};
}
// Generate real Ethereum address
function generateEthereumAddress(): { address: string; privateKey: string } {
const wallet = Wallet.createRandom();
return {
address: wallet.address,
privateKey: wallet.privateKey
};
}
// Generate Urbit point names using urbit-ob
function getUrbitPointName(id: number): string {
return patp(id.toString());
}
interface GeneratorConfig {
totalParticipants: number;
galaxyCount: number;
starCount: number;
outputDir: string;
}
interface GeneratedAccount {
zenithAddress: string;
zenithPrivateKey: string;
ethereumAddress: string;
ethereumPrivateKey: string;
}
interface VerifiedParticipant {
attestation: {
payload: {
address: string;
msg: string;
payload: {
address: string;
msg: string;
owned_points: {
galaxy: string;
stars: string[];
};
};
};
signatures: string[];
};
role: string;
}
interface GenerationStats {
totalParticipants: number;
validators: number;
delegators: number;
galaxiesAllocated: number;
starsAllocated: number;
config: GeneratorConfig;
}
export class ParticipantGenerator {
private config: GeneratorConfig;
private accounts: GeneratedAccount[] = [];
private usedGalaxies: Set<number> = new Set();
private usedStars: Set<number> = new Set();
constructor(config: GeneratorConfig) {
this.config = config;
}
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
private getAvailableGalaxies(): number[] {
const galaxies = Array.from({ length: MAX_GALAXIES }, (_, i) => i);
return this.shuffleArray(galaxies).slice(0, this.config.galaxyCount);
}
private getAvailableStars(): number[] {
// Stars start from MAX_GALAXIES (after galaxies)
const stars = Array.from({ length: MAX_STARS }, (_, i) => i + MAX_GALAXIES);
return this.shuffleArray(stars).slice(0, this.config.starCount);
}
// Distribute all available galaxies among first participants (they become validators)
private distributeGalaxies(availableGalaxies: number[]): number[] {
const galaxyAllocation: number[] = new Array(this.config.totalParticipants).fill(null);
// First galaxyCount participants get galaxies (and become validators)
for (let i = 0; i < availableGalaxies.length && i < this.config.totalParticipants; i++) {
galaxyAllocation[i] = availableGalaxies[i];
}
return galaxyAllocation;
}
// Distribute all available stars randomly among all participants (each gets at least 1)
private distributeStars(availableStars: number[]): number[][] {
const starAllocation: number[][] = Array.from({ length: this.config.totalParticipants }, () => []);
const shuffledStars = this.shuffleArray([...availableStars]);
// First, give each participant 1 star (minimum requirement)
for (let i = 0; i < this.config.totalParticipants; i++) {
starAllocation[i].push(shuffledStars[i]);
}
// Randomly distribute remaining stars
for (let i = this.config.totalParticipants; i < shuffledStars.length; i++) {
const randomParticipant = Math.floor(Math.random() * this.config.totalParticipants);
starAllocation[randomParticipant].push(shuffledStars[i]);
}
return starAllocation;
}
async generate(): Promise<{ participants: VerifiedParticipant[]; accounts: GeneratedAccount[]; stats: GenerationStats }> {
const participants: VerifiedParticipant[] = [];
this.accounts = [];
this.usedGalaxies.clear();
this.usedStars.clear();
const availableGalaxies = this.getAvailableGalaxies();
const availableStars = this.getAvailableStars();
console.log(`Generating ${this.config.totalParticipants} participants...`);
console.log(`- Validators (with galaxies): ${this.config.galaxyCount}`);
console.log(`- Delegators (stars only): ${this.config.totalParticipants - this.config.galaxyCount}`);
console.log(`- Galaxies to allocate: ${availableGalaxies.length}`);
console.log(`- Stars to allocate: ${availableStars.length}`);
// Pre-allocate all points to ensure they're all used
const galaxyAllocation = this.distributeGalaxies(availableGalaxies);
const starAllocation = this.distributeStars(availableStars);
for (let i = 0; i < this.config.totalParticipants; i++) {
const cosmosAccount = await generateCosmosAddress();
const ethereumAccount = generateEthereumAddress();
this.accounts.push({
zenithAddress: cosmosAccount.address,
zenithPrivateKey: cosmosAccount.privateKey,
ethereumAddress: ethereumAccount.address,
ethereumPrivateKey: ethereumAccount.privateKey
});
const isValidator = i < this.config.galaxyCount;
// Get pre-allocated galaxy for validators
let galaxy = '-';
if (isValidator && galaxyAllocation[i] !== null) {
galaxy = getUrbitPointName(galaxyAllocation[i]);
this.usedGalaxies.add(galaxyAllocation[i]);
}
// Get pre-allocated stars
const stars = starAllocation[i].map(starId => {
this.usedStars.add(starId);
return getUrbitPointName(starId);
});
const participant: VerifiedParticipant = {
attestation: {
payload: {
address: ethereumAccount.address,
msg: "Onboarding my Azimuth ID onto ZenithChain",
payload: {
address: cosmosAccount.address,
msg: "Onboarding my validator onto ZenithChain",
owned_points: {
galaxy,
stars
}
}
},
signatures: [
`dummy_azimuth_sig_${i}_${Math.random().toString(36).substring(2, 15)}`,
`dummy_zenith_sig_${i}_${Math.random().toString(36).substring(2, 15)}`
]
},
role: isValidator ? "validator" : "delegator"
};
participants.push(participant);
if ((i + 1) % 50 === 0) {
console.log(`Generated ${i + 1}/${this.config.totalParticipants} participants...`);
}
}
const stats: GenerationStats = {
totalParticipants: this.config.totalParticipants,
validators: this.config.galaxyCount,
delegators: this.config.totalParticipants - this.config.galaxyCount,
galaxiesAllocated: this.usedGalaxies.size,
starsAllocated: this.usedStars.size,
config: this.config
};
console.log('\nGeneration completed!');
console.log(`- Galaxies allocated: ${stats.galaxiesAllocated}/${availableGalaxies.length}`);
console.log(`- Stars allocated: ${stats.starsAllocated}/${availableStars.length}\n`);
return { participants, accounts: this.accounts, stats };
}
saveToFiles(outputDir: string, participants: VerifiedParticipant[], accounts: GeneratedAccount[], stats: GenerationStats): void {
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Save participants
const participantsPath = path.join(outputDir, 'generated-participants.json');
fs.writeFileSync(participantsPath, JSON.stringify(participants, null, 2));
console.log(`Participants saved to: ${participantsPath}`);
// Save accounts
const accountsPath = path.join(outputDir, 'generated-accounts.json');
fs.writeFileSync(accountsPath, JSON.stringify(accounts, null, 2));
console.log(`Accounts saved to: ${accountsPath}`);
// Save stats
const statsPath = path.join(outputDir, 'point-allocation-stats.json');
fs.writeFileSync(statsPath, JSON.stringify(stats, null, 2));
console.log(`Statistics saved to: ${statsPath}`);
}
}
// Validation function
function validateConfig(config: GeneratorConfig): void {
const errors: string[] = [];
if (config.totalParticipants <= 0) {
errors.push('Total participants must be greater than 0');
}
if (config.galaxyCount < 0) {
errors.push('Galaxy count cannot be negative');
}
if (config.galaxyCount > config.totalParticipants) {
errors.push('Galaxy count cannot exceed total participants');
}
if (config.galaxyCount > MAX_GALAXIES) {
errors.push(`Galaxy count cannot exceed ${MAX_GALAXIES} (maximum available galaxies)`);
}
if (config.starCount <= 0) {
errors.push('Star count must be greater than 0');
}
if (config.starCount < config.totalParticipants) {
errors.push('Star count must be at least equal to total participants (each participant needs at least 1 star)');
}
if (config.starCount > MAX_STARS) {
errors.push(`Star count cannot exceed ${MAX_STARS} (maximum available stars)`);
}
if (errors.length > 0) {
console.error('\n❌ Configuration validation failed:');
errors.forEach(error => console.error(` - ${error}`));
console.error('\nUse --help for usage information.');
process.exit(1);
}
}
// CLI interface
function parseArgs(): GeneratorConfig {
const args = process.argv.slice(2);
const config: GeneratorConfig = {
totalParticipants: 400,
galaxyCount: 200,
starCount: 2000,
outputDir: path.join(process.cwd(), 'generated')
};
for (let i = 0; i < args.length; i += 2) {
const key = args[i];
const value = args[i + 1];
switch (key) {
case '--participants':
config.totalParticipants = parseInt(value, 10);
break;
case '--galaxy-count':
config.galaxyCount = parseInt(value, 10);
break;
case '--star-count':
config.starCount = parseInt(value, 10);
break;
case '--output-dir':
config.outputDir = value;
break;
case '-h':
case '--help':
console.log(`
Usage: yarn generate-participants -- [options]
Options:
--participants <number> Number of participants to generate (default: 400)
--galaxy-count <number> Number of galaxies to allocate (default: 200, max: ${MAX_GALAXIES})
--star-count <number> Number of stars to allocate (default: 2000, max: ${MAX_STARS})
--output-dir <path> Output directory for generated files (default: <cwd>/generated)
--help Show this help message
Constraints:
- galaxy-count participants (each participant can have at most 1 galaxy)
- galaxy-count ${MAX_GALAXIES} (maximum available galaxies)
- star-count participants (each participant needs at least 1 star)
- star-count ${MAX_STARS} (maximum available stars)
Examples:
yarn generate-participants
yarn generate-participants -- --participants 200 --galaxy-count 150 --star-count 1000
yarn generate-participants -- --galaxy-count 100 --star-count 500
`);
process.exit(0);
}
}
// Validate configuration
validateConfig(config);
return config;
}
// Main execution
if (require.main === module) {
const config = parseArgs();
console.log('Participant Generator Configuration:');
console.log(JSON.stringify(config, null, 2));
// Check for existing files and warn user
const participantsPath = path.join(config.outputDir, 'generated-participants.json');
const accountsPath = path.join(config.outputDir, 'generated-accounts.json');
const statsPath = path.join(config.outputDir, 'point-allocation-stats.json');
const existingFiles = [];
if (fs.existsSync(participantsPath)) existingFiles.push('generated-participants.json');
if (fs.existsSync(accountsPath)) existingFiles.push('generated-accounts.json');
if (fs.existsSync(statsPath)) existingFiles.push('point-allocation-stats.json');
if (existingFiles.length > 0) {
console.warn(`\nWARNING: The following files will be overwritten:`);
existingFiles.forEach(file => console.warn(` - ${path.join(config.outputDir, file)}`));
console.warn(` This will replace all existing generated participant data.`);
console.warn(` Press Ctrl+C to cancel or any key to continue...`);
// Wait for user input
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.once('data', (data) => {
process.stdin.setRawMode(false);
process.stdin.pause();
// Check for Ctrl+C (ASCII code 3)
if (data[0] === 3) {
console.log('\n\nOperation cancelled by user.');
process.exit(0);
}
startGeneration();
});
} else {
startGeneration();
}
function startGeneration() {
console.log('\nStarting generation...\n');
const generator = new ParticipantGenerator(config);
generator.generate().then(({ participants, accounts, stats }) => {
generator.saveToFiles(config.outputDir, participants, accounts, stats);
console.log('\nParticipants generated successfully!');
}).catch(error => {
console.error('Error during generation:', error);
process.exit(1);
});
}
}

57
src/resolvers.ts Normal file
View File

@ -0,0 +1,57 @@
import * as fs from 'fs';
import * as path from 'path';
import { generateDataFromParticipants, saveGeneratedData } from './data-generator';
function loadOrGenerateData() {
const outputDir = process.env.GENERATED_WATCHER_EVENTS_OUTPUT_PATH || path.join(process.cwd(), 'generated');
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const dataPath = path.join(outputDir, 'watcher-events.json');
const verifiedParticipantsPath = process.env.VERIFIED_PARTICIPANTS_PATH;
// Fallback to existing data
if (fs.existsSync(dataPath)) {
console.log('Using existing watcher-events.json...');
return JSON.parse(fs.readFileSync(dataPath, 'utf8'));
}
// Check if verified-participants.json path is provided
if (verifiedParticipantsPath) {
if (!fs.existsSync(verifiedParticipantsPath)) {
throw new Error(`Verified participants file not found at: ${verifiedParticipantsPath}`);
}
console.log(`Found verified participants at ${verifiedParticipantsPath}, generating watcher-events.json...`);
const generatedData = generateDataFromParticipants(verifiedParticipantsPath);
saveGeneratedData(generatedData, dataPath);
return generatedData;
}
throw new Error('No participants file found. Please provide VERIFIED_PARTICIPANTS_PATH, or ensure watcher-events.json exists.');
}
const data = loadOrGenerateData();
export const resolvers = {
Query: {
eventsInRange: (_: any, args: { fromBlockNumber: number; toBlockNumber: number; name?: string }) => {
const events = data.data.eventsInRange;
return events.filter((event: any) => {
const blockNumber = event.block.number;
const inRange = blockNumber >= args.fromBlockNumber && blockNumber <= args.toBlockNumber;
if (args.name && args.name !== event.event.__typename) {
return false;
}
return inRange;
});
}
}
};

27
src/schema.graphql Normal file
View File

@ -0,0 +1,27 @@
type _Block_ {
hash: String!
number: Int!
timestamp: Int!
}
union Event = PointLockedEvent | LockdropClosedEvent
type PointLockedEvent {
azimuth_id: String!
point: String!
lock_period: Int!
}
type LockdropClosedEvent {
ok: Boolean! # placeholder, GQL doesn't allow empty types
}
type ResultEvent {
block: _Block_!
contract: String!
event: Event!
}
type Query {
eventsInRange(fromBlockNumber: Int!, toBlockNumber: Int!, name: String): [ResultEvent!]
}

11
src/types/urbit-ob.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
declare module 'urbit-ob' {
/**
* Converts a decimal string to a `@p` name (e.g., '0' '~zod').
*/
export function patp(n: string): string;
/**
* Converts a `@p` name to a decimal string (e.g., '~zod' '0').
*/
export function patp2dec(n: string): string;
}

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1543
yarn.lock Normal file

File diff suppressed because it is too large Load Diff