Back to Overview
Offchain Security Reviews, Typescript Reviews, Security Audits for bridge relayers, keepers, oracle backends, and transaction signing services

Hex, Lies, and BigInts: Finding Critical Bugs in TypeScript Blockchain Code

July 16, 2025

Off-chain components can introduce serious security and reliability risks, but their reviews often stay private because they contain proprietary business logic and operational details. That leaves fewer public examples for teams to learn from and avoid recurring mistakes.

In this article, we share practical lessons from our reviews of bridge relayers, keepers, oracle backends, and transaction signing services (focusing on TypeScript-specific pitfalls), so common issues can be caught earlier and prevented.

When we think about blockchain security, smart contracts tend to dominate the conversation.

Yet a growing share of blockchain application logic lives off-chain: in TypeScript backends, API servers, and order-matching engines that orchestrate what ultimately happens on-chain. These components handle everything from input parsing and signature verification to order matching and transaction submission, and they are often just as critical to the security of the overall system.

ChainSecurity has been conducting off-chain security audits alongside traditional smart contract reviews, and the findings are eye-opening.

In this post, we share some of the most interesting vulnerability patterns we have encountered in TypeScript-based blockchain infrastructure, from subtle type coercion issues to race conditions that span the on-chain/off-chain boundary.

Input Validation and BigInt Parsing

Input validation remains one of the most persistently challenging areas in off-chain code. Even in mature TypeScript codebases, subtle issues can arise from the way user-supplied strings are parsed into numeric types.

In one of our audits, we discovered a bug where a string value was parsed into a BigInt without sufficient format validation, allowing hexadecimal input to pass through unchecked. Because BigInt silently accepts hexadecimal strings, the parsed value diverged from what was ultimately stored in the database, enabling a user to bypass a minimum price constraint.

Cases like these illustrate that input validation is not just about rejecting obviously malformed data: it requires a thorough understanding of how each layer of the stack interprets the values it receives, and how discrepancies between those interpretations can be exploited.

function parseQuantity(quantity: string, decimals: number): bigint {
  const parts = quantity.split(".");
  if (parts.length === 1) {
    parts.push("".padEnd(decimals, "0"));
  }
  // No format validation — BigInt accepts hex strings like "0x01"
  return BigInt(parts.join(""));
}

// Expected: parseQuantity("1", 3) → 1000n (i.e., 1.000)
// Exploit:  parseQuantity("0x01", 3) → BigInt("0x01000") → 4096n (i.e., 4.096)
// The validation check sees 4.096, but the database stores the raw "0x01" as 1.000

Loose Equality and Type Coercion

JavaScript's type coercion rules are notoriously permissive, and while TypeScript adds a layer of static safety, it cannot catch every case, especially at system boundaries where external input enters the application.

API payloads, query parameters, and WebSocket messages arrive as untyped data, and without rigorous runtime validation, values can slip through in unexpected forms. A permission check comparing a role value with == instead of ===, for instance, might inadvertently treat `"0"` as falsy and grant access where it shouldn't. Similarly, a balance check that receives a string "0" instead of the number 0 might pass a truthy check, since non-empty strings are truthy in JavaScript.

These bugs are easy to introduce and difficult to catch in testing, because they only manifest with specific input shapes that developers rarely anticipate.

// Express route handler for withdrawals
app.post("/withdraw", async (req, res) => {
  const { amount, token } = req.body; // amount arrives as a string from JSON

  // Bug: truthy check — the string "0" is truthy, so this check passes
  if (amount) {
    await processWithdrawal(token, amount);
  }
});

Prototype Pollution

TypeScript applications frequently merge user-supplied JSON objects into internal configuration or state: whether through Object.assign(), the spread operator, or deep merge utilities.

If these inputs are not sanitized, an attacker can inject properties to modify the prototypes of built-in objects, effectively tampering with application behavior at a global level.

In a blockchain context, this can be particularly dangerous: a polluted prototype could alter how objects are serialized before signing, change default values used in transaction construction, or bypass validation logic that relies on property lookups.

Because prototype pollution affects all objects sharing the polluted prototype, a single malicious request can have far-reaching and hard-to-debug consequences across the entire application.

function deepMerge(target: any, source: any): any {
  for (const key in source) {
    if (typeof source[key] === "object" && source[key] !== null) {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// Attacker sends this as a JSON payload:
const maliciousInput = JSON.parse(
  '{"_proto__": {"isAdmin": true, "defaultGasLimit": "100"}}'
);

const config = {};
deepMerge(config, maliciousInput);

// Now every object in the application is affected:
const user = {};
console.log((user as any).isAdmin);        // true
console.log((user as any).defaultGasLimit); // "100"

JSON Parsing and Numeric Precision

A more subtle issue arises from how JavaScript handles large numbers during JSON deserialization.

JSON.parse() converts all numeric values to IEEE 754 floating-point numbers, which silently lose precision for integers beyond 2^53. In blockchain applications, this is a real concern: token amounts with 18 decimals, block numbers on mature chains, and raw transaction values can all exceed this threshold.

A backend that parses a node's JSON-RPC response with JSON.parse might end up with a token amount that is off by several units: a discrepancy that could go unnoticed until it causes a failed settlement or, worse, an exploitable inconsistency between what the off-chain system believes and what the chain enforces.

Libraries like `json-bigint` or custom reviver functions exist to address this, but they are easy to overlook, especially when the standard JSON.parse() works correctly for the smaller values encountered during development and testing.

// A token balance with 18 decimals: 1000000000000000001 (just above 1 ETH)
const jsonResponse = '{"balance": 1000000000000000001}';

const parsed = JSON.parse(jsonResponse);
console.log(parsed.balance); // 1000000000000000000 — the last digit is silently lost!

console.log(parsed.balance === 1000000000000000000); // true — wrong value, no error

Upstream Impact

While the results of our off-chain engagements are mostly private, they often have positive effects beyond the individual project. During off-chain reviews, we sometimes uncover bugs in widely used libraries that affect the broader ecosystem.

For example, we discovered that viem's verifyTypedData() accepted signatures with arbitrary length, causing a mismatch between off-chain and on-chain signature validation. Off-chain applications relying on this function could accept crafted signatures that would then fail on-chain. Reporting these upstream benefits everyone.