Skip to content

Fix: v4 action mapping must be reduced to a field element - #147

Open
shuva10v wants to merge 1 commit into
worldcoin:mainfrom
shuva10v:patch-1
Open

Fix: v4 action mapping must be reduced to a field element#147
shuva10v wants to merge 1 commit into
worldcoin:mainfrom
shuva10v:patch-1

Conversation

@shuva10v

Copy link
Copy Markdown

Fix: v4 action mapping must be reduced to a field element

File: world-id/idkit/onchain-verification.mdx (line 168)

The "Minimal mapping from IDKit result" list in section 2 (World ID 4.0) says:

action = keccak256(action) as uint256

Following this literally makes WorldIDVerifier.verify(...) revert with
InvalidAction() (0x4a7f394f).

action is a circuit public input, so it must be a BN254 field element
(modulus ≈ 2.188e76). A raw keccak256 is a full 256-bit value and frequently
exceeds 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:

action value 0x000000...94d7 (prod) 0x703a63...57DB (staging)
keccak256(action) (as documented) InvalidAction() InvalidAction()
keccak256(action) >> 8 verifies verifies

>> 8 is the standard hashToField reduction 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 inconsistent
with 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.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant