diff --git a/.github/scripts/inject-basic-auth.mjs b/.github/scripts/inject-basic-auth.mjs new file mode 100644 index 00000000000..67bdb545d94 --- /dev/null +++ b/.github/scripts/inject-basic-auth.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Injects a basic-auth edge middleware into a Vercel Build Output API v3 +// bundle (.vercel/output) after the build, so the demo deployment is gated +// without touching any app code. The gate only engages when the Vercel +// project defines BASIC_AUTH_CREDENTIALS ("user:pass"); without it the +// middleware passes every request through. +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const outputDir = process.argv[2] +if (!outputDir) { + console.error('Usage: inject-basic-auth.mjs ') + process.exit(1) +} + +const configPath = resolve(outputDir, 'config.json') +const config = JSON.parse(readFileSync(configPath, 'utf8')) + +// The middleware route must come first so it runs before the static +// filesystem handler and the serverless function routes. +if (!config.routes?.some((route) => route.middlewarePath === '_middleware')) { + config.routes = [ + { src: '/(.*)', middlewarePath: '_middleware', continue: true }, + ...(config.routes ?? []), + ] +} +writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) + +const middlewareSource = `export default function middleware(request) { + const credentials = process.env.BASIC_AUTH_CREDENTIALS + if (!credentials) { + return new Response(null, { headers: { 'x-middleware-next': '1' } }) + } + const header = request.headers.get('authorization') ?? '' + const [scheme, token, ...rest] = header.split(' ') + const authorized = + Boolean(scheme && token) && + rest.length === 0 && + scheme.toLowerCase() === 'basic' && + token === btoa(credentials) + if (authorized) { + return new Response(null, { headers: { 'x-middleware-next': '1' } }) + } + return new Response('Authentication required', { + status: 401, + headers: { 'WWW-Authenticate': 'Basic realm="uniswap-zkpassport-demo"' }, + }) +} +` + +const funcDir = resolve(outputDir, 'functions/_middleware.func') +mkdirSync(funcDir, { recursive: true }) +writeFileSync( + resolve(funcDir, '.vc-config.json'), + `${JSON.stringify( + { runtime: 'edge', entrypoint: 'index.js', envVarsInUse: ['BASIC_AUTH_CREDENTIALS'] }, + null, + 2, + )}\n`, +) +writeFileSync(resolve(funcDir, 'index.js'), middlewareSource) + +console.log(`[inject-basic-auth] Edge middleware injected into ${outputDir}`) diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 00000000000..c1d6415e020 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,91 @@ +# Builds the web app and ships it to Vercel as a prebuilt deployment. +# Lives only on the ZKPassport demo branch; requires repo secrets +# VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID and repo variables +# ZKPASSPORT_POPUP_URL, ZKPASSPORT_CREATE_POLICY_URL. Optional repo +# variables ZKPASSPORT_ATTEST_REGISTRY_SEPOLIA and +# ZKPASSPORT_ATTEST_DEPLOY_BLOCK_SEPOLIA override the registry baked +# into apps/web ZkPassport/config.ts without a commit (set both, then +# re-run this workflow). +name: Deploy demo to Vercel + +on: + push: + branches: [martin/zkpassport-demo] + workflow_dispatch: + +concurrency: + group: deploy-demo + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Check required configuration + env: + POPUP_URL: ${{ vars.ZKPASSPORT_POPUP_URL }} + CREATE_POLICY_URL: ${{ vars.ZKPASSPORT_CREATE_POLICY_URL }} + run: | + test -n "$POPUP_URL" || { echo "Repo variable ZKPASSPORT_POPUP_URL is not set"; exit 1; } + test -n "$CREATE_POLICY_URL" || { echo "Repo variable ZKPASSPORT_CREATE_POLICY_URL is not set"; exit 1; } + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Values here are not secrets: a placeholder WalletConnect id (extension + # wallets work without a real one), blanked Privy ids so the provider is + # skipped, and the public demo URLs. + - name: Write env overrides + working-directory: apps/web + env: + POPUP_URL: ${{ vars.ZKPASSPORT_POPUP_URL }} + CREATE_POLICY_URL: ${{ vars.ZKPASSPORT_CREATE_POLICY_URL }} + ATTEST_REGISTRY: ${{ vars.ZKPASSPORT_ATTEST_REGISTRY_SEPOLIA }} + ATTEST_DEPLOY_BLOCK: ${{ vars.ZKPASSPORT_ATTEST_DEPLOY_BLOCK_SEPOLIA }} + run: | + cat > .env.override <> .env.override + fi + if [ -n "$ATTEST_DEPLOY_BLOCK" ]; then + echo "ZKPASSPORT_ATTEST_DEPLOY_BLOCK_SEPOLIA=\"$ATTEST_DEPLOY_BLOCK\"" >> .env.override + fi + + - name: Build + env: + SKIP_CONFIG_PULL: "true" + run: bunx nx run @universe/web:build:vercel + + - name: Inject basic-auth middleware + run: node .github/scripts/inject-basic-auth.mjs apps/web/.vercel/output + + - name: Deploy + working-directory: apps/web + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: bunx vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN" 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..781c6d3c0eb 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,7 +171,14 @@ export function BidForm({ onInputChange, onBidSubmitted }: BidFormProps): JSX.El dispatch(setIsTestnetModeEnabled(requiredTestnetMode)) return } - if (kycStatus.canBid) { + if (zkNeedsVerify) { + setIsZkInterstitialModalOpen(true) + return + } + // A ZKPassport-gated auction is validated by its on-chain hook, so the + // backend verify-wallet outcome (unreachable from third-party origins) + // must not block a bidder who already passed the zk gate. + if (zkGate.isGated || kycStatus.canBid) { handleReviewBidClick() } else if (kycStatus.onKycAction) { kycStatus.onKycAction() @@ -177,17 +195,24 @@ 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 - ? submitState.isDisabled || !isAuctionInProgress || shouldDisableBidForm || kycStatus.kycButtonDisabled + (isWalletConnected && !needsTestnetModeSwitch && !zkNeedsVerify + ? submitState.isDisabled || + !isAuctionInProgress || + shouldDisableBidForm || + (!zkGate.isGated && kycStatus.kycButtonDisabled) : false) const shouldShowSwapBanner = @@ -343,6 +368,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 - setCurrentStep?: SetCurrentStepFn + preBidSteps?: TransactionStep[]; + setSteps?: (steps: TransactionStep[]) => void; + setCurrentStep?: SetCurrentStepFn; /** * Optional callback to run after pre-bid steps complete but before actual bid submission. * Used for post-permit2 simulation to validate bid against latest clearing price. * If this returns false, the bid submission is aborted. */ - onPreBidStepsComplete?: () => Promise + onPreBidStepsComplete?: () => Promise; } export interface SubmitState { - onSubmit: (prepared?: PreparedBidTransaction, options?: SubmitBidOptions) => Promise - prepareTransaction: () => Promise - isDisabled: boolean - isPending: boolean - onTransactionSubmitted?: () => void - error?: Error - clearError: () => void + onSubmit: ( + prepared?: PreparedBidTransaction, + options?: SubmitBidOptions + ) => Promise; + prepareTransaction: () => Promise; + isDisabled: boolean; + isPending: boolean; + onTransactionSubmitted?: () => void; + error?: Error; + clearError: () => void; } interface UseBidFormSubmitParams { evaluateMaxPrice: (options?: { shouldAutoCorrectMin?: boolean }) => { - sanitizedQ96?: bigint - sanitizedDisplayValue?: string - error?: string - } - exactMaxValuationAmount: string - setExactMaxValuationAmount: (amount: string) => void - setMaxPriceError: (error: string | undefined) => void - budgetCurrencyAmount: CurrencyAmount | undefined - accountAddress: string | undefined - auctionContractAddress: string | undefined - chainId: number | undefined - isNativeBidToken: boolean - currency: string | undefined - auctionTokenAddress?: string - resetBudgetField: () => void - resetMaxValuationField: () => void + sanitizedQ96?: bigint; + sanitizedDisplayValue?: string; + error?: string; + }; + exactMaxValuationAmount: string; + setExactMaxValuationAmount: (amount: string) => void; + setMaxPriceError: (error: string | undefined) => void; + budgetCurrencyAmount: CurrencyAmount | undefined; + accountAddress: string | undefined; + auctionContractAddress: string | undefined; + chainId: number | undefined; + isNativeBidToken: boolean; + currency: string | undefined; + auctionTokenAddress?: string; + resetBudgetField: () => void; + resetMaxValuationField: () => void; // Validation flags - budgetAmountIsZero: boolean - maxPriceAmountIsZero: boolean - exceedsBalance: boolean - isMaxPriceBelowMinimum: boolean - bidTokenDecimals: number | undefined - maxPriceQ96: bigint | undefined - onTransactionSubmitted?: () => void + budgetAmountIsZero: boolean; + maxPriceAmountIsZero: boolean; + exceedsBalance: boolean; + isMaxPriceBelowMinimum: boolean; + bidTokenDecimals: number | undefined; + maxPriceQ96: bigint | undefined; + onTransactionSubmitted?: () => void; // Analytics parameters - budgetAmountUsd?: number - maxFdvUsd?: number - pricePerToken?: number - expectedReceiveAmount?: number - minExpectedReceiveAmount?: number - maxReceivableAmount?: number - auctionTokenSymbol?: string - auctionTokenName?: string + budgetAmountUsd?: number; + maxFdvUsd?: number; + pricePerToken?: number; + expectedReceiveAmount?: number; + minExpectedReceiveAmount?: number; + maxReceivableAmount?: number; + auctionTokenSymbol?: string; + auctionTokenName?: string; } interface UseBidFormSubmitResult { - submitState: SubmitState + submitState: SubmitState; } export function useBidFormSubmit({ @@ -119,24 +132,31 @@ export function useBidFormSubmit({ auctionTokenSymbol, auctionTokenName, }: UseBidFormSubmitParams): UseBidFormSubmitResult { - const submitBidMutation = useSubmitBidMutation() - const toucanSubmitBid = useToucanSubmitBid() - const { evmAccount } = useWallet() - const trace = useTrace() - const isCentralizedPricesEnabled = useFeatureFlag(FeatureFlags.CentralizedPrices) - const preparedBidRef = useRef<{ signature: string; data: PreparedBidTransaction } | null>(null) - const [submissionError, setSubmissionError] = useState(undefined) + const submitBidMutation = useSubmitBidMutation(); + const toucanSubmitBid = useToucanSubmitBid(); + const { evmAccount } = useWallet(); + const trace = useTrace(); + const isCentralizedPricesEnabled = useFeatureFlag( + FeatureFlags.CentralizedPrices + ); + const preparedBidRef = useRef<{ + signature: string; + data: PreparedBidTransaction; + } | null>(null); + const [submissionError, setSubmissionError] = useState( + undefined + ); // Store actions for optimistic bid management - const { setOptimisticBid, setPreviousBidsCount } = useAuctionStoreActions() - const currentBidsCount = useAuctionStore((state) => state.userBids.length) + const { setOptimisticBid, setPreviousBidsCount } = useAuctionStoreActions(); + const currentBidsCount = useAuctionStore((state) => state.userBids.length); - const setCurrentTransactionStep = useCallback(() => {}, []) + const setCurrentTransactionStep = useCallback(() => {}, []); const resetForm = useEvent(() => { - resetBudgetField() - resetMaxValuationField() - }) + resetBudgetField(); + resetMaxValuationField(); + }); const handleBidSubmitSuccess = useEvent((hash: string) => { // Capture values before resetForm() clears them @@ -145,212 +165,252 @@ export function useBidFormSubmit({ maxPriceQ96: maxPriceQ96.toString(), budgetRaw: budgetCurrencyAmount.quotient.toString(), bidTokenDecimals, - bidTokenSymbol: budgetCurrencyAmount.currency.symbol ?? '', + bidTokenSymbol: budgetCurrencyAmount.currency.symbol ?? "", submittedAt: Date.now(), txHash: hash, - } + }; // Store previous count for detection when API returns new bid - setPreviousBidsCount(currentBidsCount) + setPreviousBidsCount(currentBidsCount); // Set optimistic bid - setOptimisticBid(optimisticBid) + setOptimisticBid(optimisticBid); } - preparedBidRef.current = null - resetForm() - submitBidMutation.reset() - onTransactionSubmitted?.() - }) + preparedBidRef.current = null; + resetForm(); + submitBidMutation.reset(); + onTransactionSubmitted?.(); + }); const handleBidSubmitFailure = useEvent((error: Error) => { // Clear optimistic bid on failure - setOptimisticBid(null) + setOptimisticBid(null); // Don't show popup here - the transaction popup will handle failures automatically // For user rejections, no popup should be shown at all - preparedBidRef.current = null - setSubmissionError(error) + preparedBidRef.current = null; + setSubmissionError(error); logger.error(error, { - tags: { file: 'BidFormSubmit', function: 'handleBidSubmitFailure' }, - extra: { message: 'Failed to submit bid' }, - }) - }) - - const prepareTransaction = useEvent(async (): Promise => { - const { sanitizedQ96, sanitizedDisplayValue, error } = evaluateMaxPrice({ shouldAutoCorrectMin: true }) - - if (error) { - setMaxPriceError(error) - return undefined - } - - if (!sanitizedQ96 || !budgetCurrencyAmount || !accountAddress || !auctionContractAddress || !chainId) { - return undefined - } - - const amountRaw = BigInt(budgetCurrencyAmount.quotient.toString()) - if (amountRaw === 0n) { - return undefined - } - - if (sanitizedDisplayValue && sanitizedDisplayValue !== exactMaxValuationAmount) { - setExactMaxValuationAmount(sanitizedDisplayValue) - } - - setMaxPriceError(undefined) + tags: { file: "BidFormSubmit", function: "handleBidSubmitFailure" }, + extra: { message: "Failed to submit bid" }, + }); + }); + + const prepareTransaction = useEvent( + async (): Promise => { + const { sanitizedQ96, sanitizedDisplayValue, error } = evaluateMaxPrice({ + shouldAutoCorrectMin: true, + }); + + if (error) { + setMaxPriceError(error); + return undefined; + } - const signature = [ - amountRaw.toString(), - sanitizedQ96.toString(), - chainId.toString(), - accountAddress.toLowerCase(), - auctionContractAddress.toLowerCase(), - (currency ?? zeroAddress).toLowerCase(), - isNativeBidToken ? '1' : '0', - ].join(':') + if ( + !sanitizedQ96 || + !budgetCurrencyAmount || + !accountAddress || + !auctionContractAddress || + !chainId + ) { + return undefined; + } - if (preparedBidRef.current?.signature === signature) { - return preparedBidRef.current.data - } + const amountRaw = BigInt(budgetCurrencyAmount.quotient.toString()); + if (amountRaw === 0n) { + return undefined; + } - try { - const response = await submitBidMutation.mutateAsync({ - maxPrice: sanitizedQ96.toString(), - amount: amountRaw.toString(), - walletAddress: accountAddress, - auctionContractAddress: auctionContractAddress.toLowerCase(), - chainId: chainId as ChainId, - }) - - if (!response.bid || !response.bid.data || !response.bid.to) { - handleBidSubmitFailure(new Error('Received incomplete bid response')) - return undefined + if ( + sanitizedDisplayValue && + sanitizedDisplayValue !== exactMaxValuationAmount + ) { + setExactMaxValuationAmount(sanitizedDisplayValue); } - const bidCalldata = response.bid.data - const requestId = response.requestId - const currencyLower = (currency ?? zeroAddress).toLowerCase() + setMaxPriceError(undefined); - if (!evmAccount || !isSignerMnemonicAccountDetails(evmAccount)) { - handleBidSubmitFailure(new Error('Wallet is not connected with a signer account')) - return undefined + const signature = [ + amountRaw.toString(), + sanitizedQ96.toString(), + chainId.toString(), + accountAddress.toLowerCase(), + auctionContractAddress.toLowerCase(), + (currency ?? zeroAddress).toLowerCase(), + isNativeBidToken ? "1" : "0", + ].join(":"); + + if (preparedBidRef.current?.signature === signature) { + return preparedBidRef.current.data; } - const nativeValue = (() => { - if (isNativeBidToken) { - const valueAsBigInt = BigInt(amountRaw.toString()) - return `0x${valueAsBigInt.toString(16)}` + try { + let bidCalldata: string; + let requestId: string; + if (ZKPASSPORT_ONCHAIN_BIDS) { + // CCA bidding is a permissionless direct contract call; encoding + // locally lets deployments without liquidity-backend access bid. + bidCalldata = encodeSubmitBidCalldata({ + maxPriceQ96: sanitizedQ96, + amountRaw, + owner: accountAddress, + }); + requestId = crypto.randomUUID(); + } else { + const response = await submitBidMutation.mutateAsync({ + maxPrice: sanitizedQ96.toString(), + amount: amountRaw.toString(), + walletAddress: accountAddress, + auctionContractAddress: auctionContractAddress.toLowerCase(), + chainId: chainId as ChainId, + }); + + if (!response.bid || !response.bid.data || !response.bid.to) { + handleBidSubmitFailure( + new Error("Received incomplete bid response") + ); + return undefined; + } + + bidCalldata = response.bid.data; + requestId = response.requestId; } - return '0x0' - })() + const currencyLower = (currency ?? zeroAddress).toLowerCase(); - if (!isValidHexString(bidCalldata)) { - handleBidSubmitFailure(new Error('Invalid calldata format')) - return undefined - } - - const txRequest = validateTransactionRequest({ - to: auctionContractAddress, - from: evmAccount.address, - data: bidCalldata, - value: nativeValue, - chainId, - }) + if (!evmAccount || !isSignerMnemonicAccountDetails(evmAccount)) { + handleBidSubmitFailure( + new Error("Wallet is not connected with a signer account") + ); + return undefined; + } - if (!txRequest) { - handleBidSubmitFailure(new Error('Received invalid transaction request for Toucan bid')) - return undefined - } + const nativeValue = (() => { + if (isNativeBidToken) { + const valueAsBigInt = BigInt(amountRaw.toString()); + return `0x${valueAsBigInt.toString(16)}`; + } + return "0x0"; + })(); + + if (!isValidHexString(bidCalldata)) { + handleBidSubmitFailure(new Error("Invalid calldata format")); + return undefined; + } - const info: ToucanBidTransactionInfo = { - type: TransactionType.ToucanBid, - amountRaw: amountRaw.toString(), - maxPriceQ96: sanitizedQ96.toString(), - auctionContractAddress: auctionContractAddress.toLowerCase(), - bidTokenAddress: currencyLower, - auctionTokenAddress: auctionTokenAddress?.toLowerCase(), - auctionTokenSymbol, - requestId, - dappInfo: { - name: 'Uniswap CCA', - icon: 'https://protocol-icons.s3.amazonaws.com/icons/uniswap-v4.jpg', - }, - } + const txRequest = validateTransactionRequest({ + to: auctionContractAddress, + from: evmAccount.address, + data: bidCalldata, + value: nativeValue, + chainId, + }); + + if (!txRequest) { + handleBidSubmitFailure( + new Error("Received invalid transaction request for Toucan bid") + ); + return undefined; + } - const preparedBid: PreparedBidTransaction = { - txRequest, - info, - requestId, + const info: ToucanBidTransactionInfo = { + type: TransactionType.ToucanBid, + amountRaw: amountRaw.toString(), + maxPriceQ96: sanitizedQ96.toString(), + auctionContractAddress: auctionContractAddress.toLowerCase(), + bidTokenAddress: currencyLower, + auctionTokenAddress: auctionTokenAddress?.toLowerCase(), + auctionTokenSymbol, + requestId, + dappInfo: { + name: "Uniswap CCA", + icon: "https://protocol-icons.s3.amazonaws.com/icons/uniswap-v4.jpg", + }, + }; + + const preparedBid: PreparedBidTransaction = { + txRequest, + info, + requestId, + }; + + preparedBidRef.current = { signature, data: preparedBid }; + return preparedBid; + // oxlint-disable-next-line no-shadow + } catch (error) { + handleBidSubmitFailure( + error instanceof Error ? error : new Error("Failed to submit bid") + ); + return undefined; } - - preparedBidRef.current = { signature, data: preparedBid } - return preparedBid - // oxlint-disable-next-line no-shadow - } catch (error) { - handleBidSubmitFailure(error instanceof Error ? error : new Error('Failed to submit bid')) - return undefined } - }) + ); - const handleSubmit = useEvent(async (prepared?: PreparedBidTransaction, options?: SubmitBidOptions) => { - // Clear any previous errors when starting a new submission - setSubmissionError(undefined) + const handleSubmit = useEvent( + async (prepared?: PreparedBidTransaction, options?: SubmitBidOptions) => { + // Clear any previous errors when starting a new submission + setSubmissionError(undefined); - const preparedBid = prepared ?? (await prepareTransaction()) + const preparedBid = prepared ?? (await prepareTransaction()); - if (!preparedBid) { - return undefined - } + if (!preparedBid) { + return undefined; + } - if (!evmAccount || !isSignerMnemonicAccountDetails(evmAccount)) { - handleBidSubmitFailure(new Error('Wallet is not connected with a signer account')) - return undefined - } + if (!evmAccount || !isSignerMnemonicAccountDetails(evmAccount)) { + handleBidSubmitFailure( + new Error("Wallet is not connected with a signer account") + ); + return undefined; + } - if (!chainId) { - handleBidSubmitFailure(new Error('Missing chain ID for Toucan bid submission')) - return undefined - } + if (!chainId) { + handleBidSubmitFailure( + new Error("Missing chain ID for Toucan bid submission") + ); + return undefined; + } - const analytics = getAuctionBidBaseAnalyticsProperties({ - trace, - chainId, - info: preparedBid.info, - bidTokenAmountUsd: budgetAmountUsd, - maxFdvUsd, - pricePerToken, - minExpectedReceiveAmount, - maxReceivableAmount, - tokenSymbol: auctionTokenSymbol, - tokenName: auctionTokenName, - isCentralizedPricesEnabled, - }) - - // Return a promise that resolves/rejects when the saga completes - // This allows callers to await the full submission flow - return new Promise((resolve, reject) => { - toucanSubmitBid({ - account: evmAccount, + const analytics = getAuctionBidBaseAnalyticsProperties({ + trace, chainId, - txRequest: preparedBid.txRequest, info: preparedBid.info, - setCurrentStep: options?.setCurrentStep ?? setCurrentTransactionStep, - setSteps: options?.setSteps, - preBidSteps: options?.preBidSteps, - analytics, - onPreBidStepsComplete: options?.onPreBidStepsComplete, - onSuccess: (hash: string) => { - handleBidSubmitSuccess(hash) - resolve() - }, - onFailure: (error: Error) => { - handleBidSubmitFailure(error) - reject(error) - }, - }) - }) - }) + bidTokenAmountUsd: budgetAmountUsd, + maxFdvUsd, + pricePerToken, + minExpectedReceiveAmount, + maxReceivableAmount, + tokenSymbol: auctionTokenSymbol, + tokenName: auctionTokenName, + isCentralizedPricesEnabled, + }); + + // Return a promise that resolves/rejects when the saga completes + // This allows callers to await the full submission flow + return new Promise((resolve, reject) => { + toucanSubmitBid({ + account: evmAccount, + chainId, + txRequest: preparedBid.txRequest, + info: preparedBid.info, + setCurrentStep: options?.setCurrentStep ?? setCurrentTransactionStep, + setSteps: options?.setSteps, + preBidSteps: options?.preBidSteps, + analytics, + onPreBidStepsComplete: options?.onPreBidStepsComplete, + onSuccess: (hash: string) => { + handleBidSubmitSuccess(hash); + resolve(); + }, + onFailure: (error: Error) => { + handleBidSubmitFailure(error); + reject(error); + }, + }); + }); + } + ); const isSubmitDisabled = !chainId || @@ -362,7 +422,7 @@ export function useBidFormSubmit({ exceedsBalance || isMaxPriceBelowMinimum || submitBidMutation.isPending || - !maxPriceQ96 + !maxPriceQ96; return { submitState: { @@ -374,5 +434,5 @@ export function useBidFormSubmit({ error: submissionError, clearError: () => setSubmissionError(undefined), }, - } + }; } diff --git a/apps/web/src/features/Toucan/ZkPassport/ZkPassportPolicyPicker.tsx b/apps/web/src/features/Toucan/ZkPassport/ZkPassportPolicyPicker.tsx new file mode 100644 index 00000000000..45416c483dd --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/ZkPassportPolicyPicker.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Checkbox, Flex, Text } from 'ui/src' +import { Check } from 'ui/src/components/icons/Check' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Dropdown, InternalMenuItem } from '~/components/Dropdowns/Dropdown' +import { ZKPASSPORT_CREATE_POLICY_URL } from '~/features/Toucan/ZkPassport/config' +import { useZkPassportPolicies } from '~/features/Toucan/ZkPassport/useZkPassportPolicies' +import { ExternalLink } from '~/theme/components/Links' + +/** + * "Use ZKPassport" checkbox plus a dropdown of ready-made policies fetched + * live from the chain's ZKPassportAttest registry. Selecting a policy hands + * its validation-hook address to the caller — the same value the manual + * "enter a hook address" path produces. + */ +export function ZkPassportPolicyPicker({ + chainId, + onSelectHook, +}: { + chainId: UniverseChainId + onSelectHook: (hookAddress: string | undefined) => 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/attestPopup.ts b/apps/web/src/features/Toucan/ZkPassport/attestPopup.ts new file mode 100644 index 00000000000..21f4771e2bc --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/attestPopup.ts @@ -0,0 +1,135 @@ +/** + * Minimal client for the hosted ZKPassport verify-popup's attestation + * protocol. The popup announces `ready`, the opener responds with a + * `configure` message carrying the attest request (the popup resolves the + * on-chain policy itself), and the popup reports progress and the final + * outcome back via `postMessage`. Kept dependency-free: the protocol types + * live in @zkpassport/sdk, but the attest extension is not published yet. + */ + +export interface AttestPopupConfig { + /** Chain name in the ZKPassport SDK's format, e.g. "ethereum_sepolia". */ + chain: string + /** On-chain policy id as a 0x-prefixed 32-byte hex string. */ + policyId: `0x${string}` + /** ZKPassportAttest registry address. */ + registry: `0x${string}` + /** RPC override for dev registries; the popup defaults per chain. */ + rpcUrl?: string +} + +export interface AttestOutcome { + status: 'minted' | 'unminted' | 'already-verified' + /** Recipient account the user selected in the popup; may differ from the wallet connected here. */ + walletAddress?: `0x${string}` + txHash?: `0x${string}` + reason?: string +} + +export interface AttestPopupCallbacks { + /** Verification finished; `outcome` is present when a credential mint was attempted. */ + onSuccess?: (outcome?: AttestOutcome) => void + onReject?: () => void + onError?: (message: string) => void + /** The user closed the popup before a result was produced. */ + onClose?: () => void +} + +export interface AttestPopupHandle { + close: () => void +} + +const CLOSE_POLL_INTERVAL = 500 + +/** + * Open the hosted verification page in a new tab. Must be called from a + * user gesture or the browser will block it. No window features are passed: + * the page opens with full browser chrome, and `window.opener` stays intact + * for the postMessage protocol (never add noopener here). + */ +export function openAttestPopup({ + popupUrl, + devMode, + attest, + callbacks = {}, +}: { + popupUrl: string + devMode: boolean + attest: AttestPopupConfig + callbacks?: AttestPopupCallbacks +}): AttestPopupHandle | null { + const popupOrigin = new URL(popupUrl).origin + const popup = window.open(popupUrl, 'zkpassport-verify') + if (!popup) { + return null + } + + let finished = false + let closePoll: ReturnType | null = null + + const cleanup = (): void => { + window.removeEventListener('message', onMessage) + if (closePoll) { + clearInterval(closePoll) + closePoll = null + } + } + + function onMessage(event: MessageEvent): void { + if (event.origin !== popupOrigin || event.source !== popup) { + return + } + const data = event.data as { + zkpassport?: boolean + type?: string + attest?: AttestOutcome + message?: string + } | null + if (!data?.zkpassport || typeof data.type !== 'string') { + return + } + switch (data.type) { + case 'ready': + popup?.postMessage( + { + zkpassport: true, + type: 'configure', + request: { devMode, attest }, + query: {}, + }, + popupOrigin, + ) + break + case 'success': + finished = true + callbacks.onSuccess?.(data.attest) + break + case 'rejected': + finished = true + callbacks.onReject?.() + break + case 'error': + callbacks.onError?.(String(data.message)) + break + default: + break + } + } + + window.addEventListener('message', onMessage) + closePoll = setInterval(() => { + if (popup.closed) { + cleanup() + if (!finished) { + callbacks.onClose?.() + } + } + }, CLOSE_POLL_INTERVAL) + + return { + close: () => { + cleanup() + popup.close() + }, + } +} 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..b58f227cada --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/config.ts @@ -0,0 +1,26 @@ +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:5173').replace(/\/$/, '') + +/** Chain names in the ZKPassport SDK's format, keyed by supported chain. */ +export const ZKPASSPORT_CHAIN_NAME: Partial> = { + [UniverseChainId.Sepolia]: 'ethereum_sepolia', +} + +/** + * 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]: (process.env.ZKPASSPORT_ATTEST_REGISTRY_SEPOLIA ?? + '0x2a615a175439b9eb0004b924aBdD2B4c7a871f11') as `0x${string}`, +} + +/** Block each registry was deployed at, bounding PolicyCreated log scans. */ +export const ZKPASSPORT_ATTEST_DEPLOY_BLOCK: Partial> = { + [UniverseChainId.Sepolia]: BigInt(process.env.ZKPASSPORT_ATTEST_DEPLOY_BLOCK_SEPOLIA ?? 11625471), +} + +/** 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' diff --git a/apps/web/src/features/Toucan/ZkPassport/launchedAuctionAddress.test.ts b/apps/web/src/features/Toucan/ZkPassport/launchedAuctionAddress.test.ts new file mode 100644 index 00000000000..6ec7f347fff --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/launchedAuctionAddress.test.ts @@ -0,0 +1,39 @@ +import { auctionAddressFromLogs } from '~/features/Toucan/ZkPassport/launchedAuctionAddress' + +// Logs from the sepolia launch tx 0x00c5c4ba533eb89bb506c2f1b60102b3399e71477b860876aa8073ac624702df, +// whose build-time prediction (0x46dA7929…) diverged from the deployed auction. +const launchReceiptLogs = [ + { + // ERC20 Transfer from the launched token + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x00000000000000000000000000004c4ccc709ef590f7c81102c0689f0263d4e9', + ], + }, + { + // AuctionCreated(auction, token, ...) from the CCA initializer factory + topics: [ + '0x7ede475fad18ccf0039f2b956c4d43a8b4ed0853de4daaa8ae25299f331ae3b9', + '0x00000000000000000000000044284947b3e1c7dd9a5ad0dd897c1d1b16ca9367', + '0x000000000000000000000000342ee32319e937ba6b59a15c274dca9a2d2aafa4', + ], + }, + { + topics: ['0x6d759545eb439f07e70f45431d6339af7a4f1ffef06d43e8ddf47fdb0799708c'], + }, +] + +describe('auctionAddressFromLogs', () => { + it('extracts the deployed auction address from the AuctionCreated log', () => { + expect(auctionAddressFromLogs(launchReceiptLogs)).toBe('0x44284947b3e1C7dd9A5AD0dD897c1D1B16cA9367') + }) + + it('returns undefined when the receipt has no AuctionCreated log', () => { + expect(auctionAddressFromLogs(launchReceiptLogs.filter((_, i) => i !== 1))).toBeUndefined() + }) + + it('returns undefined for an empty receipt', () => { + expect(auctionAddressFromLogs([])).toBeUndefined() + }) +}) diff --git a/apps/web/src/features/Toucan/ZkPassport/launchedAuctionAddress.ts b/apps/web/src/features/Toucan/ZkPassport/launchedAuctionAddress.ts new file mode 100644 index 00000000000..fc510fc1b77 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/launchedAuctionAddress.ts @@ -0,0 +1,43 @@ +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { getAddress } from 'viem' +import { getSessionlessPublicClient } from '~/features/Toucan/ZkPassport/sessionlessClient' + +/** AuctionCreated(address indexed auction, address indexed token, ...) emitted by the CCA initializer factory. */ +const AUCTION_CREATED_TOPIC = '0x7ede475fad18ccf0039f2b956c4d43a8b4ed0853de4daaa8ae25299f331ae3b9' + +/** The launched auction's address, from the AuctionCreated log of a launch receipt. */ +export function auctionAddressFromLogs(logs: ReadonlyArray<{ topics: readonly string[] }>): `0x${string}` | undefined { + const created = logs.find((log) => log.topics[0] === AUCTION_CREATED_TOPIC) + const auctionTopic = created?.topics[1] + return auctionTopic ? getAddress(`0x${auctionTopic.slice(-40)}`) : undefined +} + +/** + * The address the launch transaction actually deployed the auction at. + * + * The build-time CREATE2 prediction can diverge from the deployed address — + * the LBP strategy derives its own factory salt and patches the auction + * params before calling create, so replaying factory.getAddress with the + * client-side inputs does not always hash to the same address. The + * AuctionCreated log in the receipt is ground truth; the prediction is only + * a fallback for when the receipt cannot be fetched (e.g. batch-wallet ids). + */ +export async function resolveLaunchedAuctionAddress({ + chainId, + hash, + predictedAddress, +}: { + chainId: UniverseChainId + hash: string + predictedAddress: string +}): Promise { + try { + const receipt = await getSessionlessPublicClient(chainId).waitForTransactionReceipt({ + hash: hash as `0x${string}`, + timeout: 120_000, + }) + return auctionAddressFromLogs(receipt.logs) ?? predictedAddress + } catch { + return predictedAddress + } +} diff --git a/apps/web/src/features/Toucan/ZkPassport/onchainBid.test.ts b/apps/web/src/features/Toucan/ZkPassport/onchainBid.test.ts new file mode 100644 index 00000000000..ec4bb2b94bd --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/onchainBid.test.ts @@ -0,0 +1,25 @@ +import { encodeSubmitBidCalldata } from "~/features/Toucan/ZkPassport/onchainBid"; + +// Fixture: the calldata Uniswap's CreateAuction backend produced for a real +// sepolia bid (tx 0x58da71e4230e25770b5d144c4e07585f7c507417673976947a67aa305ba567ed +// on auction 0x08b301f6a61251b56ddefd8f5f6c345c4ec44c6a). The local encoder +// must reproduce it byte for byte. +const REAL_BID_CALLDATA = + "0x140fe8ee" + + "000000000000000000000000000000000000000000000001843331a5b4768280" + + "0000000000000000000000000000000000000000000000000011c37937e08000" + + "000000000000000000000000c05f5ae5e44b7a8e97217e88605203eb044bdb89" + + "0000000000000000000000000000000000000000000000000000000000000080" + + "0000000000000000000000000000000000000000000000000000000000000000"; + +describe("encodeSubmitBidCalldata", () => { + it("reproduces the backend-built calldata for a real sepolia bid", () => { + expect( + encodeSubmitBidCalldata({ + maxPriceQ96: 0x1843331a5b4768280n, + amountRaw: 5000000000000000n, + owner: "0xc05f5ae5e44b7a8e97217e88605203eb044bdb89", + }) + ).toBe(REAL_BID_CALLDATA); + }); +}); diff --git a/apps/web/src/features/Toucan/ZkPassport/onchainBid.ts b/apps/web/src/features/Toucan/ZkPassport/onchainBid.ts new file mode 100644 index 00000000000..f3e5ac50907 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/onchainBid.ts @@ -0,0 +1,46 @@ +import { encodeFunctionData, getAddress } from "viem"; + +/** + * When enabled, bids are encoded locally against the CCA auction contract + * instead of asking Uniswap's liquidity backend for calldata. Third-party + * deployments of this interface cannot reach that backend (no CORS), and CCA + * bidding is documented as a permissionless direct contract call. + */ +export const ZKPASSPORT_ONCHAIN_BIDS = + process.env.ZKPASSPORT_ONCHAIN_BIDS === "true"; + +const ccaSubmitBidAbi = [ + { + type: "function", + name: "submitBid", + stateMutability: "payable", + inputs: [ + { name: "maxPrice", type: "uint256" }, + { name: "amount", type: "uint128" }, + { name: "owner", type: "address" }, + { name: "hookData", type: "bytes" }, + ], + outputs: [], + }, +] as const; + +/** + * Builds submitBid calldata for a CCA auction. hookData stays empty: the + * ZKPassport validation hook gates on the owner's registry balance and reads + * nothing from it. + */ +export function encodeSubmitBidCalldata({ + maxPriceQ96, + amountRaw, + owner, +}: { + maxPriceQ96: bigint; + amountRaw: bigint; + owner: string; +}): `0x${string}` { + return encodeFunctionData({ + abi: ccaSubmitBidAbi, + functionName: "submitBid", + args: [maxPriceQ96, amountRaw, getAddress(owner), "0x"], + }); +} diff --git a/apps/web/src/features/Toucan/ZkPassport/onchainLaunch.test.ts b/apps/web/src/features/Toucan/ZkPassport/onchainLaunch.test.ts new file mode 100644 index 00000000000..a2a24c62ab9 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/onchainLaunch.test.ts @@ -0,0 +1,151 @@ +import { PriceRangeStrategy as ProtoPriceRangeStrategy } from "@uniswap/client-liquidity/dist/uniswap/liquidity/v1/auction_pb"; +import { MIGRATOR_PARAMETERS_PARAM } from "@uniswap/liquidity-launcher-sdk"; +import { + decodeAbiParameters, + decodeFunctionData, + parseAbi, + type PublicClient, +} from "viem"; +import { buildOnchainCreateAuction } from "~/features/Toucan/ZkPassport/onchainLaunch"; + +const WALLET = "0x89d94da1c6a8564f66e414a8c1c323f96c685006"; +const HOOK = "0x246029D5a72E346A86A31B570D9e49e819CB5A39"; +const SALT = + "0x5426b0788ec5e9876a6fc7e4422032d7e6cf6954f1a3853441f2b1d6bc331749"; +const PREDICTED_TOKEN = "0x89767f9Ff3656b11861Be5243142FC9b61E9Be50"; +const PREDICTED_AUCTION = "0x08B301F6A61251B56ddEfd8F5f6c345c4EC44c6A"; +const CCA_FACTORY = "0x000000001F26a0044BaA66024e7b6599c61963F8"; + +const LAUNCHER_ABI = parseAbi([ + "function multicall(bytes[] data)", + "function createToken(address factory, string name, string symbol, uint8 decimals, uint128 initialSupply, address recipient, bytes tokenData)", + "function distributeToken(address token, (address strategy, uint128 amount, bytes configData) params, bytes32 salt)", +]); + +const AUCTION_PARAMS_ABI = [ + { + type: "tuple", + components: [ + { name: "currency", type: "address" }, + { name: "tokensRecipient", type: "address" }, + { name: "fundsRecipient", type: "address" }, + { name: "startBlock", type: "uint64" }, + { name: "endBlock", type: "uint64" }, + { name: "claimBlock", type: "uint64" }, + { name: "tickSpacing", type: "uint256" }, + { name: "validationHook", type: "address" }, + { name: "floorPrice", type: "uint256" }, + { name: "requiredCurrencyRaised", type: "uint128" }, + { name: "auctionStepsData", type: "bytes" }, + ], + }, +] as const; + +// Answers the three reads the builder makes: token prediction, current block, +// and auction prediction (initializerFactory() then getAddress(...)). +const stubClient = { + getBlockNumber: async () => 11618000n, + readContract: async ({ functionName }: { functionName: string }) => { + switch (functionName) { + case "getUERC20Address": + case "getUSUPERC20Address": + return PREDICTED_TOKEN; + case "initializerFactory": + return CCA_FACTORY; + default: + return PREDICTED_AUCTION; + } + }, +} as unknown as PublicClient; + +const request = { + walletAddress: WALLET, + salt: SALT, + tokenInfo: { + source: { + case: "newToken" as const, + value: { + name: "MV1", + symbol: "MV1", + totalSupply: (10n ** 27n).toString(), + metadata: { description: "", image: "" }, + }, + }, + }, + auction: { + currencyAddress: "0x0000000000000000000000000000000000000000", + startTimeUnix: BigInt(Math.floor(Date.now() / 1000) + 600), + endTimeUnix: BigInt(Math.floor(Date.now() / 1000) + 15000), + floorPriceRaisePerToken: "0.000000000278", + auctionSupply: (10n ** 27n).toString(), + validationHook: HOOK, + }, + pool: { + fee: 3000, + dynamicFee: false, + priceRangeStrategy: ProtoPriceRangeStrategy.FULL_RANGE, + customRanges: [], + reservedSupplyForLp: (5n * 10n ** 26n).toString(), + lpAllocation: { kind: { case: "singlePercent" as const, value: 100 } }, + poolOwner: WALLET, + }, +}; + +describe("buildOnchainCreateAuction", () => { + it("assembles a launcher multicall equivalent to the backend plan", async () => { + const result = await buildOnchainCreateAuction({ + request, + chainId: 11155111, + publicClient: stubClient, + }); + + expect(result.predictedTokenAddress).toBe(PREDICTED_TOKEN); + expect(result.predictedAuctionAddress).toBe(PREDICTED_AUCTION); + expect(result.transactions).toHaveLength(1); + + const [tx] = result.transactions; + expect(tx.to.toLowerCase()).toBe( + "0x00004c4ccc709ef590f7c81102c0689f0263d4e9" + ); + expect(tx.from).toBe(WALLET); + expect(tx.value).toBe(0n); + + const outer = decodeFunctionData({ + abi: LAUNCHER_ABI, + data: tx.data as `0x${string}`, + }); + expect(outer.functionName).toBe("multicall"); + const [createCall, distributeCall] = outer.args[0]; + + const create = decodeFunctionData({ abi: LAUNCHER_ABI, data: createCall }); + expect(create.functionName).toBe("createToken"); + expect(create.args[1]).toBe("MV1"); + expect(create.args[4]).toBe(10n ** 27n); + + const distribute = decodeFunctionData({ + abi: LAUNCHER_ABI, + data: distributeCall, + }); + expect(distribute.functionName).toBe("distributeToken"); + expect(distribute.args[0]).toBe(PREDICTED_TOKEN); + expect(distribute.args[2]).toBe(SALT); + + const [migrator, auctionParamsHex] = decodeAbiParameters( + [MIGRATOR_PARAMETERS_PARAM, { type: "bytes" }] as const, + distribute.args[1].configData + ); + expect(migrator.token).toBe(PREDICTED_TOKEN); + expect(migrator.reservedTokenAmountForLP).toBe(5n * 10n ** 26n); + expect(migrator.poolParameters.fee).toBe(3000); + + const [auctionParams] = decodeAbiParameters( + AUCTION_PARAMS_ABI, + auctionParamsHex + ); + expect(auctionParams.validationHook).toBe(HOOK); + expect(auctionParams.floorPrice).toBeGreaterThan(0n); + expect(auctionParams.floorPrice % auctionParams.tickSpacing).toBe(0n); + expect(auctionParams.endBlock).toBeGreaterThan(auctionParams.startBlock); + expect(auctionParams.auctionStepsData.length).toBeGreaterThan(2); + }); +}); diff --git a/apps/web/src/features/Toucan/ZkPassport/onchainLaunch.ts b/apps/web/src/features/Toucan/ZkPassport/onchainLaunch.ts new file mode 100644 index 00000000000..c4c97856751 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/onchainLaunch.ts @@ -0,0 +1,312 @@ +import { type PartialMessage } from "@bufbuild/protobuf"; +import { + type CreateAuctionRequest, + PriceRangeStrategy as ProtoPriceRangeStrategy, +} from "@uniswap/client-liquidity/dist/uniswap/liquidity/v1/auction_pb"; +import { + buildLaunchTransactions, + buildLpAllocationSchedule, + buildPositionDefinitions, + computeInitializerSalt, + deriveAuctionPricing, + deriveBlocks, + deriveConvexAuctionSteps, + encodeAuctionParams, + encodeAuctionSteps, + encodeConfigData, + encodeLpAllocationSchedule, + encodePositionDefinitions, + encodeTokenData, + encodeTokenSplitterConfig, + feeToTickSpacing, + floorPriceToX96, + FUNDS_RECIPIENT_SENTINEL, + getBlockTimeSeconds, + getErc20Decimals, + getLauncherAddresses, + type LpAllocationInput, + NEW_TOKEN_DECIMALS, + predictAuctionAddress, + predictTokenAddress, + type PriceRangeKind, + requiredCurrencyRaised, + resolvePoolFee, + selectTokenFactory, +} from "@uniswap/liquidity-launcher-sdk"; +import { type Address, type Hex, type PublicClient, zeroAddress } from "viem"; + +/** + * When enabled, auction creation is assembled locally with Uniswap's public + * @uniswap/liquidity-launcher-sdk instead of asking the liquidity backend for + * the transaction plan. Third-party deployments of this interface cannot reach + * that backend (no CORS); the SDK builds "the exact shape the backend builds" + * (its words) against the permissionless launcher contracts. + */ +export const ZKPASSPORT_ONCHAIN_LAUNCH = + process.env.ZKPASSPORT_ONCHAIN_LAUNCH === "true"; + +export interface OnchainCreateAuctionResult { + predictedTokenAddress: string; + predictedAuctionAddress: string; + transactions: Array<{ + to: string; + from: string; + data: string; + value: bigint; + chainId: number; + }>; + atomicallyBundleable: boolean; + requestId: string; +} + +class OnchainLaunchUnsupportedError extends Error { + constructor(feature: string) { + super( + `${feature} is not supported by the on-chain launch fallback; disable it or launch from an origin with liquidity-backend access` + ); + this.name = "OnchainLaunchUnsupportedError"; + } +} + +function toPriceRangeKind(strategy: ProtoPriceRangeStrategy): PriceRangeKind { + switch (strategy) { + case ProtoPriceRangeStrategy.CONCENTRATED_FULL_RANGE: + return "CONCENTRATED_FULL_RANGE"; + case ProtoPriceRangeStrategy.FULL_RANGE: + return "FULL_RANGE"; + case ProtoPriceRangeStrategy.CUSTOM_RANGE: + return "CUSTOM_RANGE"; + default: + throw new OnchainLaunchUnsupportedError( + `Price range strategy ${strategy}` + ); + } +} + +function toLpAllocationInput( + lpAllocation: NonNullable< + NonNullable["pool"]>["lpAllocation"] + >, + raiseCurrencyDecimals: number +): LpAllocationInput { + const kind = lpAllocation.kind; + if (kind?.case === "singlePercent") { + return { kind: "single", percent: kind.value }; + } + if (kind?.case === "tiered") { + return { + kind: "tiered", + raiseCurrencyDecimals, + tiers: (kind.value.tiers ?? []).map((tier) => ({ + raiseMilestone: tier.raiseMilestone ?? "", + percent: tier.percent ?? 0, + })), + }; + } + throw new OnchainLaunchUnsupportedError("Unset LP allocation"); +} + +/** + * Local equivalent of the liquidity backend's CreateAuction: derives the + * contract-native launch parameters from the wizard request and assembles the + * launcher multicall. Supports the demo path — a NEW token with an optional + * validation hook and no liquidity lock; unsupported wizard features throw so + * the existing error surface reports them. + */ +export async function buildOnchainCreateAuction({ + request, + chainId, + publicClient, +}: { + request: PartialMessage; + chainId: number; + publicClient: PublicClient; +}): Promise { + const auction = request.auction; + const pool = request.pool; + const walletAddress = request.walletAddress as Address | undefined; + const salt = request.salt as Hex | undefined; + if (!auction || !pool || !walletAddress || !salt) { + throw new Error("Incomplete auction request"); + } + if (request.tokenInfo?.source?.case !== "newToken") { + throw new OnchainLaunchUnsupportedError("Launching an existing token"); + } + if (pool.liquidityLock) { + throw new OnchainLaunchUnsupportedError("Liquidity locking"); + } + const newToken = request.tokenInfo.source.value; + + const addresses = getLauncherAddresses(chainId); + const tokenFactory = selectTokenFactory(addresses); + if (!tokenFactory) { + throw new OnchainLaunchUnsupportedError( + `New-token launches on chain ${chainId}` + ); + } + + const totalSupply = BigInt(newToken.totalSupply ?? "0"); + const auctionSupply = BigInt(auction.auctionSupply ?? "0"); + const returnedSupply = BigInt(auction.returnedSupply ?? "0"); + const reservedForLp = BigInt(pool.reservedSupplyForLp ?? "0"); + const lbpAmount = auctionSupply - returnedSupply; + const soldSupply = lbpAmount - reservedForLp; + const currency = (auction.currencyAddress || zeroAddress) as Address; + const poolOwner = (pool.poolOwner || walletAddress) as Address; + + const tokenData = encodeTokenData({ + description: newToken.metadata?.description ?? "", + website: "", + image: newToken.metadata?.image ?? "", + extraData: "0x", + }); + + const [predictedTokenAddress, currentBlock, currencyDecimals] = + await Promise.all([ + predictTokenAddress(publicClient, { + factory: tokenFactory.factory, + kind: tokenFactory.kind, + launcherAddress: addresses.liquidityLauncher, + wallet: walletAddress, + name: newToken.name ?? "", + symbol: newToken.symbol ?? "", + decimals: NEW_TOKEN_DECIMALS, + homeChainId: BigInt(chainId), + }), + publicClient.getBlockNumber(), + currency === zeroAddress + ? Promise.resolve(18) + : getErc20Decimals(publicClient, currency), + ]); + + const blocks = deriveBlocks({ + startTimeUnix: BigInt(auction.startTimeUnix ?? 0n), + endTimeUnix: BigInt(auction.endTimeUnix ?? 0n), + currentBlock, + nowUnix: BigInt(Math.floor(Date.now() / 1000)), + blockTimeSeconds: getBlockTimeSeconds(chainId), + }); + + const pricing = deriveAuctionPricing( + floorPriceToX96( + auction.floorPriceRaisePerToken ?? "0", + NEW_TOKEN_DECIMALS, + currencyDecimals + ) + ); + const graduationX96 = auction.graduationPriceRaisePerToken + ? deriveAuctionPricing( + floorPriceToX96( + auction.graduationPriceRaisePerToken, + NEW_TOKEN_DECIMALS, + currencyDecimals + ) + ).floorPriceX96 + : pricing.floorPriceX96; + + const auctionParams = encodeAuctionParams({ + currency, + tokensRecipient: walletAddress, + fundsRecipient: FUNDS_RECIPIENT_SENTINEL, + startBlock: blocks.startBlock, + endBlock: blocks.endBlock, + claimBlock: blocks.claimBlock, + tickSpacing: pricing.tickSpacing, + validationHook: (auction.validationHook || zeroAddress) as Address, + floorPrice: pricing.floorPriceX96, + requiredCurrencyRaised: requiredCurrencyRaised(graduationX96, soldSupply), + auctionStepsData: encodeAuctionSteps( + deriveConvexAuctionSteps(blocks.startBlock, blocks.endBlock) + ), + }); + + const poolFee = resolvePoolFee(pool.fee ?? 0, pool.dynamicFee ?? false); + const poolTickSpacing = feeToTickSpacing(pool.fee ?? 0); + const migrator = { + token: predictedTokenAddress, + currency, + migrationBlock: blocks.migrationBlock, + reservedTokenAmountForLP: reservedForLp, + recipient: poolOwner, + positionRecipient: poolOwner, + poolParameters: { + fee: poolFee, + tickSpacing: poolTickSpacing, + hook: zeroAddress, + }, + positionDefinitions: encodePositionDefinitions( + buildPositionDefinitions( + toPriceRangeKind( + pool.priceRangeStrategy ?? ProtoPriceRangeStrategy.UNSPECIFIED + ), + (pool.customRanges ?? []).map((range) => ({ + minPercentFromClearing: Number(range.minPercentFromClearing), + maxPercentFromClearing: Number(range.maxPercentFromClearing), + liquidityPercent: range.liquidityPercent ?? 0, + })), + poolTickSpacing + ) + ), + lpAllocationSchedule: encodeLpAllocationSchedule( + buildLpAllocationSchedule( + toLpAllocationInput(pool.lpAllocation ?? {}, currencyDecimals) + ) + ), + }; + const configData = encodeConfigData(migrator, auctionParams); + + const distributions = [ + { strategy: addresses.lbpStrategy, amount: lbpAmount, configData }, + ]; + if (returnedSupply > 0n) { + distributions.push({ + strategy: addresses.tokenSplitter, + amount: returnedSupply, + configData: encodeTokenSplitterConfig([ + { recipient: walletAddress, amount: returnedSupply }, + ]), + }); + } + + const [transactions, predictedAuctionAddress] = await Promise.all([ + Promise.resolve( + buildLaunchTransactions({ + liquidityLauncher: addresses.liquidityLauncher, + token: predictedTokenAddress, + salt, + acquire: { + kind: "create", + args: { + factory: tokenFactory.factory, + name: newToken.name ?? "", + symbol: newToken.symbol ?? "", + decimals: NEW_TOKEN_DECIMALS, + initialSupply: totalSupply, + recipient: addresses.liquidityLauncher, + tokenData, + }, + }, + distributions, + }) + ), + predictAuctionAddress(publicClient, { + strategy: addresses.lbpStrategy, + token: predictedTokenAddress, + auctionSupply: soldSupply + reservedForLp, + auctionParams, + initializerSalt: computeInitializerSalt(walletAddress, salt, migrator), + }), + ]); + + return { + predictedTokenAddress, + predictedAuctionAddress, + transactions: transactions.map((tx) => ({ + ...tx, + from: walletAddress, + chainId, + })), + atomicallyBundleable: false, + requestId: crypto.randomUUID(), + }; +} diff --git a/apps/web/src/features/Toucan/ZkPassport/sessionlessClient.ts b/apps/web/src/features/Toucan/ZkPassport/sessionlessClient.ts new file mode 100644 index 00000000000..f2f412307b5 --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/sessionlessClient.ts @@ -0,0 +1,29 @@ +import { getChainInfo } from 'uniswap/src/features/chains/chainInfo' +import { RPCType, UniverseChainId } from 'uniswap/src/features/chains/types' +import { createPublicClient, fallback, http, PublicClient } from 'viem' + +const clients = new Map() + +/** + * Public client over the chain's open RPC endpoints, bypassing the app's + * UniRPC transport. UniRPC (`/entry-gateway/rpc/`) rejects every + * request with a 401 until the gateway session is established, and a viewer + * without one (fresh profile, failed Turnstile challenge on a third-party + * domain) never gets it — while the auction page still renders because its + * data rides sessionless connect endpoints. The ZKPassport gate must resolve + * for exactly those viewers, so its handful of reads use the chain's + * Default/Fallback URLs, which require neither a session nor an API key. + */ +export function getSessionlessPublicClient(chainId: UniverseChainId): PublicClient { + const cached = clients.get(chainId) + if (cached) { + return cached + } + const { rpcUrls } = getChainInfo(chainId) + const urls = [...(rpcUrls[RPCType.Default]?.http ?? []), ...(rpcUrls[RPCType.Fallback]?.http ?? [])] + const client = createPublicClient({ + transport: fallback(urls.map((url) => http(url))), + }) + clients.set(chainId, client) + return client +} 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..cf2c0232cdd --- /dev/null +++ b/apps/web/src/features/Toucan/ZkPassport/useZkPassportGate.ts @@ -0,0 +1,148 @@ +import { useQuery } from '@tanstack/react-query' +import { useCallback, useMemo } from 'react' +import type { UniverseChainId } from 'uniswap/src/features/chains/types' +import { isTestnetChain } from 'uniswap/src/features/chains/utils' +import { assume0xAddress, zeroAddress } from '~/chains' +import { gatedErc1155HookAbi, zkPassportAttestAbi } from '~/features/Toucan/ZkPassport/abi' +import { openAttestPopup } from '~/features/Toucan/ZkPassport/attestPopup' +import { + ZKPASSPORT_ATTEST_REGISTRY, + ZKPASSPORT_CHAIN_NAME, + ZKPASSPORT_POPUP_URL, +} from '~/features/Toucan/ZkPassport/config' +import { getSessionlessPublicClient } from '~/features/Toucan/ZkPassport/sessionlessClient' + +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. + * + * The reads go through the sessionless public client rather than the app's + * wagmi transport: the wagmi path is UniRPC, which 401s without a gateway + * session, and a wrong read failure here surfaces as a spurious + * "Auction not supported" banner on the bid form. + */ +export function useZkPassportGate({ + chainId, + validationHook, + walletAddress, +}: { + chainId?: UniverseChainId + validationHook?: string + walletAddress?: string +}): ZkPassportGate { + const hookAddress = validationHook && validationHook !== zeroAddress ? assume0xAddress(validationHook) : undefined + + const { data: hookViews, isLoading: isHookLoading } = useQuery({ + queryKey: ['zkpassport-hook-introspection', chainId, hookAddress], + queryFn: async () => { + if (!chainId || !hookAddress) { + throw new Error('chainId and hookAddress required') + } + const client = getSessionlessPublicClient(chainId) + const [registry, tokenId] = await Promise.all([ + client.readContract({ + address: hookAddress, + abi: gatedErc1155HookAbi, + functionName: 'erc1155', + }), + client.readContract({ + address: hookAddress, + abi: gatedErc1155HookAbi, + functionName: 'tokenId', + }), + ]) + return { registry, tokenId } + }, + enabled: Boolean(hookAddress && chainId), + staleTime: Infinity, + retry: 1, + }) + + const knownRegistry = chainId ? ZKPASSPORT_ATTEST_REGISTRY[chainId] : undefined + const hookRegistry = hookViews?.registry + const policyId = hookViews?.tokenId + const isGated = Boolean( + knownRegistry && + hookRegistry && + policyId !== undefined && + hookRegistry.toLowerCase() === knownRegistry.toLowerCase(), + ) + + const { + data: balance, + isLoading: isBalanceLoading, + refetch: refetchBalance, + } = useQuery({ + queryKey: ['zkpassport-credential-balance', chainId, hookRegistry, policyId?.toString(), walletAddress], + queryFn: async () => { + if (!chainId || !hookRegistry || policyId === undefined || !walletAddress) { + throw new Error('gate introspection and wallet required') + } + return getSessionlessPublicClient(chainId).readContract({ + address: hookRegistry, + abi: zkPassportAttestAbi, + functionName: 'balanceOf', + args: [assume0xAddress(walletAddress), policyId], + }) + }, + enabled: Boolean(isGated && chainId && walletAddress && policyId !== undefined), + }) + + const openVerify = useCallback(() => { + const chainName = chainId ? ZKPASSPORT_CHAIN_NAME[chainId] : undefined + if (!chainId || !chainName || !hookRegistry || policyId === undefined) { + return + } + openAttestPopup({ + popupUrl: ZKPASSPORT_POPUP_URL, + // 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. + devMode: isTestnetChain(chainId), + attest: { + chain: chainName, + policyId: `0x${policyId.toString(16).padStart(64, '0')}`, + registry: hookRegistry, + }, + // The recipient account is chosen inside the popup; the gate only + // unlocks if it matches the wallet connected here, so re-check the + // credential balance on any outcome, including an early close. + callbacks: { + onSuccess: () => { + void refetchBalance() + }, + onClose: () => { + void refetchBalance() + }, + }, + }) + }, [chainId, hookRegistry, policyId, refetchBalance]) + + 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)} + /> +