[第1部分] 使用Solidity[4] 和 React在以太坊上构建具有社交找回功能的智能合约钱包
我第一次对以太坊感到兴奋那会儿是阅读这10行代码的时候:
该代码在创建合约时会跟踪owner
,并且只允许“owner”使用require()
语句调用withdraw()
。
该智能合约控制自己的资金。它具有地址和余额,可以发送和接收资金,甚至可以与其他智能合约进行交互。
这是一台永远在线的公共状态机,你可以对其编程,世界上任何人都可以与它交互!
你需要事先安装 NodeJS>=10[5], Yarn[6]和 Git[7].
本教程将假定你对Web应用程序开发[8] 有基本的了解,并且稍微接触过以太坊核心概念[9]。你可以随时在文档中阅读有关Solidity的更多信息[10],但是先试试这个吧:
打开一个终端并克隆 ? scaffold-eth[11]仓库。我们构建去中心化应用程序原型所需的一切都包含在这里:
git clone https://github.com/austintgriffith/scaffold-eth
cd scaffold-eth
git checkout part1-smart-contract-wallet-social-recovery
yarn install
☢️ 警告,运行 yarn install
继续并运行接下来的三个命令时,你可能会收到看起来像错误的警告,它可能没有影响!
注意本教程是如何获取part1-smart-contract-wallet-social-recovery
分支的, ?scaffold-eth[12]是一个可fork的以太坊开发技术栈,每个教程都是一个分支,你可以fork和使用!
在你喜欢的编辑器中本地打开代码,然后概览一下:
你可以在packages/buidler/contracts
中找到SmartContract Wallet.sol
, 这是我们的智能合约(后端)。
packages/react-app/src
中的 App.js
和 SmartContractWallet.js
是我们的web应用程序(前端).
打开你的前端:
yarn start
☢️ 警告,如果没有运行接下来的两行,你的CPU会抽风:
在第二个终端中启动由?Builder[13]驱动的本地区块链:
yarn run chain
在第三个终端中,编译并部署合约:
yarn run deploy
☢️ 警告,此项目中有几个名为contracts
的目录。多花一点时间,以确保所处的目录在packages/buidler/contracts
文件夹 。
我们智能合约中的代码被编译为称为字节码
和ABI
的“工件”(artifacts)。这个ABI
定义了我们如何与合约交互,而bytecode
是“机器代码”。你可以在packages/buidler/artifacts
文件夹中找到这些工件。
为了部署合约,首先需要在交易中发送字节码
,然后我们的合约将在本地链上的特定地址
运行。这些工件会自动注入到我们的前端,以便我们可以与合约进行交互。
在浏览器中打开 http://localhost:3000[14] :
让我们快速浏览一下这个脚手架,为后面的做铺垫…
使用你的编辑器打开packages/react-app/src
文件夹下的App.js
前端文件。
? 在App.js
中scaffold-eth 有三个不同的 providers[15] :
mainnetProvider
: Infura[16]支持只读的以太坊主网,它用于获取主网余额并与现有的运行的合约交互,例如Uniswap的ETH价格或ENS域名查询。
localProvider
: Buidler[17] 是本地链,当我们在本地对Solidity进行迭代时,会将你的合约部署到这里。该provider的第一个帐户提供本地的水龙头。
injectedProvider
: 程序会先启动burner provider[18](页面加载后的即时帐户),但随后你可以点击connect
以引入由 Web3Modal[19]支持的更安全的钱包。该provider会对发送到我们的本地和主网的交易进行签名。
区块链是一个节点网络,每一节点都拥有当前状态。如果我们想访问以太坊网络,我们可以运行自己的节点,但我们不希望用户仅因为使用我们的应用程序就必须同步整条链;因此,我们将使用简单的Web请求与基础设施的provider
进行交互。
我们还将利用?scaffold-eth中的一堆美味钩子[20]比如userBalance()
来追踪地址的余额或useContractReader()
使我们的状态与合约保持同步。在此处[21]阅读更多有关React钩子的信息。
这个脚手架还包含许多用于构建Dapp的方便组件[22]。我们很快就会看到的<AddressInput />
就是一个很好的例子。在此处[23]阅读有关React组件的更多信息。
我们在packages/buidler/contracts
中的SmartContractWallet.sol
中创建一个 isOwner()
的函数. 这个函数可以查询钱包是否是某个地址的所有者:
function isOwner(address possibleOwner) public view returns (bool) {
return (possibleOwner==owner);
}
注意该函数为什么被标记为view
?函数可以写入状态或读取状态。当我们需要写入状态时,我们必须支付gas才能将交易发送给合约,但是读状态既简单又便宜,因为我们可以向任何provider询问状态。
要在智能合约上调用函数,你需要将交易发送到合约的地址。
我们再创建一个名为updateOwner()
可修改状态的函数,该函数使当前所有者可以设置新的所有者:
function updateOwner(address newOwner) public {
require(isOwner(msg.sender),"NOT THE OWNER!");
owner = newOwner;
}
我们在这里使用了msg.sender
和msg.value
,msg.sender
是发送交易的地址,msg.value
是随交易发送的以太币数量。你可以在此处详细了解单位和全局变量[24]。
注意require()
语句如何确保msg.sender
是当前的所有者。如果条件不满足,它将revert()
,并且整个交易都被撤消。
以太坊交易是原子的:要么一切正常,要么一切撤销。如果我们将一个代币发送给Alice,并且在同一合约调用中,我们未能从Bob那里获取一个代币,则整个交易将被撤消。
保存,编译和部署合约:
yarn run deploy
合约执行后,我们可以看到你的地址不是所有者:
让我们在部署智能合约时将我们的帐户地址传递给智能合约,以便我们成为所有者。首先,从右上角复制你的帐户(这个图中的操作后面还会用到,记为✅TODO LIST):
然后,在packages/builder/contracts
中编辑文件SmartContract Wallet.args
,并将地址更改为你的地址。然后,重新部署:
yarn run deploy
我们正在使用一个自动化脚本,该脚本试图找到我们的合约并进行部署。最终,我们将需要一个更具定制性的解决方案,但是你可以浏览packages/buidler
目录中的scripts/deploy.js
。
你的地址现在应该是合约的所有者:
你需要一些测试ether支付与合约交互所需的gas:
仿照“✅TODO LIST”图中的操作,并向我们的帐户发送一些测试ETH。从右上方复制你的地址,然后将其粘贴到左下方的水龙头中(然后单击发送)。你可以为你的地址提供所有想要的测试ether。
然后,尝试使用“?Deposit”按钮将一些资金存入你的智能合约中:
该操作将失败,因为向我们的智能合约传递价值的交易将被撤销,因为我们尚未添加“fallback”函数。
让我们在SmartContractWallet.sol
中添加一个payable
fallback()
函数,使其可以接受交易。在packages/buidler
中编辑你的智能合约并添加:
fallback() external payable {
console.log(msg.sender,"just deposited",msg.value);
}
每当有人与我们的合约进行交互而未指定要调用的函数名称时,都会自动调用“fallback”函数。例如,如果他们将ETH直接发送到合约地址。
编译并重新部署你的智能合约:
yarn run deploy
? 现在,当你存入资金时,合约应该执行成功!
但这是“可编程的货币”,让我们添加一些代码以将总ETH的数量限制为0.005(按今天的价格为1.00美元),以确保没有人在我们的未经审计的合约中投入100万美元。替换 你的 fallback()
为:
uint constant public limit = 0.005 * 10**18;
fallback() external payable {
require(((address(this)).balance) <= limit, "WALLET LIMIT REACHED");
console.log(msg.sender,"just deposited",msg.value);
}
译者注:在 Solidity 0.6之后的版本中,可以使用接收函数[25]
注意我们为何乘以10¹⁸?Solidity不支持浮点数,只支持整数。1 ETH等于10¹⁸wei。此外,如果你发送的交易值为1,则是1 wei,wei是以太坊中允许的最小单位。在撰写本文时,1 ETH的价格是:
现在重新部署并尝试多次depositing,调用次数达到上限后,会报错:
请注意,在智能合约中,前端如何通过require()
语句第二个参数的消息获得有价值的反馈。使用它来以及在yarn run chain
终端中显示的console.log
帮助你调试智能合约:
你可以调整钱包限额,或者只需要重新部署新合约即可重置所有内容:
yarn run deploy
假设我们要跟踪允许与我们的合约交互的朋友的地址。我们可以保留一个whilelist []
数组[26],但随后我们将拥有遍历数组比较值以查看给定地址是否在白名单中。我们还可以使用mapping[27]来追踪,但是我们将无法迭代他们。我们必须抉择使用哪种数据更好。
在链上存储数据相对昂贵。每个世界各地的矿工都需要执行和存储每个状态更改。注意不要有昂贵的循环或过多的计算。值得探索一些示例[28]和阅读有关EVM的更多信息[29]。
这就是为什么这个东西如此具有弹性/抗审查性的原因。数千个(受激励的)第三方都在执行相同的代码,并且在没有中央授权的情况下就它们存储的状态达成一致。它永不停止!
回到智能合约中,让我们使用mapping[30]存储余额。我们无法遍历合约中的所有朋友,但是它允许我们快速读取和写入任何给定地址的bool
访问权限。将此代码添加到你的合约中:
mapping(address => bool) public friends;
注意我们为什么将这个friends
映射标记为public
?这是一个公链,所以你应该假设一切都是公共的。
☢️ 警告:即使我们将此映射设置为 private
,也仅表示外部合约无法读取它,任何人仍然可以链下读取私有变量 :
创建一个函数 updateFriend()
并设置它的 true
或 false
参数:
function updateFriend(address friendAddress, bool isFriend) public {
require(isOwner(msg.sender),"NOT THE OWNER!");
friends[friendAddress] = isFriend;
console.log(friendAddress,"friend bool set to",isFriend);
}
注意我们一定要复用 msg.sender
为owner
的这行代码吗?你可以使用 修改器Modifier[31]进行清理。然后,每当你需要一个只能由所有者运行的函数时,可以在函数中添加 onlyOwner modifier
,而不是此行。完全可选).
现在,我们部署它并回到前端:
yarn run deploy
我们可以同时对前端合约和智能合约进行小的增量更改。这个紧密的开发循环使我们能够快速迭代并测试新的想法或机制。
我们将要在packages/react-app/src
目录中的SmartContractWallet.js
中的display
中添加一个表单。首先,让我们添加一个状态变量:
const [ friendAddress, setFriendAddress ] = useState("")
然后,让我们创建一个变量,该变量 创建一个函数,该函数调用updateFriend()
:
const updateFriend = (isFriend)=>{
return ()=>{
tx(writeContracts['SmartContractWallet'].updateFriend(friendAddress, isFriend))
setFriendAddress("")
}
}
注意在我们在合约上调用函数的代码结构:contract
. functionname
(args
)全部包裹在tx()
中,因此我们可以跟踪交易进度。你还可以等待
此tx()
函数以获取生成的哈希,状态等。
当你写入地址公共所有者
地址时,它会自动为此变量创建一个“getter”函数,我们可以通过useContractReader()
钩子轻松地获取它。
接下来,让我们创建一个ownerDisplay
部分,该部分仅针对owner
显示。这将显示一个带有两个按钮的AddressInput
(地址输入组件),分别用于updateFriend(false)
和updateFriend(true)
。
let ownerDisplay = []
if(props.address==owner){
ownerDisplay.push(
<Row align="middle" gutter={4}>
<Col span={8} style={{textAlign:"right",opacity:0.333,paddingRight:6,fontSize:24}}>Friend:</Col>
<Col span={10}>
<AddressInput
value={friendAddress}
ensProvider={props.ensProvider}
onChange={(address)=>{setFriendAddress(address)}}
/>
</Col>
<Col span={6}>
<Button style={{marginLeft:4}} onClick={updateFriend(true)} shape="circle" icon={<CheckCircleOutlined />} />
<Button style={{marginLeft:4}} onClick={updateFriend(false)} shape="circle" icon={<CloseCircleOutlined />} />
</Col>
</Row>
)
}
最后,将{ownerDisplay}
添加到所有者行下的display
中:
在你的应用程序?重新热加载后,尝试点击一下。(你可以在新的浏览器或隐身模式下导航到http://localhost:3000[32]以获取获取新的会话帐户以复制新地址。)
如果不进行地址迭代,很难知道在发生什么,也很难列出我们所有的朋友以及他们在前端的状态。
这是事件events的工作.
事件几乎就像是一种存储形式。它们在执行过程中从智能合约中发出的成本相对较低,但是智能合约却不能读取事件。
让我们回到智能合约 SmartContractWallet.sol
.
在updateFriend()
函数上方或下方创建一个事件:
event UpdateFriend(address sender, address friend, bool isFriend);
然后,在updateFriend()
函数中,添加此emit
:
emit UpdateFriend(msg.sender,friendAddress,isFriend);
编译并部署更改:
yarn run deploy
然后,在前端,我们可以添加事件监听器钩子。将此代码与我们的其他钩子一起添加到SmartContractWallet.js
:
const friendUpdates = useEventListener(readContracts,contractName,"UpdateFriend",props.localProvider,1);
(因为需要用在TODO List,上面这一行代码里之前已经写好了?。)
在我们的渲染中,在之后添加一个显示:
<List
style={{ width: 550, marginTop: 25}}
header={<div><b>Friend Updates</b></div>}
bordered
dataSource={friendUpdates}
renderItem={item => (
<List.Item style={{ fontSize:22 }}>
<Address
ensProvider={props.ensProvider}
value={item.friend}
/> {item.isFriend?"✅":"❌"}
</List.Item>
)}
/>
? 现在,当它重新加载时,我们应该能够添加和删除朋友!
现在我们在合约中设置了“朋友”,让我们创建一个可以触发的“恢复模式”.
让我们想象一下,我们以某种方式丢失了“所有者”的私有密钥[33],现在我们被锁定在智能合约钱包之外了 。我们需要让我们的一个朋友触发某种恢复。
我们还需要确保,如果某个朋友意外(或恶意)触发了恢复并且我们仍然可以访问所有者
帐户,我们可以在几秒钟内的timeDelay
内取消恢复。
首先,我们在SmartContractWallet.sol
中设置一些变量 :
uint public timeToRecover = 0;
uint constant public timeDelay = 120; //seconds
address public recoveryAddress;
然后赋予所有者设置recoveryAddress
的函数:
function setRecoveryAddress(address _recoveryAddress) public {
require(isOwner(msg.sender),"NOT THE OWNER!");
console.log(msg.sender,"set the recoveryAddress to",recoveryAddress);
recoveryAddress = _recoveryAddress;
}
本教程中有很多代码需要复制和粘贴。请务必花一点时间放慢速度阅读,以了解发生了什么。
如果你曾经感到困惑和沮丧,请在 Twitter DM[34]上给我留言,我们将看看能否一起解决!Github issues [35]也非常适合反馈!
让我们为朋友添加一个函数,以帮助我们找回资金:
function friendRecover() public {
require(friends[msg.sender],"NOT A FRIEND");
timeToRecover = block.timestamp + timeDelay;
console.log(msg.sender,"triggered recovery",timeToRecover,recoveryAddress);
}
我们使用block.timestamp
,你可以在 special variables here[36]阅读更多内容.
如果不小心触发了friendRecover()
,我们希望所有者能够取消恢复:
function cancelRecover() public {
require(isOwner(msg.sender),"NOT THE OWNER");
timeToRecover = 0;
console.log(msg.sender,"canceled recovery");
}
最后,如果我们处于恢复模式并且已经过去了足够的时间,任何人都可以销毁我们的合约并将其所有以太币发送到recoveryAddress
:
function recover() public {
require(timeToRecover>0 && timeToRecover<block.timestamp,"NOT EXPIRED");
console.log(msg.sender,"triggered recover");
selfdestruct(payable(recoveryAddress));
}
[selfdestruct()](https://solidity.readthedocs.io/en/v0.6.8/cheatsheet.html?highlight=selfdestruct#global-variables "selfdestruct( "selfdestruct()")")将从区块链中删除我们的智能合约,并将所有资金返还到recoveryAddress
.
☢️ 警告,具有 owner
且可以随时调用 selfdestruct()
的智能合约实际上并不是“去中心化”的。开发人员应非常注意任何个人或组织都无法控制或审查的机制。
让我们编译,部署并回到前端:
yarn run deploy
在我们的SmartContractWallet.js
和其他钩子函数中,我们将要跟踪recoveryAddress
:
const [ recoveryAddress, setRecoveryAddress ] = useState("")
这是让所有者设置recoveryAddress
表单的代码 :
ownerDisplay.push(
<Row align="middle" gutter={4}>
<Col span={8} style={{textAlign:"right",opacity:0.333,paddingRight:6,fontSize:24}}>Recovery:</Col>
<Col span={10}>
<AddressInput
value={recoveryAddress}
ensProvider={props.ensProvider}
onChange={(address)=>{
setRecoveryAddress(address)
}}
/>
</Col>
<Col span={6}>
<Button style={{marginLeft:4}} onClick={()=>{
tx(writeContracts['SmartContractWallet'].setRecoveryAddress(recoveryAddress))
setRecoveryAddress("")
}} shape="circle" icon={<CheckCircleOutlined />} />
</Col>
</Row>
)
然后我们要跟踪在合约中的currentRecoveryAddress
:
const currentRecoveryAddress =
useContractReader(readContracts,contractName,"recoveryAddress",1777);
我们还要跟踪timeToRecover
和localTimestamp
:
const timeToRecover = useContractReader(readContracts,contractName,"timeToRecover",1777);
const localTimestamp = useTimestamp(props.localProvider)
并在恢复按钮之后使用<Address />
显示恢复地址。另外,我们将为所有者添加一个cancelRecover()按钮
。将此代码放在setRecoveryAddress()
按钮之后:
{timeToRecover&&timeToRecover.toNumber()>0 ? (
<Button style={{marginLeft:4}} onClick={()=>{
tx( writeContracts['SmartContractWallet'].cancelRecover() )
}} shape="circle" icon={<CloseCircleOutlined />}/>
):""}
{currentRecoveryAddress && currentRecoveryAddress!="0x0000000000000000000000000000000000000000"?(
<span style={{marginLeft:8}}>
<Address
minimized={true}
value={currentRecoveryAddress}
ensProvider={props.ensProvider}
/>
</span>
):""}
我们在这里使用ENS[37]将名称转换为地址并返回。这类似于传统的DNS,你可以在其中注册名称.
现在,让我们来跟踪用户是否是isFriend
:
const isFriend =
useContractReader(readContracts,contractName,"friends",[props.address],1777);
如果他们是朋友,请给他们显示一个按钮,以调用friendRecover()
,然后在localTimestamp
在timeToRecover
之后最终调用recover()
。在if(props.address == owner){
检查所有者的末尾添加这个大的else if
:
}else if(isFriend){
let recoverDisplay = (
<Button style={{marginLeft:4}} onClick={()=>{
tx( writeContracts['SmartContractWallet'].friendRecover() )
}} shape="circle" icon={<SafetyOutlined />}/>
)
if(localTimestamp&&timeToRecover.toNumber()>0){
const secondsLeft = timeToRecover.sub(localTimestamp).toNumber()
if(secondsLeft>0){
recoverDisplay = (
<div>
{secondsLeft+"s"}
</div>
)
}else{
recoverDisplay = (
<Button style={{marginLeft:4}} onClick={()=>{
tx( writeContracts['SmartContractWallet'].recover() )
}} shape="circle" icon={<RocketOutlined />}/>
)
}
}
ownerDisplay = (
<Row align="middle" gutter={4}>
<Col span={8} style={{textAlign:"right",opacity:0.333,paddingRight:6,fontSize:24}}>Recovery:</Col>
<Col span={16}>
{recoverDisplay}
</Col>
</Row>
)
}
尝试一下,感受一下该应用程序。玩玩合约,玩玩前端。现在它是你的!
你可以根据需要使用不同的浏览器和隐身模式创建尽可能多的帐户。然后用水龙头给他们一些ether。
☢️ 警告,我们正在从本地链中获取时间戳,但是它不会像主网那样定时出块。因此,我们将不得不时不时地发送一些事务以更新时间戳。
运行的Demo请查看链接( https://img.learnblockchain.cn/2020/07/29/1_1Mqo-87iqGEswsyaT4jI2g.gif ),其中左边的帐户拥有钱包,在右边的帐户是朋友账户,然后最终该朋友可以恢复以太币:
我们围绕智能合约钱包构建了具有安全限制和社交找回功能的去中心化应用程序!!!
你应该已经有足够的了解,甚至可以克隆 ? scaffold-eth[38] 来构建出迄今为止最强大的应用!!!
想象这个钱包是否具有某种自治市场,世界上任何人都可以以动态定价买卖资产?
我们甚至可以铸造收藏品并在curve上出售它们?!
我们甚至可以创建了一个?♂️即时钱包以快速发送和接收资金?!
我们甚至可以构建gas花费很少应用程序以使用户愿意上车!?
我们甚至可以用提交/显示
随机数创建了一个?游戏?!
我们甚至可以创建一个本地?预测市场,只有我们的朋友和朋友的朋友可以参与?!
我们甚至可以部署了??$me代币并构建一个应用程序,持有人可以向你投资下一个应用程序??!
我们可以将这些??me代币流化为用于在?scaffold-eth[39]上构建有趣事物的帮助资源!?!
简直无限可能!!!
本教程还有一个视频:https://www.youtube.com/watch?v=7rq3TPL-tgI[40]
如果你想了解有关Solidity的更多信息,建议你玩Ethernaut[41],Crypto Zombies[42],然后甚至是RTFM[43]。
前往https://ethereum.org/developers[44]了解更多资源.
随时在Twitter DM[45] 或github仓库[46]给我留言
原文链接:https://medium.com/@austin_48503/programming-decentralized-money-300bacec3a4f[47] 作者:Austin Thomas Griffith[48]
[1]
登链翻译计划: https://github.com/lbc-team/Pioneer
[2]
Johnathan: https://learnblockchain.cn/people/720
[3]
Tiny熊: https://learnblockchain.cn/people/15
[4]
第1部分] 使用[Solidity: https://learnblockchain.cn/docs/solidity/
[5]
NodeJS>=10: https://nodejs.org/en/download/
[6]
Yarn: https://classic.yarnpkg.com/en/docs/install/
[7]
Git: https://git-scm.com/downloads
[8]
Web应用程序开发: https://reactjs.org/tutorial/tutorial.html
[9]
以太坊核心概念: https://www.youtube.com/watch?v=9LtBDy67Tho&feature=youtu.be&list=PLJz1HruEnenCXH7KW7wBCEBnBLOVkiqIi&t=13
[10]
阅读有关Solidity的更多信息: https://solidity.readthedocs.io/en/v0.6.7/introduction-to-smart-contracts.html
[11]
scaffold-eth: https://github.com/austintgriffith/scaffold-eth
[12]
scaffold-eth: https://github.com/austintgriffith/scaffold-eth
[13]
Builder: https://buidler.dev/
[14]
http://localhost:3000: http://localhost:3000
[15]
providers: https://github.com/austintgriffith/scaffold-eth#-web3-providers
[16]
Infura: http://infura.io
[17]
Buidler: http://buidler.dev
[18]
burner provider: https://www.npmjs.com/package/burner-provider
[19]
Web3Modal: https://github.com/Web3Modal/web3modal
[20]
美味钩子: https://github.com/austintgriffith/scaffold-eth#-hooks
[21]
此处: https://reactjs.org/docs/hooks-overview.html
[22]
方便组件: https://github.com/austintgriffith/scaffold-eth/blob/master/README.md#-components
[23]
此处: https://reactjs.org/docs/components-and-props.html
[24]
单位和全局变量: https://learnblockchain.cn/docs/solidity/units-and-global-variables.html
[25]
接收函数: https://learnblockchain.cn/docs/solidity/contracts.html#receive
[26]
]`[数组: https://learnblockchain.cn/docs/solidity/types.html#arrays
[27]
mapping: https://learnblockchain.cn/docs/solidity/types.html#mapping-types
[28]
探索一些示例: https://learnblockchain.cn/docs/solidity/solidity-by-example.html
[29]
阅读有关EVM的更多信息: https://learnblockchain.cn/docs/solidity/introduction-to-smart-contracts.html#index-6
[30]
mapping: https://solidity.readthedocs.io/en/v0.6.7/types.html?highlight=mapping#mapping-types
[31]
修改器Modifier: https://learnblockchain.cn/docs/solidity/structure-of-a-contract.html#modifier
[32]
http://localhost:3000: http//localhost:3000/
[33]
私有密钥: https://www.youtube.com/watch?v=9LtBDy67Tho&list=PLJz1HruEnenCXH7KW7wBCEBnBLOVkiqIi&index=4&t=0s
[34]
Twitter DM: https://twitter.com/austingriffith
[35]
Github issues : https://github.com/austintgriffith/scaffold-eth/issues
[36]
special variables here: https://solidity.readthedocs.io/zh/v0.6.7/units-and-global-variables.html?highlight=units#block-and-transaction-properties
[37]
ENS: https://ens.domains/
[38]
scaffold-eth: https://github.com/austintgriffith/scaffold-eth
[39]
scaffold-eth: https://github.com/austintgriffith/scaffold-eth
[40]
https://www.youtube.com/watch?v=7rq3TPL-tgI: https://www.youtube.com/watch?v=7rq3TPL-tgI
[41]
Ethernaut: https://ethernaut.openzeppelin.com/
[42]
Crypto Zombies: https://cryptozombies.io/
[43]
RTFM: https://learnblockchain.cn/docs/solidity
[44]
https://ethereum.org/developers: https://ethereum.org/developers/
[45]
Twitter DM: https://twitter.com/austingriffith
[46]
github仓库: https://github.com/austintgriffith/scaffold-eth
[47]
https://medium.com/@austin_48503/programming-decentralized-money-300bacec3a4f: https://medium.com/@austin_48503/programming-decentralized-money-300bacec3a4f
[48]
Austin Thomas Griffith: https://medium.com/@austin_48503