diff --git a/unlock-app/package.json b/unlock-app/package.json index 9bf516dfe07..1d15b131159 100644 --- a/unlock-app/package.json +++ b/unlock-app/package.json @@ -113,6 +113,7 @@ "deploy-fleek": "./scripts/deploy-fleek.sh", "start": "yarn build && NODE_ENV=production next start", "test": "UNLOCK_ENV=test vitest run --coverage --environment=jsdom", + "test:watch": "UNLOCK_ENV=test vitest --coverage --environment=jsdom -w", "lint": "eslint", "ci": "yarn test && yarn lint && yarn build" }, diff --git a/unlock-app/src/__tests__/utils/checkoutLockUtils.test.ts b/unlock-app/src/__tests__/utils/checkoutLockUtils.test.ts index 330d07fbfb9..1d4ad58b5a6 100644 --- a/unlock-app/src/__tests__/utils/checkoutLockUtils.test.ts +++ b/unlock-app/src/__tests__/utils/checkoutLockUtils.test.ts @@ -1,14 +1,17 @@ +import { PaywallConfigType } from '@unlock-protocol/core' import { lockKeysAvailable, lockTickerSymbol, userCanAffordKey, formattedKeyPrice, convertedKeyPrice, + getReferrers, } from '../../utils/checkoutLockUtils' -import { it, describe, expect } from 'vitest' +import { it, describe, expect, vi } from 'vitest' const lockAddress = '0x2B24bE6c9d5b70Ad53203AdB780681cd70603660' const network = 5 + describe('Checkout Lock Utils', () => { describe('lockKeysAvailable', () => { it('returns Unlimited if it has unlimited keys', () => { @@ -228,4 +231,111 @@ describe('Checkout Lock Utils', () => { ) }) }) + + describe('getReferrers', () => { + const recipients = [ + '0x2B24bE6c9d5b70Ad53203AdB780681cd70603660', + '0x1234567890123456789012345678901234567891', + ] + const lockAddress = '0x87dA72DC59674A17AD2154a25699246c51E25a57' + let paywallConfig: PaywallConfigType + + beforeEach(() => { + paywallConfig = { + locks: { + '0x87dA72DC59674A17AD2154a25699246c51E25a57': { + referrer: '0x1234567890123456789012345678901234567890', + network: 11155111, + }, + }, + referrer: '0xE5Cd62AC8d2Ca2A62a04958f07Dd239c1Ffe1a9E', + } + vi.resetModules() + }) + + it('should return paywallConfig locks referrer', async () => { + expect.assertions(1) + expect( + await getReferrers(recipients, paywallConfig, lockAddress) + ).toEqual([ + '0x1234567890123456789012345678901234567890', + '0x1234567890123456789012345678901234567890', + ]) + }) + + it('should return paywallConfig referrer', async () => { + expect.assertions(1) + paywallConfig = { + ...paywallConfig, + locks: { + '0x87dA72DC59674A17AD2154a25699246c51E25a57': { + referrer: '0x1234567890123', + }, + }, + } + + expect( + await getReferrers(recipients, paywallConfig, lockAddress) + ).toEqual([ + '0xE5Cd62AC8d2Ca2A62a04958f07Dd239c1Ffe1a9E', + '0xE5Cd62AC8d2Ca2A62a04958f07Dd239c1Ffe1a9E', + ]) + }) + + it('should return recipients if referrers are not addresses', async () => { + expect.assertions(1) + paywallConfig = { + ...paywallConfig, + locks: { + '0x87dA72DC59674A17AD2154a25699246c51E25a57': { + referrer: '0x1234567890123', + }, + }, + referrer: '0x62CcB13A72E6F991', + } + + expect( + await getReferrers(recipients, paywallConfig, lockAddress) + ).toEqual([ + '0x2B24bE6c9d5b70Ad53203AdB780681cd70603660', + '0x1234567890123456789012345678901234567891', + ]) + }) + + it('should resolve for ens if the referrer is an ens address', async () => { + vi.doMock('../../utils/resolvers', () => { + return { + onResolveName: () => + Promise.resolve({ + address: 'ensAddressMock', + }), + } + }) + + vi.doMock('@unlock-protocol/ui', () => { + return { + isEns: () => Promise.resolve(true), + } + }) + + const { getReferrers } = await import('../../utils/checkoutLockUtils') + expect.assertions(1) + paywallConfig = { + ...paywallConfig, + locks: { + '0x87dA72DC59674A17AD2154a25699246c51E25a57': { + referrer: '0x1234567890123', + }, + }, + referrer: 'test.eth', + } + + expect( + await getReferrers(recipients, paywallConfig, lockAddress) + ).toEqual(['ensAddressMock', 'ensAddressMock']) + + vi.unmock('../../utils/resolvers') + vi.unmock('@unlock-protocol/ui') + }) + }) }) diff --git a/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCard.tsx b/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCard.tsx index 2f3196a7954..3fd46aaa6c6 100644 --- a/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCard.tsx +++ b/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCard.tsx @@ -7,7 +7,7 @@ import { loadStripe } from '@stripe/stripe-js' import { useSelector } from '@xstate/react' import { PoweredByUnlock } from '../../PoweredByUnlock' import { Pricing } from '../../Lock' -import { getReferrer, lockTickerSymbol } from '~/utils/checkoutLockUtils' +import { lockTickerSymbol } from '~/utils/checkoutLockUtils' import { Lock } from '~/unlockTypes' import { RiErrorWarningFill as ErrorIcon } from 'react-icons/ri' import { usePurchase } from '~/hooks/usePurchase' @@ -22,6 +22,7 @@ import { useGetLockSettings } from '~/hooks/useLockSettings' import { getCurrencySymbol } from '~/utils/currency' import Disconnect from '../Disconnect' import { ToastHelper } from '~/components/helpers/toast.helper' +import { useGetReferrers } from '~/hooks/useGetReferrers' interface Props { checkoutService: CheckoutService @@ -171,14 +172,17 @@ export function ConfirmCard({ checkoutService, onConfirmed, onError }: Props) { network: lock!.network, purchaseData: purchaseData || [], }) + const { data: referrers } = useGetReferrers( + recipients, + paywallConfig, + lockAddress + ) const { mutateAsync: capturePayment } = useCapturePayment({ network: lock!.network, lockAddress: lock!.address, data: purchaseData, - referrers: recipients.map((recipient: string) => - getReferrer(recipient, paywallConfig, lockAddress) - ), + referrers, recipients, purchaseType: renew ? 'extend' : 'purchase', }) @@ -191,10 +195,6 @@ export function ConfirmCard({ checkoutService, onConfirmed, onError }: Props) { const onConfirmCard = async () => { setIsConfirming(true) try { - const referrers: string[] = recipients.map((recipient) => { - return getReferrer(recipient, paywallConfig, lockAddress) - }) - const stripeIntent = await createPurchaseIntent({ pricing: totalPricing!.total * 100, // // @ts-expect-error - generated types don't narrow down to the right type diff --git a/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrossmint.tsx b/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrossmint.tsx index 093369d5fbd..5f0c82eba11 100644 --- a/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrossmint.tsx +++ b/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrossmint.tsx @@ -7,7 +7,7 @@ import { Fragment, useCallback, useState } from 'react' import { useSelector } from '@xstate/react' import { PoweredByUnlock } from '../../PoweredByUnlock' import { Pricing } from '../../Lock' -import { getReferrer, lockTickerSymbol } from '~/utils/checkoutLockUtils' +import { lockTickerSymbol } from '~/utils/checkoutLockUtils' import { Lock } from '~/unlockTypes' import { RiErrorWarningFill as ErrorIcon } from 'react-icons/ri' import { usePricing } from '~/hooks/usePricing' @@ -19,6 +19,7 @@ import { TransactionAnimation } from '../../Shell' import { config } from '~/config/app' import { useGetTokenIdForOwner } from '~/hooks/useGetTokenIdForOwner' import Disconnect from '../Disconnect' +import { useGetReferrers } from '~/hooks/useGetReferrers' interface Props { checkoutService: CheckoutService @@ -46,7 +47,6 @@ export function ConfirmCrossmint({ useSelector(checkoutService, (state) => state.context) const [isConfirming, setIsConfirming] = useState(false) const [quote, setQuote] = useState(null) - const crossmintEnv = config.env === 'prod' ? 'production' : 'staging' const { @@ -73,6 +73,12 @@ export function ConfirmCrossmint({ environment: crossmintEnv, }) + const { data: referrers } = useGetReferrers( + recipients, + paywallConfig, + lock!.address + ) + // Handling payment events const onCrossmintPaymentEvent = useCallback( (paymentEvent: any) => { @@ -133,10 +139,6 @@ export function ConfirmCrossmint({ crossmintLoading || (!tokenId && renew)) - const referrers: string[] = recipients.map((recipient) => { - return getReferrer(recipient, paywallConfig, lock!.address) - }) - const values = pricingData ? [ ethers.parseUnits( @@ -170,7 +172,7 @@ export function ConfirmCrossmint({ crossmintConfig.mintConfig = { totalPrice: pricingData?.total.toString(), _values: values, - _referrers: referrers, + _referrers: referrers?.[0], _keyManagers: keyManagers || recipients, _data: purchaseData, } @@ -180,7 +182,7 @@ export function ConfirmCrossmint({ totalPrice: pricingData?.total.toString(), _tokenId: tokenId, _value: values[0], - _referrer: referrers[0], + _referrer: referrers?.[0], _data: purchaseData ? purchaseData[0] : '', } } diff --git a/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrypto.tsx b/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrypto.tsx index c33abd97676..4481fcd1071 100644 --- a/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrypto.tsx +++ b/unlock-app/src/components/interface/checkout/main/Confirm/ConfirmCrypto.tsx @@ -9,7 +9,7 @@ import { getAccountTokenBalance } from '~/hooks/useAccount' import { useSelector } from '@xstate/react' import { useWeb3Service } from '~/utils/withWeb3Service' import { Pricing } from '../../Lock' -import { getReferrer, lockTickerSymbol } from '~/utils/checkoutLockUtils' +import { getReferrers, lockTickerSymbol } from '~/utils/checkoutLockUtils' import { Lock } from '~/unlockTypes' import ReCaptcha from 'react-google-recaptcha' import { RiErrorWarningFill as ErrorIcon } from 'react-icons/ri' @@ -146,10 +146,6 @@ export function ConfirmCrypto({ pricingData?.prices.map((item) => item.amount.toString()) || new Array(recipients!.length).fill(keyPrice) - const referrers: string[] = recipients.map((recipient) => { - return getReferrer(recipient, paywallConfig, lockAddress) - }) - const onErrorCallback = (error: Error | null, hash: string | null) => { setIsConfirming(false) if (error) { @@ -165,11 +161,16 @@ export function ConfirmCrypto({ const walletService = await getWalletService(lockNetwork) if (renew) { + const referrers = await getReferrers( + [account!], + paywallConfig, + lockAddress + ) await walletService.extendKey( { lockAddress, owner: recipients?.[0], - referrer: getReferrer(account!, paywallConfig, lockAddress), + referrer: referrers[0], data: purchaseData?.[0], recurringPayment: recurringPayments ? recurringPayments[0] @@ -179,6 +180,11 @@ export function ConfirmCrypto({ onErrorCallback ) } else { + const referrers = await getReferrers( + recipients, + paywallConfig, + lockAddress + ) await walletService.purchaseKeys( { lockAddress, diff --git a/unlock-app/src/components/interface/checkout/main/Select.tsx b/unlock-app/src/components/interface/checkout/main/Select.tsx index a0c531e8aac..1fa58703df1 100644 --- a/unlock-app/src/components/interface/checkout/main/Select.tsx +++ b/unlock-app/src/components/interface/checkout/main/Select.tsx @@ -228,7 +228,6 @@ export function Select({ checkoutService }: Props) { props.network || paywallConfig.network || 1 const lockData = await web3Service.getLock(lock, networkId) - let price if (account) { diff --git a/unlock-app/src/components/interface/keychain/Extend.tsx b/unlock-app/src/components/interface/keychain/Extend.tsx index af1c3c8ca42..d2cbe24db5e 100644 --- a/unlock-app/src/components/interface/keychain/Extend.tsx +++ b/unlock-app/src/components/interface/keychain/Extend.tsx @@ -19,7 +19,7 @@ import { config } from '~/config/app' import { useWeb3Service } from '~/utils/withWeb3Service' import { Placeholder } from '@unlock-protocol/ui' import { Key } from '~/hooks/useKeys' -import { getReferrer } from '~/utils/checkoutLockUtils' +import { getReferrers } from '~/utils/checkoutLockUtils' const ExtendMembershipPlaceholder = () => { return ( @@ -121,10 +121,11 @@ export const ExtendMembershipModal = ({ walletService.signer ) } else { + const referrers = await getReferrers([account!]) await walletService.extendKey({ lockAddress: lock?.address, tokenId: ownedKey.tokenId, - referrer: getReferrer(account!), + referrer: referrers[0], recurringPayment: renewal ? renewal : undefined, totalApproval: unlimited ? MAX_UINT : undefined, }) diff --git a/unlock-app/src/components/interface/locks/CheckoutUrl/elements/BasicConfigForm.tsx b/unlock-app/src/components/interface/locks/CheckoutUrl/elements/BasicConfigForm.tsx index 4b22539f096..6415ab608da 100644 --- a/unlock-app/src/components/interface/locks/CheckoutUrl/elements/BasicConfigForm.tsx +++ b/unlock-app/src/components/interface/locks/CheckoutUrl/elements/BasicConfigForm.tsx @@ -1,15 +1,17 @@ import { BasicPaywallConfigSchema } from '~/unlockTypes' -import { useForm } from 'react-hook-form' +import { Controller, useForm, useWatch } from 'react-hook-form' import { Input, Checkbox, Button, ImageUpload, Modal, + AddressInput, } from '@unlock-protocol/ui' import { z } from 'zod' import { useState } from 'react' import { useImageUpload } from '~/hooks/useImageUpload' +import { onResolveName } from '~/utils/resolvers' interface Props { onChange: (values: z.infer) => void @@ -24,6 +26,7 @@ export const BasicConfigForm = ({ onChange, defaultValues }: Props) => { watch, setValue, formState: { errors }, + control, } = useForm>({ reValidateMode: 'onChange', defaultValues: defaultValues as any, @@ -36,6 +39,10 @@ export const BasicConfigForm = ({ onChange, defaultValues }: Props) => { onChange(updatedValues) // Call the onChange prop with updated values } + const { referrer = '' } = useWatch({ + control, + }) + return (
{ error={errors.title?.message} /> - { + return ( + { + setValue('referrer', value) + }} + /> + ) + }} /> - getReferrer(account!, paywallConfig, lock.address) - ), + referrers: recipients.map(() => referrers[0]), purchaseData: purchaseData || recipients.map(() => '0x'), srcToken: token.address, srcChainId: network, diff --git a/unlock-app/src/hooks/useGetReferrers.ts b/unlock-app/src/hooks/useGetReferrers.ts new file mode 100644 index 00000000000..a6a59c322c9 --- /dev/null +++ b/unlock-app/src/hooks/useGetReferrers.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' +import { PaywallConfigType } from '@unlock-protocol/core' + +import { getReferrers } from '~/utils/checkoutLockUtils' + +export const useGetReferrers = ( + recipients: string[], + paywallConfig: PaywallConfigType, + lockAddress?: string +) => { + return useQuery({ + queryKey: ['getReferrers', paywallConfig, lockAddress, recipients], + queryFn: async () => { + return getReferrers(recipients, paywallConfig, lockAddress) + }, + }) +} diff --git a/unlock-app/src/hooks/usePricing.ts b/unlock-app/src/hooks/usePricing.ts index 3c406e047f8..8a527c4bba6 100644 --- a/unlock-app/src/hooks/usePricing.ts +++ b/unlock-app/src/hooks/usePricing.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { PaywallConfigType } from '@unlock-protocol/core' import { networks } from '@unlock-protocol/networks' import { ethers } from 'ethers' -import { getReferrer } from '~/utils/checkoutLockUtils' +import { getReferrers } from '~/utils/checkoutLockUtils' import { useWeb3Service } from '~/utils/withWeb3Service' interface Options { @@ -33,14 +33,14 @@ export const purchasePriceFor = async ( ? await web3Service.getTokenDecimals(currencyContractAddress!, network) : networks[network].nativeCurrency?.decimals || 18 + const referrers = await getReferrers(recipients, paywallConfig, lockAddress) const prices = await Promise.all( recipients.map(async (userAddress, index) => { - const referrer = getReferrer(userAddress, paywallConfig, lockAddress) const options = { lockAddress, network, userAddress, - referrer, + referrer: referrers[index], data: data?.[index] || '0x', } const price = await web3Service.purchasePriceFor(options) diff --git a/unlock-app/src/utils/checkoutLockUtils.ts b/unlock-app/src/utils/checkoutLockUtils.ts index e8e3583fe6e..7eaee317fcd 100644 --- a/unlock-app/src/utils/checkoutLockUtils.ts +++ b/unlock-app/src/utils/checkoutLockUtils.ts @@ -3,11 +3,14 @@ // type so that it at least includes as optional all possible // properties on a lock. These are all compatible with RawLock insofar +import { PaywallConfigType } from '@unlock-protocol/core' +import { isEns } from '@unlock-protocol/ui' + import { Lock } from '~/unlockTypes' import { isAccount } from '../utils/checkoutValidators' import { locksmith } from '~/config/locksmith' import { getCurrencySymbol } from './currency' -import { PaywallConfigType } from '@unlock-protocol/core' +import { onResolveName } from './resolvers' // as they only extend it with properties that may be undefined. interface LockKeysAvailableLock { @@ -145,29 +148,61 @@ export const inClaimDisallowList = (address: string) => { return claimDisallowList.indexOf(address) > -1 } -/** - * Helper function that returns a valid referrer address - * @param recipient - * @param paywallConfig - * @param lockAddress - * @returns - */ -export const getReferrer = ( - recipient: string, +export const shouldReferrerResolveForENS = ( paywallConfig?: PaywallConfigType, lockAddress?: string -): string => { +) => { if (paywallConfig) { if ( lockAddress && paywallConfig.locks[lockAddress] && isAccount(paywallConfig.locks[lockAddress].referrer) ) { - return paywallConfig.locks[lockAddress].referrer! + return false } - if (paywallConfig.referrer && isAccount(paywallConfig.referrer)) { - return paywallConfig.referrer + if (paywallConfig.referrer && isEns(paywallConfig.referrer)) { + return true } } - return recipient + return false +} + +export const getReferrers = async ( + recipients: string[], + paywallConfig?: PaywallConfigType, + lockAddress?: string +): Promise => { + const isReferrerAddressEns = shouldReferrerResolveForENS( + paywallConfig, + lockAddress + ) + if (isReferrerAddressEns) { + if (isReferrerAddressEns && paywallConfig && paywallConfig.referrer) { + try { + // paywallConfig.referrer is always a string if isReferrerAddressEns is true + const response = await onResolveName(paywallConfig.referrer) + if (response && response.address && response.address !== null) { + return recipients.map(() => response.address as string) + } + } catch (e) { + console.log('Error resolving referrer ENS', e) + } + } + } + + return recipients.map((recipient) => { + if (paywallConfig) { + if ( + lockAddress && + paywallConfig.locks[lockAddress] && + isAccount(paywallConfig.locks[lockAddress].referrer) + ) { + return paywallConfig.locks[lockAddress].referrer! + } + if (paywallConfig.referrer && isAccount(paywallConfig.referrer)) { + return paywallConfig.referrer + } + } + return recipient + }) }