From 3d27f599fc11e74b801a6fec1e1237f91008006e Mon Sep 17 00:00:00 2001 From: mverzilli Date: Wed, 2 Sep 2026 11:46:27 +0000 Subject: [PATCH 01/11] feat: Gate Toucan auctions with ZKPassport policy verification Integrates the ZKPassportAttest registry into the Launches flow: - Creator wizard: a 'Use ZKPassport' option sources the policy dropdown from on-chain PolicyCreated logs and links out for custom policies. - Auction page: a Verify button opens the ZKPassport popup with the policyId read from the auction's validation hook, and eligibility refreshes from the popup's postMessage result without a reload. - Popup and policy-creator URLs are env-overridable (ZKPASSPORT_POPUP_URL, ZKPASSPORT_CREATE_POLICY_URL) with localhost fallbacks for dev. Also carries the workspace unblocks the build needs on a fresh checkout: nx.json/bun.lock updates and tsconfig/package stubs for workspace projects absent from this fork. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TQ9i1sp3EJA9wwPAViJS7X --- apps/cli/tsconfig.json | 3 + apps/dev-portal/tsconfig.json | 3 + apps/mission-control/tsconfig.json | 3 + .../Toucan/Auction/BidForm/BidForm.tsx | 38 +- .../ZkPassport/ZkPassportPolicyPicker.tsx | 110 + .../web/src/features/Toucan/ZkPassport/abi.ts | 88 + .../src/features/Toucan/ZkPassport/config.ts | 39 + .../Toucan/ZkPassport/useZkPassportGate.ts | 122 + .../ZkPassport/useZkPassportPolicies.ts | 89 + .../CreateAuction/components/KycCard.tsx | 11 +- bun.lock | 4829 ++--------------- nx.json | 186 +- packages/transactional/tsconfig.json | 3 + .../src/i18n/locales/source/en-US.json | 480 +- tools/uniswap-nx/package.json | 13 + tools/uniswap-nx/tsconfig.json | 8 + 16 files changed, 1186 insertions(+), 4839 deletions(-) create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/dev-portal/tsconfig.json create mode 100644 apps/mission-control/tsconfig.json create mode 100644 apps/web/src/features/Toucan/ZkPassport/ZkPassportPolicyPicker.tsx create mode 100644 apps/web/src/features/Toucan/ZkPassport/abi.ts create mode 100644 apps/web/src/features/Toucan/ZkPassport/config.ts create mode 100644 apps/web/src/features/Toucan/ZkPassport/useZkPassportGate.ts create mode 100644 apps/web/src/features/Toucan/ZkPassport/useZkPassportPolicies.ts create mode 100644 packages/transactional/tsconfig.json create mode 100644 tools/uniswap-nx/package.json create mode 100644 tools/uniswap-nx/tsconfig.json diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 00000000000..d946318dcb5 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,3 @@ +{ + "files": [] +} diff --git a/apps/dev-portal/tsconfig.json b/apps/dev-portal/tsconfig.json new file mode 100644 index 00000000000..d946318dcb5 --- /dev/null +++ b/apps/dev-portal/tsconfig.json @@ -0,0 +1,3 @@ +{ + "files": [] +} diff --git a/apps/mission-control/tsconfig.json b/apps/mission-control/tsconfig.json new file mode 100644 index 00000000000..d946318dcb5 --- /dev/null +++ b/apps/mission-control/tsconfig.json @@ -0,0 +1,3 @@ +{ + "files": [] +} diff --git a/apps/web/src/features/Toucan/Auction/BidForm/BidForm.tsx b/apps/web/src/features/Toucan/Auction/BidForm/BidForm.tsx index 915d23e4dfa..5aa0671a580 100644 --- a/apps/web/src/features/Toucan/Auction/BidForm/BidForm.tsx +++ b/apps/web/src/features/Toucan/Auction/BidForm/BidForm.tsx @@ -39,6 +39,7 @@ import { AuctionProgressState } from '~/features/Toucan/Auction/store/types' import { useAuctionStore, useAuctionStoreActions } from '~/features/Toucan/Auction/store/useAuctionStore' import { getRequiredTestnetMode } from '~/features/Toucan/Shared/getRequiredTestnetMode' import { InlineAlertBanner } from '~/features/Toucan/Shared/InlineAlertBanner' +import { useZkPassportGate } from '~/features/Toucan/ZkPassport/useZkPassportGate' const VerticalLineContainer = styled(Flex, { width: '100%', @@ -82,6 +83,7 @@ export function BidForm({ onInputChange, onBidSubmitted }: BidFormProps): JSX.El const [isReviewModalOpen, setIsReviewModalOpen] = useState(false) const [isKycInterstitialModalOpen, setIsKycInterstitialModalOpen] = useState(false) + const [isZkInterstitialModalOpen, setIsZkInterstitialModalOpen] = useState(false) const [isKycFailedModalOpen, setIsKycFailedModalOpen] = useState(false) const [showTokenWarningModal, setShowTokenWarningModal] = useState(false) @@ -139,16 +141,25 @@ export function BidForm({ onInputChange, onBidSubmitted }: BidFormProps): JSX.El currentBlockNumber, }) + const zkGate = useZkPassportGate({ + chainId, + validationHook, + walletAddress: accountAddress, + }) + const zkNeedsVerify = zkGate.isGated && isWalletConnected && !zkGate.isEligible + const { showDisabledState, shouldShowWarningBanner, shouldDisableBidForm } = useBidFormWarningState({ chainId, currency, auctionProgressState, userBids, - validationHook, + // A ZKPassport hook is resolved entirely on-chain, so it is neither an + // unsupported validation hook nor a verify-wallet backend concern. + validationHook: zkGate.isGated ? undefined : validationHook, // Only treat KYC as an unsupported-auction signal once a wallet is connected; // otherwise the disabled verify-wallet query is misread as an error and surfaces // the warning banner instead of the connect-wallet CTA on the action button. - validationError: isWalletConnected && kycStatus.isError, + validationError: !zkGate.isGated && isWalletConnected && kycStatus.isError, }) const handleButtonPress = (): void => { @@ -160,6 +171,10 @@ export function BidForm({ onInputChange, onBidSubmitted }: BidFormProps): JSX.El dispatch(setIsTestnetModeEnabled(requiredTestnetMode)) return } + if (zkNeedsVerify) { + setIsZkInterstitialModalOpen(true) + return + } if (kycStatus.canBid) { handleReviewBidClick() } else if (kycStatus.onKycAction) { @@ -177,16 +192,20 @@ export function BidForm({ onInputChange, onBidSubmitted }: BidFormProps): JSX.El if (needsTestnetModeSwitch) { return requiredTestnetMode ? t('toucan.action.enableTestnetMode') : t('toucan.action.disableTestnetMode') } + if (zkNeedsVerify) { + return t('toucan.zkpassport.verify') + } return (kycStatus.kycButtonLabel ?? showDisabledState) ? t('toucan.auction.bidForm.auctionConcluded') : t('toucan.bidForm.reviewBid') })() // The testnet-mode-switch CTA stays tappable regardless of the bid inputs, since switching mode is - // always a valid action and is a prerequisite to bidding at all. + // always a valid action and is a prerequisite to bidding at all. The same goes for the ZKPassport + // verify CTA: it opens the verification popup, so the bid inputs don't constrain it. const buttonDisabled = isGeoRestricted || - (isWalletConnected && !needsTestnetModeSwitch + (isWalletConnected && !needsTestnetModeSwitch && !zkNeedsVerify ? submitState.isDisabled || !isAuctionInProgress || shouldDisableBidForm || kycStatus.kycButtonDisabled : false) @@ -343,6 +362,17 @@ export function BidForm({ onInputChange, onBidSubmitted }: BidFormProps): JSX.El onClose={() => setIsKycInterstitialModalOpen(false)} onContinue={kycStatus.onKycAction} /> + setIsZkInterstitialModalOpen(false)} + onContinue={() => { + setIsZkInterstitialModalOpen(false) + zkGate.openVerify() + }} + providerName="ZKPassport" + providerTermsUrl="https://zkpassport.id/terms" + providerPrivacyUrl="https://zkpassport.id/privacy" + /> setIsKycFailedModalOpen(false)} /> {shouldShowTokenWarning && token && ( void +}) { + const { t } = useTranslation() + const [useZkPassport, setUseZkPassport] = useState(false) + const [dropdownOpen, setDropdownOpen] = useState(false) + const [selectedPolicyId, setSelectedPolicyId] = useState() + const { policies, isLoading } = useZkPassportPolicies(chainId) + + const selected = policies.find((option) => option.policyId === selectedPolicyId) + + const toggle = (checked: boolean) => { + setUseZkPassport(checked) + if (!checked) { + setSelectedPolicyId(undefined) + onSelectHook(undefined) + } + } + + return ( + + + toggle(!useZkPassport)} /> + toggle(!useZkPassport)}> + {t('toucan.zkpassport.usePolicy')} + + + + {useZkPassport && ( + + + {selected?.label ?? (isLoading ? '…' : t('toucan.zkpassport.policyDropdownPlaceholder'))} + + } + > + {policies.map((option) => ( + { + setSelectedPolicyId(option.policyId) + onSelectHook(option.hook) + setDropdownOpen(false) + }} + > + {option.label} + {selectedPolicyId === option.policyId ? ( + + ) : null} + + ))} + + + + + {t('toucan.zkpassport.customPolicy')} + + + + )} + + ) +} diff --git a/apps/web/src/features/Toucan/ZkPassport/abi.ts b/apps/web/src/features/Toucan/ZkPassport/abi.ts new file mode 100644 index 00000000000..4e887b76d98 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/abi.ts @@ -0,0 +1,88 @@ +/** + * Minimal ABI fragments for reading ZKPassport state on-chain. Everything the + * interface needs is the stock GatedERC1155ValidationHook surface plus the + * ERC-1155 reads (balanceOf/uri) and the registry's policy views — no vendor + * SDK involved. + */ + +export const gatedErc1155HookAbi = [ + { + type: 'function', + name: 'erc1155', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'address' }], + }, + { + type: 'function', + name: 'tokenId', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint256' }], + }, +] as const + +export const zkPassportAttestAbi = [ + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [ + { name: 'account', type: 'address' }, + { name: 'id', type: 'uint256' }, + ], + outputs: [{ type: 'uint256' }], + }, + { + type: 'function', + name: 'uri', + stateMutability: 'view', + inputs: [{ name: 'policyId', type: 'uint256' }], + outputs: [{ type: 'string' }], + }, + { + type: 'function', + name: 'getPolicy', + stateMutability: 'view', + inputs: [{ name: 'policyId', type: 'uint256' }], + outputs: [ + { + type: 'tuple', + components: [ + { name: 'owner', type: 'address' }, + { name: 'validityPeriod', type: 'uint64' }, + { name: 'unique', type: 'bool' }, + { name: 'saltedNullifierOnly', type: 'bool' }, + { name: 'minAge', type: 'uint8' }, + { name: 'sanctionsCheck', type: 'bool' }, + { name: 'excludedCountries', type: 'string[]' }, + { name: 'metadataURL', type: 'string' }, + { name: 'hook', type: 'address' }, + { name: 'retiredAt', type: 'uint64' }, + ], + }, + ], + }, + { + type: 'event', + name: 'PolicyCreated', + inputs: [ + { name: 'policyId', type: 'uint256', indexed: true }, + { name: 'owner', type: 'address', indexed: true }, + { name: 'hook', type: 'address', indexed: false }, + ], + }, +] as const + +export type ZkPassportPolicy = { + owner: `0x${string}` + validityPeriod: bigint + unique: boolean + saltedNullifierOnly: boolean + minAge: number + sanctionsCheck: boolean + excludedCountries: readonly string[] + metadataURL: string + hook: `0x${string}` + retiredAt: bigint +} diff --git a/apps/web/src/features/Toucan/ZkPassport/config.ts b/apps/web/src/features/Toucan/ZkPassport/config.ts new file mode 100644 index 00000000000..9d050cbc93e --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/config.ts @@ -0,0 +1,39 @@ +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { CHAIN_ID_TO_URL_PARAM } from 'uniswap/src/features/chains/chainUrlParam' +import { UniverseChainId } from 'uniswap/src/features/chains/types' + +/** Origin serving the ZKPassport verification popup. */ +export const ZKPASSPORT_POPUP_URL = (process.env.ZKPASSPORT_POPUP_URL ?? 'http://localhost:3010').replace(/\/$/, '') + +/** + * ZKPassportAttest registry per chain. Used to source the creator-flow policy + * dropdown and to recognize auction validation hooks that gate on it. + */ +export const ZKPASSPORT_ATTEST_REGISTRY: Partial> = { + [UniverseChainId.Sepolia]: '0x0FF14Da5e8A6AE442772Fc810BA815A73240d566', +} + +/** Block each registry was deployed at, bounding PolicyCreated log scans. */ +export const ZKPASSPORT_ATTEST_DEPLOY_BLOCK: Partial> = { + [UniverseChainId.Sepolia]: 11584016n, +} + +/** Link for creators who want a policy beyond the ready-made list. */ +export const ZKPASSPORT_CREATE_POLICY_URL = process.env.ZKPASSPORT_CREATE_POLICY_URL ?? 'http://localhost:3001/creator' + +export function zkPassportVerifyUrl({ + chainId, + registry, + policyId, +}: { + chainId: UniverseChainId + registry: string + policyId: bigint +}): string { + const chainParam = CHAIN_ID_TO_URL_PARAM[chainId] + // The mobile app roots proofs in the mainnet registries unless dev mode is + // requested, which switches to the testnet registries — so testnet chains + // only verify dev-mode proofs. + const dev = getChainInfo(chainId).testnet ? '&dev=1' : '' + return `${ZKPASSPORT_POPUP_URL}/?chain=${chainParam}®istry=${registry}&policyId=${policyId}${dev}` +} diff --git a/apps/web/src/features/Toucan/ZkPassport/useZkPassportGate.ts b/apps/web/src/features/Toucan/ZkPassport/useZkPassportGate.ts new file mode 100644 index 00000000000..33811610863 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/useZkPassportGate.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo } from 'react' +import type { EVMUniverseChainId, UniverseChainId } from 'uniswap/src/features/chains/types' +import { useReadContract, useReadContracts } from 'wagmi' +import { assume0xAddress, zeroAddress } from '~/chains' +import { gatedErc1155HookAbi, zkPassportAttestAbi } from '~/features/Toucan/ZkPassport/abi' +import { ZKPASSPORT_ATTEST_REGISTRY, zkPassportVerifyUrl } from '~/features/Toucan/ZkPassport/config' + +export interface ZkPassportGate { + /** The auction's validation hook gates on a ZKPassport credential */ + isGated: boolean + /** Wallet holds a valid, unexpired credential for the hook's policy */ + isEligible: boolean + /** Still resolving the hook introspection or the balance read */ + isLoading: boolean + policyId?: bigint + registry?: `0x${string}` + /** Opens the ZKPassport verification popup for the hook's policy */ + openVerify: () => void +} + +/** + * Resolves whether an auction's validation hook gates bids on a ZKPassport + * credential, and whether the connected wallet already holds one. Both reads + * are the stock ERC-1155 surface (hook.erc1155()/tokenId(), then balanceOf), + * so no vendor API is involved; the popup handles the actual verification and + * posts back when a credential is minted, which refreshes the balance read. + */ +export function useZkPassportGate({ + chainId, + validationHook, + walletAddress, +}: { + chainId?: UniverseChainId + validationHook?: string + walletAddress?: string +}): ZkPassportGate { + const evmChainId = chainId as EVMUniverseChainId | undefined + const hookAddress = validationHook && validationHook !== zeroAddress ? assume0xAddress(validationHook) : undefined + + const { data: hookViews, isLoading: isHookLoading } = useReadContracts({ + contracts: [ + { + address: hookAddress, + chainId: evmChainId, + abi: gatedErc1155HookAbi, + functionName: 'erc1155', + }, + { + address: hookAddress, + chainId: evmChainId, + abi: gatedErc1155HookAbi, + functionName: 'tokenId', + }, + ], + query: { + enabled: Boolean(hookAddress && chainId), + staleTime: Infinity, + retry: 1, + }, + }) + + const knownRegistry = chainId ? ZKPASSPORT_ATTEST_REGISTRY[chainId] : undefined + const hookRegistry = hookViews?.[0].result + const policyId = hookViews?.[1].result + const isGated = Boolean( + knownRegistry && + hookRegistry && + policyId !== undefined && + hookRegistry.toLowerCase() === knownRegistry.toLowerCase(), + ) + + const { + data: balance, + isLoading: isBalanceLoading, + refetch: refetchBalance, + } = useReadContract({ + address: isGated ? hookRegistry : undefined, + chainId: evmChainId, + abi: zkPassportAttestAbi, + functionName: 'balanceOf', + args: walletAddress && policyId !== undefined ? [assume0xAddress(walletAddress), policyId] : undefined, + query: { + enabled: Boolean(isGated && walletAddress && policyId !== undefined), + }, + }) + + useEffect(() => { + if (!isGated) { + return undefined + } + const onMessage = (event: MessageEvent): void => { + const data = event.data as { type?: string; policyId?: string } | null + if (data?.type === 'zkpassport-attest-result' && data.policyId === policyId?.toString()) { + void refetchBalance() + } + } + window.addEventListener('message', onMessage) + return () => window.removeEventListener('message', onMessage) + }, [isGated, policyId, refetchBalance]) + + const openVerify = useCallback(() => { + if (chainId && hookRegistry && policyId !== undefined) { + window.open( + zkPassportVerifyUrl({ chainId, registry: hookRegistry, policyId }), + 'zkpassport-verify', + 'width=480,height=720', + ) + } + }, [chainId, hookRegistry, policyId]) + + return useMemo( + () => ({ + isGated, + isEligible: isGated && (balance ?? 0n) > 0n, + isLoading: Boolean(hookAddress) && (isHookLoading || (isGated && Boolean(walletAddress) && isBalanceLoading)), + policyId: isGated ? policyId : undefined, + registry: isGated ? hookRegistry : undefined, + openVerify, + }), + [isGated, balance, hookAddress, isHookLoading, walletAddress, isBalanceLoading, policyId, hookRegistry, openVerify], + ) +} diff --git a/apps/web/src/features/Toucan/ZkPassport/useZkPassportPolicies.ts b/apps/web/src/features/Toucan/ZkPassport/useZkPassportPolicies.ts new file mode 100644 index 00000000000..97615c47495 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/useZkPassportPolicies.ts @@ -0,0 +1,89 @@ +import { useQuery } from '@tanstack/react-query' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { usePublicClient } from 'wagmi' +import { zkPassportAttestAbi, type ZkPassportPolicy } from '~/features/Toucan/ZkPassport/abi' +import { ZKPASSPORT_ATTEST_DEPLOY_BLOCK, ZKPASSPORT_ATTEST_REGISTRY } from '~/features/Toucan/ZkPassport/config' + +export interface ZkPassportPolicyOption { + policyId: bigint + hook: `0x${string}` + label: string + policy: ZkPassportPolicy +} + +const policyCreatedEvent = zkPassportAttestAbi.find((entry) => entry.type === 'event' && entry.name === 'PolicyCreated') + +/** Compact human-readable summary of what a policy checks. */ +export function zkPassportPolicyLabel(policy: ZkPassportPolicy): string { + const parts: string[] = [] + if (policy.minAge > 0) { + parts.push(`${policy.minAge}+`) + } + if (policy.sanctionsCheck) { + parts.push('sanctions-clear') + } + if (policy.excludedCountries.length > 0) { + parts.push(`excludes ${policy.excludedCountries.join(', ')}`) + } + if (policy.unique) { + parts.push('one per document') + } + return parts.length > 0 ? parts.join(' · ') : 'No requirements' +} + +/** + * Live list of policies on the chain's ZKPassportAttest registry, enumerated + * from PolicyCreated logs and enriched via getPolicy. Retired policies are + * dropped. Each option carries its per-policy validation hook — the address + * the auction's validationHook parameter takes. + */ +export function useZkPassportPolicies(chainId?: UniverseChainId): { + policies: ZkPassportPolicyOption[] + isLoading: boolean +} { + const registry = chainId ? ZKPASSPORT_ATTEST_REGISTRY[chainId] : undefined + const deployBlock = chainId ? ZKPASSPORT_ATTEST_DEPLOY_BLOCK[chainId] : undefined + const publicClient = usePublicClient({ chainId }) + + const { data, isLoading } = useQuery({ + queryKey: ['zkpassport-policies', chainId, registry], + enabled: Boolean(registry && publicClient), + queryFn: async (): Promise => { + if (!registry || !publicClient || !policyCreatedEvent) { + return [] + } + const logs = await publicClient.getLogs({ + address: registry, + event: policyCreatedEvent, + fromBlock: deployBlock ?? 0n, + toBlock: 'latest', + }) + const options = await Promise.all( + logs.map(async (log) => { + const policyId = log.args.policyId + if (policyId === undefined) { + return undefined + } + const policy = (await publicClient.readContract({ + address: registry, + abi: zkPassportAttestAbi, + functionName: 'getPolicy', + args: [policyId], + })) as ZkPassportPolicy + if (policy.retiredAt !== 0n) { + return undefined + } + return { + policyId, + hook: policy.hook, + label: zkPassportPolicyLabel(policy), + policy, + } + }), + ) + return options.filter((option): option is ZkPassportPolicyOption => option !== undefined) + }, + }) + + return { policies: data ?? [], isLoading } +} diff --git a/apps/web/src/pages/Liquidity/CreateAuction/components/KycCard.tsx b/apps/web/src/pages/Liquidity/CreateAuction/components/KycCard.tsx index b14c54e1ac1..a0d11854b3b 100644 --- a/apps/web/src/pages/Liquidity/CreateAuction/components/KycCard.tsx +++ b/apps/web/src/pages/Liquidity/CreateAuction/components/KycCard.tsx @@ -6,6 +6,7 @@ import { UserCheck } from 'ui/src/components/icons/UserCheck' import { X } from 'ui/src/components/icons/X' import { UniswapHelpUrls } from 'uniswap/src/constants/urls' import { shortenAddress } from 'utilities/src/addresses' +import { ZkPassportPolicyPicker } from '~/features/Toucan/ZkPassport/ZkPassportPolicyPicker' import { KycHookSetupModal } from '~/pages/Liquidity/CreateAuction/components/KycHookSetupModal' import { useCreateAuctionStore, @@ -81,7 +82,10 @@ export function KycCard() { } if (kycValidationHookAddress) { - const short = shortenAddress({ address: kycValidationHookAddress, chars: 6 }) + const short = shortenAddress({ + address: kycValidationHookAddress, + chars: 6, + }) return ( @@ -139,6 +143,11 @@ export function KycCard() { + setKycValidationHookAddress(hookAddress)} + /> +