Skip to content

Commit

Permalink
feat(unlock-app): multi password hook (#13503)
Browse files Browse the repository at this point in the history
* wip

* locksmith

* adidng support for password hook with cap

* using the hook for checkout

* refactored the password hook UI in checkout

* adding changelog
  • Loading branch information
julien51 authored Mar 24, 2024
1 parent 1186f39 commit 9b72f27
Show file tree
Hide file tree
Showing 13 changed files with 456 additions and 78 deletions.
4 changes: 4 additions & 0 deletions packages/unlock-js/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changes

# 0.45.0

- adding support for a password hook with caps and multiple passwords per lock

# 0.44.0

- adding the possibility to pass a custom url endpoint to the `SubgraphService` constructor.
Expand Down
2 changes: 1 addition & 1 deletion packages/unlock-js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unlock-protocol/unlock-js",
"version": "0.44.0",
"version": "0.45.0",
"description": "This module provides libraries to include Unlock APIs inside a Javascript application.",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down
77 changes: 77 additions & 0 deletions packages/unlock-js/src/abis/passwordCapHookAbi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export const passwordCapHookAbi = [
{ inputs: [], stateMutability: 'nonpayable', type: 'constructor' },
{ inputs: [], name: 'NOT_AUTHORIZED', type: 'error' },
{ inputs: [], name: 'WRONG_PASSWORD', type: 'error' },
{
inputs: [
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'address', name: '', type: 'address' },
],
name: 'counters',
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{ internalType: 'string', name: 'message', type: 'string' },
{ internalType: 'bytes', name: 'signature', type: 'bytes' },
],
name: 'getSigner',
outputs: [
{ internalType: 'address', name: 'recoveredAddress', type: 'address' },
],
stateMutability: 'pure',
type: 'function',
},
{
inputs: [
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'address', name: 'recipient', type: 'address' },
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'bytes', name: 'signature', type: 'bytes' },
],
name: 'keyPurchasePrice',
outputs: [
{ internalType: 'uint256', name: 'minKeyPrice', type: 'uint256' },
],
stateMutability: 'view',
type: 'function',
},
{
inputs: [
{ internalType: 'uint256', name: '', type: 'uint256' },
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'address', name: 'recipient', type: 'address' },
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'bytes', name: 'signature', type: 'bytes' },
{ internalType: 'uint256', name: '', type: 'uint256' },
{ internalType: 'uint256', name: '', type: 'uint256' },
],
name: 'onKeyPurchase',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{ internalType: 'address', name: 'lock', type: 'address' },
{ internalType: 'address', name: 'signer', type: 'address' },
{ internalType: 'uint256', name: 'usages', type: 'uint256' },
],
name: 'setSigner',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{ internalType: 'address', name: '', type: 'address' },
{ internalType: 'address', name: '', type: 'address' },
],
name: 'signers',
outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
stateMutability: 'view',
type: 'function',
},
]
24 changes: 24 additions & 0 deletions packages/unlock-js/src/walletService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { WalletServiceCallback, TransactionOptions } from './types'
import UnlockService from './unlockService'
import utils from './utils'
import { passwordHookAbi } from './abis/passwordHookAbi'
import { passwordCapHookAbi } from './abis/passwordCapHookAbi'
import { discountCodeHookAbi } from './abis/discountCodeHookAbi'
import { discountCodeWithCapHookAbi } from './abis/discountCodeWithCapHookAbi'
import UnlockSwapPurchaser from '@unlock-protocol/contracts/dist/abis/utils/UnlockSwapPurchaser.json'
Expand Down Expand Up @@ -1015,6 +1016,29 @@ export default class WalletService extends UnlockService {
return tx
}

/**
* Set signer for `Password with cap hook contract`
*/
async setPasswordWithCapHookSigner(params: {
lockAddress: string
signerAddress: string
contractAddress: string
cap: number
network: number
}) {
const { lockAddress, signerAddress, contractAddress, network, cap } =
params ?? {}
const contract = await this.getHookContract({
network,
address: contractAddress,
abi: passwordCapHookAbi,
})
const tx = await contract
.connect(this.signer)
.setSigner(lockAddress, signerAddress, cap)
return tx.wait()
}

