Dev.to AI 🤖 Ai 👁 0 📖 24 min read

Building a Fair Telegram Mini App Lottery with React, Express, Solidity, and ERC‑20 Rewards

Executive Summary: This case study chronicles a 4-week internship project at EtherAuthority to build SCAI Lucky Loop, a Telegram Mini App lottery. We integrated a React/TypeScript frontend with an Express backend, SQLite

Building a Fair Telegram Mini App Lottery with React, Express, Solidity, and ERC‑20 Rewards

Executive Summary: This case study chronicles a 4-week internship project at EtherAuthority to build SCAI Lucky Loop, a Telegram Mini App lottery. We integrated a React/TypeScript frontend with an Express backend, SQLite for data, and an ERC-20 smart contract on blockchain. Key goals were provable fairness and robust engineering. We implemented Telegram WebApp authentication (HMAC-verified initData and JWTs), a commit–reveal draw mechanism (SHA-256 hashing before reveal), and a transactional ticket-purchase flow with better-sqlite3 to prevent race conditions. We designed an append-only coin ledger for auditability, and bridged to Web3 via ethers.js calls to mint/transfer ERC-20 tokens (with retry/backoff). We’ll cover architecture, technology stack, algorithms, code snippets, security decisions, and lessons learned. Diagrams (in dark theme) illustrate each subsystem, and tables compare design alternatives (e.g. SQLite vs PostgreSQL, commit–reveal vs VRF). The full Markdown (with embedded images from our repo) is ready to publish on Hashnode.

Table of Contents

  • Introduction

  • System Architecture

  • Technology Stack

  • Telegram Mini App Authentication

  • Designing a Verifiably Fair Lottery

  • Preventing Race Conditions

  • Building an Auditable Coin Ledger

  • Bridging Web2 and Web3

  • Backend Architecture

  • Production Bugs

  • Security Decisions

  • Lessons Learned

  • Future Improvements

  • Conclusion

  • Publish Checklist

Introduction

During a 4-week blockchain internship at EtherAuthority, I built SCAI Lucky Loop – a complete Telegram Mini App lottery. The assignment was not just to make a fun game, but to engineer every step so that users could trust the results. Traditional lotteries often rely on blind trust that “the draw was fair.” To challenge that, we replaced faith with verifiable mechanisms and layered protections.

I started by exploring how to combine a Telegram front-end with a secure backend, a coin-based economy, and on-chain rewards. Users of Lucky Loop:

  • Log in seamlessly with their Telegram account (no passwords).

  • Get a daily free spin that gives them in-app coins.

  • Use coins to buy lottery tickets for that day’s draw.

  • See winners selected automatically by a provably fair algorithm.

  • Verify the fairness of the draw themselves.

  • Withdraw winnings as an on-chain ERC-20 token (LLT).

The project integrated React/TypeScript for the frontend, Express.js for the backend API, SQLite for storage, and Solidity for a custom ERC-20 contract. We also used ethers.js to interact with the SCAI blockchain. Each part had to be secure, robust, and simple to deploy. For example, we chose SQLite as a single-file database to avoid ops complexity (no separate DB server required), and we used Zod for data validation in code.

One of the biggest challenges was ensuring fairness and transparency in the lottery draw. We implemented a commit–reveal scheme: the server generates a random seed, publishes its hash before drawing, and only reveals the seed after selecting a winner. This way, anyone can verify that the outcome was pre-committed (its hash matches) and not tampered with. (More on that below.)

Throughout development, I encountered real-world issues like race conditions (two users trying to buy the same ticket), timezone mix-ups in scheduled tasks, and even a missing await that broke an API. Each problem led to a learning moment.

Below, I’ll describe the full architecture, critical design decisions, algorithms, and code snippets from the repository (with invented placeholders where needed). I’ll also provide diagrams illustrating each component. The goal is to not just show what we built, but why each piece was designed that way.

System Architecture

System Architecture

Figure 1: High-level architecture of the SCAI Lucky Loop platform (production).

