Skip to content
Tech Interview Prep home

Top 100 Blockchain Developer Interview Questions and Answers

The questions most likely to actually come up in your Blockchain Developer interview, ranked by likelihood — with detailed, senior-level answers covering what an interviewer is really listening for.

Curated: · Written: · Reviewed:

Reviewed 56Review pending 44
QA-1A transfer shows status success at the latest head. Why is that not Ethereum settlement, and which Gasper checkpoint actually is?(show answer)

The first thing I would pin down about inclusion versus Gasper finality is which confirmation tier actually changes the decision.

Ethereum under Gasper separates fork-choice inclusion from Casper FFG finality: a block can be the LMD-GHOST head and still be replaced; only a supermajority-justified then finalized checkpoint is economically irreversible under the slashable-vote rules.

Concretely, fetch the receipt by transaction hash (it has no block tag). Treat inclusion as "the receipt's block is on the current head," and settle only when that block is an ancestor of the current finalized checkpoint — not when receipt.blockHash equals the checkpoint hash. Quote slot=12s; typical tx finality is closer to 2.5 epochs (~16 min) because a mid-epoch inclusion waits for a later checkpoint.

The reason for that specificity is a failure I have seen: An exchange credited 840 ETH after 1 confirmation at slot 9_102_441; a 7-block reorg dropped the deposit and the desk paid out $2.1M before the covering finalized checkpoint arrived ~16 minutes later.

Same tx, three eth_getBlockByNumber tags.

TagSlotMeaningReorgable?
latest9102448LMD-GHOST headyes, even 7 blocks
safe9102416justified / reorg-safe under honestynot FFG-final
finalized9102384Casper FFG checkpointnot under honest 2/3

I would not consider it settled without evidence: Log the receipt's blockHash and the consensus client's finalized block; settle only if the receipt block is canonical and at or below that finalized ancestor.

Inclusion is a vote of the current head; finality is a 2/3 FFG checkpoint.

Curated: · Written: · Reviewed:

QA-2Why does Casper FFG need a justified checkpoint and then a later supermajority on its child before you call a deposit final?(show answer)

I would start Casper FFG two-epoch finality from the trust boundary, not from the first Solidity snippet that compiled.

FFG finalizes epoch N only after a 2/3 supermajority of stake attests to N as justified and then to a descendant, so two consecutive justified epochs produce one finalized ancestor.

Concretely, an epoch is 32 slots. Validators attest; if ≥2/3 of effective balance votes source→target correctly, the target becomes justified. The next such pair finalizes the earlier target. Checkpoints take about two epochs; a tx included mid-epoch waits an extra partial epoch, so do not encode "always 64 slots from inclusion."

The reason for that specificity is a failure I have seen: A bridge released 12_000 wrapped tokens 6.4 minutes after inclusion, halfway through the first following epoch; the covering checkpoint was not yet finalized, and the lock on L1 vanished for 19 minutes.

Epoch arithmetic from slot to payout.

EventSlotEpochFFG state
tx included3200100head only
epoch 100 justified3232100not payable
epoch 101 justified3264101epoch 100 finalized
use finalized tagvariestypically ~2.5 epochsrelease

I would not consider it settled without evidence: Resolve the finalized checkpoint to its execution block and prove the receipt block is an ancestor. Comparing only floor(depositSlot/32) to finalized_checkpoint.epoch is unsafe because a checkpoint covers the start of its epoch, not every later block in that epoch.

One justified epoch is progress; two in a row is the finality you can pay against.

Curated: · Written: · Reviewed:

QA-3If two blocks compete at the same height, what does LMD-GHOST actually pick, and why is that not the same as an FFG vote?(show answer)

This is a place where an explorer green tick and a correct handling of LMD-GHOST fork choice are not the same event.

LMD-GHOST (latest-message-driven greedy heaviest observed subtree) follows the fork with the most recent honest attestation weight; it chooses a head every slot and can still flip that head until FFG finalizes an ancestor.

Concretely, each validator's latest attestation is one vote. Sum effective balances on branches; the child with greater weight wins, with proposer boost on the timely proposal. Store the head for gossip; store the finalized checkpoint for money.

The reason for that specificity is a failure I have seen: A market maker quoted on head block 18_440_102, then LMD-GHOST switched 3 slots later (36s); 410 ETH of fills settled on the discarded branch and the book was −$740k for 11 minutes.

Two children, one head, weights in ETH.

Child rootAttestation weightProposer boostHead?
0xaa…8.2e6 ETH0no
0xbb…8.1e6 ETH0.4e6yes
3 slots later 0xaa…8.5e60yes (flip)

I would not consider it settled without evidence: Dump fork-choice weight per child root from the beacon node at slot n and n+1; a flip without an FFG change is expected, not a bug.

Fork choice names a live head; it does not freeze history.

Curated: · Written: · Reviewed:

QA-4A product owner wants "6 confirmations like Bitcoin." What do you actually encode on Ethereum PoS, and where does that policy fail?(show answer)

My answer to confirmation depth as policy begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

Confirmation depth is an application risk budget on reorg probability, not a protocol primitive; on Gasper you still wait for FFG finality for high value and use a documented N-block head lag only for low-value UX.

Concretely, pick N from your loss table (for example 12 slots ≈ 2.4 min for coffee, finalized for >$50k). Subscribe to newHeads and rewind N; never treat N as "the chain cannot reorg."

The reason for that specificity is a failure I have seen: A checkout used 6 blocks (72s) for a $180k NFT mint; a 9-block reorg at slot 7_881_200 unminted the token after the warehouse shipped, $180k of inventory gone.

Loss table that must exist before N is picked.

ValuePolicyWaitResidual risk
<$502 slots24ssmall reorg
<$5k12 slots2.4 minuncommon
≥$50kfinalized~2–3 epochsslashable 1/3

I would not consider it settled without evidence: A reorg drill that rewinds 12 then 64 slots in staging and asserts the credit table matches finalized receipts only.

Six blocks is a UX delay; finalized is the settlement tier.

Curated: · Written: · Reviewed:

QA-5Beacon APIs expose safe and finalized. When is safe still the wrong tag for a withdrawal, and what is it for?(show answer)

I would treat safe head versus finalized checkpoint as a state machine over pending, included, and finalized, not as a single RPC result.

The execution API's safe tag is the most recent block considered reorg-safe under honest-majority and synchrony assumptions, typically the justified checkpoint; it is not Casper FFG finality. Withdrawals and mint/burn still use finalized unless the product explicitly eats remaining reorg risk.

Concretely, map JSON-RPC tags: latest → raw head, safe → justified / reorg-safe under those assumptions, finalized → FFG checkpoint. For custody, fetch the receipt by hash and require its block to be an ancestor of the current finalized block — equality with the checkpoint hash is the wrong test.

The reason for that specificity is a failure I have seen: A custodian paid 3_200 USDC on safe at slot 11_004_088, treating it as settlement; 4 minutes later the justified view moved and the USDC had already left the hot wallet.

Tags at one query time.

TagBlockAgeUse
latest190010100smempool UX
safe19000978~epoch-scalelow-value display
finalized18999946~2–3 epochswithdrawals

I would not consider it settled without evidence: Side-by-side logs of safe.hash and finalized.hash for 2 hours; any payout whose hash only ever matched safe is a policy bug.

Safe is a better head, not a finalized checkpoint.

Curated: · Written: · Reviewed:

QA-6Your indexer is at height H when the node reports a reorg back to H-8. What must you undo, and what must stay?(show answer)

The useful question for handling an N-block reorg is what still happens on a reorg, a callback, or a second chain.

A reorg invalidates receipts, logs, and derived balances on the discarded branch; finalized state stays, and every write keyed by (chainId, blockHash) must delete or rewind rather than append.

Concretely, on common-ancestor hash C, delete rows with blockNumber > C.number or blockHash not on the new canonical path, then replay from C+1. Idempotent upserts on (txHash, logIndex, blockHash) prevent double credits.

The reason for that specificity is a failure I have seen: An indexer appended the new branch without deleting 8 blocks of swaps; users saw 2.4× volume for 47 minutes and a liquidator repaid $1.1M against phantom collateral.

Rewind from H to common ancestor.

StepHeightActionRows
detect1008 vs 1000common ancestor 1000
delete1001–1008drop by blockHash4_812 logs
replay1001–1008'insert new hashes4_790 logs

I would not consider it settled without evidence: A fixture that feeds an 8-block fork then the canonical chain and asserts balances equal a node eth_call at the new head.

Reorgs are deletes of a hash-chain, not extra inserts.

Curated: · Written: · Reviewed:

QA-7A minority of validators cannot see the majority for hours. What does the inactivity leak do, and how does that change your finality wait?(show answer)

I would settle inactivity leak under partition against a counter-example first, so the clever opcode has to survive it.

If FFG cannot gather 2/3, Gasper leaks inactive validators' balances until the online set again exceeds 2/3, restoring finality liveness. A prolonged partition can in the limit let both sides leak the other and finalize conflicting histories, after which social recovery is the remaining safety valve.

Concretely, during a leak, do not treat "we have not finalized in 2 epochs" as a bug in your app; lengthen settlement to "first finalized after leak ends" and halt mint/burn. Track currentEpoch - finalizedEpoch as a gauge.

The reason for that specificity is a failure I have seen: During a 3.1-hour leak on a test fork, a vault kept releasing after 12.8 minutes of wall time; $640k left on a non-finalized head that later lost 22% of its attestations.

Lag gauge during a leak.

tfinalized lag (epochs)Policy
02normal ~12.8 min
40 min8freeze payouts
3.1 h29still freeze
after leak2resume

I would not consider it settled without evidence: Alert when finalized lag > 5 epochs (~32 min) and freeze settlement until lag returns to 2.

The leak buys finality later by burning inactive weight; it does not let you skip FFG now.

Curated: · Written: · Reviewed:

QA-8Someone says "wait one block." On Ethereum PoS, what times are you actually talking about, and which clock does finality use?(show answer)

The judgement in slot and epoch clocks is which bytes are authenticated, not which library call looks shortest.

A slot is 12 seconds of proposer opportunity; an epoch is 32 slots (6.4 minutes); FFG votes and finality are epoch-scoped, so "one block" is the wrong unit for settlement math.

Concretely, a typical mid-epoch inclusion waits roughly 2.5 epochs: about 80 slots, or 80×12 = 960s. The exact wait depends on where inclusion falls in the epoch. Missed slots still advance the slot clock without a block, so counting receipts is not counting time.

The reason for that specificity is a failure I have seen: A keeper counted "64 produced blocks" while 18 slots were missed, so it waited ~82 slots and acted 3.6 minutes late; it liquidated 14 positions that an elapsed-slot or finalized-checkpoint clock would have handled on time.

Wall time versus receipt count.

MeasureCountSeconds
slots80960
missed in window18still 960
receipts seen62~744 if you count blocks

I would not consider it settled without evidence: Use slot numbers from the beacon API, not the count of eth_getBlockByNumber increments.

Finality is an epoch machine; blocks are optional occupants of slots.

Curated: · Written: · Reviewed:

QA-9Why is 2/3 of stake the number that matters for Casper FFG, and what happens if you design around 51% like a hash-rate story?(show answer)

Where candidates lose the interview on two-thirds supermajority is usually treating a receipt as settlement.

FFG needs ≥2/3 of the honest-plus-voting stake so that two conflicting finalized checkpoints would require ≥1/3 of stake to be slashably double-voting — 51% of hash or stake is a different attack story and does not finalize.

Concretely, quorum = ceil(2/3 × total effective balance). A 51% coalition can influence LMD-GHOST; it cannot finalize two histories without slashing 1/3. Quote both thresholds when asked "who controls the chain."

The reason for that specificity is a failure I have seen: A sidechain copied "51% finalize" and a 52% coalition stamped two checkpoints 9 minutes apart; 7_400 bridged ETH was minted on both histories.

Stake thresholds, same 32e6 ETH set.

ClaimStake neededEffect
LMD-GHOST bias>50%head games
FFG justify/finalize≥2/3 ≈ 21.3e6checkpoints
prevent two finalsslash 1/3 ≈ 10.7e6economic safety

I would not consider it settled without evidence: Show validator effective balances summing to ≥2/3 on the target root in the beacon committee report.

Fifty-one percent moves a head; two-thirds plus slashing is FFG.

Curated: · Written: · Reviewed:

QA-10The block number has not incremented for 36 seconds. Has the chain halted, and how should a keeper treat the gap?(show answer)

I would answer missed slots and empty proposals by separating what consensus agreed from what an oracle or a bridge merely claimed.

Missed slots are normal: a proposer can be offline and the slot expires empty; the slot clock still advances and fork choice continues, so wall-clock gaps are not automatic finality or a halt.

Concretely, key jobs on slot or timestamp from the beacon node, not on "new block number." After 3 empty slots (36s) retry reads; after 64 empty slots investigate liveness, still without treating latest as final.

The reason for that specificity is a failure I have seen: A keeper armed on blockNumber++ slept 36s, then double-fired when two blocks arrived in one poll, sending 2 liquidations of 90 ETH each and paying 4.8 ETH extra gas.

Three slots, one block number bump.

SlotProposerExecution block
5000missednone
5001missednone
5002presentN+1 (36s gap)

I would not consider it settled without evidence: Metrics: empty_slots_total and time_since_slot; alert on empty_slots ≥ 8 in 10 minutes, not on a single 36s pause.

Empty slots are missed turns, not a stopped ledger.

Curated: · Written: · Reviewed:

QA-11A founder asks whether Ethereum finality is "mathematically impossible to reverse." What do you say, with numbers?(show answer)

The engineering content of economic versus cryptographic finality is the invariant and the exit path, not the token ticker.

Gasper finality is economic: reversing a finalized checkpoint requires getting 1/3 of stake slashably contradicting itself (or a social fork); it is not a cryptographic impossibility like a hash preimage.

Concretely, state the assumption: honest or rational ≥2/3, plus slashing of double votes. For $X at risk, compare to 1/3 of staked ETH (tens of billions at 32e6 ETH) and to your insurance, then still wait for the finalized tag.

The reason for that specificity is a failure I have seen: A pitch deck called finalized deposits "unforgeable"; after a client bug scare, the desk still had a 45-minute halt because they had no social-recovery plan for a 1/3 slash event.

Cost sketch, 32e6 ETH at $3_000.

ObjectFigure
1/3 stake~10.7e6 ETH
notional~$32B
your $5M mintnot in that class
still requiredfinalized tag

I would not consider it settled without evidence: Write the assumption in the risk memo: "reversal ⇒ ≥1/3 slashable or user-activated fork," with current stake USD.

Finality is a slashable-vote machine, not a proof that history is physics.

Curated: · Written: · Reviewed:

QA-12An intern caches eth_getLogs from latest every 15 seconds and never records blockHash. What breaks on a 4-block reorg?(show answer)

Before deploying I would write what a correct result for latest tag as canonical history looks like after a 12-block reorg.

Logs without blockHash are not canonical; latest moves, and the same (txHash, logIndex) can exist on two hashes with different data.

Concretely, persist (chainId, blockHash, logIndex). On reorg, delete by hash. Re-query from the common ancestor with fromBlock/toBlock plus hash checks.

The reason for that specificity is a failure I have seen: A rewards job summed Transfer logs by txHash only; after a 4-block reorg it double-paid 18_600 tokens ($93k) because the replacement logs reused the same tx hashes on a different nonce sequence.

Same logIndex, two hashes.

blockHashlogIndexamount
0xold31000
0xnew30
keyed by txHash—paid 1000 twice

I would not consider it settled without evidence: Unique constraint on (blockHash, logIndex) and a reorg test that expects a delete+insert, not an upsert on txHash.

Canonical means a hash, not the word latest.

Curated: · Written: · Reviewed:

QA-13What does a valid secp256k1 signature on an Ethereum transaction actually prove, and what does it not prove?(show answer)

The first thing I would pin down about secp256k1 ECDSA transaction signatures is which confirmation tier actually changes the decision.

Verification shows that the canonical RLP (or typed) payload was signed by the private key matching the recovered address; it does not prove the signer understood the dapp UI, that the key is uncompromised, or that the tx is finalized.

Concretely, hash the signing payload (Keccak of the typed envelope), recover (r,s,v) with the secp256k1 curve order n, reject s > n/2 (EIP-2), then derive the address. Quote v ∈ {27,28} or y-parity for typed txs.

The reason for that specificity is a failure I have seen: A custody API accepted a signature over a hex dump the user never hashed; an attacker swapped 220 ETH of calldata and the recovered address still matched because the dump was not the typed payload.

Recover checklist.

CheckValue
curvesecp256k1
s ≤ n/2EIP-2
v27 or 28 (legacy)
recovered0xA1… matches signer

I would not consider it settled without evidence: Cross-check recovered address against the intended signer and byte-equal the signed hash to a locally serialized tx.

A signature binds bytes to a key, not intent to a screen.

Curated: · Written: · Reviewed:

QA-14How is an Ethereum address computed from a public key, and why is a 20-byte truncation not a checksum of the key?(show answer)

I would start address derivation from a public key from the trust boundary, not from the first Solidity snippet that compiled.

The address is the last 20 bytes of Keccak-256 of the uncompressed 64-byte XY public key (no 0x04 prefix in the hash); truncation is many-to-one and is not authentication of the intended recipient. A generic collision appears after about 2^80 addresses, while targeting one fixed address still takes about 2^160 trials.

Concretely, pubkey[1:] if 65 bytes with 0x04, keccak256, take [12:32], then EIP-55 for display. Never invent a 20-byte string from a truncated mnemonic word list.

The reason for that specificity is a failure I have seen: A script hashed the 33-byte compressed key and sent 45 ETH to a well-formed but wrong address; the funds were irrecoverable in 8 minutes.

Byte pipeline.

StepBytesNote
uncompressed XY64drop 0x04
keccak25632
addresslast 20[12:32]
fixed-address preimage work~2^160generic collision ~2^80

I would not consider it settled without evidence: A test vector: known secp256k1 pubkey → address matching a hardware wallet display, including the 0x prefix and EIP-55 casing.

Twenty bytes are a name derived from a hash, not a proof of the whole key.

Curated: · Written: · Reviewed:

QA-15A user pastes a mixed-case address. What does EIP-55 actually detect, and what can it not catch?(show answer)

This is a place where an explorer green tick and a correct handling of EIP-55 checksummed addresses are not the same event.

EIP-55 encodes bits of the address hash into letter case so many single-character typos fail to parse; it does not authenticate the recipient, stop a lookalike contract, or protect a paste normalized to all lowercase or all uppercase.

Concretely, compute keccak of the lowercase hex without 0x; for each hex letter, uppercase if the corresponding hash nibble ≥ 8. Reject mixed-case that fails this. All-one-case input is commonly accepted for compatibility but carries no EIP-55 check bits.

The reason for that specificity is a failure I have seen: Support told a user "checksum passed" on a lookalike that differed in 3 hex chars chosen to still checksum; $28k went to the wrong contract.

Three strings, one identity.

FormEIP-55Safe to send $10k?
mixed validpassif also allowlisted
mixed 1-char typofailno
all one caseskip checkonly with extra confirm

I would not consider it settled without evidence: Unit tests: one flipped character in mixed case must throw; an allowlist of known destinations for amounts > $2k.

Checksums catch sloppy typing; they do not name the intended person.

Curated: · Written: · Reviewed:

QA-16Why can a signed Ethereum transaction from chain 1 be worthless on chain 137, and what field makes that true?(show answer)

My answer to EIP-155 chain-id replay protection begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

EIP-155 (and typed txs) bind the signature to chainId so the same nonce and gas on another EVM network verify against a different signing hash and recover a different or invalid signature.

Concretely, legacy: v = 35 + 2*chainId + yParity (or 27/28 pre-155). Typed 0x01/0x02 include chainId in the hash. Always set chainId explicitly in the signer; never reuse a raw signed blob across RPCs.

The reason for that specificity is a failure I have seen: A bot broadcast a mainnet-signed payload to a cheap L2 RPC; the signature was invalid there, but a forked testnet with chainId 1 replayed 900 ETH of approvals.

Same nonce, two chains.

NetworkchainIdSignature verifies?
Ethereum1yes, intended
Polygon137no
Evil fork claiming 11yes — social, not EIP-155

I would not consider it settled without evidence: Verify recovered payload.chainId == expected; reject wallets that still produce v ∈ {27,28} for mainnet production.

The chain id is inside the signed bytes, or the tx is a souvenir from 2016.

Curated: · Written: · Reviewed:

QA-17Ethereum rejects high-s signatures. What attack does that close, and what still remains?(show answer)

I would treat ECDSA s-value malleability as a state machine over pending, included, and finalized, not as a single RPC result.

For a curve point, (r, s) and (r, n−s) can both verify; EIP-2 requires s ≤ n/2 so a third party cannot mutate a tx hash without the private key. Malleability of the tx hash is closed; the signer can still broadcast two different valid txs.

Concretely, if s > secp256k1n/2, negate s and flip v. Nodes drop high-s. Do not treat txHash as a unique business id until it is in a finalized block; replacements share nonce, not hash.

The reason for that specificity is a failure I have seen: A 2016-era relayer accepted high-s and an attacker published the low-s twin; a bridge keyed on txHash double-unlocked 1_250 tokens.

n/2 bound.

sValid on mainnet?
0x7f… (< n/2)yes
n − thatno (EIP-2)
n~1.158e77

I would not consider it settled without evidence: Fuzz signatures with s' = n−s and assert the node rejects or canonicalizes before your indexer's unique key.

Low-s makes the hash stable; it does not make the nonce unique across replacements.

Curated: · Written: · Reviewed:

QA-18Your contract uses ecrecover to auth a voucher. Which return values must you reject, and why is 0x0 a trap?(show answer)

The useful question for ecrecover precompile pitfalls is what still happens on a reorg, a callback, or a second chain.

The ecrecover precompile returns address(0) on failure instead of reverting, so a bad (v,r,s) looks like a signature from the zero address if you only check recovered == signer without requiring signer ≠ 0 and a tight v.

Concretely, require v ∈ {27,28}, r in (0,n), s in (0,n/2], recovered != address(0), and recovered == expected. Prefer ECDSA.recover from OpenZeppelin, which rejects high-s malleability and reverts on malformed signatures.

The reason for that specificity is a failure I have seen: A voucher ignored v=0 and treated recovered 0x0 as a skip; attackers minted 50_000 points by passing empty r,s and hitting the uninitialized owner slot of 0x0.

Solidity guard.

require(v == 27 || v == 28, "v");
address rec = ecrecover(hash, v, r, s);
require(rec != address(0) && rec == signer, "sig");
// 0x0 is 1 forbidden signer, not a wildcard

I would not consider it settled without evidence: Negative tests: v=0, r=0, s=0, s=n, and s>n/2 must all revert, never authorize.

Zero from ecrecover is failure, not a signer.

Curated: · Written: · Reviewed:

QA-19A mobile wallet stores a 12-word mnemonic in app storage. What is the actual secret, and how do account indexes change the address?(show answer)

I would settle HD paths and mnemonic exposure against a counter-example first, so the clever opcode has to survive it.

The mnemonic plus passphrase is the root seed; BIP-32/44 path m/44'/60'/0'/0/i yields the i-th Ethereum account. Anyone with the words derives every i; the path is not confidentiality.

Concretely, generate 128–256 bits of entropy, show words once, store in a hardware element if possible. Never log the seed. Document the path; MetaMask default is m/44'/60'/0'/0/0 for the first account.

The reason for that specificity is a failure I have seen: A crash reporter uploaded mnemonics from 1_104 devices; attackers swept 310 ETH within 22 minutes using index 0 and 1.

Same seed, two indexes.

PathiAddress role
m/44'/60'/0'/0/00default
m/44'/60'/0'/0/11second
leaked mnemonicall itotal loss

I would not consider it settled without evidence: A known mnemonic test vector that matches MetaMask address 0 and 1, plus a secret-scan CI rule on seed-like strings.

The words are the key to every index; the path only names which child.

Curated: · Written: · Reviewed:

QA-20Why sign EIP-712 structs instead of a raw keccak of concatenated fields, and what must the domain separator include?(show answer)

The judgement in EIP-712 typed data hashing is which bytes are authenticated, not which library call looks shortest.

EIP-712 hashes a domain (name, version, chainId, verifyingContract, optional salt) plus a typehash of the struct so the same fields cannot be replayed on another contract, chain, or version.

Concretely, digest = keccak256("\x19\x01" ‖ domainSeparator ‖ structHash). Domain must include chainId and verifyingContract. Permit and many bridges use this; the signature does not transfer tokens by itself.

The reason for that specificity is a failure I have seen: A marketplace hashed name‖price without a domain; the signature replayed on a clone at chainId 1 fork and sold a 40 ETH NFT for 40 wei.

Domain fields that stop replays.

FieldExampleReplay if omitted
chainId1other EVMs
verifyingContract0xEx…clone
version"1"v2 with same types

I would not consider it settled without evidence: Change verifyingContract by 1 byte and assert ecrecover fails; include chainId 1 vs 5 in the same test.

Typed data is a domain plus a struct, not a pretty JSON blob.

Curated: · Written: · Reviewed:

QA-21A user signed a permit for token A. Why must token B reject that signature even if the owner address matches?(show answer)

Where candidates lose the interview on signature replay across contracts is usually treating a receipt as settlement.

A signature is valid for the exact digest; if two contracts share a poorly scoped domain or hash the same fields, the signature replays. Nonces are per-contract (and per-owner for permit), not global.

Concretely, each verifyingContract has its own EIP-712 domain and nonce mapping. Never accept a digest that did not include this address. After success, increment nonce so the same digest dies.

The reason for that specificity is a failure I have seen: Two tokens copied the same DOMAIN_SEPARATOR bytes; a permit for 1e18 of token A granted 1e18 of token B, and a bot drained $410k in 90 seconds.

Nonce is local.

Contractnonce[owner]Permit A usable?
Token A0 → 1once
Token B0no, different domain
Token A again1old sig dead

I would not consider it settled without evidence: Deploy two clones and assert a signature for A reverts on B; log nonce before/after (0 → 1).

Matching owner is not matching digest.

Curated: · Written: · Reviewed:

QA-22What inputs determine a CREATE2 address, and why is "the address of the contract after it upgrades" the wrong answer?(show answer)

I would answer CREATE2 address formula by separating what consensus agreed from what an oracle or a bridge merely claimed.

CREATE2 address = keccak256(0xff ‖ deployer ‖ salt ‖ keccak256(init_code))[12:]; it is a function of those four pieces at deploy time, not of later storage, proxy admin, or runtime bytecode after selfdestruct.

Concretely, same deployer, salt, and initcode hash ⇒ same address on any chain with CREATE2. Changing constructor args changes initcode hash. Runtime code can be different after a later deploy to the same address only if the account was empty again — do not design for that.

The reason for that specificity is a failure I have seen: A factory used salt=user but hashed runtime bytecode instead of initcode; 2_200 predicted safes missed by 1 byte and users sent 88 ETH to empty accounts.

Four inputs, one address.

InputBytes
0xff1
deployer20
salt32
keccak(initcode)32
addresslast 20 of keccak

I would not consider it settled without evidence: Compute the address off-chain with the initcode bytes you pass to the factory and match address(new C{salt: s}()).

CREATE2 names initcode, not future semantics.

Curated: · Written: · Reviewed:

QA-23Why is if (tx.origin == owner) a phishing primitive, and what should authorize instead?(show answer)

The engineering content of tx.origin versus msg.sender is the invariant and the exit path, not the token ticker.

tx.origin is the EOA that started the transaction; msg.sender is the immediate caller. A malicious contract called by the owner has tx.origin == owner and can pass an origin check.

Concretely, authorize with msg.sender (or ERC-1271/4337 smart-account validation). Never use tx.origin for auth, and treat tx.origin == msg.sender only as a weak "not a contract caller" hint that still fails with 4337 and some proxies.

The reason for that specificity is a failure I have seen: A wallet connected to a fake mint; the mint contract called withdraw() on a vault that checked tx.origin and drained 62 ETH in one tx.

Call stack.

Framemsg.sendertx.origin
Owner EOA—Owner
AttackerOwnerOwner
VaultAttackerOwner
origin == owner?yes — drain

I would not consider it settled without evidence: A two-contract test: Owner → Attacker → Vault; origin check passes, msg.sender check fails.

Authorize the caller, not the original EOA.

Curated: · Written: · Reviewed:

QA-24A sequencer signs 2_000 batches a day. Where should the private key live, and what policy sits around it?(show answer)