/**
* Change lock manager for a specific key
* @param {*} params
Expand Down
25 changes: 25 additions & 0 deletions packages/unlock-js/src/web3Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ETHERS_MAX_UINT } from './constants'
import { TransactionOptions, WalletServiceCallback } from './types'
import { passwordHookAbi } from './abis/passwordHookAbi'
import { discountCodeHookAbi } from './abis/discountCodeHookAbi'
import { passwordCapHookAbi } from './abis/passwordCapHookAbi'

import {
CurrencyAmount,
Expand Down Expand Up @@ -1191,4 +1192,28 @@ export default class Web3Service extends UnlockService {
count: ethers.BigNumber.from(count).toNumber(),
}
}

/**
* Get signer for `Password hook contract`
*/
async getPasswordHookWithCapValues(params: {
lockAddress: string
contractAddress: string
network: number
signerAddress: string
}) {
const { lockAddress, contractAddress, network, signerAddress } =
params ?? {}
const contract = await this.getHookContract({
network,
address: contractAddress,
abi: passwordCapHookAbi,
})
const cap = await contract.signers(lockAddress, signerAddress)
const count = await contract.counters(lockAddress, signerAddress)
return {
cap: ethers.BigNumber.from(cap).toNumber(),
count: ethers.BigNumber.from(count).toNumber(),
}
}
}
101 changes: 80 additions & 21 deletions unlock-app/src/components/interface/checkout/main/Password.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { CheckoutService } from './checkoutMachine'
import { FaCheck } from 'react-icons/fa'
import { FaXmark } from 'react-icons/fa6'

import { Connected } from '../Connected'
import { Button, Input } from '@unlock-protocol/ui'
import { Fragment } from 'react'
import { Button, Input, Badge } from '@unlock-protocol/ui'
import { Fragment, useEffect, useState } from 'react'
import { ToastHelper } from '~/components/helpers/toast.helper'
import { useActor } from '@xstate/react'
import { PoweredByUnlock } from '../PoweredByUnlock'
Expand All @@ -10,7 +13,10 @@ import { ethers } from 'ethers'
import { useForm } from 'react-hook-form'
import { useAuth } from '~/contexts/AuthenticationContext'
import { getEthersWalletFromPassword } from '~/utils/strings'
import { usePasswordHookSigner } from '~/hooks/useHooks'
import LoadingIcon from '../../Loading'