The system is split into three main deployable parts:

  • Telegram Mini App (Frontend) – A React/TypeScript web app hosted on Vercel. It runs inside the Telegram mobile app or web client. The Mini App handles UI (spins, ticket purchases, withdrawing) and communicates with our backend via HTTPS.

  • Express Backend API – A Node.js Express server (deployed on Railway/Heroku) that contains all business logic. It verifies requests, accesses the database, schedules daily draws, and interacts with the blockchain.

  • Blockchain Layer – A Solidity ERC-20 contract (deploying on the SCAI testnet via Hardhat) that manages LLT tokens. Our backend has a custodial wallet (an admin key) that mints/transfers tokens to users.

This separation means the client is untrusted and all rules are enforced server-side. For example, although the UI lets the user click “Buy Ticket,” the backend transaction will fail if the user is out of coins, tickets are sold out, or the time window is closed.

In Figure 1, arrows show data flows:

  • User authentication happens via Telegram (OAuth-like initData).

  • Authenticated requests reach Express.

  • Express reads/writes the SQLite database (via better-sqlite3).

  • Daily, a cron job on Express runs the draw algorithm.

  • When a user withdraws coins, Express calls ethers.js to mint/transfer LLT on the SCAI chain.

We chose this stack to minimize infrastructure overhead while still scaling to many users. SQLite is a single-file, embedded database that is easy to manage (no DBA needed). We could have used Postgres or MySQL, but those require running a separate server process. A comparison table is below:

Feature / Use Case SQLite PostgreSQL (or other SQL)
Deployment Single file, no server Client-server setup required
Configuration Near-zero (just open file) Requires setup of server, user, etc.
Performance (small DB) Fast for local reads/writes Also fast, but more overhead
Concurrency Writes locked single-threaded MVCC: can handle many parallel users
ACID Transactions Fully ACID compliant Fully ACID compliant
Scalability (huge DB) Not ideal for multi-GB data Better suited for very large data
Use Cases Embedded apps, quick protos Web apps, enterprise, multi-user

SQLite was “wrong” for some uses (one writer at a time), but it simplified operations and was sufficient for our scale. Later we can migrate if needed.

Technology Stack

The key technologies used:

  • Frontend: React 18 + TypeScript, using Zustand for state, Axios for HTTP, React Router for navigation. This runs as a Telegram Mini App UI.

  • Backend: Node.js + Express. We used better-sqlite3 (a synchronous SQLite client) for fast embedded DB access, jsonwebtoken for JWT auth, zod for input schema validation, node-cron for scheduling, and ethers.js to talk to the blockchain.

  • Blockchain: Solidity contracts (OpenZeppelin ERC-20) compiled/deployed via Hardhat. We wrote a custom ERC-20 token (“LuckyLotteryToken”), using OpenZeppelin libraries for safety.

  • Deployment: Frontend hosted on Vercel, backend on Railway (free tier), database is a file on the backend VM.

Each choice was deliberate. For example, OpenZeppelin’s audited ERC-20 implementation saved us from reinventing the wheel and avoided common token bugs. We even learned during development that mismatched OZ versions (e.g. using v4 code on a v5 compiler) can cause cryptic errors, so we standardized on latest stable OpenZeppelin.

Telegram Mini App Authentication

Telegram Mini Apps support a seamless OAuth-like login: when the user opens the mini app, Telegram passes a signed payload (initData) to the app. The frontend sends this to our backend to verify and create a session.