Before deploying I would write what a correct result for hot keys versus hardware signers looks like after a 12-block reorg.

A high-frequency signer can be a hot key in an HSM or isolated signer; a treasury key should require hardware plus human quorum. The key that can mint or upgrade is not the key that can sequence.

Concretely, split roles: batch-signer (rate-limited, destination-bound), pause (multishig), upgrade (timelock). Never put the upgrade key on the sequencer host. Rotate on a 90-day calendar with a tested ceremony.

The reason for that specificity is a failure I have seen: One laptop held sequencer and upgrade keys; malware sent setOwner in the same hour as 1_800 honest batches and took a $19M proxy.

Role split.

RoleSigns/dayCustody
sequencer2000HSM hot
pause0–23-of-5
upgrade0–14-of-7 + 48h timelock

I would not consider it settled without evidence: An allowlist of to/value on the HSM and an audit log of 2_000 signs/day without raw key export.

Frequency and blast radius pick the box; they are different keys.

Curated: · Written: · Reviewed:

QA-25Two uint128s sit in one slot. What do you pack for, and what breaks if a later upgrade inserts a uint256 in between?(show answer)

The first thing I would pin down about storage slot packing is which confirmation tier actually changes the decision.

EVM storage is 32-byte slots; consecutive static variables pack into one slot to save writes. A zero-to-nonzero SSTORE is 20_000 gas plus 2_100 if the slot is cold (22_100), not "20_000 because it is cold." An upgrade that inserts a type changes layout for every later variable and silently reads the wrong slot.

Concretely, order by packing, then freeze the layout. Use a gap array in upgradeable contracts (uint256[50] __gap). Hash-map and dynamic array data sit at keccak(slot), not in the packed word.

The reason for that specificity is a failure I have seen: An upgrade inserted an address between two packed uint128s; balances of 12_400 users read as 0 and a keeper liquidated $2.7M of healthy debt.

Slot 0 before and after a bad insert.

VersionSlot 0Slot 1
v1a:uint128, b:uint128owner
v2 bada, ownerb (shifted)
users12400wrong b

I would not consider it settled without evidence: storageLayout JSON diff in CI; fail the build on any slot shift for existing names.

Packing is a gas trick; layout is a consensus ABI with your future self.

Curated: · Written: · Reviewed:

QA-26Where does mapping(address => uint256) balances[user] live, and why is slot 0 of the contract not the answer?(show answer)

I would start mapping storage slot formula from the trust boundary, not from the first Solidity snippet that compiled.

A mapping's declaration slot p holds no values; the value for key k is at keccak256(h(k) ‖ p) for value types, so you cannot iterate storage and you must know p to prove a balance.

Concretely, for balances at slot 3, location = keccak256(pad(user) ‖ uint256(3)). Nested maps hash again. Tools: cast storage, forge inspect. Never assume address(this).balance equals the mapping sum.

The reason for that specificity is a failure I have seen: An auditor "zeroed slot 3" thinking it cleared balances; 1 mapping declaration went to 0 while 8_900 users still had 4.1e6 tokens in keccak slots.

Location for user 0xabc… slot p=3.

key = abi.encode(user, uint256(3))
slot = keccak256(key)
// 32-byte slot, not slot 3
// 8900 users => 8900 distinct slots

I would not consider it settled without evidence: Compute the slot for a known user off-chain and match vm.load in a Foundry test.

Mappings live at hashed keys, not at the named slot.

Curated: · Written: · Reviewed:

QA-27A function takes bytes calldata payload. When do you copy to memory, and what is the gas mistake of always using storage?(show answer)

This is a place where an explorer green tick and a correct handling of calldata versus memory versus storage are not the same event.

Calldata is read-only input; memory is transient; storage is 20_000/5_000 gas class. Copy calldata to memory only if you must mutate or pass to a memory-typed internal function.

Concretely, external + calldata avoids a copy. string.concat and abi.decode may need memory. A storage bytes write of 1 kB is tens of thousands of gas versus ~16 gas/nonzero calldata byte inbound.

The reason for that specificity is a failure I have seen: A bridge copied 128 kB proofs from calldata into storage "for debugging" and each call cost 6.2e6 gas; 40 proofs stalled the queue for 3 hours.

1_024-byte payload, three homes.

LocationRough extra gasMutate?
calldata0 copyno
memory~3k copyyes, ephemeral
storage~200k+yes, permanent

I would not consider it settled without evidence: forge snapshot before/after changing the location keyword; quote the gas delta on a 1_024-byte payload.

Calldata is the wire; storage is the database; memory is the scratchpad.

Curated: · Written: · Reviewed:

QA-28What stays the same under delegatecall, what changes, and why is that the proxy primitive?(show answer)

My answer to delegatecall execution context begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

delegatecall runs the target's code with the caller's storage, msg.sender, and msg.value; call runs the target's code with the target's storage. Proxies delegatecall implementation so state lives in the proxy.

Concretely, address(impl).delegatecall(data). Success bits and returndata come back; storage writes hit the proxy slots. If impl writes to slot 0 as "owner" and the proxy uses slot 0 as "impl", you clobber the implementation pointer.

The reason for that specificity is a failure I have seen: A "library" used delegatecall but declared its own state; one swap overwrote the proxy's slot 0 and bricked $8.4M for 14 hours until a rescue with a known slot layout.

Who owns slot 0?

OpcodeCodeStoragemsg.sender
calltargettargetcaller
delegatecalltargetcalleroriginal
staticcalltargetnone (read)caller

I would not consider it settled without evidence: A test that delegatecall increments a counter in the caller, not in the callee contract's own storage.

delegatecall is borrow-code, keep-storage.

Curated: · Written: · Reviewed:

QA-29A transparent proxy stores the implementation at a random slot. Why did the first Solidity member still destroy a fork of it?(show answer)

I would treat proxy storage collisions as a state machine over pending, included, and finalized, not as a single RPC result.

If implementation and proxy both use slot 0 for different meanings, delegatecall writes collide. EIP-1967 puts impl at bytes32(uint256(keccak256("eip1967.proxy.implementation"))-1), away from sequential slots.

Concretely, use OZ ERC1967. Implementation must not declare overlapping state at low slots unless it is the same layout. Initialize via initialize(), not constructor, because constructors write the impl's storage, not the proxy's.

The reason for that specificity is a failure I have seen: A custom proxy put impl at slot 0; the token's totalSupply also slot 0; the first mint set impl to 1e18 and the next user call executed empty code, freezing 2.2e6 tokens.

EIP-1967 implementation slot.

ItemSlot
keccak("eip1967.proxy.implementation")-10x360894a13ba1a321…
app totalSupply0
collision if impl at 01e18 interpreted as address

I would not consider it settled without evidence: Read EIP-1967 slot in tests and assert it is not slot 0; storage layout of impl starts at 0 for app state only.

The proxy's pointer and the app's first variable cannot share a slot.

Curated: · Written: · Reviewed:

QA-30When do you pick UUPS over a transparent proxy, and what extra function must the implementation keep?(show answer)

The useful question for UUPS versus transparent proxies is what still happens on a reorg, a callback, or a second chain.

Transparent proxies dispatch admin calls in the proxy and user calls to the implementation; UUPS puts the upgrade mechanism in the implementation, reducing proxy runtime overhead and deployment size, but a broken implementation can lose that mechanism. Both patterns still delegate ordinary user calls once.

Concretely, on OpenZeppelin 5, UUPS exposes upgradeToAndCall with _authorizeUpgrade and a proxiableUUID compatibility check; upgradeTo was removed. Transparent proxies use a ProxyAdmin and internal admin dispatch. Upgrading through an unsafe custom mechanism to an implementation without UUPS support can freeze the proxy.

The reason for that specificity is a failure I have seen: A custom UUPS implementation bypassed the compatibility check and upgraded to code without upgradeToAndCall; the next bug could not be patched and $6.1M sat behind frozen logic for 11 days.

Where does upgradeToAndCall live?

PatternUpgrade lives inUser extra gas
transparentproxy/adminadmin-check
UUPSimplementation~0
unsafe UUPS target without upgrade entrynowherefrozen

I would not consider it settled without evidence: A test that upgrades through upgradeToAndCall to a UUPS-compatible implementation, plus a negative test that proxiableUUID mismatch is rejected before the pointer changes.

UUPS saves user gas by putting the gun in the implementation; do not delete the gun.

Curated: · Written: · Reviewed:

QA-31Why does a proxy's implementation constructor not initialize the proxy's storage, and what race exists on initialize?(show answer)

I would settle proxy constructors versus initializers against a counter-example first, so the clever opcode has to survive it.

Constructors run in the implementation's own context at deploy; the proxy's storage is still zeros. initialize() must be called via the proxy, once, with an initializer modifier.

Concretely, disable initializers in the impl constructor (_disableInitializers). Call initialize in the same tx as proxy deploy (or use a factory). Uninitialized proxies are takeovers: anyone becomes owner.

The reason for that specificity is a failure I have seen: A team deployed proxy then initialized 40 minutes later; an attacker initialized first as owner and swept 900 ETH of seed liquidity.

Same 40-minute gap.

tProxy storage ownerWho
0 min0x0uninitialized
12 minattackerinitialize()
40 minteam tx revertstoo late

I would not consider it settled without evidence: Trace the deploy tx: proxy bytecode, then delegatecall initialize in the same transaction; a separate tx is a bug.

Constructor writes the impl; initialize writes the proxy; do both atomically.

Curated: · Written: · Reviewed:

QA-32When does uint256 a + b revert in Solidity 0.8, and when can it still wrap?(show answer)

The judgement in Solidity 0.8 checked arithmetic is which bytes are authenticated, not which library call looks shortest.

0.8+ inserts overflow checks on + - * for integers; wrap only inside unchecked, or via shrinking casts (uint256 → uint8) that are not the same as the checked ops.

Concretely, default: a+b reverts if > type(uint256).max. Use unchecked for gas in loops where you have a proven bound. SafeCast for downcasts. Do not compile with 0.7 and assume 0.8 behavior.

The reason for that specificity is a failure I have seen: A 0.8 vault used uint8(fee) on a 300 bps uint256; 300 wrapped to 44 and fees undercharged 85% for 6 days ($220k).

Three additions.

uint256 x = type(uint256).max;
// x + 1 reverts in 0.8
unchecked { x + 1; } // wraps to 0
uint8(uint256(300)); // 44, no revert

I would not consider it settled without evidence: Tests: a runtime type(uint256).max + 1 reverts; unchecked wraps; uint8(uint256(256)) == 0.

Checked math is the default; casts and unchecked are the remaining wrap doors.

Curated: · Written: · Reviewed:

QA-33A loop does i++ to n where n is calldata length. Where is unchecked justified, and what number still needs a check?(show answer)

Where candidates lose the interview on unchecked blocks for gas is usually treating a receipt as settlement.

unchecked is for increments that cannot exceed the bound you already proved; it is not a license to add user-supplied balances.

Concretely, for (uint256 i; i < n;) { ... unchecked { ++i; } } is fine if n is length. balance + amount must stay checked. Comment the invariant next to unchecked.

The reason for that specificity is a failure I have seen: A team wrapped a whole function in unchecked to save 1_200 gas; a user amount overflowed a share calculation and minted 2^256-1 shares, taking 100% of a $4.8M pool.

Gas vs safety.

Siteunchecked?Why
i++ < nyesn ≤ 2^256-1 practical
shares += amountnouser input
saved gas if whole fn1200lost $4.8M

I would not consider it settled without evidence: Gas snapshot of the loop-only unchecked versus a full-function unchecked that your fuzzer must reject.

Uncheck the counter, not the money.

Curated: · Written: · Reviewed:

QA-34Why can uint256 x = 256; uint8(x) equal 0 in 0.8 without a revert, and what helper do you use instead?(show answer)

I would answer narrowing integer casts by separating what consensus agreed from what an oracle or a bridge merely claimed.

Overflow checks apply to arithmetic operators, not to type conversion; downcasts truncate modulo 2^bits unless you use SafeCast or an explicit require(x <= type(uint8).max).

Concretely, safeCast.toUint8(x) reverts above 255. For fees in bps, keep uint256 or uint16 with a max of 10_000. Review every uint8/uint16 on values that came from uint256.

The reason for that specificity is a failure I have seen: A 256-bit price from an oracle was cast to uint128; prices above 2^128-1 became small and a liquidator bought $3.3M of collateral at a 99% discount.

256 into 8 bits.

ExpressionResult
x=uint256(256); uint8(x)0
uint8(255)255
SafeCast.toUint8(256)revert
2^128 as uint128 price0 if from 2^128

I would not consider it settled without evidence: A Foundry test that SafeCast.toUint8(x) reverts for runtime x=256 and that uint8(x)==0; uint8(256) as a direct constant is rejected by modern Solidity rather than demonstrating runtime truncation.

A cast is a modulo, not a checked conversion.

Curated: · Written: · Reviewed:

QA-35Who receives the base fee after London, who receives the priority fee, and what is the user's maxFeePerGas cap?(show answer)

The engineering content of EIP-1559 base fee burn is the invariant and the exit path, not the token ticker.

Under EIP-1559 the base fee is burned (removed from ETH supply); the priority fee (tip) goes to the block proposer; the user pays min(maxFeePerGas, baseFee+maxPriorityFee) per gas, with refund of unused maxFee.

Concretely, effectiveTip = min(maxPriorityFeePerGas, maxFeePerGas-baseFee). Do not send leftover maxFee to the proposer. Coinbase.transfer(basefee * gas) is the wrong mental model.

The reason for that specificity is a failure I have seen: A gas station contract forwarded basefee*gasUsed to the miner "like legacy"; after London it overpaid proposers 12% on 8.4e6 gas/day for a month, ~$180k.

gasUsed=21_000, base=30 gwei, tip=2, maxFee=40.

ComponentETH
burned21000×30 gwei
proposer21000×2 gwei
user max bound21000×40 gwei
refund21000×8 gwei

I would not consider it settled without evidence: A mainnet trace: base fee not in proposer delta; proposer delta ≈ priorityFee * gasUsed.

Base fee dies; the tip is the only proposer payment.

Curated: · Written: · Reviewed:

QA-36A tx with maxFeePerGas = 20 gwei sits pending while base fee is 25 gwei. Why will it never land, and how do you replace it?(show answer)

Before deploying I would write what a correct result for priority fee versus max fee looks like after a 12-block reorg.

