Worldcoin

World ID

Verifying Proofs On-Chain

World ID proofs can be fully verified on-chain. After all, the source of truth for the decentralized protocol is on-chain. To verify a World ID proof, your smart contract will embed a call to the verifyProof method of the World ID contract, and then execute the rest of its logic as usual.

The smart contract starter kit (Foundry, Hardhat) and the frontend & on-chain monorepo template are great resources to help you get started with World ID. Using one of these repositories is strongly recommended to get started with World ID on-chain.

The following examples demonstrate the most common use case: verifying proofs from only Orb-verified users, for a single action, with a user's wallet address as the signal, while also enabling sybil-resistance. This setup is recommended for most users, as it is the most gas-efficient. For more information on use cases that require more complex setups (such as multiple actions or other types of signals), see the Advanced On-Chain Verification page.

At the core of the World ID Protocol is the use of Semaphore. Semaphore is a zk-SNARK based privacy protocol that allows for the creation of anonymous credential systems developed by the Ethereum Foundation. Read more about The Protocol and Semaphore.

IDKit Configuration

When verifying proofs on-chain, there are a few changes you have to make to your IDKit configuration. You must ensure that the app created in the Developer Portal is configured as an on-chain app, and you should only accept Orb credentials in IDKit, as device credentials are not currently supported on-chain.

import { IDKitWidget, solidityEncode } from '@worldcoin/idkit' // add import for solidityEncode

const { address } = useAddress() // get the user's wallet address

<IDKitWidget
    app_id="app_GBkZ1KlVUdFTjeMXKlVUdFT" // must be an app set to on-chain
    action="claim_nft"
    signal={address} // prevents tampering with a message
    onSuccess={onSuccess}
    // no use for handleVerify, so it is removed
    // leave credential_types unspecified (orb-only by default), as device credentials are not supported on-chain
    enableTelemetry
>
    {({ open }) => <button onClick={open}>Verify with World ID</button>}
</IDKitWidget>

Contract Addresses

The World ID Router contract is what you should use to verify proofs. It is deployed on multiple chains, and you can find the addresses for each chain in our contracts Address Book.

hashToField helper function

We recommend including this helper function in your smart contract. It properly computes the keccak256 hash used by Semaphore, and is frequently used in the example code below. You can find it in our template repository here.

function hashToField(bytes memory value) internal pure returns (uint256) {
    return uint256(keccak256(abi.encodePacked(value))) >> 8;
}

Constructor

The externalNullifier is the unique identifier of the action performed in Semaphore, and its keccak256 hash (named externalNullifierHash) is what is passed to the World ID Router contract. It is a combination of the app ID and the action. You should typically set it in the constructor to save gas (as is done in this example), as it will not change when all users perform the same action.

We additionally set the groupId to 1, which limits this example to Orb-verified users only. Device credentials are currently not supported on-chain.

/// @notice Thrown when attempting to reuse a nullifier
error InvalidNullifier();

/// @dev The address of the World ID Router contract that will be used for verifying proofs
IWorldID internal immutable worldId;

/// @dev The keccak256 hash of the externalNullifier (unique identifier of the action performed), combination of appId and action
uint256 internal immutable externalNullifierHash;

/// @dev The World ID group ID (1 for Orb-verified)
uint256 internal immutable groupId = 1;

/// @dev Whether a nullifier hash has been used already. Used to guarantee an action is only performed once by a single person
mapping(uint256 => bool) internal nullifierHashes;

/// @param _worldId The WorldID instance that will verify the proofs
/// @param _appId The World ID app ID
/// @param _actionId The World ID action ID
constructor(
    IWorldID _worldId,
    string memory _appId,
    string memory _action
) {
    worldId = _worldId;
    externalNullifierHash = abi
        .encodePacked(abi.encodePacked(_appId).hashToField(), _action)
        .hashToField();
}

verifyProof

The verifyProof method reverts if the proof is invalid, meaning you can just call it as part of your smart contract's logic and execute the rest of your logic after as usual.

Note that calling the verifyProof function by itself does not provide sybil-resistance, or prevent proof reuse -- it just verifies that the proof is valid.

However, this example does implement sybil-resistance by checking that the nullifierHash has not been verified before.

The verifyProof method takes the arguments below.

  • root - The World ID root to verify against. This is obtained from the IDKit widget, and should be passed as-is.
  • groupId - This must be 1 for Orb-verified users. Device credentials are currently not supported on-chain.
  • signalHash - The keccak256 hash of the signal to verify.
  • nullifierHash - Anonymous user ID for this action. This is obtained from the IDKit widget, and should just be passed as-is.
  • externalNullifierHash - The externalNullifierHash, which identifies which app and action the user is verifying for.
  • proof - The proof to verify. This is obtained from the IDKit widget.
/// @param signal An arbitrary input from the user that cannot be tampered with. In this case, it is the user's wallet address.
/// @param root The root (returned by the IDKit widget).
/// @param nullifierHash The nullifier hash for this proof, preventing double signaling (returned by the IDKit widget).
/// @param proof The zero-knowledge proof that demonstrates the claimer is registered with World ID (returned by the IDKit widget).
function verifyAndExecute(
    address signal,
    uint256 root,
    uint256 nullifierHash,
    uint256[8] calldata proof
) public {
    // First, we make sure this person hasn't done this before
    if (nullifierHashes[nullifierHash]) revert InvalidNullifier();

    // We now verify the provided proof is valid and the user is verified by World ID
    worldId.verifyProof(
        root,
        groupId, // set to "1" in the constructor
        abi.encodePacked(signal).hashToField(),
        nullifierHash,
        externalNullifierHash,
        proof
    );

    // We now record the user has done this, so they can't do it again (sybil-resistance)
    nullifierHashes[nullifierHash] = true;

    // Finally, execute your logic here, knowing the user is verified
}

All arguments are of type uint256, with the exception of proof, which is of type uint256[8]. Depending on how you're calling your smart contract, you might be required to unpack it into a uint256[8] before passing it to the verifyProof method. To unpack it, use the following code:

import { decodeAbiParameters } from 'viem'

const unpackedProof = decodeAbiParameters([{ type: 'uint256[8]' }], proof)[0]