像StepN一樣在Dapp中內建錢包

語言: CN / TW / HK

目前的錢包(包括MetaMask)覆蓋使用者程度不高,可能會增加使用者的使用門檻,這也是StepN內建錢包的原因(StepN所處的solana鏈錢包覆蓋度更低),本文將介紹如何在我們的DApp中內建錢包。

如何發行一款NFT(下) 的官方搭建中,我們支援了MetaMask錢包,但如果是一款希望能夠"破圈"的應用,目前的錢包(包括MetaMask)覆蓋使用者程度不高,可能會增加使用者的使用門檻,這也是StepN內建錢包的原因(StepN所處的solana鏈錢包覆蓋度更低),本文將介紹如何在我們的DApp中內建錢包。

錢包基礎概念

錢包中的幾個關鍵概念,私鑰,公鑰,錢包賬戶地址3者的關係我們需要理解清楚。

如上圖所示,私鑰可以計算出公鑰,公鑰可以計算出錢包賬戶地址,每一步都是單向可行,比如,通過公鑰是無法計算出私鑰的。所以對於錢包賬戶,我們的核心資料只有1個,那就是私鑰,有了私鑰我們就可以推導公鑰和錢包賬戶地址。 錢包內的餘額等資訊均儲存於鏈上,鏈上儲存著錢包賬戶地址->餘額的對映,如果發生轉賬等操作需要進行私鑰簽名,公鑰驗證。

目標規劃

  1. 連線錢包: 使用者需要能夠連線到我們的內建錢包,同時支援新建立錢包賬戶並連線或者匯入之前已建立的錢包賬戶並連線兩種方式。
  2. 展示已連線錢包賬戶資訊,包括錢包賬戶地址,餘額,私鑰(預設不展示),並支援斷開已連線的錢包賬戶。
  3. 使用已連線錢包賬戶完成轉賬,並支援在轉賬前進行二次彈窗確認。

技術選型

  • 基礎框架:由於我們是在之前的NFT官網基礎上繼續完善,所以我們依舊還是使用react+mobx+ether的組合。
  • 持久化:由於我們需要持久化儲存錢包賬戶的連線,避免頁面重新整理連線斷開,所以我們使用localStorage持久化已連線的錢包賬戶。
  • 節點代理(管理節點):由於我們不再使用metamask,所以我們需要自己去連線以太坊節點來進行鏈上操作,在如何發行一款NFT(上)中我們介紹過Infura,我們繼續使用Infura作為節點服務代理。本文不再介紹如何申請Key相關,如果有疑問可以參考 如何發行一款NFT(上) 中的介紹。

連線錢包

建立新錢包賬戶並連線

在wallet_utils.js中新增建立錢包賬戶,連線錢包的方法。建立錢包本質就是尋找1個隨機數作為私鑰,並使用這個私鑰生成公鑰和錢包地址。

//建立一個新的錢包賬戶
export const createNewWallet = (): Wallet => {
    const wallet = ethers.Wallet.createRandom();
    return wallet;
}

const NETWORK = "rinkeby"
const API_KEY = "在infura中申請的key"

//連線內建錢包賬戶
export const connectCustomWallet =  (wallet:Wallet) : Wallet => {
    const provider = new ethers.providers.InfuraProvider(NETWORK, API_KEY);
    const conncetWallet = wallet.connect(provider);
    return conncetWallet;
}

新增內建錢包的元件CustomWalletComponent.js並在App.js中新增,這個元件將會負責管理內建錢包連線頁面和展示內建錢包資訊頁面。

import { Store } from '../Store';
import './wallet-page.css';
import { observer } from 'mobx-react';
import ConnectCustomWalletComponent from './ConnectCustomWalletComponent';
import CustomWalletInfoComponent from './CustomWalletInfoComponent';