A 1559 tx is invalid for inclusion while baseFee > maxFeePerGas; the tip cannot bribe past that cap. Replacement is the same nonce with a higher maxFee and typically 10%+ bump.

Concretely, set maxFee ≥ expected base + tip. Watch pending base fee. Replace via same nonce; a new nonce just queues behind the stuck one.

The reason for that specificity is a failure I have seen: Airdrop sent 50_000 txs at maxFee 15 gwei into a 40 gwei spike; they stalled 6 hours and users submitted a second wave, doubling nonces into chaos.

Stuck then replaced, nonce 17.

TxmaxFee gweibaseStatus
A2025never
B same nonce4525included
C nonce 184525waits on 17

I would not consider it settled without evidence: eth_getTransactionCount pending vs latest; stuck nonce 17 with maxFee 20 while base is 25.

The cap is a hard ceiling against the burned base fee, not a suggestion.

Curated: · Written: · Reviewed:

QA-37Is blob gas the same market as EIP-1559 execution gas, and who pays the blob base fee?(show answer)

The first thing I would pin down about EIP-4844 blob fee market is which confirmation tier actually changes the decision.

Blobs have a separate fee market (excess blob gas → blob base fee) from execution gas; blob fees are burned analogously, and blob data is not calldata on the EVM stack.

Concretely, a blob-carrying tx pays execution gas plus blobGasUsed × blobBaseFee. Limits are fork-dependent: after Fusaka and its second Blob-Parameter-Only fork, Ethereum mainnet targets 14 blobs and permits 21 per block; client configuration can change them again. Do not price DA as 16 gas/nonzero calldata byte.

The reason for that specificity is a failure I have seen: A rollup budgeted DA as calldata 4.2 gwei-equivalent and underpaid blob fees 8× during a 3-hour blob spike; 2_400 batches missed the slot.

One block, two markets.

MarketMeasureBurned?
executionbaseFee × gasyes
blobblobBaseFee × blobGasyes
tippriority × gasto proposer

I would not consider it settled without evidence: Compare blobBaseFee and baseFee in the same block header; they move independently.

Blobs are a second burned market, not cheap calldata.

Curated: · Written: · Reviewed:

QA-38A rollup stores proofs "on Ethereum DA" via blobs. How long are those bytes retrievable from Ethereum nodes, and what must you add?(show answer)

I would start blob data expiry from the trust boundary, not from the first Solidity snippet that compiled.

EIP-4844 blobs are pruned after the retention window (~4096 epochs ≈ 18 days); they are not permanent L1 calldata. Long-term DA is your archive, a DA committee, or posting calldata if you need forever.

Concretely, after ~18 days, consensus nodes may drop blobs. Keep a replica of blob sidecars for the dispute window plus margin (optimistic 7 days is inside 18; a 30-day legal hold is not).

The reason for that specificity is a failure I have seen: A team told counsel "it's on Ethereum forever"; at day 22 the blob was gone from public CLs and they paid $90k to reconstruct from a single operator's disk.

18-day clock.

DayBlob on public CL?Dispute?
0yesstart
7yesoptimistic end
18prunegone
22noneed your archive

I would not consider it settled without evidence: A calendar: blob posted day 0, challenge window ends day 7, prune day 18; a 21-day auditor request misses Ethereum.

Blobs expire; calldata (mostly) does not.

Curated: · Written: · Reviewed:

QA-39Why can nonce 5 be pending on Ethereum and confirmed on an L2 for the same address, and how do you replace a stuck tx?(show answer)

This is a place where an explorer green tick and a correct handling of account nonce per chain are not the same event.

The account nonce is per (address, chainId) EVM state; networks do not share it. Replacement on one chain is same nonce, higher fee, same chain; it does not cancel the other chain.

Concretely, read eth_getTransactionCount(addr, "pending") on that RPC. Replace with identical nonce. Never increment nonce on chain A to unstick chain B.

The reason for that specificity is a failure I have seen: Support told a user to "send a 0 ETH tx to yourself to unstick" on the wrong chain; they spent $70 on Polygon while mainnet nonce 5 still sat 4 hours.

One address, two nonces.

ChainlatestpendingStuck tx
156nonce 5 low fee
1374242none
replacenonce 5 @ +15%chain 1 only

I would not consider it settled without evidence: Two RPCs: nonce latest/pending for chain 1 and 137 printed side by side in the wallet debug screen.

Nonce is a per-chain counter; replacement is that counter plus more fee.

Curated: · Written: · Reviewed:

QA-40Why did address.transfer and send use a 2300 stipend, and why is that the wrong withdrawal pattern now?(show answer)

My answer to 2300 gas stipend and send begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

transfer/send forward 2300 gas, enough for an event but not for a receiving contract that writes storage or calls back; after Istanbul gas costs, some fallbacks even fail that. Pull-payments with withdraw() and call{value} plus checks-effects are the pattern.

Concretely, prefer (bool ok,) = payable(to).call{value: amt}(""); require(ok). Guard with reentrancy lock. Do not rely on 2300 to block reentrancy; that was a myth that broke.

The reason for that specificity is a failure I have seen: A 2017-style transfer() payout reverted for a Gnosis Safe receiver (needs >2300); 1_800 users could not withdraw $3.1M until a migration.

Receiver needs 40_000 gas.

MethodGas forwardedSafe receives?
transfer2300no
send2300no, false
callleftoveryes

I would not consider it settled without evidence: A test: receiver whose fallback SSTOREs must still get paid via call, and a reentrancy test that the lock holds.

2300 is a fossil; reentrancy locks are the guard.

Curated: · Written: · Reviewed:

QA-41When does receive() run versus fallback(), and what happens if you only write fallback and someone sends empty calldata with ETH?(show answer)

I would treat receive versus fallback as a state machine over pending, included, and finalized, not as a single RPC result.

receive() handles empty calldata + ETH; fallback() handles unknown selectors (with or without ETH). If receive is missing, empty-calldata ETH goes to fallback if payable, else reverts.

Concretely, mark receive external payable and keep it minimal, without business logic in fallback that assumes a selector, then log msg.value and msg.data.length in tests.

The reason for that specificity is a failure I have seen: A merchant contract had only a payable fallback that decoded a selector; empty ETH transfers reverted and 640 payments (22 ETH) bounced over a weekend.

Dispatch table.

calldataETHFunction
0 bytes>0receive
0xdeadbeef0fallback
empty, no receive>0payable fallback or revert

I would not consider it settled without evidence: Three tests: empty data+ETH → receive; 4-byte unknown → fallback; ETH+unknown → payable fallback or revert as designed.

Empty data is receive; anything else is fallback.

Curated: · Written: · Reviewed:

QA-42Order a withdraw that updates a balance and then calls the user. Where does the external call sit, and why?(show answer)

The useful question for checks-effects-interactions is what still happens on a reorg, a callback, or a second chain.

Checks-effects-interactions: validate, write all storage, then call out, so a reentering caller sees the updated state. A lock (nonReentrant) is extra, not a substitute for order.

Concretely, balances[u]-=amt; then call. If you call first, the hook can withdraw again. ERC-777/ERC-20 callbacks and ERC-721 onERC721Received are interactions too.

The reason for that specificity is a failure I have seen: A pool minted then called the hook then updated supply; a reentrant donate pulled $6.3M (the 2020s classic pattern) in one 12-second slot.

Wrong then right.

// wrong: call then balances[u]=0
// right:
uint256 a = balances[u];
balances[u] = 0; // effect
(bool ok,) = u.call{value: a}(""); // interaction
require(ok);

I would not consider it settled without evidence: A Foundry attacker contract that reenters withdraw; CEI + lock both required to pass.

Write first, talk second; hooks are talk.

Curated: · Written: · Reviewed:

QA-43The token is ERC-777 or has a transfer hook. Why is a reentrancy lock on your only "withdraw" function not enough?(show answer)

I would settle token-hook reentrancy against a counter-example first, so the clever opcode has to survive it.

Hooks run in the token's _callTokensToSend / ERC-20 callback / ERC-721 receiver during transfer, so the attacker reenters any function that is not locked and that trusts intermediate balances.

Concretely, nonReentrant on every state-changing entry, including deposit, skim, harvest. Prefer ERC-20 without callbacks, or CEI that does not read balances until after the transfer returns. Snapshot balances before the hook.

The reason for that specificity is a failure I have seen: A vault locked withdraw only; the ERC-777 send hook reentered deposit and inflated shares by 11_000% in 1 tx, $2.0M.

Lock surface.

FunctionLocked?Hook can hit?
withdrawyesno
depositnoyes — bug
harvestnoyes — bug

I would not consider it settled without evidence: An ERC777 attacker in tests that reenters deposit during transfer; the suite must fail on the unlocked path.

The hook is another public function you did not write.

Curated: · Written: · Reviewed:

QA-44A view function reports virtual price from a pool that is mid-callback. How does a "read-only" reentrancy steal funds?(show answer)

The judgement in read-only reentrancy is which bytes are authenticated, not which library call looks shortest.

If a view reads balances before the pool finishes updating, an attacker in a callback can query that view from another contract (oracle, vault share price) and act on a stale invariant — no ETH needs to be stolen in the first contract.

Concretely, lock views too (or revert if reentrancyGuard entered), or compute prices only after all transfers. Curve-style virtual price must not be read in the same tx as an imbalance.

The reason for that specificity is a failure I have seen: A lending market used a Curve virtualPrice view during add_liquidity callback; 800 ETH of collateral was mispriced for 1 block and drained $1.4M.

Same transaction, two readers.

StepvirtualPriceHonest?
before add1.00yes
mid callback1.42no
after1.01yes
lender used1.42$1.4M

I would not consider it settled without evidence: A test: enter callback, eth_call get_virtual_price, assert it reverts or matches post-state, never mid-state.

A view can be a weapon if it speaks during someone else's callback.

Curated: · Written: · Reviewed:

QA-45Alice sets allowance from 100 to 50. How does Bob steal 150, and what API replaces approve-to-new-value?(show answer)

Where candidates lose the interview on ERC-20 approve race is usually treating a receipt as settlement.

approve overwrites the slot; a spender can front-run spend the old 100 then still spend the new 50. Use increaseAllowance/decreaseAllowance, or set to 0 and wait for inclusion before setting 50.

Concretely, document the race in UX. Permit (EIP-2612) also sets an absolute allowance, so it has the same overwrite race unless the permit is consumed atomically with transferFrom in one transaction. Permit still does not transfer by itself.

The reason for that specificity is a failure I have seen: A UI "replace allowance 1e6 → 2e6" was front-run; the spender took 1e6 then 2e6, 3e6 total ($3.0M) from a DAO ops key.

Two spends, one UI click.