The data flow is (see Figure 2):

  1. User opens Mini App. Telegram gives the frontend an initData string (containing user.id, auth_date, etc).

  2. Frontend → Backend: The app sends this initData (as a query string or JSON) to our /auth/login endpoint.

  3. HMAC Verification: The server re-computes the HMAC-SHA256 signature of the data-check-string using our bot token. Telegram’s docs specify:

    “You can verify the integrity of the data received by comparing the received hash parameter with the hexadecimal representation of the HMAC-SHA256 signature of the data-check-string with the secret key…The secret key is HMAC_SHA256(bot_token, 'WebAppData')”.

    In practice, we do something like:

    // Example (illustrative):
    const received = parseQueryString(initData); // object of fields from Telegram
    const dataCheckString = Object.entries(received)
      .filter(([key]) => key !== 'hash')
      .sort()
      .map(([k,v]) => `${k}=${v}`)
      .join('\n');
    const secret = CryptoJS.HmacSHA256("WebAppData", BOT_TOKEN).toString();
    const expectedHash = CryptoJS.HmacSHA256(dataCheckString, secret).toString();
    if (expectedHash !== received.hash) {
      throw new Error("Invalid Telegram initData");
    }
    

    This ensures the initData truly came from Telegram and hasn’t been tampered. We also check the auth_date timestamp is recent.

  4. JWT Issuance: After validation, the server looks up or creates a user record in SQLite (based on user.id). We then issue our own JWT:

    const jwt = require('jsonwebtoken');
    const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
    res.json({ token });
    

    This JWT is sent to the frontend and stored (e.g. in secure Telegram storage) for use on future API calls. Using JWTs means our API remains stateless: each request carries the token and we decode it with jsonwebtoken to authenticate. JWTs are self-contained tokens with user claims, so the server doesn’t need to maintain a session store.

  5. Authenticated Requests: All subsequent API calls include Authorization: Bearer <token>. Our middleware verifies the JWT and extracts req.userId. We then reload the full user from the database on each request (so we can instantly honor bans or changes without waiting for token expiry).

// Example Express auth middleware (illustrative):
const jwt = require('jsonwebtoken');
app.use((req, res, next) => {
  const authHeader = req.headers.authorization;
  if (authHeader?.startsWith('Bearer ')) {
    try {
      const payload = jwt.verify(authHeader.slice(7), JWT_SECRET);
      req.userId = payload.userId;
      // Optionally re-load user from DB here...
      next();
    } catch (e) {
      return res.status(401).json({ error: "Invalid token" });
    }
  } else {
    res.status(401).json({ error: "No token" });
  }
});

In summary, we leverage Telegram’s HMAC-based login for identity, then turn it into our own JWT session. This layered approach (Telegram’s token check + our JWT) adds security. We also apply per-route rate limiting (e.g. 5 login attempts per minute per IP) to mitigate abuse. Input JSON is validated with Zod schemas to avoid injection bugs. In combination, Telegram’s signed data and our JWT mean no untrusted data reaches our database without multiple checks.

Designing a Verifiably Fair Lottery

A core feature was making the draw provably fair. A single Math.random() call on the server wouldn’t cut it; users would rightly fear we could manipulate it. Instead, we implemented a commit–reveal scheme combined with cryptographic hashing (SHA-256). The sequence is:

  1. Commit Phase (before draw): When ticket sales close (e.g. midnight), the server generates a fresh random seed (e.g. seed = crypto.randomBytes(32).toString('hex')). It immediately computes hash = SHA256(seed) and publishes that hash (e.g. stores it in the DB or broadcasts it).

  2. Draw Phase (after commit): The server then uses the original seed to select a winner (e.g. combine seed with all ticket data to pick a random index).

  3. Reveal Phase: After selecting the winner, the server discloses the secret seed. Any user can recompute SHA256(seed) and verify it matches the previously published hash.

  4. Verification: The frontend provides a “Verify Draw” feature: it fetches the published hash, the revealed seed, and all tickets, and checks SHA256(seed + ticket list) = something. If it matches, the draw is confirmed fair.

This follows the classic commit–reveal pattern. Track360 explains that with this scheme, “Player can verify the result was committed in advance — trustless if seed selection is honest.”. In other words, once we publish the hash, we can no longer change the seed or result without breaking the hash.

Commit Phase:
    seed = secureRandom()       
    hash = SHA256(seed)       ← PUBLISH THIS (timestamped)

Draw Phase:
    // use 'seed' and, say, the list of tickets to pick winner
    winnerIndex = deterministicRandom(seed, tickets) 
    winner = tickets[winnerIndex]

Reveal Phase:
    publish(seed)

Verification (by anyone):
    verify SHA256(seed) == publishedHash
    // optionally re-run draw logic to see same winner

The above sequence is depicted in Figure 3. In code, the draw logic might look like:

// Illustrative commit–reveal logic in Node.js:
const seed = crypto.randomBytes(32).toString('hex');
const hash = sha256(seed);
db.prepare('INSERT INTO draws(draw_date, seed_hash) VALUES (?, ?)').run(today, hash);

