Fix: v4 action mapping must be reduced to a field element - #147
Open
shuva10v wants to merge 1 commit into
Open
Conversation
shuva10v
added a commit
to shuva10v/scuba-swap
that referenced
this pull request
Jul 25, 2026
…treamed Debugged World ID 4.0 against the live WorldIDVerifier proxies on World Chain until both a production and a staging proof verified, then characterised the verifier by perturbing every public input. Adds the staging fixture and three FRICTION entries. W-05: the v4 docs specify an `action` value the verifier always rejects. "Minimal mapping from IDKit result" says `action = keccak256(action)`, but `action` is a circuit public input and must be a BN254 field element. A raw keccak256 routinely exceeds the modulus - ours came out at ~2.601e76 - and reverts InvalidAction() (0x4a7f394f) on both proxies. Correct value is the standard hashToField reduction, keccak256(action) >> 8, which the v3 example higher up the same page already uses. Cost about an hour, most of it spent suspecting rpId. Fix submitted as worldcoin/developer-docs#147. W-06: the v4 verifier does not enforce expiry. expiresAtMin looks like the freshness primitive v3 lacked, but it is never compared to block.timestamp - both fixtures verified while already expired, by 314s and 189s respectively. It IS committed into the proof (perturb it by 1 and verification fails), so a documented-exactly integration silently accepts proofs forever. The guard must carry require(expiresAtMin >= block.timestamp). That single line is the whole anti-bot property this project is built on. W-07: v4 is deployed only on World Chain, and Aqua is deployed everywhere except World Chain (eth_getCode returns 0x on chain 480). No chain carries both, so the architecture choice is forced by deployment topology: v3 + canonical Aqua on Ethereum with no liveness, or v4 + self-deployed Aqua on World Chain with real freshness. We take the latter. Also corrects W-01 and W-04, which claimed v4 solved liveness. It does not on its own: verify() has no presence parameter either, and both v4 fixtures carried user_presence_completed=false yet verified. Only the STAGING fixture is committed. A production nullifier is a persistent pseudonymous identifier tied to a real Orb-verified identity, stable per (identity, rp_id, action), so publishing one would let anyone link that person's future verifications. .gitignore now blocks *production*.json and *.PRIVATE.json, verified against a decoy file. FRICTION entries renumbered into ascending order; no content changed. 19/19 tests still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shuva10v
added a commit
to shuva10v/scuba-swap
that referenced
this pull request
Jul 25, 2026
Adds ByteHasher, IWorldIDVerifier, and a test suite that locks our on-chain
hashing to values produced outside Solidity. This is the one bug class a mock
verifier structurally cannot catch: a mock accepts whatever we hand it, so it
would happily confirm a guard computing signalHash in a way IDKit never would.
Every expected value comes from an independent source - World ID's published
constants or the IDKit hashSignal algorithm - never from running the contract
and pasting the answer back.
hashToField == keccak256 >> 8 is now proven, not assumed: bytes("").hashToField()
equals the empty-signal default World ID publishes
(0x00c5d246...85a4). That constant can only match if the shift is exact.
Signal encoding confirmed to agree with IDKit. hashSignal branches on the
signal's runtime shape: a 0x-prefixed valid-hex string is hex-decoded to raw
bytes, anything else is UTF-8 encoded. Passing an address as "0x..." takes the
decode branch and yields the raw 20 bytes - byte-identical to Solidity's
abi.encodePacked(address). Both sides agree because they hash the same bytes.
test_idkitUtf8BranchIsNotInterchangeable pins the trap: hashing the 42-char
ASCII text instead produces a completely unrelated field element, and nothing
on either side would flag it - the proof just fails with no indication why.
Also pins the action value that actually verified on World Chain, and asserts
raw keccak256("world-demo-v2") really does exceed the BN254 modulus while the
shifted value fits - FRICTION W-05 in executable form, guarding
worldcoin/developer-docs#147 against regression.
IWorldIDVerifier documents both live traps at the call site: action must be
hashToField, and expiresAtMin is committed but never compared to
block.timestamp, so freshness is the caller's job.
26/26 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix: v4
actionmapping must be reduced to a field elementFile:
world-id/idkit/onchain-verification.mdx(line 168)The "Minimal mapping from IDKit result" list in section 2 (World ID 4.0) says:
Following this literally makes
WorldIDVerifier.verify(...)revert withInvalidAction()(0x4a7f394f).actionis a circuit public input, so it must be a BN254 field element(modulus ≈ 2.188e76). A raw
keccak256is a full 256-bit value and frequentlyexceeds it. For the action string
"world-demo-v2":keccak256("world-demo-v2") = 26010334832445723922903112885823480789134808060722738567577694943080833342878
≈ 2.601e76 → above the modulus → InvalidAction()
▎ ▎ 8 = 101602870439241109073840284710247971832557843987198197529600370871409505245
▎ ▎ → verifies
Verified against both live proxies on World Chain:
actionvalue0x000000...94d7(prod)0x703a63...57DB(staging)keccak256(action)(as documented)InvalidAction()InvalidAction()keccak256(action) >> 8>> 8is the standardhashToFieldreduction already used throughout World ID —including the v3 example higher up on this same page, which correctly calls
abi.encodePacked(signal).hashToField(). So the page is currently inconsistentwith itself: the v3 section reduces to the field, the v4 mapping list does not.
Worth fixing because the failure mode is unhelpful: every other parameter maps
exactly as documented, so you get a well-formed call that reverts naming the one
field you copied verbatim from the docs, with no hint that it needs reducing.