Skip to content
Merged
3 changes: 2 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@
"xlarge",
"xlink",
"zerion",
"zubin"
"zubin",
"merkel"
],
"ignoreWords": [
"aicanft",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const useStyles = makeStyles()((theme) => ({
},
linkWrapper: {
position: 'relative',
display: 'block',
width: 172,
height: 172,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ export function PreviewCard(props: PreviewCardProps) {
const {
boxId,
boxState,
isQualified,
holderToken,
boxStateMessage,
boxInfo,
boxMetadata,
Expand Down Expand Up @@ -198,14 +196,9 @@ export function PreviewCard(props: PreviewCardProps) {
size="medium"
fullWidth
variant="contained"
disabled={boxState !== BoxState.READY || !isQualified}
disabled={boxState !== BoxState.READY}
onClick={() => setOpenDrawDialog(true)}>
{(() => {
if (!isQualified) {
const { symbol, decimals } = holderToken ?? {}
const tokenPrice = `${formatBalance(boxInfo?.holderMinTokenAmount, decimals)} $${symbol}`
return `You must hold at least ${tokenPrice}`
}
return boxState === BoxState.READY && paymentTokenAddress ? (
<>
{boxStateMessage} (
Expand Down
3 changes: 2 additions & 1 deletion packages/mask/src/plugins/MaskBox/SNSAdaptor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ export default sns
function Renderer(props: React.PropsWithChildren<{ url: string }>) {
const [, chainId] = props.url.match(/chain=(\d+)/i) ?? []
const [, boxId] = props.url.match(/box=(\d+)/i) ?? []
const [, hashRoot] = props.url.match(/rootHash=([\dA-Za-z]+)/) ?? []

const shouldNotRender = !chainId || !boxId
usePluginWrapper(!shouldNotRender)
if (shouldNotRender) return null

return (
<EthereumChainBoundary chainId={Number.parseInt(chainId, 10)}>
<Context.Provider initialState={{ boxId }}>
<Context.Provider initialState={{ boxId, hashRoot }}>
<PreviewCard />
</Context.Provider>
</EthereumChainBoundary>
Expand Down
1 change: 1 addition & 0 deletions packages/mask/src/plugins/MaskBox/apis/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './rss3'
export * from './merkleProof'
16 changes: 16 additions & 0 deletions packages/mask/src/plugins/MaskBox/apis/merkleProof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import urlcat from 'urlcat'
import { MERKLE_PROOF_ENDPOINT } from '../constants'

export async function getMerkleProof(leaf: string, root: string) {
try {
const response = await fetch(
urlcat(MERKLE_PROOF_ENDPOINT, {
leaf,
root: root.replace(/^0x/, ''),
}),
)
return (await response.json()) as { proof?: string[]; message?: string; module?: string }
} catch (err: any) {
throw new Error(err?.message)
}
}
Comment on lines +13 to +16

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} catch (err: any) {
throw new Error(err?.message)
}
}
} catch (error: unknown) {
throw error
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then why you need to add a try-catch anyway?

2 changes: 2 additions & 0 deletions packages/mask/src/plugins/MaskBox/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ import { PluginId } from '@masknet/plugin-infra'

export const PLUGIN_ID = PluginId.MaskBox
export const RSS3_ENDPOINT = 'https://hub.pass3.me'
export const MERKLE_PROOF_ENDPOINT =
'https://lf8d031acj.execute-api.ap-east-1.amazonaws.com/api/v1/merkle_tree/leaf_exists'
97 changes: 71 additions & 26 deletions packages/mask/src/plugins/MaskBox/hooks/useContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import * as ABICoder from 'web3-eth-abi'
import { useAsyncRetry } from 'react-use'
import fromUnixTime from 'date-fns/fromUnixTime'
import addDays from 'date-fns/addDays'
Expand All @@ -24,29 +25,39 @@ import {
ZERO_ADDRESS,
isZeroAddress,
isNativeTokenAddress,
formatBalance,
} from '@masknet/web3-shared-evm'
import type { NonPayableTx } from '@masknet/web3-contracts/types/types'
import { BoxInfo, BoxState } from '../type'
import { useMaskBoxInfo } from './useMaskBoxInfo'
import { useMerkelProof } from './useMerkleProof'
import { useMaskBoxStatus } from './useMaskBoxStatus'
import { useMaskBoxCreationSuccessEvent } from './useMaskBoxCreationSuccessEvent'
import { useMaskBoxTokensForSale } from './useMaskBoxTokensForSale'
import { useMaskBoxPurchasedTokens } from './useMaskBoxPurchasedTokens'
import { formatCountdown } from '../helpers/formatCountdown'
import { useOpenBoxTransaction } from './useOpenBoxTransaction'
import { useMaskBoxMetadata } from './useMaskBoxMetadata'
import { useIsWhitelisted } from './useIsWhitelisted'
import { isGreaterThanOrEqualTo, isLessThanOrEqualTo, isZero, multipliedBy, useBeat } from '@masknet/web3-shared-base'
import { useQualification } from './useQualification'
import {
isGreaterThan,
isGreaterThanOrEqualTo,
isLessThanOrEqualTo,
multipliedBy,
useBeat,
} from '@masknet/web3-shared-base'

function useContext(initialState?: { boxId: string }) {
function useContext(initialState?: { boxId: string; hashRoot: string }) {
const now = new Date()
const beat = useBeat()
const account = useAccount()
const chainId = useChainId()
const { NATIVE_TOKEN_ADDRESS } = useTokenConstants(ChainId.Mainnet)
const { MASK_BOX_CONTRACT_ADDRESS } = useMaskBoxConstants()
const coder = ABICoder as unknown as ABICoder.AbiCoder

const [boxId, setBoxId] = useState(initialState?.boxId ?? '')
const [rootHash, setRootHash] = useState(initialState?.hashRoot || '')
const [paymentTokenAddress, setPaymentTokenAddress] = useState('')

// #region the box info
Expand All @@ -56,6 +67,7 @@ function useContext(initialState?: { boxId: string }) {
loading: loadingMaskBoxInfo,
retry: retryMaskBoxInfo,
} = useMaskBoxInfo(boxId)

const {
value: maskBoxStatus = null,
error: errorMaskBoxStatus,
Expand Down Expand Up @@ -133,10 +145,37 @@ function useContext(initialState?: { boxId: string }) {
maskBoxStatus,
maskBoxCreationSuccessEvent,
])
// #endregion

// #region qualification
const { value, error: errorProof, loading: loadingProof } = useMerkelProof(rootHash)
const proofBytes = value?.proof
? coder.encodeParameters(['bytes32[]'], [value?.proof?.map((p) => `0x${p}`) ?? []])
: undefined
const qualification = useQualification(
boxInfo?.qualificationAddress,
account,
value?.proof ? coder.encodeParameters(['bytes', 'bytes32'], [proofBytes, rootHash]) : undefined,
)

// not in whitelist
const notInWhiteList = value?.message === 'leaf not found'

// at least hold token amount
const { value: holderToken } = useERC20TokenDetailed(boxInfo?.holderTokenAddress)
const { value: holderTokenBalance = '0' } = useERC20TokenBalance(holderToken?.address)
const holderMinTokenAmountBN = new BigNumber(boxInfo?.holderMinTokenAmount ?? 0)
const insufficientHolderToken =
isGreaterThan(holderMinTokenAmountBN, 0) && !holderMinTokenAmountBN.lte(holderTokenBalance)
// #endregion

const boxState = useMemo(() => {
if (errorMaskBoxInfo || errorMaskBoxStatus || errorBoxInfo) return BoxState.ERROR
if (loadingMaskBoxInfo || loadingMaskBoxStatus || loadingBoxInfo) {
if (notInWhiteList) return BoxState.NOT_IN_WHITELIST
if (insufficientHolderToken) return BoxState.INSUFFICIENT_HOLDER_TOKEN
if (qualification?.error_msg) return BoxState.NOT_QUALIFIED
if (errorMaskBoxInfo || errorMaskBoxStatus || errorBoxInfo || (rootHash ? errorProof : false))
return BoxState.ERROR
if (loadingMaskBoxInfo || loadingMaskBoxStatus || loadingBoxInfo || (rootHash ? loadingProof : false)) {
if (!maskBoxInfo && !boxInfo) return BoxState.UNKNOWN
}
if (maskBoxInfo && !boxInfo) return BoxState.UNKNOWN
Expand All @@ -147,19 +186,21 @@ function useContext(initialState?: { boxId: string }) {
if (boxInfo.startAt > now || !boxInfo.started) return BoxState.NOT_READY
if (boxInfo.endAt < now || maskBoxStatus?.expired) return BoxState.EXPIRED
return BoxState.READY
}, [boxInfo, loadingBoxInfo, errorBoxInfo, maskBoxInfo, loadingMaskBoxInfo, errorMaskBoxInfo, beat])

const isWhitelisted = useIsWhitelisted(boxInfo?.qualificationAddress, account)
const isQualifiedByContract =
boxInfo?.qualificationAddress && !isZeroAddress(boxInfo?.qualificationAddress) ? isWhitelisted : true

// #region check holder min token
const { value: holderToken } = useERC20TokenDetailed(boxInfo?.holderTokenAddress)
const { value: holderTokenBalance = '0' } = useERC20TokenBalance(holderToken?.address)
const holderMinTokenAmountBN = new BigNumber(boxInfo?.holderMinTokenAmount ?? 0)
const isQualified =
(isZero(holderMinTokenAmountBN) || holderMinTokenAmountBN.lte(holderTokenBalance)) && isQualifiedByContract
// #endregion
}, [
boxInfo,
loadingBoxInfo,
errorBoxInfo,
maskBoxInfo,
loadingMaskBoxInfo,
errorMaskBoxInfo,
qualification,
loadingProof,
errorProof,
rootHash,
notInWhiteList,
insufficientHolderToken,
beat,
])

const boxStateMessage = useMemo(() => {
switch (boxState) {
Expand All @@ -179,16 +220,24 @@ function useContext(initialState?: { boxId: string }) {
return countdown ? `Start sale in ${countdown}` : 'Loading...'
case BoxState.SOLD_OUT:
return 'Sold Out'
case BoxState.NOT_IN_WHITELIST:
return 'You are not in the whitelist.'
case BoxState.INSUFFICIENT_HOLDER_TOKEN:
const { symbol, decimals } = holderToken ?? {}
const tokenPrice = `${formatBalance(boxInfo?.holderMinTokenAmount, decimals)} $${symbol}`
return `You must hold at least ${tokenPrice}.`
case BoxState.NOT_QUALIFIED:
return qualification?.error_msg ?? 'Not qualified.'
case BoxState.DRAWED_OUT:
return 'Purchase limit exceeded'
return 'Purchase limit exceeded.'
case BoxState.ERROR:
return 'Something went wrong.'
case BoxState.NOT_FOUND:
return 'Failed to load box info.'
default:
unreachable(boxState)
}
}, [boxState, boxInfo?.startAt, beat])
}, [holderToken, boxState, boxInfo?.startAt, qualification, beat])

useEffect(() => {
if (!boxInfo || boxInfo.started) return
Expand All @@ -198,8 +247,6 @@ function useContext(initialState?: { boxId: string }) {
}
}, [boxInfo, beat])

// #endregion

// #region the box metadata
const { value: boxMetadata, retry: retryBoxMetadata } = useMaskBoxMetadata(boxId, boxInfo?.creator ?? '')
// #endregion
Expand Down Expand Up @@ -255,13 +302,14 @@ function useContext(initialState?: { boxId: string }) {
paymentTokenIndex,
paymentTokenPrice,
paymentTokenDetailed,
proofBytes,
openBoxTransactionOverrides,
)
const { value: erc20Allowance, retry: retryAllowance } = useERC20TokenAllowance(
isNativeToken ? undefined : paymentTokenAddress,
MASK_BOX_CONTRACT_ADDRESS,
)
const canPurchase = !isBalanceInsufficient && isQualified && !!boxInfo?.personalRemaining
const canPurchase = !isBalanceInsufficient && !!boxInfo?.personalRemaining
const allowToPurchase = boxState === BoxState.READY
const isAllowanceEnough = isNativeToken ? true : costAmount.lte(erc20Allowance ?? '0')
const { value: openBoxTransactionGasLimit } = useAsyncRetry(async () => {
Expand All @@ -276,9 +324,6 @@ function useContext(initialState?: { boxId: string }) {
boxId,
setBoxId,

isQualified,
holderToken,

// box info & metadata
boxInfo,
boxMetadata,
Expand Down
13 changes: 0 additions & 13 deletions packages/mask/src/plugins/MaskBox/hooks/useIsWhitelisted.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AbiItem } from 'web3-utils'
import { useContract } from '@masknet/web3-shared-evm'
import type { MaskBoxQualification } from '@masknet/web3-contracts/types/MaskBoxQualification'
import MASK_BOX_QUALIFICATION_CONTRACT from '@masknet/web3-contracts/abis/MaskBoxQualification.json'

export function useMaskBoxQualificationContract(address?: string) {
return useContract<MaskBoxQualification>(address, MASK_BOX_QUALIFICATION_CONTRACT as AbiItem[])
}
16 changes: 16 additions & 0 deletions packages/mask/src/plugins/MaskBox/hooks/useMerkleProof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useAsyncRetry } from 'react-use'
import { useAccount } from '@masknet/web3-shared-evm'
import { getMerkleProof } from '../apis'

export function useMerkelProof(root?: string) {
const account = useAccount()
return useAsyncRetry(async () => {
if (!root) return

const leaf = Buffer.from(
(account.replace(/0x/, '').match(/.{2}/g) ?? []).map((x) => Number.parseInt(x, 16)),
).toString('base64')

return getMerkleProof(leaf, root)
}, [account, root])
}
15 changes: 13 additions & 2 deletions packages/mask/src/plugins/MaskBox/hooks/useOpenBoxTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function useOpenBoxTransaction(
paymentTokenIndex: number,
paymentTokenPrice: string,
paymentTokenDetailed: FungibleTokenDetailed | null,
proof?: string,
overrides?: NonPayableTx | null,
) {
const account = useAccount()
Expand All @@ -25,7 +26,17 @@ export function useOpenBoxTransaction(
? multipliedBy(paymentTokenPrice, amount).toFixed()
: undefined,
},
method: maskBoxContract.methods.openBox(boxId, amount, paymentTokenIndex, '0x0'),
method: maskBoxContract.methods.openBox(boxId, amount, paymentTokenIndex, proof ?? '0x00'),
}
}, [account, amount, boxId, maskBoxContract, paymentTokenIndex, paymentTokenPrice, paymentTokenDetailed, overrides])
}, [
account,
amount,
boxId,
maskBoxContract,
paymentTokenIndex,
paymentTokenPrice,
paymentTokenDetailed,
proof,
overrides,
])
}
13 changes: 13 additions & 0 deletions packages/mask/src/plugins/MaskBox/hooks/useQualification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useAsyncRetry } from 'react-use'
import { useMaskBoxQualificationContract } from './useMaskBoxQualificationContract'

export function useQualification(address?: string, account?: string, proof?: string) {
const qualificationContract = useMaskBoxQualificationContract(address)
const { value: qualification = { qualified: false, error_msg: '' } } = useAsyncRetry(async () => {
if (!qualificationContract || !account) return null
return qualificationContract.methods.is_qualified(account, proof ?? '0x00').call({
from: account,
})
}, [account, qualificationContract, proof])
return qualification
}

This file was deleted.

6 changes: 6 additions & 0 deletions packages/mask/src/plugins/MaskBox/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export enum BoxState {
ERROR = 7,
/** 404 */
NOT_FOUND = 8,
/** leaf not found */
NOT_IN_WHITELIST = 9,
/** insufficient holder token */
INSUFFICIENT_HOLDER_TOKEN = 10,
/** not qualified */
NOT_QUALIFIED = 11,
}

export interface PaymentOption {
Expand Down
Loading