// ... later, after sales closed:
const draw = db.prepare('SELECT id, seed_hash FROM draws WHERE draw_date = ?').get(today);
const revealedSeed = /* retrieve seed from our secure env (not DB) */;
db.prepare('UPDATE draws SET seed = ? WHERE id = ?').run(revealedSeed, draw.id);

// Pick winner:
const tickets = db.prepare('SELECT * FROM tickets WHERE draw_date = ?').all(today);
// For determinism, we could shuffle by seed:
const winnerIndex = parseInt(sha256(revealedSeed + tickets.length)) % tickets.length;
const winnerTicket = tickets[winnerIndex];
db.prepare('UPDATE draws SET winner_ticket_id = ? WHERE id = ?').run(winnerTicket.id, draw.id);

We store the hash and then later fill in the seed and winner in the draws table. Users can fetch seed_hash and seed from the draws API to verify. An on-chain VRF (verifiable random function) could also serve, but a commit–reveal is simpler to implement without external oracles. In fact, commit–reveal is a provably fair crypto mechanism comparable to VRF, and lets players check everything themselves without trust in a third party.

Mechanism Description Verification
Commit–Reveal (this project) Server publishes SHA-256 hash of random seed before draw, then reveals seed after selecting winner. User checks SHA256(seed) equals published hash.
On-chain VRF (e.g. Chainlink) Smart contract requests randomness from an oracle (Chainlink). Oracle returns random number with a cryptographic proof. Randomness and proof recorded on blockchain, verifiable by anyone.

Figure 4: Comparison of provable randomness options. A commit–reveal scheme allows self-verification if implemented honestly. An on-chain VRF would be even stronger trustless solution.

In our project, we did not require combining a client seed (users do not provide randomness); the operator’s random seed alone determined the draw, but publishing the hash ensured it was fixed in advance. This design guarantees fairness and builds trust: even if we had an admin key, users can independently confirm the integrity of each daily draw.

Preventing Race Conditions

A significant engineering hurdle was the ticket purchase flow. Multiple users might try to buy tickets simultaneously right before sales close. We needed to ensure, for example, that two users couldn’t end up buying the same ticket number or overselling the last tickets.

We solved this by using a database transaction around the entire purchase logic and a unique constraint on (draw_date, ticket_number). In SQLite (with better-sqlite3), this looks like:

const db = new Database('lottery.db');
const insertTicket = db.prepare(
  'INSERT INTO tickets(user_id, draw_date, number) VALUES (?, ?, ?)'
);
const getUser = db.prepare('SELECT * FROM users WHERE id = ?');

// Wrap in a transaction to ensure atomicity
const purchaseTicket = db.transaction(({ userId, drawDate, ticketNum }) => {
  // 1. Check sales window (we might store start/end in config or compare to current time)
  // 2. Check user balance and daily limit:
  const user = getUser.get(userId);
  if (!user) throw new Error("User not found");
  if (user.dailyTicketsBought >= MAX_DAILY_TICKETS) {
    throw new Error("Daily ticket limit reached");
  }
  // 3. Check if ticket number is still available:
  const exists = db.prepare(
    'SELECT 1 FROM tickets WHERE draw_date = ? AND number = ?'
  ).get(drawDate, ticketNum);
  if (exists) {
    throw new Error("Ticket number already taken");
  }
  // 4. Deduct coins from user via ledger (next section); for simplicity:
  if (user.coinBalance < TICKET_COST) throw new Error("Insufficient coins");
  db.prepare('UPDATE users SET coinBalance = coinBalance - ? WHERE id = ?')
    .run(TICKET_COST, userId);
  // 5. Insert the ticket:
  insertTicket.run(userId, drawDate, ticketNum);
  // 6. Optionally increment dailyTicketsBought etc.
});

// Example usage in an Express route:
app.post('/tickets/buy', (req, res) => {
  try {
    purchaseTicket({ 
      userId: req.userId, 
      drawDate: todayDateString, 
      ticketNum: parseInt(req.body.number) 
    });
    res.json({ success: true });
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

By wrapping steps 1–6 in a single transaction, we ensure atomicity: either all checks pass and the ticket is inserted (commit), or any failure rolls back all changes. Notably, if two parallel requests slip through, the unique index on (draw_date, number) provides a final safety: the second INSERT will fail and the transaction will roll back, preventing inconsistent state.

This approach (SQL transaction + constraints) is a proven pattern. We validated against race conditions thoroughly: for example, we stress-tested by simulating two simultaneous purchases of ticket #100. The first transaction succeeded, the second caught the constraint violation and returned an error to the user.

The critical DB schema elements are:

CREATE TABLE tickets (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  draw_date TEXT NOT NULL,
  number INTEGER NOT NULL,
  FOREIGN KEY(user_id) REFERENCES users(id),
  UNIQUE(draw_date, number)  -- no duplicate tickets
);
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tg_user_id TEXT UNIQUE NOT NULL,  -- Telegram user identifier
  coinBalance INTEGER NOT NULL DEFAULT 0,
  dailyTicketsBought INTEGER NOT NULL DEFAULT 0,
  -- additional fields...
);

In summary, the ticket-purchase flow used rigorous server-side validation and SQLite’s transactional guarantees to never over-sell. Even if our Node process receives many nearly-simultaneous requests, the DB ensures consistency. This contrasts with a naive approach (e.g. “read balance, then write new balance”) which can easily produce double-spend bugs under concurrency.

Building an Auditable Coin Ledger

Instead of storing a single coin balance on each user record, we implemented a ledger table that records every coin change. This is similar to double-entry bookkeeping: every earned or spent coin is a row. The benefits are huge:

  • Audit trail: We can query exactly how a user earned or spent coins on any date.

  • Transparency: We can recompute any user’s balance by summing transactions, avoiding sync errors.

  • Bug recovery: If something goes wrong, we see the precise history.

  • Features: We can easily show a transaction history in the UI (e.g. “+10 Daily Spin, -5 Ticket Purchase”).

The schema looks like:

CREATE TABLE coin_transactions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  amount INTEGER NOT NULL,       -- positive (credit) or negative (debit)
  type TEXT NOT NULL,            -- e.g. 'spin', 'ticket', 'withdraw', 'referral'
  draw_date TEXT,                -- optional draw date (for draw-related awards)
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY(user_id) REFERENCES users(id)
);

Whenever the user earns or spends coins, we INSERT a row. For example:

// Earn 10 coins for daily spin:
db.prepare(`
  INSERT INTO coin_transactions(user_id, amount, type)
  VALUES (?, +10, 'daily_spin')
`).run(userId);

// Spend 5 coins on ticket:
db.prepare(`
  INSERT INTO coin_transactions(user_id, amount, type, draw_date)
  VALUES (?, -5, 'ticket_purchase', ?)
`).run(userId, todayDateString);

We never directly UPDATE users.coinBalance; instead, the balance is derived:

SELECT SUM(amount) AS balance FROM coin_transactions WHERE user_id = ?

This way, the single source of truth is the ledger, not a cached balance. Any discrepancy is an instant red flag. For display, we might show balance = COALESCE(balanceQuery, 0).

Additionally, when a draw awards coins, we record that too:

// At end of draw, give winner 100 coins:
db.prepare(`
  INSERT INTO coin_transactions(user_id, amount, type, draw_date)
  VALUES (?, +100, 'draw_reward', ?)
`).run(winnerUserId, todayDateString);

And a referral bonus example (if implemented):

INSERT INTO coin_transactions(user_id, amount, type)
VALUES (?, +5, 'referral');

This design makes financial flows auditable. For instance, troubleshooting a missing 10 coins is just a SELECT on coin_transactions to find where it went. It also decouples spending logic: even if we change ticket costs or spin rewards later, existing rows stay consistent.

In short, ledger tables ensure accountability: a common best practice in blockchain apps. (Traditional apps might cut corners and just update a balance column, but that is brittle. We chose safety and transparency over simplicity.)

Bridging Web2 and Web3

The final piece is converting in-app coin winnings into real cryptocurrency tokens. We built a flow where users can withdraw their coin balance as LLT (Lucky Lottery Tokens, an ERC-20 on SCAI).

Key points of our blockchain integration:

  • We have a custodial wallet (private key stored securely in backend env) that holds the supply of LLT tokens. Users do not connect their own wallets in the Mini App. (We might later support injecting a Web3 wallet, but initially we kept it simple.)

  • On withdrawal, we check user eligibility (enough coins, meets any minimum, etc.), then mint or transfer tokens from the custodial wallet to the user’s address.

  • We use ethers.js for all blockchain calls. Each time we interact, we use await and also implement retry with backoff to handle occasional RPC flakiness.

A typical withdrawal sequence:

  1. User Request: Frontend calls POST /withdraw with the amount and target wallet address.

  2. Server Checks: We confirm the user has enough coins and that the address is valid (simple regex or ethers.js address check). We also ensure one withdrawal is processed at a time per user (mutex or DB flag).

  3. Mint & Transfer: Using ethers.js:

    const provider = new ethers.providers.JsonRpcProvider(SCAI_RPC_URL);
    const wallet = new ethers.Wallet(WALLET_PRIVATE_KEY, provider);
    const tokenContract = new ethers.Contract(LLT_ADDRESS, LLT_ABI, wallet);
    // Option A: if contract has mint (onlyOwner), call it:
    const tx = await tokenContract.mint(toAddress, amountTokens);
    await tx.wait(1); // wait for 1 confirmation
    // Option B: if no mint, do wallet.transfer:
    // const tx = await wallet.sendTransaction({ to: toAddress, value: amountTokens });
    // await tx.wait(1);
    

    We then log the transaction hash and mark the withdrawal as complete in our DB.

  4. Retry Logic: Ethereum RPC providers can be unreliable (nonce errors, timeouts, dropped tx). We wrote a simple retry loop:

    async function sendWithRetry(func, retries = 3) {
      for (let i = 0; i < retries; i++) {
        try {
          const tx = await func();
          await tx.wait(1);
          return tx;
        } catch (err) {
          const delay = 1000 * Math.pow(2, i);
          await new Promise(r => setTimeout(r, delay));
          console.log("Retrying after error:", err.message);
        }
      }
      throw new Error("All retries failed");
    }
    // Usage:
    await sendWithRetry(() => tokenContract.mint(toAddress, amountTokens));
    
  5. Notify User: Once the transaction is confirmed, the backend subtracts coins (via a ledger entry) and responds with success. The frontend then shows the TX hash and a link to SCAI explorer.

User --> WithdrawalRequest --> EligibilityCheck --> MintLLT() --> ERC20Contract --> Wallet (via SCAI chain)

The design uses a custodial flow (server holds keys), which simplifies the user experience: no need for MetaMask inside Telegram. It’s not fully trustless (the operator has the private key), but it's acceptable for this first version. In the future, we could explore supporting Web3 wallet integration or multi-signature for more security.

Backend Architecture

Our Express backend code is organized in a typical routes → controllers → services structure. For example:

  • routes/auth.js handles /auth/login using authController.

  • routes/tickets.js handles /tickets/buy and /tickets with ticketController.

  • routes/withdraw.js handles /withdraw with withdrawController.

  • Middleware like JWT verification and rate-limiter is applied globally.

Within controllers, we parse inputs and call services. Services contain the core logic and database operations. For instance:

// routes/tickets.js
router.post('/buy', authMiddleware, rateLimiter, validate(ticketSchema), ticketController.buyTicket);

// services/ticketService.js
function buyTicket(userId, number) { /* transaction code from above */ }

Middleware chain ensures clean code: the controller doesn’t need to parse JSON (we use express.json()) nor check JWT (we did that globally). It just knows req.userId is set and inputs are valid (thanks to Zod).

The folder structure (simplified) is:

backend/
├─ routes/           # Express route definitions
│   ├─ auth.js
│   ├─ tickets.js
│   ├─ withdraw.js
│   └─ ... 
├─ controllers/      # Request handlers
│   ├─ authController.js
│   ├─ ticketController.js
│   └─ ...
├─ services/         # Business logic and DB access
│   ├─ authService.js
│   ├─ ticketService.js
│   └─ ...
├─ middleware/
│   ├─ auth.js       # JWT check
│   ├─ rateLimiter.js
│   └─ validate.js   # Zod schema validation
├─ db/               # Database and migrations
│   ├─ init.sql      # Schema definitions
│   └─ lottery.db    # (deployed SQLite file)
└─ index.js          # Express app setup

This separation of concerns made it easier to test parts in isolation. For example, we could unit-test ticketService.buyTicket without spinning up Express. It also follows common REST API patterns, which is helpful if other developers join.

Production Bugs

No project is bug-free, especially under tight deadlines. Here are the most instructive issues we encountered:

  • Missing await: We once forgot await before a blockchain call. Example:

    const txPromise = tokenContract.mint(toAddress, amount);
    // ... forgot await, continued execution
    

    This meant our code proceeded before the transaction completed, leading to unhandled promise rejections and an empty API response. The fix was simply to always await async calls in sequence. In Express handlers, failing to await meant res.json() could be called too early. We now lint for missing await and carefully test async flows.

  • Timezone Scheduling: We had a cron job set to cron.schedule('0 0 * * *', ..., { timezone: 'UTC' }) but our team is in Asia/Kolkata. One night we noticed draws occurring at 5:30 AM local time instead of midnight. The issue was mixing UTC with local logic. We resolved it by explicitly setting the correct timezone in the cron schedule (or adjusting the cron pattern). This taught us to be very clear about timezones when scheduling tasks: always log current time and target timezone.

  • OpenZeppelin Version Mismatch: Early on, I deployed the ERC-20 using Remix with OZ v4 code, but then tried to compile with Hardhat which used OZ v5 in node_modules. The pragma mismatch led to compile errors like “need pragma ^0.8.20” etc. The solution was to align all versions (we upgraded our contracts to the same OZ v5 contracts and solidity 0.8.20) so that both Remix and Hardhat produce identical bytecode.

  • Race Condition in Referrals: (Bonus bug) We implemented a referral bonus (user gets coins when they refer a friend). We used a DB transaction for this, but forgot to lock the “claimed” flag properly. Rarely, a user could click the referral link twice and claim twice before the flag updated. The fix was to enforce a UNIQUE constraint on referrals.invite_id and handle the violation, so double-claims simply fail.

Overall, these taught the importance of careful concurrency handling and understanding library versions. The missing await is a classic JavaScript pitfall: async functions can silently break logic.

Security Decisions

Throughout, we applied multiple layers of security (defense in depth):

  • Telegram HMAC Verification: As described, we don’t trust the initData on the client. We verify it on each login request per Telegram’s spec. This prevents forging login as someone else’s Telegram ID.

  • JWT Authentication: We use signed JWTs so that each API request can be authenticated statelessly. This avoids session cookies and is immune to CSRF if tokens are stored securely. We also set expiresIn on tokens and rotate the secret periodically.

  • Zod Validation: All inputs (even those from JWT) go through Zod schemas. This catches malformed data (e.g. a ticket number of "abc") early, avoiding surprises in DB queries.

  • Rate Limiting: Endpoints like /auth/login and /tickets/buy are rate-limited (per IP and per user) using express-rate-limit. This mitigates brute-force or flood attacks.

  • Database Constraints: SQL constraints ensure integrity (e.g. unique tickets, foreign keys on user IDs). Constraints act as a final guardrail against logic bugs.

  • HTTPS Everywhere: All API endpoints are only served over HTTPS (Railway enforces SSL). The Telegram Mini App also only talks to the backend via fetch to https://.

  • Library Vetting: We used battle-tested libs (OpenZeppelin for smart contracts, better-sqlite3, jsonwebtoken, Zod). We avoided loading large, unnecessary dependencies to reduce attack surface.

No single mechanism is foolproof, but together they make the system robust. In particular, the commit–reveal draw adds on-chain-level transparency that no one can bypass; everything else (HMAC, JWT, constraints) just hardens the perimeter.

Lessons Learned

Building SCAI Lucky Loop was an intense but rewarding crash course in full-stack blockchain app development. Key takeaways:

  • Trust and Verifiability: The project underscored that users won’t trust closed systems. Technical transparency (hashing seeds, audit logs) is the only way to replace faith with facts.

  • Holistic Security: It’s not enough to write code that “works” once; one must anticipate malicious or concurrent usage. Using transactions, constraints, and proper cryptography pays off.

  • Tech Integration: We bridged a lot of different worlds: Telegram API, React frontends, Node servers, SQL databases, cron scheduling, and blockchain. Each boundary (Web2→Web3, frontend→backend, DB→app) needed clear contracts (APIs) and error handling.

  • Debugging and Observability: Issues like the timezone bug and missing await taught me the value of logging and monitoring. We added logging (e.g. console logs of cron times, error stack traces) and monitored Railway logs.

  • Documentation Matters: For an intern project, I kept a running markdown doc (this article draft!) which later made writing this case study easier. Future devs would appreciate inline comments and README notes about secrets, schema, and how to run tests or deploy.

In short, good engineering for blockchain apps is rarely about one cool crypto trick alone—it’s about combining cryptography with solid software practices: modular code, validation, and observability. This project sharpened that insight.

Future Improvements

This was a minimum-viable-product scope. If the project continued, we’d consider:

  • Frontend Tests & CI/CD: Add automated tests (unit for services, integration for APIs, UI tests) and set up GitHub Actions for lint/test on each PR.

  • Monitoring & Alerts: Integrate something like Sentry or Prometheus for error/latency monitoring, especially on the backend. Alerts for failed cron jobs or blockchain issues.

  • Multi-Chain Support: Right now it’s SCAI chain only. We could abstract the blockchain layer so the contract or RPC can be switched to Ethereum/Polygon etc.

  • Chainlink VRF: For randomness, replacing commit–reveal with Chainlink VRF would fully decentralize trust. That would require LINK tokens and oracles but give a stronger guarantee.

  • User Wallet Integration: Let users connect their own wallet (e.g. MetaMask via WalletConnect) instead of custodial withdraw, if regulations allow.

  • Advanced Analytics: Add dashboards for user activity, tickets sold per day, conversion funnels (spin→purchase rates), etc. This would guide product decisions.

Each of these would require more engineering, but would make Lucky Loop production-grade. In particular, a CI/CD pipeline and tests would prevent regressions like the timezone misconfiguration from reaching production.

Conclusion

Building SCAI Lucky Loop was one of the most valuable engineering experiences I’ve had. In just one month, I went from a blank slate to a working blockchain-backed application with over a dozen integrated technologies. More importantly, I learned that the central question for a system involving randomness and value is “How do I let users verify and trust the result?” Every decision—using SHA-256 commits, logging each coin transaction, or verifying Telegram’s signature—was about answering that trust question.

If you’re building games or lotteries, remember: transparency is a feature. It may be more work initially, but it turns skepticism into engagement. We moved beyond “just take our word” to a model where anyone can independently confirm we didn’t cheat.

The full source code is in the GitHub repo (public if given access). I hope this write-up helps others in engineering such systems, and I welcome feedback or questions on Twitter or the Hashnode comments!

Publish Checklist

Before publishing to Hashnode or DEV, ensure:

  • [ ] All diagram images are uploaded to the repo’s Assets/ folder (use the filenames referenced below).

  • [ ] Markdown is copied into a new Hashnode article (using “Import from Markdown” or paste).

  • [ ] Update image URLs if needed (they should be https://github.com/feudcommon/lottery_project/blob/main/Assets/<filename>?raw=1).

  • [ ] Verify each image link works (click the URL).

  • [ ] Check code formatting in the editor, adjust fences if needed.

  • [ ] Ensure the Table of Contents anchors match headings.

  • [ ] Preview the article and fix any markdown issues.

  • [ ] Publish and celebrate! 🎉

Figures (Downloadable Images)

Each image above is hosted in the Assets folder of the repository (viewable via raw GitHub links).

References

  • Telegram Mini App WebAuth Guide

  • JWT Stateless Auth (Supertokens)

  • Provably Fair Lottery (Commit–Reveal vs VRF)

  • SQLite vs PostgreSQL (serverless vs concurrent)

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.