web3区块链前端开发

1,029 阅读7分钟

1.区块链知识点(前端相关)

1.1 Provider、Connector、Abi

Provider:主要用于从区块链中获取数据。常用的有:INFURAAlchemyEtherscan等,或者也可以自定义一个RPC URL。

Connector:如果你想修改区块链中的数据,你需要发送一笔交易,发送交易的前提就是连接钱包,连接钱包就需要用到Connector,常用的有Injected,MetaMask(特殊的Injected)、WalletConnect(集成了很多种钱包)、Coinbase等等

Abi:一个普通的json文件。调用合约拿数据或者发送交易必须要知道两个东西,一个是调用的合约地址,另外一个就是调用的合约方法,这个合约方法就是在Abi文件中,它包含了方法的入参和出参,就类似于接口文档,但是这个实际执行的时候也是需要这个文件的,而不像接口文档只是用来看看的

1.2 Token

用的最多的就是ERC20 Token,了解它主要是为了获取Token的一些基本信息,如 decimals(精度)、balanceOf(余额)、approve(授权)、transfer转账、totalSupply(池子总供应量)等

(1).主币没有代币地址,所以没法调用这些方法

(2).关于授权,主币不需要授权,授权就是用户授权允许自己的钱包向某个合约转入一定数量代币,主要目的就是为了防止合约把你钱包的代码都弄走了,为了你的资金安全考虑的。一般如果涉及到想某个合约转入代币都需要先授权。授权调用approve方法,获取授权额度调用allowance方法。

1.3 签名

签名一般用于链下授权,主要是为了安全考虑,主要用到的是personal_signeth_signTypedData_v4,后者比较复杂一些,感兴趣可以去了解下

image.png

还有不少jsonrpc api可以去参考下metamask官网, docs.metamask.io/wallet/refe…

2.前端工具链

目前比较流行的基础类库为ethers.jsweb3.jsethers.js后出来目前使用的人更多些。

不过这些类库只提供了与区块链交互的基本方法,如果希望与我们的前端框架配合开发更流畅、效率更高,还需要借助一些更上层的库,如web3-reactwagmiuseDApp,这三个库都是支持React,但是不支持Vue。所以如果你希望开发区块链,UI框架最好还是选择React,主要这玩意是老外搞出来的,所以相关生态对Vue的支持不是很好。

我个人用过web3-reactwagmi,web3-react目前比较知名的一些平台都用的它,如uniswapaave等,wagmi现在比较火,目前的迭代也比较频繁,它自带了很多实用React Hooks,对于开发来说体验很好,提升了很多效率,不过兼容性和稳定性做的还不是特别好。不过我仍然还是推荐它,因为就是好用。

下面是wagmi官网上对三个框架的对比

image.png

3.代码实践

3.1 wagmi基础hooks

钱包连接断开

主要是useConnectuseDisconnect包含了连接、断开钱包等方法和状态

链信息或者切换链

useNetworkuseSwitchNetwork

交易信息获取与发送

useContractReaduseContractReads(批量获取)、useContractWrite

上面是一些常用的,然后还有一些大家自行去官网上看看,然后下面主要讲基于项目自定义的一些

3.2 自定义组件与hooks

useApprove.jsx (授权方法和一些状态)
import { useState, useCallback, useContext, useEffect } from 'react'
import { useNotification } from '@hooks/useNotification'
import { MaxUint256 } from '@ethersproject/constants'
import { useContractWriteWithNotification, useWaitForTransactionWithNotification } from '@hooks/useContractWithNotification'
import { useContractRead, usePrepareContractWrite, erc20ABI, useAccount } from 'wagmi'
import useTokenAllowance from './useTokenAllowance'
import { updateTokenAllowance } from '@state/tokenSlice'
import useTokenList from './useTokenList'
import { useBlockNumber } from 'wagmi'
import { NATIVE_TOKEN_ADDRESS } from '@constants/index'

const getIsNeedApprove = (allowance, spendAmount, address) => {
    return address !== NATIVE_TOKEN_ADDRESS && ((!Number(spendAmount) && !allowance) || allowance < Number(spendAmount))
}

export function useApprove({ address, spenderAddress, spendAmount } = {}, { onSuccess, onReject } = {}) {
    const { address: account } = useAccount()
    const [allowancePending, setAllowancePending] = useState()
    const [allowanceInsufficient, setAllowanceInsufficient] = useState(false)
    const { data: blockNumber } = useBlockNumber({
        watch: true,
    })
    const {
        getAllowanceByAddress,
        fetchAllowance,
        isFetching: isAllowanceLoading,
    } = useTokenAllowance([
        {
            address,
            spenderAddress,
        },
    ])

    //获取allowance
    const allowance = getAllowanceByAddress(address, spenderAddress)

    useEffect(() => {
        if (address && spenderAddress) {
            fetchAllowance()
        }
    }, [account, blockNumber, address, spenderAddress])

    console.log('allowance-spendAmount', allowance, Number(spendAmount))
    //需要授权    不为主币 && ((allowance为0 && spendAmount也没有)|| allowance<spendAmount)
    const isNeedApprove = getIsNeedApprove(allowance, spendAmount, address)
    console.log('isNeedApprove', isNeedApprove)

    //授权方法
    const { config } = usePrepareContractWrite({
        address,
        abi: erc20ABI,
        functionName: 'approve',
        args: [spenderAddress, MaxUint256],
        enabled: !!spenderAddress && isNeedApprove,
    })
    const { data, isLoading, isSuccess, isError, write, error, reset } = useContractWriteWithNotification({
        ...config,
        onReject() {
            onReject?.()
        },
    })
    const waitForTransaction = useWaitForTransactionWithNotification({
        hash: data?.hash,
        onSuccess: async (data) => {
            //授权成功,重新获取allowance
            setAllowanceInsufficient(false) //重置授权不足标记
            setAllowancePending(true)
            const allowance = await fetchAllowance?.()
            console.log('allowance777', allowance)
            const curTokenAllowance = allowance.data[`${address}_${spenderAddress}`]
            const curTokenIsNeedApprove = getIsNeedApprove(curTokenAllowance, spendAmount, address)
            console.log('curTokenIsNeedApprove', curTokenIsNeedApprove)
            //授权额度超过需要的额度才能执行回调,否则后续执行的交易会报错
            if (!curTokenIsNeedApprove) {
                    onSuccess?.(data)
            }
            setAllowanceInsufficient(curTokenIsNeedApprove)
            setAllowancePending(false)
        },
    })

    const approveReset = useCallback(() => {
        setAllowanceInsufficient(false)
        reset()
    }, [setAllowanceInsufficient, reset])

    console.log('allowance', allowance, isAllowanceLoading)

    return {
        approveHandle: write,
        approvePending: isLoading || waitForTransaction.isLoading,
        approveStatus: {
            approveLoading: isLoading,
            approveWaitLoading: waitForTransaction.isLoading,
            approveIsSuccess: isSuccess,
            approveIsError: isError,
            approveWaitIsSuccess: waitForTransaction.isSuccess,
            approveWaitIsError: waitForTransaction.isError,
            approveError: error,
            approveWaitError: waitForTransaction.error,
            allowancePending: allowancePending,
            allowanceInsufficient: allowanceInsufficient,
        },
        approveReset,
        allowancePending: isAllowanceLoading,
        allowance,
        isNeedApprove,
    }
}

approveBtn.jsx (授权按钮组件)
import React, { useEffect, useMemo, useRef } from 'react'
import { Button, Alert } from 'antd'
import classNames from 'classnames/bind'
import styles from './styles/index.scss'
const cx = classNames.bind(styles)
import { useTranslation } from 'react-i18next'
import { useApprove } from '@hooks/useApprove'

export default ({ tokenName, tokenAddress, spendAmount = 0, spenderAddress, children, hideApproveBtn, hideApproveBtnWithFn, approveHandle, approvePending, isNeedApprove, ...restProps }) => {
    const onApproveSuccessRef = useRef()
    const { approveHandle: approveHandleSelf, approvePending: approvePendingSelf, isNeedApprove: isNeedApproveSelf } = useApprove({ address: tokenAddress, spenderAddress, spendAmount }, { onSuccess: onApproveSuccessRef.current })
    const { t, i18n } = useTranslation()

    const approveHandleResolved = approveHandle ?? approveHandleSelf
    const approvePendingResolved = approvePending ?? approvePendingSelf
    const isNeedApproveResolved = isNeedApprove ?? isNeedApproveSelf

    //不显示授权按钮,直接在原本按钮上加授权功能,授权完成后直接执行原本按钮操作
    if (hideApproveBtnWithFn) {
        if (hideApproveBtn || children?.props?.disabled || !isNeedApproveResolved) {
            return React.cloneElement(children, {
                ...children.props,
                ...restProps,
            })
        } else {
            const { loading: originLoading, onClick: originOnClick, ...otherOriginProps } = children.props
            onApproveSuccessRef.current = originOnClick
            return React.cloneElement(children, {
                ...otherOriginProps,
                ...restProps,
                loading: approvePendingResolved || originLoading,
                onClick: () => {
                    approveHandleResolved?.()
                },
            })
        }
    }

    return (
        <>
            {hideApproveBtn || children?.props?.disabled || !isNeedApproveResolved ? (
                React.cloneElement(children, {
                    ...children.props,
                    ...restProps,
                })
            ) : (
                <Button type="primary" {...restProps} size={children.props?.size} loading={approvePendingResolved} className={cx('btn-full', children.props.className)} onClick={() => approveHandleResolved?.()}>
                    {t('index.total_assests.approve')}
                    {tokenName ? ' ' + tokenName : null}
                </Button>
            )}
        </>
    )
}

useSwitchNetwork (切换网络,主要添加了网络不存在添加网络逻辑)
import { useState, useEffect, useMemo } from 'react'
import { SUPPORTED_CHAINS } from '@constants/index'
import { useChainId } from './useChainId'
import { useSwitchNetwork as useWagmiSwitchNetWork } from 'wagmi'

export const useSwitchNetwork = () => {
    const chainId = useChainId(false)
    const [loading, setLoading] = useState(false)
    const [currentChainId, setCurrentChainId] = useState()

    const { switchNetworkAsync } = useWagmiSwitchNetWork({
        onSuccess(data) {
            console.log('Success', data)
            window.location.reload()
        },
    })

    const switchChain = async (selectedChainId) => {
        if (selectedChainId === chainId) return
        try {
            setLoading(selectedChainId)
            //连接钱包可以走wagmi这个切换网络
            if (switchNetworkAsync) {
                await switchNetworkAsync(selectedChainId)
            } else {
                await ethereum.request({
                    method: 'wallet_switchEthereumChain',
                    params: [{ chainId: '0x' + selectedChainId.toString(16) }],
                })
            }
            setLoading(false)
            setCurrentChainId(selectedChainId)
        } catch (switchError) {
            console.log('switchError', switchError)
            if (switchError.code === 4902) {
                //构造添加网络的参数
                console.log('SUPPORTED_CHAINS', SUPPORTED_CHAINS)
                const targetChain = SUPPORTED_CHAINS.find((item) => item.id === selectedChainId)
                console.log('targetChain', targetChain)
                const rpcUrls = [
                    ...new Set(
                        Object.values(targetChain.rpcUrls)
                            .map((item) => item.http)
                            .flat()
                    ),
                ]
                const blockExplorerUrls = [...new Set(Object.values(targetChain.blockExplorers).map((item) => item.url))]

                const addEthereumChainParameter = [
                    {
                        chainId: '0x' + targetChain.id.toString(16), // A 0x-prefixed hexadecimal string
                        chainName: targetChain.name,
                        nativeCurrency: targetChain.nativeCurrency,
                        rpcUrls: rpcUrls,
                        blockExplorerUrls: blockExplorerUrls,
                    },
                ]

                console.log('addEthereumChainParameter', addEthereumChainParameter)

                try {
                    await ethereum.request({
                        method: 'wallet_addEthereumChain',
                        params: addEthereumChainParameter,
                    })
                    window.location.reload()
                } catch (addError) {
                    console.log(addError)
                } finally {
                    setLoading(false)
                }
            } else {
                setLoading(false)
            }
        }
    }

    return {
        switchChain,
        currentChainId,
        loading,
    }
}

useChainId.jsx(获取链id,主要处理没有连接钱包情况下获取链id)
import { useState, useEffect, useMemo } from 'react';
import detectEthereumProvider from '@metamask/detect-provider'
import { SUPPORTED_CHAIN_IDS, DEFAULT_CHAIN_ID } from '@constants/index'
import { useNetwork } from 'wagmi';
import { SUPPORTED_CHAINS } from '@constants/index'

export function useChainId (isUnSupportedSetFallback = true) {
    const { chain } = useNetwork()
    //CoinHub等小众钱包window.ethereum.chainId返回的是十进制数字
    let initChainId = useMemo(() => {
        let initChainId = DEFAULT_CHAIN_ID;
        if (window.ethereum && window.ethereum.chainId) {
            if (String(window.ethereum.chainId).indexOf('0x') > -1) {
                initChainId = parseInt(window.ethereum.chainId, 16)
            } else {
                initChainId = window.ethereum.chainId
            }
        } else {
            initChainId = DEFAULT_CHAIN_ID
        }
        return initChainId;
    }, [window.ethereum && window.ethereum.chainId]);

    if (isUnSupportedSetFallback && SUPPORTED_CHAIN_IDS.indexOf(initChainId) === -1) {
        initChainId = DEFAULT_CHAIN_ID;
    }

    const [chainId, setChainId] = useState(initChainId)

    useEffect(() => {
        //如果连接了钱包同时是支持的网络则直接用wagmi的,否则就通过etherum检测
        if (chain?.id) {
            if(chain?.unsupported && isUnSupportedSetFallback){
                setChainId(DEFAULT_CHAIN_ID)
            }else{
                setChainId(chain.id)
            }
        } else {
            (async () => {
                let provider = await detectEthereumProvider()
                if (provider) {
                    console.log('Ethereum successfully detected!')

                    // From now on, this should always be true:
                    // provider === window.ethereum

                    // Access the decentralized web!

                    // Legacy providers may only have ethereum.sendAsync
                    const chainId = await provider.request({
                        method: 'eth_chainId'
                    })
                    if (chainId) {
                        if (isUnSupportedSetFallback && SUPPORTED_CHAIN_IDS.indexOf(parseInt(chainId, 16)) === -1) {
                            setChainId(DEFAULT_CHAIN_ID)
                        } else {
                            setChainId(parseInt(chainId, 16))
                        }
                    }

                } else {
                    // if the provider is not detected, detectEthereumProvider resolves to null
                    // console.error('Please install MetaMask!')
                }
            })()
        }

    }, [window.ethereum, chain?.id, isUnSupportedSetFallback])
    return chainId;
}

export const useConnectedChain = ()=>{
    const chainId = useChainId();
    console.log('SUPPORTED_CHAINS',SUPPORTED_CHAINS)
    const connectedChain = SUPPORTED_CHAINS.find(item=>item.id === chainId)
    console.log('connectedChain',connectedChain)
    return connectedChain
}
useContractWithNotification.tsx (交易发送后添加侧边提示)
import React from 'react'
import { useContractWrite, UseContractWriteConfig, useWaitForTransaction } from 'wagmi'
import { useNotification } from '@hooks/useNotification'
import { addTransaction, finalizeTransaction, failedTransaction } from '@state/transactionSlice'
import { useDispatch } from 'react-redux'
import { BigNumber } from 'ethers'
import { calculateGasMargin } from '@utils/index'

export const useContractWriteWithNotification = function (config: any) {
    const { onSuccess, onError, onReject, ...restConfig } = config
    const dispatch = useDispatch()
    const { showPendingTransaction, showTransactionReject, showFailedTransaction } = useNotification()
    const configParams = {
        onSuccess(data: any, ...restArgs: any[]) {
            console.log('Success', data)
            dispatch(addTransaction({ hash: data?.hash }))
            showPendingTransaction(data?.hash)
            onSuccess && onSuccess(data, ...restArgs)
        },
        onError(error: any, ...restArgs: any[]) {
            const code = error?.code
            if (code === 4001 || code === -32000) {
                console.log('useContractWrite Error', error)
                showTransactionReject()
                onReject?.()
            } else {
                showFailedTransaction(undefined, undefined, error?.message)
            }
            onError && onError(error, ...restArgs)
        },
        ...restConfig,
    }
    //修改估算的gasLimit
    if (restConfig?.request && restConfig?.request?.gasLimit) {
        configParams.request = {
            ...restConfig?.request,
            gasLimit: calculateGasMargin(restConfig?.request?.gasLimit), // equivalent to a gasLimitMultiplier of 1.1
        }
    }
    return useContractWrite(configParams)
}

export const useWaitForTransactionWithNotification = function (config: any): any {
    const { onSuccess, onError, ...restConfig } = config
    const dispatch = useDispatch()
    const { showConfirmedTransaction, showFailedTransaction } = useNotification()
    return useWaitForTransaction({
        onSuccess(data) {
            console.log('Success', data)
            // dispatch(finalizeTransaction({ hash: data?.transactionHash, transactionReceipt: data }));
            // showConfirmedTransaction(data?.transactionHash);
            onSuccess && onSuccess(data)
        },
        onError(error: Error) {
            // dispatch(failedTransaction({ hash: restConfig?.hash }));
            // showFailedTransaction(restConfig?.hash)
            onError && onError(error)
        },
        ...restConfig,
    })
}
transactionUpdater.tsx

交易完成后添加浮动通知,这个代码放在所有页面公用的js文件中,保证页面及时切换了也能监听到交易是否已完成

import React, { useEffect, useMemo, useRef } from 'react';
import useTokenBalance from '@hooks/useTokenBalance';
import { useAccount, useBlockNumber, useWaitForTransaction } from 'wagmi';
import { useDispatch, useSelector } from 'react-redux';
import { failedTransaction, finalizeTransaction } from '@state/transactionSlice';
import { useNotification } from '@hooks/useNotification';
import { AppState } from '@src/configStore';
import { TransactionStatus } from '@constants/index';

export default () => {
    const dispatch = useDispatch()
    const { showConfirmedTransaction, showFailedTransaction } = useNotification()
    const transactions = useSelector<AppState, AppState['transactionSlice']>(state => state.transactionSlice)
    const pendingTransactions = Object.values(transactions).filter(item => {
        return item.status === TransactionStatus.Pending
    }).sort((a, b) => b.addedTime - a.addedTime)

    const watchTransaction = pendingTransactions[0]

    useWaitForTransaction({
        hash: watchTransaction?.hash,
        onSuccess(data) {
            console.log('Success', data)
            dispatch(finalizeTransaction({ hash: data?.transactionHash, transactionReceipt: data }));
            showConfirmedTransaction(data?.transactionHash);
        },
        onError(error: Error) {
            dispatch(failedTransaction({ hash: watchTransaction?.hash }));
            showFailedTransaction(watchTransaction?.hash)
        },
    })

    return null
}