//使用observer修飾元件,observable的值發生變化元件重新渲染
const CustomWalletComponent = observer(() => {
    switch (Store.customWalletPageShow.get()) {
        case Custom_Wallet_Page_Show_Type.show_connect_page:
            //內建錢包連線元件
            return <ConnectCustomWalletComponent />;
        case Custom_Wallet_Page_Show_Type.show_info_page:
            //內建錢包資訊元件
            return <CustomWalletInfoComponent/>;
        default:
            //預設不展示
            return <div className='display-none'></div>;
    }
});

export default CustomWalletComponent;

//定義元件展示形態列舉
//hidden: 不展示彈窗   
//show_connect_page:展示內建錢包連線頁面
//show_connect_page:展示內建錢包資訊頁面
export const Custom_Wallet_Page_Show_Type = {
    hidden: 0,
    show_connect_page: 1,
    show_info_page: 2
}

在內建錢包連線元件ConnectCustomWalletComponent中新增我們的建立新錢包賬戶元件CreateNewCustomWalletComponent。

import { Store } from '../Store';
import './wallet-page.css';
import { useState } from 'react';
import { connectCustomWallet, createNewWallet } from '../utils/wallet_utils';
import { Custom_Wallet_Page_Show_Type } from './CustomWalletComponent';

const CreateNewCustomWalletComponent = () => {
    const [address, setAddress] = useState("");
    const [privateKey, setPrivateKey] = useState("");
    const [wallet, setWallet] = useState(null);
    //呼叫createNewWallet建立新錢包賬戶,並展示其公鑰和私鑰,暫存wallet
    const create_wallet_click = () => {
        const wallet = createNewWallet()
        setWallet(wallet);
        setPrivateKey(wallet.privateKey);
        setAddress(wallet.address);
    }
    //呼叫connectCustomWallet連線錢包賬戶,並更新全域性狀態
    const connect_wallet_click = () => {
        const connctWallet = connectCustomWallet(wallet);
        Store.updataCustomWallet(connctWallet);
        Store.customWalletPageShow.set(Custom_Wallet_Page_Show_Type.hidden);
    }
    return (
        <div className='custom-wallet-page-panel-item-create'>
            <h2 >建立新賬戶</h2>
            <div className='custom-wallet-page-panel-item-button' onClick={e => { create_wallet_click() }}> 建立賬戶 </div>
            <div className='custom-wallet-page-panel-item-text'>賬戶地址</div>
            <div className='custom-wallet-page-panel-item-text'>{address}</div>
            <div className='custom-wallet-page-panel-item-text'>私鑰:</div>
            <div className='custom-wallet-page-panel-item-text'>{privateKey}</div>
            <div className='custom-wallet-page-panel-item-button' onClick={e => { connect_wallet_click() }}> 連線錢包 </div>
        </div>
    );
};

export default CreateNewCustomWalletComponent;

管理錢包賬戶狀態

錢包賬戶狀態是1個全域性狀態,而且頁面退出再進入也應該保持連線狀態,所以我們將錢包賬戶持久化到本地,並在頁面初始化的時候讀取本地儲存的錢包賬戶。那麼錢包賬戶裡面資訊很多,我們是否需要全部儲存?按照我們開始介紹的錢包基礎概念,錢包賬戶核心就是私鑰,只要有私鑰我們就可以計算得到錢包賬戶的其他資訊,所以我們持久化私鑰即可。 先在wallet_utils.js中新增通過私鑰生成錢包賬戶的方法

//通過私鑰恢復錢包賬戶其他資訊
export const restoreConnectCustomWallet = (privateKey) => {
    const wallet = new ethers.Wallet(privateKey);
    const provider = new ethers.providers.InfuraProvider(NETWORK, API_KEY);
    const conncetWallet = wallet.connect(provider);
    return conncetWallet;
}

我們在Store.js中對本地錢包賬戶做一次快取,這樣方便頁面內狀態監聽。

const customWallet = observable.box(null);

