Cross-language vulnerability patterns: When TypeScript meets Rust

Modern protocol architectures increasingly split responsibilities between languages: Rust for on-chain smart contracts and core logic, TypeScript for SDKs, frontends, and developer tooling. This creates a class of vulnerabilities that exists in neither language alone — they emerge from the boundary between them.

After auditing several multi-language protocol codebases, I've identified four recurring patterns where TypeScript SDKs silently diverge from their Rust counterparts, producing hash mismatches, signature failures, and exploitable edge cases that unit tests in either language will never catch.

Pattern 1: The 32-bit integer trap

JavaScript has no integer type. All numbers are IEEE 754 doubles, and critically, all bitwise operators (>>, <<, &, |) internally convert their operands to 32-bit signed integers via the ECMAScript ToInt32 abstract operation.

This is fine until a TypeScript SDK needs to replicate what Rust does with a u64:

TypeScript
// encoding a u64 value as 8 bytes big-endian
function toBigEndian(num: number, bytes: number): Uint8Array {
    const result = new Uint8Array(bytes);
    for (let i = bytes - 1; i >= 0; i--) {
        result[i] = num & 0xff;
        num >>= 8; // ToInt32 truncation happens here
    }
    return result;
}
Rust
// correct u64 encoding
let bytes = value.to_be_bytes(); // always correct for the full u64 range

The TypeScript version works perfectly for values below 231 (~2.1 billion). Above that, >> silently wraps via ToInt32, producing incorrect byte sequences. The Rust version has no such boundary.

Where this shows up: Timestamp encoding (Unix timestamps exceed 231 in January 2038), token amounts with high precision, block heights on active chains.

The fix: Use BigInt for any value that might exceed 231, or use DataView.setBigUint64() for direct byte encoding.

Why tests miss this: Test vectors almost always use "reasonable" current values that fit in 32 bits. The bug is a ticking time bomb that only detonates at scale or in the future.

Pattern 2: String length ≠ byte length

When a protocol hashes a length-prefixed string, the SDK and the contract must agree on what "length" means. They rarely do.

TypeScript
const domain = "example.com";
const lengthPrefix = domain.length; // UTF-16 code units
const encoded = new TextEncoder().encode(domain); // UTF-8 bytes
Rust
let length_prefix = domain.len(); // byte count (UTF-8)

For ASCII strings, these are identical. For anything with non-ASCII characters — internationalized domain names, user-provided labels, emoji — they diverge. A domain like "例え.jp" has String.length of 5 in JavaScript but a UTF-8 byte length of 9. Rust's str::len() returns 9.

If the protocol hashes [length_prefix | encoded_bytes], the SDK and contract now produce different hashes for the same input. Signatures computed client-side will be rejected on-chain.

The fix: Always use byte length after encoding. new TextEncoder().encode(s).length matches Rust's str::len().

Pattern 3: Floating-point scaling to integer math

Cross-language protocols often need to convert floating-point prices (from oracles or APIs) into integer arithmetic for on-chain computation:

const SCALE = 1_000_000;
const priceScaled = BigInt(Math.round(price * SCALE));
// ... use priceScaled in BigInt arithmetic
const result = numerator / priceScaled; // division

Two failure modes:

Underflow to zero: If price is very small (< 1/SCALE), then Math.round(price * SCALE) rounds to 0, and BigInt(0) causes a RangeError: Division by zero downstream. This affects low-value tokens — exactly the kind of token a DeFi protocol should handle gracefully.

Precision loss: IEEE 754 doubles have ~15.9 significant decimal digits. Multiplying a price by a scale factor can lose trailing digits before Math.round even runs. The Rust side, using u128 or fixed-point libraries, preserves full precision.

The fix: Use a larger scale factor (1e18), validate that scaled values are non-zero before division, and consider passing prices as string representations to avoid float intermediaries entirely.

Pattern 4: Entropy budgets in hybrid nonces

A common pattern in cross-language systems is to embed metadata (timestamps, counters, version bytes) into nonce values. This is fine in Rust where you can be explicit about byte layouts. In TypeScript, the ergonomic path often leads to reduced entropy:

function createTimestampedNonce(time: Date): Uint8Array {
    const nonce = new Uint8Array(16);
    const view = new DataView(nonce.buffer);
    view.setBigInt64(0, BigInt(time.getTime()), true);
    crypto.getRandomValues(nonce.subarray(8)); // only 8 random bytes = 64 bits
    return nonce;
}

64 bits of randomness has a 50% collision probability after ~232 nonces (birthday bound). For most applications this is fine, but for high-throughput protocols processing millions of transactions, it's uncomfortably close. The timestamp also leaks timing information about when an intent was created, which may have MEV implications.

· · ·

The meta-pattern

All four patterns share a root cause: SDK test suites use values that happen to be compatible across languages. Small integers, ASCII strings, reasonable prices, low-throughput scenarios. The bugs live in the margins — large numbers, Unicode, dust-value tokens, high-frequency usage.

Cross-language auditing requires a different test strategy:

Boundary value analysis across type systems. What's the largest value that fits in JS's safe integer range? Test one above it.

Encoding roundtrip tests. Hash the same input in both languages. Compare byte-for-byte.

Adversarial input generation. Non-ASCII strings, zero-value prices, timestamps past 2038, nonces under collision pressure.

The most dangerous bugs aren't the ones that crash. They're the ones that produce a different valid-looking result in each language — a hash that's plausible on both sides but doesn't match.

· · ·

Cross-language boundary bugs are the supply-chain attacks of protocol security. They don't live in either component — they live in the assumption that both components agree on semantics that were never specified.

If your protocol has a Rust core and a TypeScript SDK, your audit needs to explicitly verify semantic equivalence at every serialization and computation boundary. Language-specific audits will miss these entirely.