tAllowanceBob pulls
0100
1100100
250 (Alice's tx)
3050 more

I would not consider it settled without evidence: A mempool test: pending approve(50) while allowance is 100; attacker pull 100 then 50.

Approve is not monotonic; it is a set, and sets race.

Curated: · Written: · Reviewed:

QA-46Does permit move tokens, what does it consume, and how is it different from a transfer signed off-chain?(show answer)

I would answer EIP-2612 permit by separating what consensus agreed from what an oracle or a bridge merely claimed.

permit is an EIP-712 signature that sets allowance and increments the owner's permit nonce; it does not transfer — a later transferFrom moves tokens, unlike a signed transfer typed data.

Concretely, inputs are owner, spender, value, deadline, v,r,s; check deadline, nonce, and domain (chainId, token), then _approve. A relayer must permit then transferFrom in the same transaction; a later standalone permit still races like approve.

The reason for that specificity is a failure I have seen: A dapp treated permit as payment and marked an invoice paid before transferFrom; 1_200 invoices ($84k) closed with allowance set and 0 moved.

Two calls.

Callnonceallowanceowner balance
before401000
permit(500)55001000
transferFrom 50050500

I would not consider it settled without evidence: After permit, balanceOf(owner) unchanged; nonce+1; allowance==value. Then transferFrom changes balances.

Permit writes allowance and nonce; cash is still transferFrom.

Curated: · Written: · Reviewed:

QA-47When is approve(type(uint256).max) defensible, and what incident number makes it indefensible?(show answer)

The engineering content of infinite ERC-20 allowances is the invariant and the exit path, not the token ticker.

Infinite allowance saves gas and approve races for a trusted spender contract; it is indefensible for a random EOA or an upgradeable spender you do not control, because one bug is the whole balance forever.

Concretely, prefer exact or Permit2 with expiration. If infinite, the spender must be non-upgradeable or behind a timelock you govern. Revoke on a 90-day review.

The reason for that specificity is a failure I have seen: Users infinite-approved an upgradeable router; a 1-line upgrade added sweep() and pulled $24M within 8 minutes.

Spender classes.

SpenderApproveExpiry
immutable routermaybe maxnever, known code
upgradeable routerexact/Permit224h
random EOAnever max—

I would not consider it settled without evidence: A token allowance table: protocol contracts capped or Permit2; EOAs never max.

Max means max remaining risk, not max convenience.

Curated: · Written: · Reviewed:

QA-48Which fields of latestRoundData must you check before using answer as a price, and what happens if you only require answer > 0?(show answer)

Before deploying I would write what a correct result for Chainlink latestRoundData validation looks like after a 12-block reorg.

Validate the aggregator address, decimals, updatedAt freshness, a nonzero updatedAt, and a sane positive answer; a positive stale price still liquidates incorrectly. answeredInRound is deprecated on modern feeds and is not a substitute for age.

Concretely, (, answer, , updatedAt, ) = feed.latestRoundData(); require(updatedAt != 0 && updatedAt >= block.timestamp - HEARTBEAT && answer > 0). Confirm decimals() == expected (8 for many USD pairs).

The reason for that specificity is a failure I have seen: A market used ETH/USD with only answer>0; during a 61-minute sequencer blip updatedAt was 3_700 seconds old (past a 3_600s max-age) and $4.6M of collateral was kept as if ETH were still $3_400.

HEARTBEAT 3600s, now=1_700_000_000.

CheckBad valueAction
updatedAtnow-3200pass (inside 3600s)
updatedAtnow-3601revert
decimals18 not 8revert
feed addressclonerevert

I would not consider it settled without evidence: Fork tests at a 3_600-second maximum age: warp 3_200s and accept; warp 3_601s and revert. Also reject updatedAt=0, a nonpositive answer, the wrong decimals, and the wrong feed address.

A positive number is not a live round.

Curated: · Written: · Reviewed:

QA-49Your feed heartbeats every 3_600 seconds or 0.5% deviation. How do you combine that with a circuit breaker on a 20% jump?(show answer)

The first thing I would pin down about stale oracle heartbeat is which confirmation tier actually changes the decision.

Freshness and deviation are different: a 3_600s-old price can be exact, a 1s-old price can be a 40% wiggle. Bound both age and |p-p_last|/p_last.

Concretely, revert if age > heartbeat + grace. Pause if spot moved > X% versus a 30-min TWAP. Do not liquidate solely on a 1-block spike.

The reason for that specificity is a failure I have seen: A 1-block 32% print on a thin feed liquidated 220 accounts ($8.1M) that a 30-minute TWAP would have kept solvent.

Two gates.

GateBoundFail
age≤3600sstale
vs TWAP≤20%spike
32% 1-block—pause, no liq

I would not consider it settled without evidence: Logs of updatedAt, spot, TWAP; a breaker trip counter.

Heartbeat is age; the breaker is shape.

Curated: · Written: · Reviewed:

QA-50A vault mints shares = assets * totalSupply / totalAssets. Who should round in whose favor, and what is the first-deposit trap?(show answer)

I would start rounding in share math from the trust boundary, not from the first Solidity snippet that compiled.

Round against the user on mint/deposit (fewer shares) and against the user on redeem (fewer assets), so the vault never inflates. First depositor with totalSupply=0 needs a dead-shares or virtual offset; the inflation attack is deposit 1 wei, then donate a large amount without minting, then let the victim round to zero shares.

Concretely, mulDiv with rounding direction. Seed 1e3 dead shares, or ERC-4626 inflation mitigations (OpenZeppelin offset). Never use truncating division in the attacker's favor.

The reason for that specificity is a failure I have seen: Attacker deposited 1 wei for 1 share, donated 1e18, then a victim depositing 1e18 received floor(1e18 / (1e18+1)) = 0 shares and the attacker redeemed the $1.2M pool.

Inflation sketch.

SteptotalAssetstotalSupplyattacker shares
deposit 1111
donate 1e181e18+111
victim 1e18~2e181victim 0 shares

I would not consider it settled without evidence: A test that donation+inflate cannot extract more than 1 share of rounding dust.

The vault keeps the dust; the first depositor does not keep the pool.

Curated: · Written: · Reviewed:

QA-51A function loops over recipients and does call{value: amounts[i]}. Why can summing amounts and checking == msg.value still fail, and what is the double-spend?(show answer)

This is a place where an explorer green tick and a correct handling of msg.value in a loop are not the same event.

msg.value is constant for one call frame; a nested callback sees only ETH attached to that new call, not the outer msg.value. The double-spend is leftover credit or a remaining-value variable that is not zeroed before the external call.

Concretely, load msg.value into local remaining; subtract each send before the call; require remaining==0 at end; reentrancy lock. Do not re-read msg.value after a callback as if it were new money, and do not pay from a stored credit that is still nonzero.

The reason for that specificity is a failure I have seen: A splitter stored credit = msg.value, paid from credit, then reentered before credit was zeroed, paying 3× the 10 ETH sent ($90k) from the contract's idle balance.

10 ETH, three recipients.

isendremaining
028
135
250
reenter10 againbug if unlocked

I would not consider it settled without evidence: Attacker receive() calls split again; balances must not go down by more than the original msg.value.

msg.value does not teleport into the callback; leftover credit does.

Curated: · Written: · Reviewed:

QA-52A protocol has pause, mint, and upgrade. Why is a single owner address the wrong ACL, and how do roles split the blast radius?(show answer)

My answer to Ownable versus role-based access begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

One EOA that can pause, mint, and upgrade couples three blast radii; AccessControl (or similar) gives each action its own role and a different multisig.

Concretely, dEFAULT_ADMIN_ROLE only grants/revokes. PAUSER, MINTER, UPGRADER on separate 3-of-5 / 4-of-7. Renounce minter on the deployer. Delay upgrades with a timelock even if the role is right.

The reason for that specificity is a failure I have seen: A leaked deployer key paused and minted 50e6 tokens in 4 minutes; markets dumped 38% before the 12-hour "ops review."

Blast radius.

RoleKeyCan mint?
PAUSER3-of-5no
MINTER4-of-7yes, cap 1e6/day
UPGRADERtimelockno mint
leaked deployer—yes, 50e6

I would not consider it settled without evidence: A role matrix in the repo and a test that the pauser cannot mint.

Roles are different keys; owner is one key pretending to be three.

Curated: · Written: · Reviewed:

QA-53Governance votes an implementation hash. Why wait 48 hours on a timelock, and what can still go wrong during the wait?(show answer)

I would treat timelock delay on upgrades as a state machine over pending, included, and finalized, not as a single RPC result.

A timelock is a public delay so users can exit or fork before new code runs; it is not a security review by itself. The queued payload must be the exact bytes that later execute.

Concretely, schedule(target, value, data, predecessor, salt, delay) with users verifying data = upgradeTo(newImpl), a min delay of 48h for irreversible upgrades, and a shorter separate emergency pause.

The reason for that specificity is a failure I have seen: A 48h timelock queued upgradeTo(implB) then a second schedule replaced salt with implC; watchers tracked the first hash and missed C, which added a $9.2M backdoor.

Two hashes in 48h.

tQueuedWatchers saw
0himplBB
10himplC (new salt)missed
48hexecute C$9.2M

I would not consider it settled without evidence: Bots that decode every queued tx; a mismatch between vote description and calldata fails CI.

The delay is useful only if the queued bytes are the ones you read.

Curated: · Written: · Reviewed:

QA-54What should pause() stop, when should exits remain open, and when can an exploit require a full freeze?(show answer)

The useful question for pause as a circuit breaker is what still happens on a reorg, a callback, or a second chain.

The default pause blocks new risk while preserving withdraw, repay, and unwind paths. But if the vulnerable path is itself an exit or token transfer, keeping it open can finish the drain; a narrowly scoped full freeze can then be safer than an absolute "withdrawals must never stop" rule.

Concretely, put separate pause bits on deposit/borrow and withdraw/transfer. The 3-of-5 incident key first stops risk-in, escalates to affected exits only when the exploit demands it, and hands recovery to the slower upgrade/timelock path. Never auto-unpause.

The reason for that specificity is a failure I have seen: A pause blocked withdraw during a $14M oracle incident; users sat 31 hours while the token depegged another 12%.

Function × paused.

FunctionPausedAllowed?
deposityesno
borrowyesno
withdrawrisk-in pauseyes
vulnerable withdrawexploit freezetemporarily no, scoped
31h blanket freeze—$14M trapped

I would not consider it settled without evidence: Tests cover two modes: risk-in pause makes deposit revert while healthy withdraw succeeds; exploit freeze blocks only the affected exit and leaves repay or an emergency recovery path available.

Preserve exits by default; freeze an exit only when that exit is the exploit, and scope the freeze.

Curated: · Written: · Reviewed:

QA-55When do you use CREATE (nonce-based) versus CREATE2 (salt-based), and what address prediction fails if you pick wrong?(show answer)

I would settle CREATE versus CREATE2 factories against a counter-example first, so the clever opcode has to survive it.

CREATE address = keccak(rlp([deployer, nonce]))[12:]; it changes every deploy. CREATE2 is deterministic from salt+initcode, which is what counterfactual wallets need.

Concretely, factories that must match across chains use CREATE2 with the same deployer key and salt. A CREATE factory on two chains diverges as soon as nonces differ by 1.

The reason for that specificity is a failure I have seen: A "same address on 8 chains" launch used CREATE; after a failed nonce-0 tx on chain 10, the token landed at a different address and $1.6M of liquidity went to a hollow clone.

Nonce drift.

ChainnonceCREATE address
140xaaa…
1050xbbb…
CREATE2 salt=0x01any0xccc… both

I would not consider it settled without evidence: Simulate both opcodes in a test; CREATE2 matches an off-chain formula, CREATE matches only current nonce.

Nonce names CREATE; salt+initcode names CREATE2.

Curated: · Written: · Reviewed:

QA-56transferFrom decreases allowance except when it is max. Why do fee-on-transfer tokens still break a naive amount-out check?(show answer)

The judgement in ERC-20 transferFrom accounting is which bytes are authenticated, not which library call looks shortest.

The ERC-20 spec moves amount from sender to recipient as the token defines; fee-on-transfer credits less than amount, so a router that assumes recipient += amount desynchronizes reserves.

Concretely, balance-delta: before = token.balanceOf(pair); transferFrom; received = balanceOf(pair)-before; use received, not amount. USDT-class tokens also miss return booleans — use SafeERC20.

The reason for that specificity is a failure I have seen: A pool took amount=1000 of a 1% fee token and credited 1000; 10 tokens vanished per swap and LP value leaked $410k over 9 days.

1% fee token, amount 1000.

MeasureValue
amount arg1000
pair delta990
naive reserve+1000 (lie)
9-day leak$410k

I would not consider it settled without evidence: A mock ERC-20 that skims 1% and a test that reserves grow by received.

Credit what arrived, not what the caller typed.

Curated: · Written: · Reviewed:

QA-57Why does safeTransferFrom call onERC721Received, and how is that a reentrancy door a marketplace must lock?(show answer)

Where candidates lose the interview on ERC-721 safeTransfer callbacks is usually treating a receipt as settlement.

safeTransferFrom notifies the recipient contract; that callback can reenter the marketplace before the listing storage is cleared.

Concretely, cEI: delete listing, then safeTransfer. Or lock. unsafe transferFrom skips the hook but can brick tokens in contracts that cannot handle them — a different bug.

The reason for that specificity is a failure I have seen: A marketplace transferred then marked sold=true; onERC721Received bought the same listing again at the old price, cloning 18 NFTs' proceeds ($2.4M).

Order of writes.

Steplisting.activeHook runs?
wrong: transfer then cleartrue in hookyes, double buy
right: clear then transferfalseyes, second buy reverts
18 NFTs—$2.4M

I would not consider it settled without evidence: A receiver that reenters buy(); the second buy must revert.

The safe in safeTransfer is for the token, not for your listing state.

Curated: · Written: · Reviewed:

QA-58A batch transfer of 20 ids in one tx: what must you check that a single safeTransfer does not, and where do rounding and callbacks hide?(show answer)

I would answer ERC-1155 batch transfers by separating what consensus agreed from what an oracle or a bridge merely claimed.

ERC-1155 batch is one callback (onERC1155BatchReceived) after all ids move; lengths of ids and amounts must match, and a reentrancy sees the whole batch already sent if you did not CEI.

Concretely, require(ids.length == amounts.length && ids.length > 0). Update all balances, then one hook. Cap batch size (e.g. 32) for gas and griefing.

The reason for that specificity is a failure I have seen: A game ignored length mismatch (20 ids, 19 amounts) in a custom fork and minted id[19] as 0 while charging 20 items; 7_200 packs mis-settled.

Length check.

idsamountsValid?
2020yes
2019revert
2020, unlocked hookreenter risk

I would not consider it settled without evidence: Fuzz ids/amounts length mismatch must revert; a receiver reentering batch must fail the lock.

A batch is one atomic move and one hook, or it is a trap.

Curated: · Written: · Reviewed:

QA-59ERC-4626 has convertToShares and previewDeposit. Which is the idealized average-rate conversion, which quotes the deposit path, and why can they differ?(show answer)

The engineering content of ERC-4626 deposit convert functions is the invariant and the exit path, not the token ticker.

convertToShares is an idealized, caller-independent conversion that excludes fees and rounds down. previewDeposit simulates current deposit conditions, includes deposit fees, and must return no more shares than the same-transaction deposit actually mints. A difference is slippage, fees, or another on-chain condition—not merely an interchangeable rounding API.

Concretely, use previewDeposit for the UI, then require shares >= minSharesOut on deposit. Do not treat convertToShares as a slippage bound. Inflation attacks still need the offset/dead-share fix.

The reason for that specificity is a failure I have seen: A solver used convertToShares as minOut on a vault with a 1-share deposit fee; previewDeposit and deposit returned 999 while the idealized conversion returned 1_000, so 18% of honest deposits reverted and stalled a $7M fill for 2 hours.

1e18+1 assets.

FnShares
convertToShares (no fees)1000
previewDeposit (fee included)999
deposit return≥999
minOut=convertrevert

I would not consider it settled without evidence: A table: previewDeposit vs deposit() return on 1, 1e18, and 1e18+1 assets.

The preview is the promise; convert is math; deposit is the chain.

Curated: · Written: · Reviewed:

QA-60A token rebases every 8 hours. Why is storing amount at deposit time the wrong collateral accounting?(show answer)

Before deploying I would write what a correct result for rebasing token balances looks like after a 12-block reorg.

Rebasing tokens change balanceOf without Transfer events (or with elastic supply); a vault that stored a static amount will over- or under-collateralize after the rebase.

Concretely, store shares of the rebase token, or wrap to a static-balance wrapper (wstETH-style) before accounting. Do not rely on Transfer-only indexers for supply.

The reason for that specificity is a failure I have seen: A lender stored 100 tokens; after a +8% rebase the user withdrew 108 while debt math still saw 100, leaving $2.8M unbacked for 16 hours.

8-hour rebase.

tbalanceOfstored amountGap
01001000
8h1081008
withdraw108debt on 100$2.8M

I would not consider it settled without evidence: A mock rebase +8% with no Transfer; the vault's internal collateral must track balanceOf.

If balanceOf moves without your call, you must store shares, not snapshots.

Curated: · Written: · Reviewed:

QA-61How does Permit2 differ from EIP-2612 permit, and what does an expiration of 0 mean?(show answer)

The first thing I would pin down about Permit2 allowance expiration is which confirmation tier actually changes the decision.

Permit2 is a shared allowance contract: users approve Permit2 once on the token, then sign per-spender permits with amount and expiration. It is not EIP-2612 (that lives on the token). Expiration 0 is not "infinite."

Concretely, check signature expiration against block.timestamp, nonce in Permit2, and amount. Infinite token approve is to Permit2, not to the dapp. Revoke by nonce or by token.approve(Permit2, 0).

The reason for that specificity is a failure I have seen: A dapp treated expiration 0 as forever; signatures 90 days old still spent, and a leaked wallet lost $310k in 20 minutes.

Two permit worlds.

SystemWhere nonce livesExpiry
2612tokendeadline
Permit2Permit2 contractexpiration ≠ 0
leaked sig—$310k if 0=inf

I would not consider it settled without evidence: A test: expiration = now-1 reverts; expiration = now+3600 works once.

Permit2 expires; 2612 permit is a token-native allowance set.

Curated: · Written: · Reviewed:

QA-62USDC is 6 decimals, WETH is 18, a feed is 8. Where do you scale, and what is a 10^12 bug worth?(show answer)

I would start token decimals mismatches from the trust boundary, not from the first Solidity snippet that compiled.

Every multiply of amount × price must name decimals in and out; mixing 6 and 18 without a scale factor is a 10^12 error.

Concretely, normalize to 18 internally or use mulDiv(amount, price, 10^(decToken+decFeed-decOut)). Unit tests with 1 USDC and 1 WETH both quoting $1.

The reason for that specificity is a failure I have seen: A collateral factor used USDC as 18 decimals; 1e6 units looked like 1e-12 USD and $18M of USDC counted as dust, under-liquidating for 2 days.

One unit of each.

Tokendecimals1.0 in uint
USDC61_000_000
WETH181e18
feed USD81e8 = $1
1e6 as 18dec—10^12 off

I would not consider it settled without evidence: A table of 1 whole token in raw units for USDC, WETH, WBTC (8).

Raw uints are not dollars until you write the exponents.

Curated: · Written: · Reviewed:

QA-63tokenURI returns ipfs://Qm… today. What can an upgradeable URI still do tomorrow, and how do you freeze it?(show answer)

This is a place where an explorer green tick and a correct handling of NFT metadata mutability are not the same event.

Metadata is not the ERC-721 owner mapping; a mutable baseURI can swap art, traits, or a "revealed" rug. Permanence needs a frozen URI or content-addressed hash in an immutable slot.

Concretely, store a bytes32 contentHash, freeze with freezeURI() that sets a flag, pin the IPFS CID on-chain, and have marketplaces snapshot that CID at list time.

The reason for that specificity is a failure I have seen: A collection mutated baseURI 40 days after mint and replaced 10_000 traits; secondary volume of $6.5M had been priced on the old rarity.

Day 0 versus day 40.

DayCIDRare trait %
0QmOld1.2
40QmNew18.0
frozenQmOld1.2

I would not consider it settled without evidence: A test that after freeze, setBaseURI reverts; the CID in the listing equals the frozen one.

Owning the NFT is not owning the string the URI points at unless you froze it.

Curated: · Written: · Reviewed:

QA-64A Uniswap v2 pair takes 0.3% on the input. When is x*y=k true, and which k do you compare?(show answer)

My answer to Uniswap v2 xy equals k after fee begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

The invariant holds on reserves after the fee is applied to the input (997/1000); k = x*y increases by the fee, so k_after ≥ k_before. Spot is reserveOut/reserveIn, not a TWAP.

Concretely, amountInWithFee = amountIn * 997; newX = x + amountIn; newY = k / (x + amountIn*997/1000) with the classic formula. Check k growth in tests. 0.3% is 3 bps × 10, not 0.3 bps.

The reason for that specificity is a failure I have seen: An integrator used x*y=k without the 997 factor and offered 0.3% too much output; searchers took $1.1M of arb in 26 minutes.

Pool 100 / 100, in=1, fee 0.3%.

Modeloutnew k
no fee0.99010000
997/10000.987>10000
forgot 997overpayssearchers

I would not consider it settled without evidence: A swap of 1e18 in a 100e18/100e18 pool: compute out with 997 and without; only 997 matches the pair.

k grows by the fee; the 997 is the swap, not a comment.

Curated: · Written: · Reviewed:

QA-65Why is token.balanceOf(pair)/balanceOf(other) not an oracle, even inside the same block as your borrow?(show answer)

I would treat spot price manipulation on AMMs as a state machine over pending, included, and finalized, not as a single RPC result.

Spot reserves can be moved with a flash loan in the same transaction; any protocol that reads pair.getReserves() as a price is pricing the attacker's temporary book.

Concretely, use a TWAP over ≥30 minutes (v2 cumulative prices), a Chainlink feed, or both with a bound. Never getReserves() for collateral.

The reason for that specificity is a failure I have seen: A lending fork used Uniswap v2 spot; an 80_000 ETH flash moved the price 40% and borrowed $30M, the 2020-style pattern, in 1 tx.

Same block.

tETH/USDC spotTWAP 30m
start34003398
after flash20403398
borrow on spot$30Mshould revert

I would not consider it settled without evidence: A Foundry flash-loan test that moves reserves 40% and asserts borrow reverts.

Spot is the attacker's canvas; a windowed TWAP is a photograph.

Curated: · Written: · Reviewed:

QA-66Uniswap v2 cumulative prices can build a TWAP. Why is a 1-block TWAP useless, and what window do you pick for a $5M pool?(show answer)

The useful question for TWAP window length is what still happens on a reorg, a callback, or a second chain.

Uniswap v2 accumulates the previous block-ending price, so even a one-block TWAP is not a same-transaction flash-loan spot — the attacker must end a prior block at a bad price and hold inventory across blocks. Short windows still cheap that attack; 30–60 minutes raise the cost; too long lags a real crash.

Concretely, consult(pair, 1800) using the cumulative last, require updatedAt in the pair, bound versus a second feed, and document N in the risk memo.

The reason for that specificity is a failure I have seen: A vault used a 2-block TWAP (24s); an attacker held a skewed reserve across two blocks, shifted the TWAP 18%, and extracted $4.2M without needing a same-tx flash-loan unwind.

Window versus attack cost.

WindowBlocksFlash-loanable?
12s1cross-block, cheap
24s2still cheap
1800s150capital for 30 min

I would not consider it settled without evidence: Cost model: $5M pool, 1800s, attacker needs to keep imbalance for 1800s of real blocks, not one tx.

TWAP without a window is still spot.

Curated: · Written: · Reviewed:

QA-67A v3 position is in range only between ticks 200000 and 200200. What is the price of that position outside the range, and why do v2-style x*y formulas fail?(show answer)

I would settle Uniswap v3 concentrated ticks against a counter-example first, so the clever opcode has to survive it.

v3 liquidity is active only between ticks; outside, the position is 100% one asset. Spot in a v3 pool is still manipulable; you need the oracle observations, not slot0.sqrtPriceX96 alone for value.

Concretely, sqrtPriceX96 from slot0 is the current tick and TWAP comes from observe(); value a position with the official math library, not reserve ratios, because a 1-tick range is a tight LP, not an oracle.

The reason for that specificity is a failure I have seen: A collateral adapter treated a v3 NFT as v2 reserves; out-of-range ETH-USDC was counted as 50/50 and $3.7M of "ETH" was empty.

Range 200000–200200.

TickComposition
200100mixed
199000100% token0
201000100% token1
slot0 as oraclemanipulable

I would not consider it settled without evidence: Move price out of range in a test; the adapter's ETH amount must go to 0.

Out of range means one token; slot0 is still spot.

Curated: · Written: · Reviewed:

QA-68LTV is 75% and liquidation threshold is 80%. What can a user borrow on $10_000 collateral, and when does liquidation start?(show answer)

The judgement in lending LTV and liquidation threshold is which bytes are authenticated, not which library call looks shortest.

LTV caps new borrows; the liquidation threshold is a higher line where a keeper can seize. Health factor = (collateral×LT)×price / debt; HF<1 is liquidatable, not HF<LTV.

Concretely, max borrow = 0.75×10_000 = $7_500. Liquidation when debt > 0.80×collateral_value. Do not let users borrow up to the liquidation line on day one.

The reason for that specificity is a failure I have seen: A fork set LTV=threshold=90%; a 1.2% wiggle liquidated 640 accounts ($11M) in 8 minutes.

$10_000 collateral.

DebtHF at LT=80%Liquidatable?
75001.067no
80001.000edge
80100.999yes

I would not consider it settled without evidence: A table of HF at LTV, between LTV and LT, and above LT.

LTV is the front door; the threshold is the eviction line; they are not equal.

Curated: · Written: · Reviewed:

QA-69A keeper liquidates $100 of debt at a 5% bonus and 50% close factor. How much collateral leaves, and why does a 100% close factor cascade?(show answer)

Where candidates lose the interview on liquidation bonus and close factor is usually treating a receipt as settlement.

Close factor caps debt repaid per tx (often 50%); bonus pays the keeper from collateral. A 100% close plus a large bonus lets one tx wipe a position and dump the asset.

Concretely, repay = min(debt×closeFactor, repayAmount) and collateralSeized = repay×priceRatio×(1+bonus), requiring HF<1 and paying keepers in the seized token.

The reason for that specificity is a failure I have seen: Close factor 100% and bonus 15% let one bot clear $22M in 3 blocks, then dump, knocking 40 more accounts under HF 1.

$100 debt, 5% bonus, 50% close.

ItemUSD
repay50
collateral out52.5
100% close + 15%115 on 100, cascade

I would not consider it settled without evidence: Simulate HF 0.99 with 50% versus 100% close; the 50% path should leave HF>1 if the bonus is 5%.

The bonus is a fee to keepers; the close factor is how much of the book they can take per swing.

Curated: · Written: · Reviewed:

QA-70A flash loan of 1_000_000 USDC must be repaid in the same transaction. What can the borrower do in the middle, and what can they not roll past the tx end?(show answer)

I would answer flash loan atomicity by separating what consensus agreed from what an oracle or a bridge merely claimed.

Atomicity means the pool sees repay+fee before the tx ends, or the whole tx reverts; the borrower can still manipulate spots, liquidate, vote, or drain another protocol in the middle.

Concretely, fee = amount × bps (e.g. 9 bps) and the callback must return tokens, so your protocol should assume attackers have infinite intra-tx capital rather than "they could not have borrowed that much."

The reason for that specificity is a failure I have seen: A governance quorum check used token.balanceOf at vote time; a 4e6-token flash voted a malicious impl and repaid, passing 12% turnout in 1 tx.

1e6 USDC, 9 bps.

StepPool USDC
lend-1_000_000
attacker acts0 owed yet
repay+1_000_900
if notrevert all

I would not consider it settled without evidence: A test that flash-loans votes and asserts snapshot-at-block or checkpointing blocks it.

The loan vanishes at the semicolon; the damage in between does not.

Curated: · Written: · Reviewed:

QA-71A user swaps 50 ETH with 0.5% slippage on a $2M pool. How does a sandwich work, and which number actually protects them?(show answer)

The engineering content of sandwich attacks and slippage is the invariant and the exit path, not the token ticker.

A searcher buys before (pushing price) and sells after; the user's minAmountOut is the only on-chain bound. 0.5% on a large trade in a thin pool is an invitation, not protection.

Concretely, set minOut from a quote minus a tight bps, not a default 0.5%. Use private orderflow if the size is >1% of pool. Deadline blocks stale inclusion.

The reason for that specificity is a failure I have seen: A DAO swapped 800 ETH at 1% slippage; sandwiches extracted $190k (2.1%) across 6 txs in one hour.

50 ETH, 0.5% minOut.

LegPrice impact
searcher buy+0.3%
user+0.5% worst
searcher sellprofit ~0.4%
user minOutthe cap

I would not consider it settled without evidence: Simulate a sandwich around the exact calldata; if profit > gas, tighten minOut or split.

Slippage is the sandwich's budget; you wrote it.

Curated: · Written: · Reviewed:

QA-72Aave-style indexes use 1e27 (ray). Why do you never multiply two token amounts in ray without dividing, and what does index=1.04e27 mean?(show answer)

Before deploying I would write what a correct result for interest index ray math looks like after a 12-block reorg.

Principal * index / RAY converts scaled shares to assets; a missing /RAY multiplies by ~1e27 and explodes. 1.04e27 is +4% cumulative since index start.

Concretely, scaledBalance * liquidityIndex / 1e27 = assets. Update index once per tx via liquidityIndex = index * (1 + rate * dt / year). Use mulDiv.

The reason for that specificity is a failure I have seen: A fork forgot /1e27 on repay; a user repaid "104e27 units" and wiped a $6.0M reserve in one call.

RAY = 1e27.

scaledindexassets
1000e181e271000e18
1000e181.04e271040e18
forgot /RAY—1.04e48

I would not consider it settled without evidence: A test: 1000e18 scaled, index 1.04e27 → 1040e18 assets, not 1.04e48.

Ray is a fixed-point 1.0, not an extra token amount.

Curated: · Written: · Reviewed:

QA-73A rate model is 0–80% utilization at slope1, then a steep slope2. Why is 90% utilization not "slightly more" than 80%, and who gets liquidated?(show answer)

The first thing I would pin down about utilization kink rate model is which confirmation tier actually changes the decision.

Kink models make borrow APR jump after the kink to defend liquidity; crossing 80% to 90% can double APR in a block, stressing HF for leveraged users.

Concretely, aPR = base + slope1min(u,kink) + slope2max(0,u-kink). Quote APY vs APR. Keepers watch utilization, not only price.

The reason for that specificity is a failure I have seen: Utilization jumped 80%→96% in 14 minutes; APR 8%→92% and 180 leveraged loops hit HF<1, $5.4M liquidated.

kink=80%, slope2 steep.

uAPR
79%7.9%
80%8.0%
96%92%
14 min180 liqs

I would not consider it settled without evidence: A chart of APR at u=79,80,81,95 with the actual slopes from the contract.

After the kink, utilization is a liquidation feed.

Curated: · Written: · Reviewed:

QA-74A USDC/USD feed prints $0.87 for 25 minutes. Do you liquidate ETH-USDC vaults on that print, and what alternative bound do you use?(show answer)

I would start stablecoin depeg in a feed from the trust boundary, not from the first Solidity snippet that compiled.

A fiat stable depeg can be real, but one feed cannot distinguish it from a bad round. Cap collateral valuation at $1, and when the feed falls more than 5%, pause new borrowing and stable-leg liquidations until independent venues confirm a valuation policy; blindly applying min(feed,$1) still cascades on a bad print.

Concretely, circuit: if |p-1|>0.05, pause borrows and stable-leg liquidations, compare a second oracle plus liquid CEX/DEX venues, then resume with a governance-approved conservative price or haircut. ETH legs still use their own feeds.

The reason for that specificity is a failure I have seen: A market treated $0.87 as gospel and liquidated $40M of USDC collateral that recovered to $0.99 an hour later.

25-minute print.

tfeedAction
01.00normal
5 min0.87pause borrows
25 min0.87corroborate, set haircut policy
60 min0.99resume after checks

I would not consider it settled without evidence: A runbook with 15-minute pause and dual-feed; a replay of a historical depeg.

A stable at $0.87 might be truth or a broken round; pausing is cheaper than a cascade.

Curated: · Written: · Reviewed:

QA-75A 400 ETH swap hits the public mempool. What extra cost do you expect versus an MEV-Share/private relay path, and what do you not get?(show answer)

This is a place where an explorer green tick and a correct handling of private orderflow versus public mempool are not the same event.

Public mempool broadcasts the trade to searchers; private orderflow hides it from the general gossip net but still trusts a builder set. Privacy is not finality.

Concretely, for size > ~0.5% of pool, send via a private tx service. Still set minOut. Measure inclusion rate and leaked-vs-private sandwich incidence.

The reason for that specificity is a failure I have seen: A foundation's 400 ETH public swap was sandwiched $62k in 12s; the next week's private path paid 0.4 ETH extra priority and lost $0 to sandwiches.

400 ETH swap.

PathSandwichExtra fee
public$62k0
private$00.4 ETH
minOut stillrequiredboth

I would not consider it settled without evidence: A week of sandwich incidence public vs private, in USD.

Private gossip hides from some searchers; minOut hides from the rest.

Curated: · Written: · Reviewed:

QA-76Why does an optimistic withdrawal wait ~7 days, and what happens if you treat L2 inclusion as L1 funds?(show answer)

My answer to optimistic rollup challenge window begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

Optimistic rollups assume state is correct unless a fraud proof wins inside the challenge window; withdrawals finalize on L1 only after that window (often 7 days), not when the sequencer sequenced you.

Concretely, initiate withdrawal on L2, wait root publication, wait remaining challenge time, prove on L1. Fast bridges are other people's credit risk. Do not credit a CEX deposit as L1 ETH after 1 L2 block.

The reason for that specificity is a failure I have seen: A desk credited 2_200 ETH after 2 L2 minutes; a 7-day window later a reorg-and-fraud story never came, but a sequencer stall of 16 hours already broke their books.

7×24×60 minutes.

tStatusCreditable L1?
0sequencedno
2 minon L2 explorerno
7 dwindow endyes if unchallenged
fast bridgeIOUcounterparty

I would not consider it settled without evidence: A state machine: initiated / pending / challengeable / finalized, with timestamps.

The week is the fraud-proof market; the sequencer tick is not.

Curated: · Written: · Reviewed:

QA-77A zk rollup posts a validity proof. Why can settlement be faster than optimistic, and what still delays a user withdrawal?(show answer)

I would treat zk validity proof settlement as a state machine over pending, included, and finalized, not as a single RPC result.

A validity proof convinces L1 the state transition is correct without a 7-day fraud window; you still wait for the proof to be generated, batched, verified on L1, and for any L1 finality on that verify tx.

Concretely, quote prover time (minutes to hours), L1 verify gas, and Ethereum finality (typically about 2.5 epochs, ~16 minutes from a mid-epoch inclusion). Circuit bugs and trusted setup (if any) are remaining risk. Faster cryptographically ≠ instant UX.

The reason for that specificity is a failure I have seen: A wallet showed "final" when the proof job queued 0 of 4 hours; users spent 190 ETH of L2 balances that were not yet L1-proven during a 3-hour prover outage.

Four clocks.

StageTypical
sequenceseconds
prove20–240 min
L1 verify1 block + gas
L1 finalizedtypically ~16 min from inclusion

I would not consider it settled without evidence: Timestamps: sequenced, proof-submitted, L1-verified, L1-finalized.

Validity kills the challenge week; it does not kill the prover queue or L1 finality.

Curated: · Written: · Reviewed:

QA-78The sequencer is down for 10 hours. How does a user still get a transaction included, and what design has no escape?(show answer)

The useful question for sequencer downtime escape hatch is what still happens on a reorg, a callback, or a second chain.

A safe L2 lets users enqueue L1 messages (forced inclusion) after a timeout so the sequencer cannot censor forever; a design that only accepts sequencer batches has no escape.

Concretely, document the delay (e.g. 12–24h) and the L1 inbox. Test depositing via L1 while the sequencer is mocked down. Exits must not require the sequencer after the force-include.

The reason for that specificity is a failure I have seen: A custom rollup had no inbox; a 10-hour outage trapped $48M of "pending" withdrawals that were only in the sequencer's RAM.

10-hour outage.

DesignUser path$48M
L1 inbox after 12hwait + enqueuerecoverable
sequencer onlynonetrapped
RAM mempoolgonetrapped

I would not consider it settled without evidence: A chaos test: kill sequencer, force-include a withdraw, see it in the next L1-derived batch.

If the only door is the sequencer, downtime is custody.

Curated: · Written: · Reviewed:

QA-79You emitted a withdrawal on L2. Which Merkle (or similar) proof do you present on L1, and why is the L2 tx hash not enough?(show answer)

I would settle L2 to L1 message proving against a counter-example first, so the clever opcode has to survive it.

L1 only knows a published state or message root; you prove inclusion of the withdrawal in that root. An L2 tx hash is not an L1 object.

Concretely, wait for the output/state root on L1, fetch a Merkle proof from the L2 node against that root, call proveWithdrawal on the L1 portal. Replay-protect with nonce.

The reason for that specificity is a failure I have seen: A relayer submitted L2 tx hashes to a custom portal that "trusted the hash"; 3_400 fake withdrawals minted 3_400 ETH on L1 in 9 minutes.

Objects.

ObjectChainEnough to mint?
L2 tx hashL2no
message rootL1commitment
Merkle proofbothyes if matches

I would not consider it settled without evidence: A proof that verifies against the posted root and fails against a mutated log.

L1 verifies a proof against a root it stored, not a hash it has never seen.

Curated: · Written: · Reviewed:

QA-80A deposit tx is finalized on L1. When may the L2 credit the user, and what reorg still matters?(show answer)

The judgement in L1 to L2 deposit finality is which bytes are authenticated, not which library call looks shortest.

The L2 should credit after the L1 deposit is finalized (or after its own conservative delay), because an L1 reorg would drop the deposit. L2 inclusion of a derived deposit is not extra L1 finality.

Concretely, bridge watches finalized L1 logs, then mints on L2. Fast UX that credits on L1 latest is the exchange mistake again. Quote a typical ~16-minute mid-epoch wait, not a fixed 64-slot timer.

The reason for that specificity is a failure I have seen: An L2 credited on L1 latest; a 6-block L1 reorg unminted 720 ETH that had already been swapped on L2, leaving the AMM -$1.9M.

720 ETH deposit.

L1 tagL2 mints?
latestno
safestill no for this size
finalizedyes
6-block reorg if early$1.9M hole

I would not consider it settled without evidence: A reorg fixture on a local L1 that rewinds a deposit; L2 mint must rewind too.

L2 mint of L1 money waits on L1 finality, not on the L2 block it appears in.

Curated: · Written: · Reviewed:

QA-81A rollup can post batches as calldata or as blobs. What do you gain in cost, and what do you lose in permanence?(show answer)

Where candidates lose the interview on calldata DA versus blob DA is usually treating a receipt as settlement.

Calldata is expensive per byte and lives with the chain history; blobs are cheaper and expire (~18 days). The rollup's security model must match the retention it actually needs.

Concretely, if fraud proofs need data beyond 18 days, archive blobs yourself or use calldata. Price both markets. After 4844, default is blobs plus an archive.

The reason for that specificity is a failure I have seen: A team switched to blobs to cut $120k/month, deleted their archive, then could not serve a day-20 fraud proof and halted withdrawals for 4 days.

128 kB batch.

Channel~costRetention
calldata$80–400chain history
blob$1–20~18 days
no archive$0cannot prove day 20

I would not consider it settled without evidence: A cost table: 128 kB as calldata gas vs blob fee at the current blobBaseFee, plus archive S3 cost.

Cheap DA that evaporates is not the same DA as calldata.

Curated: · Written: · Reviewed:

QA-82Name one operational difference that is not "zk is faster": who must stay online, and with what data?(show answer)

I would answer fraud proofs versus validity proofs by separating what consensus agreed from what an oracle or a bridge merely claimed.

Optimistic security needs at least one honest verifier online with the data during the window; validity proofs need a prover and a correct circuit, not a week of watchers.

Concretely, if you run an optimistic chain, fund a watcher with blob/calldata access for 7 days. If zk, fund proving and verify the verifier contract. Hybrid still has both bills.

The reason for that specificity is a failure I have seen: A DAO assumed "the community will watch"; no funded watcher had the 7-day blobs, and a bad root sat unchallenged until hour 160 of 168.

Who is awake?

ModelMust be onlineWindow
optimistic≥1 watcher + DA~7 d
zkproverprove time
nobody watching—hour 160 scare

I would not consider it settled without evidence: An on-call rota and a disk that holds the window; a missed-blob alert.

Optimistic: someone watches with data. zk: someone proves with a circuit.

Curated: · Written: · Reviewed:

QA-83A sequencer omits one user's txs for 48 hours. What mechanism forces inclusion, and what is a fake version of it?(show answer)

The engineering content of forced inclusion censorship resistance is the invariant and the exit path, not the token ticker.

Forced inclusion is an L1-enqueued transaction that must enter a later batch by protocol rule (timeout + ordering). A "we'll include it if you tweet" policy is not a mechanism.

Concretely, user calls L1 inbox with fee. After max_time, the batch poster that skips it is invalid. Measure the worst-case hours in the spec.

The reason for that specificity is a failure I have seen: A sequencer TOSed "fair ordering" with no inbox; an address was censored 48 hours during a $9M liquidation, the user could not add collateral.

48-hour omit.

MechanismForced byHours
L1 inbox timeoutinvalid batch12–24
tweet policynone48
user liquidated—$9M

I would not consider it settled without evidence: A spec clause with a number of L1 blocks; a test that a skipped inbox message makes the batch verify fail.

Censorship resistance is an L1 queue with a clock, not a promise.

Curated: · Written: · Reviewed:

QA-84The L1 contract stores a 32-byte state root. Why can you still be unable to prove a leaf, and what extra publication is required?(show answer)

Before deploying I would write what a correct result for state root is not data availability looks like after a 12-block reorg.

A root commits to data; it does not publish the data. Without DA (blobs, calldata, or a DA layer you trust), Merkle proofs cannot be constructed by honest users.

Concretely, require batch bytes on a DA path you can retrieve for the fraud/validity window. A root posted without blobs is a hash of a secret.

The reason for that specificity is a failure I have seen: A chain posted roots every 2 minutes and blobs "later"; during a 6-hour blob backlog nobody could prove, and $13M of exits queued blind.

2-minute roots, missing blobs.

ObjectOn L1?User prove?
state rootyesno
blob sidecarnono
bothyesyes
6 h gap—$13M blind

I would not consider it settled without evidence: A monitor: root timestamp vs blob sidecar present; alert if delta > 2 slots.

A root without bytes is a commitment to something only the sequencer has.

Curated: · Written: · Reviewed:

QA-85A SNARK verifier on L1 returns true. What class of bug can still print money, and how is a STARK different on setup?(show answer)

The first thing I would pin down about zk circuit and setup risk is which confirmation tier actually changes the decision.

A true verifier means the proof matched the circuit, not that the circuit matched the intended state machine. Groth16 and KZG-based systems add structured-reference-string assumptions; transparent STARKs and IPA-based Halo2 deployments avoid a trusted ceremony, but every choice still has circuit and implementation risk.

Concretely, audit circuits, freeze them, use multiple provers, restrict mint to proven state diffs. Do not treat "proof verified" as "economically impossible to print."

The reason for that specificity is a failure I have seen: A circuit omitted a range check; proofs of negative amounts verified and minted 8.8e6 extra tokens before a 2-hour pause.

What a true verifier still allows.

LayerBugPrints money?
pairing checkbroken verifieryes
missing rangebad circuityes, 8.8e6
trusted setup leakGroth16maybe
STARKno ceremonycircuit still code

I would not consider it settled without evidence: A known-constraint list and a differential test against a reference interpreter on 10_000 random traces.

The verifier checks the circuit; the circuit is still code.

Curated: · Written: · Reviewed:

QA-86Someone says "use CCIP for the NFT metadata." Which CCIP is messaging and which is CCIP Read, and why mixing them is a design error?(show answer)

I would start Chainlink CCIP versus EIP-3668 from the trust boundary, not from the first Solidity snippet that compiled.

Chainlink CCIP is a cross-chain messaging protocol with lanes, tokens, and a risk management network. EIP-3668 CCIP Read is an eth_call revert OffchainLookup so clients fetch bytes off-chain. They share an acronym, not a stack.

Concretely, bridging value uses Chainlink CCIP; cheap metadata lookup uses EIP-3668 then an on-chain hash, not a CCIP message to resolve a tokenURI.

The reason for that specificity is a failure I have seen: A project budgeted $40k/month in CCIP messages to refresh metadata that EIP-3668 would have fetched for RPC cost; 12 million messages later the treasury was empty.

Acronym split.

NameJobCost model
Chainlink CCIPcross-chain sendper message
EIP-3668offchain lookupRPC
12e6 messagesmetadata$40k/mo

I would not consider it settled without evidence: A one-page choice: value/messages vs lookup; the wrong box costs 12e6 messages.

One CCIP moves messages across chains; the other is a lookup revert.

Curated: · Written: · Reviewed:

QA-87When you bridge ETH to an L2, is the L2 token a lock-mint or a native burn-mint, and what fails if the lock is stolen?(show answer)

This is a place where an explorer green tick and a correct handling of lock-and-mint versus burn-and-mint bridges are not the same event.

Lock-and-mint: L1 escrow holds the asset and L2 mints an IOU, so if escrow is drained the IOU is unbacked; burn-and-mint of a native L2 token is a different supply, and canonical L2 ETH is usually lock-based from L1.

Concretely, know which contract holds the L1 reserve, rate-limit mint, pause on reserve ≠ supply, and do not treat a third-party lock as the canonical token.

The reason for that specificity is a failure I have seen: A third-party lock of 80_000 ETH was drained; the minted L2 token still traded 40 minutes at $0.98 until the oracle caught up, $22M of exits.

80_000 ETH lock.

SideAmount
L1 escrow80_000 then 0
L2 token80_000
gap100% unbacked
40 min$22M exits

I would not consider it settled without evidence: A dashboard: L1 escrow balance vs L2 token supply, alert on 1% gap.

If the lock is empty, the mint is a picture of ETH.

Curated: · Written: · Reviewed:

QA-88A user presents a Merkle proof that a leaf hashes to a published root. What have they proved, and what can still be missing?(show answer)

My answer to Merkle proof is not availability begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

A Merkle proof shows a leaf is consistent with a root you already trust; it does not show that the rest of the tree's bytes are published or that you can reconstruct other leaves.

Concretely, verify path length, index, and hash pairing (left/right). Fetch the root from L1 storage, not from the prover. DA is a separate check: can anyone download the batch?

The reason for that specificity is a failure I have seen: A bridge verified proofs against a root the operator posted, but never posted leaves; only the operator could exit, and 4_100 users waited 11 days.

Two checks.

CheckPasses if
Merkle pathleaf ↔ root
DAbatch retrievable
only proofoperator-only exits
4100 users11 days

I would not consider it settled without evidence: A test: valid proof against root R; mutated sibling fails; missing batch bytes fails a DA check even if the proof is valid.

Proof is membership in a hash; availability is bytes on a wire.

Curated: · Written: · Reviewed:

QA-89You truncate a 256-bit hash to 80 bits for a commitment id. Why is 2^40 the number you quote, and when is that too small?(show answer)

I would treat hash collision birthday bound as a state machine over pending, included, and finalized, not as a single RPC result.

Birthday collisions appear near 2^{n/2} hashes, not 2^n. For n=80, ~2^40 ≈ 1e12 trials; for 160-bit addresses, 2^80 is still huge, but 64-bit ids are a toy.

Concretely, keep 256-bit commitments on-chain. If you must truncate, n≥128 (2^64 birthday) for low-value ids. Do not use 80-bit "to save gas" on money.

The reason for that specificity is a failure I have seen: An 80-bit order id collided after ~1.2e12 off-chain trials; two fills shared an id and a matcher paid both, $700k.

2^{n/2}.

n bitsBirthday trials
642^32 ≈ 4e9
802^40 ≈ 1e12
1282^64
2562^128

I would not consider it settled without evidence: A note in the spec: collision work = 2^{n/2}, with n the truncated bits.

Birthday is square root, not the full space.

Curated: · Written: · Reviewed:

QA-90An indexer queue consumes Transfer events into a SQL table. How do you make the consumer idempotent across a 12-block reorg?(show answer)

The useful question for event indexer reorg cursors is what still happens on a reorg, a callback, or a second chain.

Idempotency is a unique key that includes blockHash (or a finalized-only cursor). A cursor of "last blockNumber" double-applies or skips when the hash at that number changes.

Concretely, upsert on (chainId, blockHash, logIndex). On reorg, delete where blockNumber > ancestor and hash not in new canonical set, then continue. Finalized cursor for money.

The reason for that specificity is a failure I have seen: A rewards consumer keyed on (txHash, logIndex); after a 12-block reorg it skipped the new hashes (same numbers) and underpaid 44_000 users $0.90 each, $39.6k, then "caught up" twice.

Cursor designs.

KeyAfter 12-block reorg
blockNumberwrong rows
txHash onlycollisions
blockHash+logIndexdelete+insert
44000 users$39.6k

I would not consider it settled without evidence: A replay of 12-block reorg in the queue test; row count matches a node.

The queue key is a hash, or the reorg is a double-spend of your SQL.

Curated: · Written: · Reviewed:

QA-91A worker mints an NFT when it sees a paid event. The message is delivered 3 times. What exactly do you store so you mint once?(show answer)

I would settle idempotent queue consumers against a counter-example first, so the clever opcode has to survive it.

At-least-once queues need one stable event id carried through database and contract boundaries. A processed row alone cannot prevent a duplicate if the worker crashes after the on-chain mint but before recording success, and an EOA nonce only orders transactions—it does not express business idempotency.

Concretely, derive eventId = keccak(chainId, txHash, logIndex). Insert an outbox command under a unique eventId in the same DB transaction as the event record, and make the mint contract reject an eventId already consumed. Retries may resubmit, but the contract state makes the second execution a no-op or revert.

The reason for that specificity is a failure I have seen: A worker minted on each of 3 Kafka deliveries; 3 NFTs per payment for 1_150 orders (2_300 extras) over 50 minutes.

3 deliveries.

Deliveryprocessed rowmint
1outbox insert + consume id1
2same id already consumed0
3same id already consumed0
without key—3

I would not consider it settled without evidence: A test that delivers the same payload 3 times and asserts tokenCounter += 1.

Three deliveries are one log; the unique key is the log, not the worker's hope.

Curated: · Written: · Reviewed:

QA-92During a network partition, Gasper stops finalizing rather than finalizing two heads. Which CAP corner is that, and when would a product pick the other?(show answer)

The judgement in CAP as finality versus liveness is which bytes are authenticated, not which library call looks shortest.

FFG prefers safety (never two conflicting finals) over liveness of finality during a partition — a CP-ish choice for the finalized checkpoint, while LMD-GHOST still offers an available head that can flip.

Concretely, product: payments wait for finalized (safety). Games might follow latest (availability) and unwind. Write the choice. Do not claim Ethereum "beats CAP."

The reason for that specificity is a failure I have seen: A game credited items on latest during a 90-minute partition with two heads; 12_000 items duplicated, $260k of support refunds.

90-minute partition.

ViewProgressSafe to credit $$?
latestyes, may splitno
finalizedstalledstill last final
game on latest12000 dupes$260k

I would not consider it settled without evidence: A partition table: finalized lag grows, latest still moves, product policy named.

Finality is the safe side; the head is the live side; pick which one your credit uses.

Curated: · Written: · Reviewed:

QA-93An airdrop is a Merkle tree of (address, amount). What does a 20-hash proof show, and what must the contract still prevent?(show answer)

Where candidates lose the interview on Merkle membership proofs is usually treating a receipt as settlement.

The proof shows (address,amount) is in the tree of merkleRoot; the contract must also mark the leaf spent, check msg.sender or a signature, and not trust the proof's address field without binding.

Concretely, leaf = keccak(abi.encode(addr, uint256(amount))) with a standard encoding (double-hash if you need domain). mapping claimed[index]. 20 levels ≈ 1e6 leaves (2^20). Packed address+uint256 is already fixed-width; packed collisions need two dynamic or variable-length fields.

The reason for that specificity is a failure I have seen: A distributor hashed encodePacked of two dynamic strings without length prefixes, so distinct pairs produced the same bytes; 6_400 extra tokens left ($88k).

2^20 leaves, 20 hashes.

ItemValue
leaves1_048_576
proof length20
claimed[i]once
encodePacked trap$88k

I would not consider it settled without evidence: Canonical encoding tests and a claimed[index] that blocks the same leaf twice.

Membership is the tree; uniqueness is your claimed map and encoding.

Curated: · Written: · Reviewed:

QA-94Why does a passing unit test on a mock ERC-20 not replace a fork test against USDT, and what block number do you pin?(show answer)

I would answer Foundry fork tests by separating what consensus agreed from what an oracle or a bridge merely claimed.

Mocks omit missing returns, fee-on-transfer, and storage quirks; a fork at a pinned block talks to the real bytecode. Unpinned "latest" makes CI non-reproducible.

Concretely, vm.createSelectFork(rpc, 19_000_000), then test approve/transfer on USDT and snapshot gas, without hitting live RPCs without a pin in CI.

The reason for that specificity is a failure I have seen: Mocks returned bool; mainnet USDT did not, and a router bricked $1.5M the first hour after launch.

USDT at block 19e6.

TestUSDT returns bool?Router
mockyespasses
fork 19000000noSafeERC20
launch without fork—$1.5M

I would not consider it settled without evidence: CI pin 19_000_000 and a USDT transferFrom that succeeds with SafeERC20.

The mock is a sketch; the fork is the city.

Curated: · Written: · Reviewed:

QA-95A fuzzer finds nothing in 1_000 runs. Why is that not an invariant proof, and what invariant would have caught a first-depositor inflation?(show answer)

The engineering content of invariant and fuzz testing is the invariant and the exit path, not the token ticker.

Fuzzing samples; invariants are properties that must hold for all explored states (e.g. sum of shares ≤ totalAssets + offset). 1_000 runs on a small ABI miss the 1-wei donation path.

Concretely, write invariant_totalAssets_ge_sum with sender/value handlers and 10_000+ calls in CI, using Foundry invariant tests with a ghost of expected reserves.

The reason for that specificity is a failure I have seen: 1_000 fuzz runs missed the donation; a human later stole $1.2M with 1 wei + 1e18, the same class as the share-rounding story.

Runs versus a known bug.

CampaignHits donation?
1000 randomno
handler+donateyes in 50
invariantmust fail on fixture

I would not consider it settled without evidence: An invariant that fails on the known donation fixture in <50 runs once the handler allows donate.

A quiet fuzzer is a sample, not a model check.

Curated: · Written: · Reviewed:

QA-96eth_call says a swap returns 12_400 USDC. The mined tx returns 11_900. What differed, and which number is the user's money?(show answer)

Before deploying I would write what a correct result for eth_call versus mined execution looks like after a 12-block reorg.

eth_call is a read-only simulation at a state tag (often latest) with your gas/state overrides; mined execution is the proposer's ordering, base fee, and intervening txs. The receipt is money; the call is a quote.

Concretely, simulate at the pending block with the same from/nonce if you must. Still set minOut. Do not send based on a call that used from=address(0) or a different block.

The reason for that specificity is a failure I have seen: A bot treated eth_call profit of $8_200 as guaranteed; a backrun took $6_100 and the mined profit was $90.

Same calldata.

SourceUSDC out
eth_call latest12400
mined11900
minOut 11800ok
believed 12400$6100 missing

I would not consider it settled without evidence: Log call result and receipt logs side by side for 100 txs; expect a distribution, not equality.

eth_call is a rehearsal; the receipt is the show.

Curated: · Written: · Reviewed:

QA-97One RPC returns a receipt with status 1 and another says the tx is unknown. What do you trust, and how many providers settle a payout?(show answer)

The first thing I would pin down about lying RPC providers is which confirmation tier actually changes the decision.

An RPC is not consensus; a lying or stale node can invent receipts. Compare at least two independent providers and ultimately a node you run, against a finalized block hash.

Concretely, quorum: 2-of-3 matching blockHash+status. On disagreement, halt payouts. Do not scrape a single explorer API for $50k+.

The reason for that specificity is a failure I have seen: A compromised public RPC showed status 1 for a never-mined 500 ETH transfer; an OTC desk released $1.4M of fiat in 8 minutes.

500 ETH OTC.

SourceReceipt
RPC A (bad)status 1
RPC Bunknown
your CL finalizedno tx
paid fiat$1.4M

I would not consider it settled without evidence: A mismatch alarm between provider A and B on receipt.blockHash.

Consensus is many validators; one HTTPS endpoint is a blog.

Curated: · Written: · Reviewed:

QA-98A smart account's UserOperation nonce is not the EOA nonce. What collision does a 4337 nonce prevent, and what still needs a signature?(show answer)

I would start ERC-4337 user operation nonce from the trust boundary, not from the first Solidity snippet that compiled.

4337 account nonces (often 2D: key+seq) stop replay of the same UserOperation in the EntryPoint; they do not replace EIP-712 validation of the op hash or chainId.

Concretely, entryPoint.getNonce(account, key). After a successful op, seq increments. Bundlers drop reused nonce. Validate the op hash in the account's validateUserOp.

The reason for that specificity is a failure I have seen: A wallet reused seq 0 after a failed op that actually succeeded on another bundler; 2 payments of 3 ETH went out ($18k).

seq 0 twice.

BundlerseqResult
A0success 3 ETH
B0should drop
if both take—6 ETH

I would not consider it settled without evidence: A test: same UserOperation twice, second reverts at EntryPoint.

The 4337 nonce is the account's replay counter at the EntryPoint, not the EOA's.

Curated: · Written: · Reviewed:

QA-99A Multicall3 batch sends two payable subcalls. Why can msg.value be spent twice, and how do you write a safe batch?(show answer)

This is a place where an explorer green tick and a correct handling of multicall and msg.value are not the same event.

Delegatecall multicall shares one msg.value across subcalls; each subcall can see the full value. A naive "pay each" double-spends the ETH from the contract.

Concretely, do not delegatecall user-controlled targets with leftover ETH. Use call and track remaining. Multicall3's value-forwarding helpers need an explicit amount per subcall.

The reason for that specificity is a failure I have seen: A batch paid a 2 ETH order twice from a 2 ETH msg.value via delegatecall, draining 2 ETH of the aggregator's float × 300 users in 1 hour ($1.8M).

msg.value = 2 ETH.

SubcallSees msg.valueShould send
122
22 if delegatecall0 remaining
300 users—$1.8M

I would not consider it settled without evidence: A test: two subcalls each requiring 2 ETH with msg.value=2 must revert.

One msg.value, many subcalls: count the wei down.

Curated: · Written: · Reviewed:

QA-100A sale opens at block.timestamp >= T. How much can a proposer move T, and when is that enough to steal?(show answer)

My answer to block.timestamp dependence begins at the adversary: if I cannot name who profits from getting it wrong, I do not have a design.

Post-Merge Ethereum fixes block.timestamp as genesis_time + slot × 12; a payload with another timestamp is invalid. A proposer can include, delay, or withhold a transaction in their slot, but cannot pick a different timestamp for that slot. That is still enough to bias inclusion, not to skip a 7-day vest.

Concretely, timestamp is appropriate for coarse time gates such as a vest, because the slot fixes it; allow enough margin for delayed inclusion. Do not use it for entropy or same-slot winner selection—use commit-reveal or a verifiable randomness source there.

The reason for that specificity is a failure I have seen: An NFT drop used (block.timestamp / 12) % 1000 for rarity, which is just the slot; the proposer withheld until their assigned slot's remainder hit the legendary bin and minted 40 legendaries ($1.1M).

±12 seconds.

UseProposer can steal?
7-day vestno
same-block raffleyes
timestamp % 100040 legendaries
$1.1Mone slot

I would not consider it settled without evidence: A test that vm.warp ±12s changes the rarity; that means the design is broken.

On PoS the timestamp is the slot; the proposer chooses inclusion, not the clock.

Curated: · Written: · Reviewed: