diff --git a/docs/apps/guides/verify-onchain.mdx b/docs/apps/guides/verify-onchain.mdx new file mode 100644 index 000000000..b965e728e --- /dev/null +++ b/docs/apps/guides/verify-onchain.mdx @@ -0,0 +1,377 @@ +--- +title: "Verify Users Onchain" +description: "Enforce Sybil resistance and policy gating inside any Base contract. Base Verify signs a short-lived verification your contract checks in the same transaction as a claim, deposit, or vote, so one real identity counts once and only wallets that meet your bar can participate." +--- + +## Summary + +**Base Verify Onchain lets your smart contract enforce "one real person, once" and gate on real-world traits, like an active Coinbase One membership. The check runs in your contract, so you don't run a verification backend.** Base Verify signs a short-lived verification your contract checks in the same transaction as a claim, deposit, or vote. + + + Live on Base Sepolia! Try the + [demo](https://base-verify-onchain-demo.vercel.app/). Please [reach + out](https://forms.gle/WTcuWyKkvUV6gGik6) if you have use cases in mind! + + +Integration is three steps: + +1. **Deploy or upgrade a contract** to extend `BaseVerifyConsumer` and declare an immutable `provider` and `conditions` (your eligibility policy). +2. **Fetch a verification** in your app: the user signs a SIWE message naming your contract, which you POST to `POST /v1/onchain_verifications`. +3. **Submit** the returned `{ identityHash, expiration, signature }` to your contract, which calls `registry.verifyVerification(...)` and dedupes on `identityHash`. + +| What | Value (Base Sepolia) | +| :--------------- | :------------------------------------------------------------------------------------------------------------------------------ | +| `SignerRegistry` | [`0x4f15593fbF7e3491d15080e1610E7AF8deBA1a02`](https://sepolia.basescan.org/address/0x4f15593fbF7e3491d15080e1610E7AF8deBA1a02) | +| API base URL | `https://verify.base.dev/v1` | +| Chain | Base Sepolia (`84532`) | +| Consumer base | `BaseVerifyConsumer.sol` | + +Two example consumers to copy from: + +- [Verified X account example](https://sepolia.basescan.org/address/0x691fedA6dfCd10082b195b2453EBC7c87ff31678) — gates on a verified X account. +- [Coinbase One example](https://sepolia.basescan.org/address/0x3ccD255C67a129e780F945Fa1773441Ec100059f) — gates on an active Coinbase One membership. + +## What is Base Verify Onchain? + +[Base Verify](/base-account/guides/verify-social-accounts) lets users prove ownership of verified accounts (X, Coinbase, Instagram, TikTok) without revealing their account details. It solves two problems that wallets alone cannot: **Sybil resistance** (one real identity counts once, no matter how many wallets it splits across) and **policy gating** (admit only users who meet a real-world bar, such as an active Coinbase One membership, even when a wallet has little onchain history). + +**Base Verify Onchain enforces both directly in your contract.** The Base Verify backend signs a short-lived [EIP-712](https://eips.ethereum.org/EIPS/eip-712) verification that your contract checks in the same transaction as a claim, deposit, mint, or vote. No backend at claim time, and your contract never learns who the user is: + +- **Sybil resistance** comes from the `identityHash`. The same real-world identity always produces the same hash for your contract, regardless of which wallet it uses, so your contract counts each real person once. +- **Policy gating** comes from your contract's policy. You declare a `provider` and `conditions` (for example, X followers ≥ 10,000 or an active Coinbase One membership); Base Verify checks the user's real credential against them and only signs when they pass. + +A single check can do both at once: gate on your policy _and_ dedupe on identity in the same transaction. If your app already enforces this offchain (your own backend and database), start with [Verify Social Accounts](/base-account/guides/verify-social-accounts) instead. This guide is for enforcing it in a contract. + +## Core concepts + +### Verification + +A short-lived object signed by the Base Verify backend that your contract checks through the `SignerRegistry`. It is signed as EIP-712 typed data and contains: + +- `identityHash` — a one-way hash of the user's real-world identity (your dedupe key). +- `policyHash` — binds the verification to your contract's policy on a specific chain. The registry recomputes it onchain, so it never travels in the response. +- `expiration` — unix seconds; verifications are short-lived (a few minutes). + +### identityHash + +The dedupe key. It is deterministic per identity and per contract: the same real person always produces the same `identityHash` for your contract, across any wallet they verify from. You store each `identityHash` and reject repeats. + +- **One-way** — you cannot recover the user's identity from it. +- **Per-contract** — different for every contract, so identities cannot be correlated across apps. +- **Cross-wallet** — a second wallet for the same person produces the same hash, so your contract blocks the duplicate. + +### Policy (provider + conditions) + +Your contract declares who is eligible: one `provider` plus one or more `conditions` (for example, an active Coinbase One membership, or X followers greater than or equal to 1000). The Base Verify backend reads this policy directly from your contract and checks the user's stored credential against it before signing. + +### How eligibility is enforced + +"Base Verify" here means the Base Verify backend, the off-chain service that holds the signer key, not Base Chain. It reads your contract's `provider` and `conditions` onchain (via `eth_call`), evaluates them against the user's stored credential, and signs a verification only when they pass. Conditions come from your contract, never from the user, so a user cannot strip or fake one to obtain a verification they are not entitled to. + +## Architecture and flow + +A claim moves through your app, the Base Verify API, and your contract: + +1. The user connects their wallet in your app. +2. Your app builds a SIWE message that names your contract (in the `Resources` line) and has the user sign it. +3. Your app posts the message and signature to the Base Verify API. +4. Base recovers the wallet from the signature, reads your contract's `provider` and `conditions` onchain, and checks the wallet's stored credential against them. +5. On success, Base returns a signed verification (`identityHash`, `expiration`, `signature`). If the user isn't verified or doesn't meet the conditions, it returns a `404` or `400` instead. +6. Your app submits the verification to your contract's `enroll` function (or your deposit, borrow, or claim path). +7. Your contract calls `registry.verifyVerification(...)`, which checks the signature and expiry and recomputes `policyHash` from your live policy. Your contract then dedupes on `identityHash` and lets the user participate. + +## Implementation + + + + Extend `BaseVerifyConsumer` so your policy is readable onchain, check verifications through the registry, and dedupe on `identityHash`. This example enrolls one verified, policy-gated identity per real person, so a single farmer can't multiply rewards across wallets. + + ```solidity IncentiveProgram.sol lines expandable highlight={15-23,25-33} theme={null} + // SPDX-License-Identifier: MIT + pragma solidity 0.8.28; + + import {BaseVerifyConsumer} from "@baseverify/BaseVerifyConsumer.sol"; + + contract IncentiveProgram is BaseVerifyConsumer { + mapping(bytes32 identityHash => bool enrolled) public enrolled; + mapping(address wallet => bool active) public isParticipant; + + error AlreadyEnrolled(); + + // Pass the SignerRegistry address for your chain. + constructor(address registry_) BaseVerifyConsumer(registry_) {} + + // Your eligibility policy. Both MUST be immutable (constant / pure). + function provider() external pure override returns (string memory) { + return "coinbase"; + } + + function conditions() external pure override returns (Condition[] memory) { + Condition[] memory c = new Condition[](1); + c[0] = Condition({name: "coinbase_one_active", op: "eq", value: "true"}); + return c; + } + + function enroll(bytes32 identityHash, uint40 expiration, bytes calldata signature) external { + // One enrollment per real identity, across every wallet they control. + if (enrolled[identityHash]) revert AlreadyEnrolled(); + + // Binds msg.sender as the verified wallet; reverts on a bad or expired verification. + _verify(identityHash, expiration, signature); + + enrolled[identityHash] = true; + isParticipant[msg.sender] = true; + + // ... start accruing boosted rewards for msg.sender ... + } + } + ``` + + + `provider()` and `conditions()` must be immutable (return constants). They are folded into `policyHash`; if they change, every outstanding verification stops verifying and one identity can re-enroll under a new hash, breaking your Sybil resistance. + + + + + + Have the user sign a [SIWE](https://eips.ethereum.org/EIPS/eip-4361) message that names your contract, then POST it to the Base Verify API. + + ```typescript lib/fetch-verification.ts lines expandable highlight={6-14,20-24} theme={null} + import { createSiweMessage, generateSiweNonce } from 'viem/siwe'; + + const MY_CONTRACT_ADDRESS = '0x...'; // your deployed consumer + const CHAIN_ID = 84532; // Base Sepolia + + export async function fetchVerification( + userAddress: `0x${string}`, + signMessageAsync: (args: { message: string }) => Promise, + ) { + // The statement and Resources line below are required by the API, exactly as written. + const message = createSiweMessage({ + domain: window.location.host, + address: userAddress, + statement: 'Claim eligibility for a Base Verify onchain benefit.', + uri: window.location.origin, + version: '1', + chainId: CHAIN_ID, + nonce: generateSiweNonce(), + resources: [`eip155:${CHAIN_ID}:${MY_CONTRACT_ADDRESS}`], + }); + + const signature = await signMessageAsync({ message }); + + const res = await fetch('https://verify.base.dev/v1/onchain_verifications', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, signature }), + }); + + if (!res.ok) { + // See error handling below (e.g. 404 means send the user to Base Verify first). + throw new Error(`verification failed: ${res.status}`); + } + + // { identityHash, expiration, signature } + return res.json(); + } + ``` + + + No API key: the request needs no `Authorization` header because the SIWE signature is the credential. + + + + + + Pass the API response straight to your contract's `enroll` function (or your deposit, borrow, or claim path). + + ```typescript enroll.ts highlight={7-9} theme={null} + import { INCENTIVE_PROGRAM_ABI } from './abi'; + + const { identityHash, expiration, signature } = await fetchVerification(userAddress, signMessageAsync); + + await writeContract({ + address: MY_CONTRACT_ADDRESS, + abi: INCENTIVE_PROGRAM_ABI, + functionName: 'enroll', + args: [identityHash, expiration, signature], + }); + ``` + + Your contract calls `registry.verifyVerification(...)`; if the signature, expiry, and policy all check out, `enroll()` records the `identityHash`. A second wallet for the same person produces the same `identityHash` and is rejected. + + + + +### Error handling + +If the API does not return `200`, do not submit the transaction. Handle the response by status: + +| Response | What to do | +| :--------------------------------- | :---------------------------------------------------------------------------------------------------------- | +| **404** `contract_not_found` | The named contract isn't deployed on this chain or doesn't expose a policy. Check the address and chain. | +| **404** `verification_not_found` | The wallet has no credential for your contract's provider. Redirect the user to Base Verify to verify. | +| **404** `needs_reauth` | The credential is older than your contract's cutoff block. Send the user to Base Verify to re-authenticate. | +| **400** `conditions_not_satisfied` | The wallet is verified but does not meet your conditions. Show a message; do not redirect or retry. | +| **400** `invalid_policy` | Your contract's provider/condition/operator combination is unsupported. Fix the contract's policy. | +| **400** `invalid_argument` | Malformed or expired SIWE, wrong statement, or wrong chain. Rebuild the message. | +| **200** | Submit `identityHash`, `expiration`, and `signature` to your contract. | + +To send a user to Base Verify to complete OAuth, redirect to `https://verify.base.dev` with your app URL and the provider: + +```text Base Verify redirect URL format theme={null} +https://verify.base.dev?redirect_uri={your_app_url}&providers={provider} +``` + +## API reference + +### POST /v1/onchain_verifications + +Exchanges a SIWE signature for a signed, short-lived onchain verification. No API key: the SIWE signature is the credential, and the verification is only usable by the signing wallet at the contract it names. + +#### Request + +```json POST /v1/onchain_verifications request body theme={null} +{ + "message": "", + "signature": "0x" +} +``` + + + The SIWE message. Its `statement` must be exactly `Claim eligibility for a Base Verify onchain benefit.`, its `chainId` must be the chain you are claiming on (Base Sepolia `84532` during the test phase), and its `Resources` must include `eip155::`. + + + + The wallet's signature over the SIWE message. Base Account smart-wallet + ([ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) / + [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) signatures are supported. + + +#### Example request + +```bash POST /v1/onchain_verifications cURL example wrap theme={null} +curl -X POST https://verify.base.dev/v1/onchain_verifications \ + -H "Content-Type: application/json" \ + -d '{ + "message": "app.example.com wants you to sign in with your Ethereum account:...", + "signature": "0x1234..." + }' +``` + +#### Response `200` + +```json 200 OK response theme={null} +{ + "identityHash": "0x88c9f0a1b2c3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b", + "expiration": 1723497600, + "signature": "0x4d3c2b1a..." +} +``` + + + One-way hash of the user's real-world identity. Deterministic per identity and + per contract. Store it and dedupe on it. + + + + Unix seconds after which the verification is invalid. Verifications are + short-lived (a few minutes), so submit the transaction promptly. + + + + EIP-712 signature from a Base-operated signer. Pass it to your contract. + + +The response omits `policyHash` and the contract address: your contract recomputes `policyHash` from its own policy, and the verified wallet is the `msg.sender` your consumer passes to the registry. + +For error responses, see [Error handling](#error-handling). + +## Contracts + +### SignerRegistry + +A stateless verifier deployed by Base. It holds only a signer allowlist and answers one question: was this verification signed by a trusted signer, unexpired, and bound to the calling contract's policy? Deduplication of `identityHash` is left to each consumer. + +Your contract calls `verifyVerification`, which reverts unless the verification is valid: + +```solidity SignerRegistry.verifyVerification lines expandable theme={null} +function verifyVerification( + address user, + bytes32 identityHash, + uint40 expiration, + bytes calldata signature +) external view; +// Reverts VerificationExpired(expiration) when block.timestamp > expiration. +// Reverts InvalidSignature() when the signature cannot be recovered. +// Reverts NotSigner(signer) when the recovered signer is not on the allowlist. +``` + +`msg.sender` is the consumer contract, so a verification cannot be replayed at a different contract: the registry recomputes `policyHash` from the caller's live policy, and another contract's policy produces a different hash that fails signature recovery. + +### BaseVerifyConsumer + +The base contract your consumer extends. It exposes your policy onchain and gives you a helper that binds the caller as the verified wallet. + +```solidity BaseVerifyConsumer interface lines expandable highlight={2-4,7,15-16} theme={null} +abstract contract BaseVerifyConsumer { + struct Condition { + string name; // e.g. "followers" + string op; // eq | gt | gte | lt | lte | in + string value; // e.g. "1000" + } + + // Your eligibility policy — MUST be immutable (constant / pure). + function provider() external view virtual returns (string memory); + function conditions() external view virtual returns (Condition[] memory); + + // Optional: reject credentials last authenticated before this block. Return 0 for no cutoff. + function cutoffBlock() external view virtual returns (uint256); + + // Passes msg.sender as the verified wallet, so a verification can only be spent by its owner. + function _verify(bytes32 identityHash, uint40 expiration, bytes calldata signature) internal view; +} +``` + + + `cutoffBlock()` is not part of `policyHash`, so you can change it without + invalidating outstanding verifications. Use it to require a fresh + authentication (for example, after a policy or security change). + + +### Supported providers and conditions + +A policy is one `provider` plus one or more `conditions`. Supported operators are `eq`, `gt`, `gte`, `lt`, `lte`, and `in`. + +| Provider | Condition | Type | Operators | Example | +| :---------- | :------------------------------------------------ | :----- | :------------------------- | :---------------------------- | +| `x` | `followers` | int | `eq` `gt` `gte` `lt` `lte` | `followers gte 1000` | +| `x` | `verified` | bool | `eq` | `verified eq true` | +| `x` | `verified_type` | string | `eq` | `verified_type eq blue` | +| `coinbase` | `coinbase_one_active` | bool | `eq` | `coinbase_one_active eq true` | +| `coinbase` | `coinbase_one_billed` | bool | `eq` | `coinbase_one_billed eq true` | +| `instagram` | `followers_count` | int | `eq` `gt` `gte` `lt` `lte` | `followers_count gte 5000` | +| `instagram` | `username` | string | `eq` | `username eq base` | +| `tiktok` | `follower_count` | int | `eq` `gt` `gte` `lt` `lte` | `follower_count gte 10000` | +| `tiktok` | `following_count` / `likes_count` / `video_count` | int | `eq` `gt` `gte` `lt` `lte` | `video_count gte 50` | + +When you declare multiple conditions for a provider, **all** must be satisfied (AND logic). + +## Security + +### What Base Verify enforces + +- **Eligibility** is checked against the user's real credential, read from your contract's policy, so users cannot fake conditions. +- **One identity, one action**, via the deterministic `identityHash` you dedupe on. +- **Verifications are bound** to your contract and chain and expire quickly (a few minutes). +- **Only the signing wallet** can submit a verification, so it cannot be front-run out of the mempool (when you extend `BaseVerifyConsumer` and pass `msg.sender`). + +### Requirements + +- Keep your policy (`provider` and `conditions`) immutable. +- Dedupe on `identityHash` in your claim path. +- Treat a successful check as proof of a unique verified identity, not of any specific account or personal data. + +## Support + +**Want to integrate Base Verify Onchain?** Fill out the [interest form](https://forms.gle/WTcuWyKkvUV6gGik6) and the team will reach out. diff --git a/docs/docs.json b/docs/docs.json index 10d4e9b06..f6ce1a40a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -666,7 +666,8 @@ "pages": [ "apps/technical-guides/base-notifications", "apps/guides/migrate-to-standard-web-app", - "apps/guides/accept-b20-payments" + "apps/guides/accept-b20-payments", + "apps/guides/verify-onchain" ] }, {