import { useDebounce } from 'react-use'
import { useWeb3Service } from '~/utils/withWeb3Service'
interface Props {
injectedProvider: unknown
checkoutService: CheckoutService
Expand All @@ -22,6 +28,12 @@ interface FormData {

export function Password({ injectedProvider, checkoutService }: Props) {
const { account } = useAuth()
const [password, setPassword] = useState<string | undefined>('')
const [hookAddress, setHookAddress] = useState<string>()
const [isPasswordLoading, setPasswordLoading] = useState<boolean>(false)
const [isPasswordCorrect, setIsPasswordCorrect] = useState<boolean>(false)

const web3Service = useWeb3Service()
const [state, send] = useActor(checkoutService)
const { recipients, lock } = state.context
const {
Expand All @@ -33,11 +45,17 @@ export function Password({ injectedProvider, checkoutService }: Props) {
})
const users = recipients.length > 0 ? recipients : [account!]

const { isLoading: isLoadingSigner, data: passwordSigner } =
usePasswordHookSigner({
lockAddress: lock!.address,
network: lock!.network,
})
useEffect(() => {
const getHookAddress = async () => {
setHookAddress(
await web3Service.onKeyPurchaseHook({
lockAddress: lock!.address,
network: lock!.network,
})
)
}
getHookAddress()
}, [lock, web3Service])

const onSubmit = async (formData: FormData) => {
try {
Expand All @@ -62,34 +80,75 @@ export function Password({ injectedProvider, checkoutService }: Props) {
}
}

useDebounce(
async () => {
if (hookAddress && password) {
setPasswordLoading(true)
setIsPasswordCorrect(false)
const privateKeyFromAccount = await getEthersWalletFromPassword(
password
)
// Now check if this is a valid signer!
const passwordDetails = await web3Service.getPasswordHookWithCapValues({
lockAddress: lock!.address,
network: lock!.network,
contractAddress: hookAddress,
signerAddress: privateKeyFromAccount.address,
})
setIsPasswordCorrect(passwordDetails.cap > 0)
setPasswordLoading(false)
}
},
200,
[password, hookAddress, lock]
)

const iconRight = isPasswordLoading
? LoadingIcon
: isPasswordCorrect
? () => (
<Badge variant="green" size="tiny">
<FaCheck />
</Badge>
)
: password
? () => (
<Badge variant="red" size="tiny">
<FaXmark />
</Badge>
)
: undefined

let error = errors.password?.message
if (!error && password && !isPasswordCorrect) {
error = 'This password is not correct. '
}

return (
<Fragment>
<Stepper service={checkoutService} />
<main className="h-full px-6 py-2 overflow-auto">
<form id="password" className="space-y-4">
<Input
// @ts-ignore
iconRight={iconRight}
label="Enter password"
description="You need to enter the password to purchase the key. If password is wrong, purchase will fail."
description={
!password &&
'You need to enter the password to complete this step.'
}
required
type="password"
size="small"
autoComplete="off"
{...register('password', {
required: true,
min: 1,
validate: (password: string) => {
const { address } = getEthersWalletFromPassword(password) ?? {}
// check if password match
if (passwordSigner && passwordSigner !== address) {
return 'Wrong password...'
}
return true
},
})}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
console.log(event.target.value)
setPassword(event.target.value)
}}
error={errors.password?.message}
error={error}
/>
</form>
</main>
Expand All @@ -102,11 +161,11 @@ export function Password({ injectedProvider, checkoutService }: Props) {
type="submit"
form="password"
className="w-full"
disabled={isLoadingSigner}
disabled={!isPasswordCorrect}
loading={isSubmitting}
onClick={handleSubmit(onSubmit)}
>
Submit password
Next
</Button>
</Connected>
<PoweredByUnlock />
Expand Down
29 changes: 13 additions & 16 deletions unlock-app/src/components/interface/checkout/main/Promo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,22 +119,19 @@ export function PromoContent({

const iconRight = isLoading
? LoadingIcon
: () => {
if (hasDiscount) {
return (
<Badge variant="green" size="tiny">
{promoCodeDetails.discount / 100}% Discount
</Badge>
)
} else if (promoCodeDetails?.discount > 0) {
return (
<Badge variant="dark" size="tiny">
Code expired
</Badge>
)
}
return null
}
: hasDiscount
? () => (
<Badge variant="green" size="tiny">
{promoCodeDetails.discount / 100}% Discount
</Badge>
)
: promoCodeDetails?.discount > 0
? () => (
<Badge variant="dark" size="tiny">
Code expired
</Badge>
)
: undefined

let error = errors.promo?.message
if (!error && code && promoCodeDetails?.discount === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const HookIdMapping: Partial<Record<HookType, CheckoutHookType>> = {
CAPTCHA: 'captcha',
PROMOCODE: 'promocode',
PROMO_CODE_CAPPED: 'promocode',
PASSWORD_CAPPED: 'password',
}

export function useCheckoutHook(service: CheckoutService) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useAuth } from '~/contexts/AuthenticationContext'
import { Hook, HookName, HookType } from '@unlock-protocol/types'
import { CustomContractHook } from './hooksComponents/CustomContractHook'
import { PasswordContractHook } from './hooksComponents/PasswordContractHook'
import { PasswordCappedContractHook } from './hooksComponents/PasswordCappedContractHook'
import { DEFAULT_USER_ACCOUNT_ADDRESS } from '~/constants'
import { useConfig } from '~/utils/withConfig'
import { CaptchaContractHook } from './hooksComponents/CaptchaContractHook'
Expand Down Expand Up @@ -77,6 +78,11 @@ export const HookMapping: Record<FormPropsKey, HookValueProps> = {
value: HookType.PASSWORD,
component: (args) => <PasswordContractHook {...args} />,
},
{
label: 'Password with caps',
value: HookType.PASSWORD_CAPPED,
component: (args) => <PasswordCappedContractHook {...args} />,
},
{
label: 'Captcha required',
value: HookType.CAPTCHA,
Expand Down
Loading

0 comments on commit 9b72f27

Please sign in to comment.