//初始化方法
const init = () => {
    //判斷是否本地有儲存內建錢包賬戶私鑰,如果有則恢復連線,沒有則開始監聽MetaMask
    const privateKey = localStorage.getItem("wallet_private_key");
    if (privateKey) {
            console.log("already connect " + privateKey);
            customWallet.set(restoreConnectCustomWallet(privateKey));
    } else {
        registerAccountChange();
    }
}      

//更新錢包賬戶,同時更新Store.js快取和本地儲存。
const updataCustomWallet = (wallet) => {
    customWallet.set(wallet);
    localStorage.setItem("wallet_private_key", wallet ? wallet.privateKey : "");
   }

調整合約互動

我們之前構建合約的時候都是預設使用MetaMask,但目前引入了內建錢包,我們需要進行區分構建,在contract_utils.js進行調整

const getContract = () => {
    const address = "合約地址";
    const abi = require("../abi/NFT_WEB3_EXPOLRER.json").abi;
    return new ethers.Contract(address, abi, getProvider());
}

const getProvider = () => {
    //是否有已經連線的內建錢包賬號,有則使用Infura節點代理,沒有則使用MetaMask
    if (Store.customWallet.get()) {
        return new ethers.providers.InfuraProvider(NETWORK, API_KEY);
    } else {
        return new ethers.providers.Web3Provider(window.web3.currentProvider);
    }
}

匯入錢包賬戶

如果我們已經擁有1個錢包賬戶需要匯入到內建錢包中,這一過程其實和我們頁面初始化時從本地讀取錢包賬戶一致,都是需要通過私鑰恢復完整的錢包賬戶。

import { Store } from '../Store';
import './wallet-page.css';
import { useState } from 'react';
import { Custom_Wallet_Page_Show_Type } from './CustomWalletComponent';

const ImportCustomWalletComponent = () => {
    const [inputValue, setInputValue] = useState("");

    //通過使用者輸入的私鑰匯入錢包賬戶,並更新錢包賬戶的儲存
    const import_wallet_click = () => {
        const connctWallet = restoreConnectCustomWallet(inputValue);
        Store.updataCustomWallet(connctWallet);
        Store.customWalletPageShow.set(Custom_Wallet_Page_Show_Type.hidden);
    }

    const handleChange = (event) => {
        console.log("input value is" + event.target.value);
        setInputValue(event.target.value);
    }
    return (
        <div className='custom-wallet-page-panel-item-import' >
            <h2>匯入現有賬戶</h2>
            <h4>請輸入已有賬戶私鑰</h4>
            <input type='text' value={inputValue} onChange={handleChange}></input>
            <div className='custom-wallet-page-panel-item-button' onClick={e => { import_wallet_click() }}> 匯入賬戶並連線 </div>
        </div>
    );
};

export default ImportCustomWalletComponent;

展示已連線錢包賬戶資訊

之前我們頁面右上角的連線錢包入口在連線完成後設定是無法點選的,現在有了內建錢包,我們將其修改為連線內建錢包賬戶時點選開啟內建錢包賬戶的資訊展示頁面,展示錢包賬戶地址,餘額,私鑰(預設不展示),並在該頁面中提供斷開錢包賬戶連線的功能。其中按照開始介紹的錢包基礎概念,錢包賬戶餘額存在鏈上,所以獲取餘額需要請求節點代理是1個非同步過程。斷開錢包賬戶連線其實就是清空本地儲存的私鑰資訊。

import { Store } from '../Store';
import './wallet-page.css';
import { useEffect, useState } from 'react';
import { Custom_Wallet_Page_Show_Type } from './CustomWalletComponent';

const CustomWalletInfoComponent = () => {
    const [balance, setBalance] = useState("");
    const [isShowPrivateKey, setShowPrivateKey] = useState(false);

    useEffect(() => {
        //獲取當前內建錢包賬戶的餘額,需要請求節點代理
        Store.customWallet.get().getBalance()
            .then((b) => {
                console.log(b);
                setBalance(b?.toString());
            })
            .catch((e) => {
                console.error(e);
            });
    }, []);

    //私鑰預設不展示
    const show_private_key = () => {
        setShowPrivateKey(true);
    }

    //斷開連線,清空儲存的賬戶資訊
    const disconncet = () => {
        Store.updataCustomWallet(null);
        Store.customWalletPageShow.set(Custom_Wallet_Page_Show_Type.hidden);
    }

    console.log("privateKey" + Store.customWallet.get()?.privateKey);
    return (<div className='wallet-page-bg' onClick={() => { cancel_click() }}>
        <div className='custom-wallet-page-panel-info' onClick={e => { panel_click(e) }}>
            <div className='custom-wallet-page-panel-item-info' >
                <h2 >賬戶資訊</h2>
                <div className='custom-wallet-page-panel-item-text'>錢包地址:</div>
                <div className='custom-wallet-page-panel-item-text'>{Store.customWallet.get()?.address}</div>
                <div className='custom-wallet-page-panel-item-text'>餘額:</div>
                <div className='custom-wallet-page-panel-item-text'>{balance}wei</div>
                <div className='custom-wallet-page-panel-item-button' onClick={e => { show_private_key() }}> 顯示私鑰 </div>
                <div className='custom-wallet-page-panel-item-text'>私鑰:</div>
                <div className='custom-wallet-page-panel-item-text'>{isShowPrivateKey ? Store.customWallet.get()?.privateKey : ""}</div>
                <div className='custom-wallet-page-panel-item-button' onClick={e => { disconncet() }}> 斷開連線 </div>
            </div>
        </div>
    </div>);

};

export default CustomWalletInfoComponent;

使用已連線錢包完成轉賬

在我們的NFT官網中mint是一種轉賬操作,該操作需要向合約賬戶轉一定數量的ETH,轉賬操作會需要私鑰的簽名,之前我們預設使用MetaMask的Singer,現在在contract_utils.js中新增方法,根據是否連線內建錢包選擇不同的Singer。補充一點,Wallet繼承自Singer,所以Wallet可以直接作為Singer使用。

const getSigner = () => {
    //已連線內建錢包,則優先使用內建錢包的賬戶
    if (Store.customWallet.get()) {
        return Store.customWallet.get();
    } else {
        const provider = new ethers.providers.Web3Provider(window.web3.currentProvider);
        return provider.getSigner();
    }
}

轉賬對使用者進行提示

在官網進行轉賬操作,我們有義務提示使用者進行二次確認,避免在使用者不知情的情況下發生轉賬行為給使用者帶來資金損失,所以修改contract_utils.js中的mint方法,在執行Mint這一轉賬操作前,彈出彈窗讓使用者進行確認。

export const mint = async () => {
    const contract = getContract();
    const contractWithSigner = contract.connect(getSigner());
    const price = await contract.PRICE_PER_TOKEN();
    console.log("price is" + price);
    //呼叫Mint方法前先彈確認彈窗讓使用者進行確認
    const r = window.confirm("你將向地址: "+ contract.address + "  轉賬: " + price +"wei");
    if (r) {
        const tx = await contractWithSigner.mint(1, { value: price });
        console.log(tx);
        await tx.wait();
        window.alert("mint成功");
    }
}

至此,我們為我們的Dapp增加了1個功能完整的內建錢包。

結尾

由於以太坊中賬戶校驗規則都是一致的,所以使用該內建錢包生成的賬戶也可連線到MetaMask, 或者將MetaMask生成的賬戶匯入到內建錢包也可以正常使用。此外,為了減少篇幅沒有介紹助記詞相關,如果有興趣,可以瞭解BIP39在本文基礎上很容易拓展支援助記詞。內建錢包可以減少使用者使用Dapp的門檻,希望各位小夥伴早日探索開發出可以破圈的Dapp。

本文參與登鏈社群寫作激勵計劃 ,好文好收益,歡迎正在閱讀的你也加入。

  • 發表於 8分鐘前
  • 閱讀 ( 13 )
  • 學分 ( 0 )
  • 分類:DApp