π€π« Verify human
This article explains how the anti-bot feature is implemented in the Gift Boxes game β using Cloudflare Turnstile for human verification on both the frontend and backend, and EIP-712 signature verification on the smart contract side.
Frontend Turnstileβ
Embed widget on frontend, explicit rendering.
Get the site key on cloudflare dashboard, go to https://dash.cloudflare.com/, sign in, select Turnstile on sidebar.
Embed it on nextjs app.
Turnstile typeβ
// global.d.ts
declare global {
interface Window {
turnstile?: {
render: (
element: HTMLElement | string,
options: {
sitekey: string;
size?: "normal" | "compact" | "invisible";
callback?: (token: string) => void;
"error-callback"?: () => void;
"expired-callback"?: () => void;
}
) => string; // returns widgetId
execute: (widgetId: string) => void;
remove: (widgetId: string) => void;
};
}
}
export {};
Turnstile componentβ
Embed and control Turnstile with execute and remove actions.
import {
useEffect,
useRef,
forwardRef,
useImperativeHandle,
} from "react";
export interface TurnstileRef {
execute: () => Promise<string>;
remove: () => void;
}
const siteKey = "0x4AA...f6"; // Your Turnstile site key
const Turnstile = forwardRef<TurnstileRef, {}>(
({}, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() => {
// Load Turnstile script if not already loaded
if (!document.querySelector("#cf-turnstile")) {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
script.id = "cf-turnstile";
document.body.appendChild(script);
}
}, []);
// Expose methods to parent
useImperativeHandle(ref, () => ({
execute: () => {
return new Promise<string>((res, rej) => {
if (window.turnstile && containerRef.current) {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
size: "normal",
callback: (token) => res(token),
"error-callback": () => rej('error'),
"expired-callback": () => rej('expired'),
});
} else {
rej('turnstile not loaded');
}
})
},
remove: () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current);
}
},
}));
return (
<div
ref={containerRef}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}}
></div>
);
}
);
Turnstile.displayName = "Turnstile";
export default Turnstile;
Usageβ
import { useAccount } from 'wagmi'
// ...
import Turnstile, { TurnstileRef } from '@/components/Turnstile'
export const GameComponent = () => {
const turnstileRef = useRef<TurnstileRef>(null)
const { address } = useAccount()
const doAction = useCallback(async () => {
try {
const token = await turnstileRef.current?.execute()
if (token) {
const rs = await verifyHuman(address, token) // check the token on backend
if (rs.success) {
// if token verified success
// - get the signature from backend
// - interact with contract with that valid signature
const { signature } = rs.signData
// call contract with signature
}
}
} catch (e) {
console.log('Turnstile execute error', e)
} finally {
// remove the turnstile to prepare for next action
turnstileRef.current?.remove()
}
}, [])
return (
<>
//... GameUI Element
<Turnstile ref={turnstileRef} />
</>
)
}
Backendβ
On server-side:
- Verify the turnstile token from client on Cloudflare api.
- Generate signature using Viem.
Verify turnstile tokenβ
Get the Secret Key on Cloudflare dashboard. Validate the token
interface TurnstileResponse {
success: boolean;
'error-codes'?: string[]; // optional
}
// get turnstile secret key
const SECRET_KEY = process.env.SECRET_KEY
export async function validateTurnstile(token: string): Promise<TurnstileResponse> {
const formData = new FormData();
// secret key from dashboard
formData.append('secret', SECRET_KEY);
// token from client
formData.append('response', token);
try {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData
});
const result = (await response.json()) as TurnstileResponse;
console.log('Turnstile validation result:', result);
return result;
} catch (error) {
console.error('Turnstile validation error:', error);
return { success: false, 'error-codes': ['internal-error'] };
}
}
Generate signatureβ
Generate the signature using the signer private key, return it to client.
import { privateKeyToAccount } from 'viem/accounts'
export interface SignatureResponse {
user: Address
deadline: number
signature: `0x${string}`
}
// get the signer key
const signerPrivateKey = process.env.SOME_KEY
export async function generateSignature(user: Address): Promise<SignatureResponse> {
const deadline = Math.floor(Date.now() / 1000) + 300
const signAccount = privateKeyToAccount(signerPrivateKey)
const signature = await signAccount.signTypedData({
domain: {
name: 'PixelGames Token Gift Box', // must match the value used in contract
version: '1', // must match the value used in contract
chainId: 8453, // base mainnet chain id
verifyingContract: GiftContractAddress, // contract address
},
// must match type in contract
types: {
Permit: [
{ name: 'user', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'Permit',
message: {
user,
deadline: BigInt(deadline)
},
})
return { user, deadline, signature }
}
EIP-712 Signatureβ
On contract side, we verify the signature using EIP712 utility from openzeppelin.
import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract PixelGift is ..., EIP712 {
// must match types when create signature with viem
bytes32 private constant _PERMIT_TYPEHASH =
keccak256("Permit(address user,uint256 deadline)");
// signer, to verify human
address public signer;
// must match values when create signature with viem
constructor() EIP712("PixelGames Token Gift Box", "1") {}
function claimBox(uint16 position, uint256 deadline, bytes calldata signature) public {
// ...
if (signer != address(0)) {
_validateClaim(msg.sender, deadline, signature);
}
// ...
}
/**
* @notice Helper to check whether a signature is valid
*/
error InvalidClaimSignature();
error ClaimExpired();
function _validateClaim(address user, uint256 deadline, bytes calldata signature) internal view {
if (block.timestamp > deadline) revert ClaimExpired();
bytes32 structHash = keccak256(abi.encode(_PERMIT_TYPEHASH, user, deadline));
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
if (recovered != signer) revert InvalidClaimSignature();
}
}
With the signerβs private key kept secure, the claimBox function is effectively protected from bots using Cloudflare Turnstile and EIP-712 signatures.