在Coinbase,我们希望可以创建一个开放的金融系统。我们坚信提高金融的自由度可以让世界更美好。去中心化金融,简称DeFi是一个开放,无界限并且可以程序化的金融,是提供金融自由度的一种方式。
智能合约
DeFi是运行在去中心化网络上(例如以太坊[4]),由智能合约(例如USD币:一种区块链上美元代币)驱动的。智能合约其实是很好理解的,Nick Szabo是数字货币和加密学的先驱者,在1997年他[最早提出智能合约最早提出智能合约") )并将其比喻为自动贩卖机。
自动贩卖机可以在无人干涉情况下,很好的履行他的合约精神。
现代智能合约[5]工作原理也是类似的,合约的条件是用可执行的代码来表达的。去中心化网络保证按要求执行,并且任何人都不能破坏规则或者篡改结果。因为网络会一字不差地执行代码,有瑕疵的智能合约会产生预想不到的后果。(“代码是条例”)
把握当下
很多人觉得在区块链上去搭建应用比较困难,认为只有高级玩家可以尝试。但是近几年出现来了很多工具,开发者界面,帮助编程能力一般的人去实现构建。
最近,DeFi[6]生态呈现爆发式地增长。USDC不到2年捕获的总价值达到10亿美元[7],同时各种各样的DeFi服务在不到3年的时间,总价值超过20亿美金。当下可谓是DeFi发展的最佳时机。
下面的教程主要目的是介绍如何开发自己的DeFi智能合约。我们希望,本教程可以帮助创建一个全球、开放的金融体系。
开始
本系列教程假设你有使用JavaScript[8]的经验,这是世界上使用最广泛的编程语言。你还将学习Solidity[9],Ethereum[10]上使用的智能合约编程语言。最后,你也会认识USDC[11],这是DeFi应用程序中最广泛采用的由法币支持的稳定代币。
设置开发环境
首先,我们需要一个类unix的环境,并在上面安装Node.js v12.x[12] (LTS的最新版本)。macOS本身就是Unix环境,Windows用户可以通过从微软商店安装Ubuntu on WSL[13]来获得它。更详细的步骤macOS可以查看这里[14],Windows查看这里[15]。对于文本编辑器,强烈推荐使用Visual Studio Code[16],因为你将使用的项目模板是预先配置的,但你可以使用任何编辑器。哦,我更喜欢Vim的快捷键绑定方式[17]。
建立项目
建立一个Solidity项目需要一些工作,而且老实说,在这个阶段我们不希望被搭建项目琐碎的工作而分心了,所以已经为你准备了一个预配置模板[18]。
通过在终端中运行以下命令下载和设置模板:
$ git clone https://github.com/CoinbaseStablecoin/solidity-tutorial.git
$ cd solidity-tutorial
$ npm install -g yarn # Install yarn package manager
$ yarn # Install project dependencies
当yarn在安装的时候,你可能会看到一些编译错误。你可以忽略这些错误。当你最后看到“完成”信息,你就可以开始了。
在Visual Studio Code打开项目
在Visual Studio Code中打开项目文件夹(solidity-tutorial)。项目第一次打开时,Visual Studio Code可能会提示你安装扩展。继续并点击“安装所有”,这将增加各种有用的扩展,如代码自动格式化和solidity语法高亮。
在以太坊建立账户
在以太坊上做任何事情之前,你需要有一个帐户。账户通常被称为“钱包”,因为它们可以包含像ETH和USDC这样的数字资产。终端用户通常使用以太坊钱包应用,像Coinbase钱包[19]或Metamask[20]来创建钱包,但通过程序使用ethers.js[21]方式创建一个账户也很简单。
在src目录下,创建一个新的js文件createWallet.js,写入如下代码:
const ethers = require(“ethers”);
const wallet = ethers.Wallet.createRandom();
console.log(Mnemonic: ${wallet.mnemonic.phrase}
);
console.log(Address: ${wallet.address}
);
保存文件,然后使用Node.js来执行文件
$ node src/createWallet.js
Mnemonic: rabbit enforce proof always embrace tennis version reward scout shock license wing
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
刚才发生了什么?好吧,你得到了一个全新的Ethereum账号。“mnemonic”是“助记符”或被称为的“恢复短语”,是用于帐户执行操作所需的加密密钥,地址是帐户的名称。记得把它们写下来。另外,为了防止你们使用我的助记符,我已经做了轻微的修改,请使用你自己的!
可以把这些看作是密码和银行账户的帐号,不过钱包地址可以在几秒钟内创建一个,而且你不需要填写申请表格或分享任何个人信息。而且你可以在任何地方运行此代码。
助记符必须保密。如果你丢失了它,你将永远无法访问你的帐户和帐户中存储的任何资产,没有人能够帮助你!把它放在安全的地方!
从技术上讲,你并没有真正“创造”一个帐户本身。相反,你创建的是一个私有/公共密钥对。如果你好奇到底发生了什么,可以看下椭圆曲线密码学[22],比特币和以太坊规范BIP39[23], BIP32[24],EIP55[25]及其在本项目中[26]的实现。
关于Gas和挖矿
以太坊是一个去中心化的网络,由世界各地成千上万台计算机组成,但是它们并不是免费运行的。要在区块链上执行变更状态,如存储和更新数据,你必须用用ETH向网络支付交易费,在以太坊上也称为“gas”。gas[27]费用和增加新区块获得的奖金就是激励矿工运算的激励。这个过程被称为“挖矿”,不断做运算的被称为“挖矿者”。我们将在稍后的教程中再次讨论这个问题(gas,gas价格和gas限额)。
获得测试网络ETH
现在你有了账户,你应该存一些ETH。在开发的时候我们不想浪费真正的ETH,所以我们需要一些ETH用于在测试网络开发和测试网络(“testnet”)。现在有许多不同的Ethereum测试网络,我们将会使用Ropsten,因为获得测试代币比较容易。首先,让我们使用Etherscan[28]检查当前余额,这是一个以太坊的区块信息的浏览器。你可以在浏览器中输入以下URL,将你的地址替换为之前创建的地址,以0x开始。
你可以看到现在余额是0。保持该页面打开,并在另一个页面中打开Ropsten Ethereum Faucet[30]。在第二个页面中,输入你的地址,然后点击“发送我(Send me)”按钮。完成后可能只需要几秒钟到一两分钟。稍后再次检查Etherscan,你应该会看到新的余额为1ETH和转入交易。
通过编程获取ETH余额
连接以太坊网络
我们可以使用Etherscan查看余额,但是使用代码也可以很容易查看余额。在我们写代码之前,我们需要连接到以太坊网络。有许多方法可以实现,包括在自己的计算机上运行一个网络节点,但到目前为止,最快和最简单的方法是通过一个托管节点来实现,例如INFURA[31]或Alchemy[32]。前往INFURA[33],创建一个免费帐户并创建一个新项目来获取API密钥(项目ID)。
Go Ethereum (“geth”)[34] 和 Open Ethereum[35](之前被称为Parity Ethereum)。这两个是最为广泛使用地节点软件。
通过代码查看ETH余额
首先,通过读取助记符进入到我们的账户中。在src文件夹下,创建一个名为wallet.js的JavaScript文件。敲入以下代码:
const ethers = require(“ethers”);
// 在这里替换你自己的助记符
const mnemonic =
“rabbit enforce proof always embrace tennis version reward scout shock license wing”;
const wallet = ethers.Wallet.fromMnemonic(mnemonic);
console.log(Mnemonic: ${wallet.mnemonic.phrase}
);
console.log(Address: ${wallet.address}
);
module.exports = wallet;
用你自己的字符串替换代码中的助记符字符串。请注意,在生产中,助记符不应该像这样直接写在代码中。理想的是它从配置文件或环境变量中读取,这样它就不会因为写在源代码中而泄漏。
执行代码,你应该能够看到和之前相同的地址
$ node src/wallet.js
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
接下来,在同一个文件夹中,创建一个名为provider.js的新文件。在这个文件中,我们将使用前面获得的INFURA API密钥。记得替换成你自己的api key:
const ethers = require(“ethers”);
const provider = ethers.getDefaultProvider(“ropsten”, {
// 替换INFURA API KEY
infura: “0123456789abcdef0123456789abcdef”,
});
module.exports = provider;
最后,我们会引用wallet.js和provider.js,在同一目录下创建新的文件getBalance.js
const ethers = require(“ethers”);
const wallet = require("./wallet");
const provider = require("./provider");
async function main() {
const account = wallet.connect(provider);
const balance = await account.getBalance();
console.log(ETH Balance: ${ethers.utils.formatUnits(balance, 18)}
);
}
main();
执行代码,你就可以看到余额了
$ node src/getBalance.js
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
ETH Balance: 1.0
代币换算
我们刚刚创建的代码非常容易理解,但是你会想知道**ethers.utils.formatUnits(balance, 18)**的作用。嗯,ETH实际上有18位,最小的单位叫“wei”(发音为“way”)。换句话说,一个ETH等于1000,000,000,000,000,000,000 wei。另一个常见的单位是Gwei(发音为“Giga-way”),也就是1,000,000,000 wei。getBalance方法是以wei中返回了结果,因此我们必须通过将结果除以10的18次方将其转换回ETH。你可以在这里[36]找到全部的单位名称。
你也可以使用 ethers.utils.formatEther(balance), 相当于**ethers.utils.formatUnits(balance, 18)**的简写.
获得测试网络的USDC
你账户里的只有ETH,略显孤单,所以我们打算增加一些USDC。我已经在Ropsten testnet上部署了一个伪USDC智能合约[37]。虽然我们没有专门获得免费USDC的网站,但是在合约中已经包含了该功能,当你调用它时,它会给你一些免费的testnet USDC。你可以在Etherscan中的合约代码栏目[38]找到合约,并在合约源代码中搜索gimmeSome。我们将调用这个函数来将一些USDC发送到我们的帐户。
发起交易来调用智能合约
在以太坊的智能合约中有主要有两类方法:读写和只读。第一种方式可以修改区块链上的数据,而第二种仅仅是读取区块链上的数据,但是不能修改数据。 只读方法不用通过交易来调用,所以不会耗费ETH,除非是在读写方法中的一部分。读写方法是一定要通过交易来调用,所以一定会消耗ETH。调用gimmeSome方法会改变USDC数量的改变,所以必须通过一次交易来完成。
调用智能合约的方法需要再多些步骤,但是也不复杂。第一,需要知道调用方法的完整接口,被称为函数签名或函数原型。我们看下gimmeSome方法的源码如下:
function gimmeSome() external
这是一个没有任何参数的方法,而且被标记为external,表示只能从外部可以调用,不能被合约内的其他方法调用。这个对我们来说不影响,因为我们就是从外部调用。
在主链上的真实的USDC合约[39]是没有gimmeSome 方法的
在src 文件夹下创建一个新文件,命名为getTestnetUSDC.js,然后输入以下代码
const ethers = require(“ethers”);
const wallet = require("./wallet");
const provider = require("./provider");
async function main() {
const account = wallet.connect(provider);
const usdc = new ethers.Contract(
“0x68ec573C119826db2eaEA1Efbfc2970cDaC869c4”,
[“function gimmeSome() external”],
account
);
const tx = await usdc.gimmeSome({ gasPrice: 20e9 });
console.log(Transaction hash: ${tx.hash}
);
const receipt = await tx.wait();
console.log(Transaction confirmed in block ${receipt.blockNumber}
);
console.log(Gas used: ${receipt.gasUsed.toString()}
);
}
main();
代码开始部分, 使用我们感兴趣的gimmeSome的接口和测试网络的地址USDC合约0x68ec⋯69c4[40]地址实例化了一个合约对象(new ethers.Contract)。 这个方法是不需要任何参数,但是你可以在最后加入一个参数。这次我20 Gwei的gas费,来加快交易打包速度。与网络交互的所有方法在本质上是异步的,返回一个Promise[41],所以我们使用JavaScript的await[42]。完成后会返回交易的hash值,这是用于查看交易的惟一标识符。
运行该代码,你将看到如下内容:
$ node src/getTestnetUSDC.js
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Transaction hash: 0xd8b4b06c19f5d1393f29b408fc0065d0774ec3b4d11d41be9fd72a8d84cb6208
Transaction confirmed in block 8156350
Gas used: 35121
好的,祝贺你通过代码的方式完成了第一次ETH的交易。在Ropsten Etherscan[43]查看下你的账户地址和交易hash。你应该可以查看到,账户里有10个测试USDC,ETH的余额小于1,因为支付了gas费用。
如果你在看Etherscan交易,你会发现这是一笔发送0个ETH连同4个字节的数据到合约地址。如果调用方法时有参数,就会有超过4字节的数据。如果你想了解该数据是如何编码的,请阅读Ethereum合约ABI规范[44]。
Gas,Gas费用 和 Gas限制
之前我提到过,我们给这笔交易20Gwei的gas价格来加快交易速度,程序也显示了使用的gas的量。这一切意味着什么?嗯,以太坊是由网络运营商组成的网络。可以把它想象成一台世界计算机。这不是一台免费的电脑,你在这台电脑上运行的每条指令都要花钱。这台电脑也被全世界的人共享,这意味着每个人都必须互相竞争,以获得他们使用这台电脑的时间。
我们怎样才能做到公平呢?嗯,我们可以把这台电脑上的时间进行拍卖,你愿意出的价越高,你执行的效率也更快。这当然不是十全十美的,因为可能会导致只有有很多ETH的人才有特权使用这个电脑。然而,在系统变得更可扩展并能够容纳更多交易之前,这是我们可以选择的一个可行解决方案。
回到区块链术语上来, “gas used”是在完成交易所消耗的计算资源的数量,“gas price”是你愿意为每一单位gas支付的价格。一般来说,你愿意支付的金额越高,你的交易优先级就越高,通过网络确认的速度也就越快。上面我们使用20 Gwei作为gas价格,所使用的gas为35,121(可以在Etherscan中查看交易),所以总共使用gas费用为35,121 * 20 Gwei = 702,420 Gwei或0.00070242 ETH。
因为gas需要消耗金钱,你可能想要设定你愿意花费的最多gas。幸运的是,你可以通过“gas limit”设置。如果交易最终需要的gas超过规定的限额,交易就会失败,而不会继续执行。需要注意的是如果交易因为gas限额而失败,已经花费的gas将不会退还给你。
通过调用智能合约读取数据
你可以在Etherscan上查看到收到了10个USDC,让我们通过代码检查余额来确认这一点。
我们修改下src文件夹下的getBalance.js文件
const ethers = require(“ethers”);
const wallet = require("./wallet");
const provider = require("./provider");
async function main() {
const account = wallet.connect(provider);
// 定义合约接口
const usdc = new ethers.Contract(
“0x68ec573C119826db2eaEA1Efbfc2970cDaC869c4”,
[
“function balanceOf(address _owner) public view returns (uint256 balance)”,
],
account
);
const ethBalance = await account.getBalance();
console.log(ETH Balance: ${ethers.utils.formatEther(ethBalance)}
);
// 调用balanceOf方法
const usdcBalance = await usdc.balanceOf(account.address);
console.log(USDC Balance: ${ethers.utils.formatUnits(usdcBalance, 6)}
);
}
main();
USDC是ERC20代币,因此它包含ERC20规范[45]中定义的所有方法。balanceOf就是其中之一,它的接口直接来自规范定义的。 balanceOf是一个只读函数,所以它可以免费调用。最后,值得注意的是,USDC使用6位小数精度,而其他许多ERC20代币使用18位小数。
你可以在这里[46]了解更多关于Solidity方法。
执行以下代码,你就可以看到USDC余额
$ node src/getBalance.js
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
ETH Balance: 0.9961879
USDC Balance: 10.0
ETH和USDC转账
现在我们来看看怎么可以使用账户中的ETH和USDC
使用ETH
在src文件夹下创建transferETH.js文件
const ethers = require(“ethers”);
const wallet = require("./wallet");
const provider = require("./provider");
async function main(args) {
const account = wallet.connect(provider);
let to, value;
// 生成第一个参数——接受地址
try {
to = ethers.utils.getAddress(args[0]);
} catch {
console.error(Invalid recipient address: ${args[0]}
);
process.exit(1);
}
// 生成第二个参数——数量
try {
value = ethers.utils.parseEther(args[1]);
if (value.isNegative()) {
throw new Error();
}
} catch {
console.error(Invalid amount: ${args[1]}
);
process.exit(1);
}
const valueFormatted = ethers.utils.formatEther(value);
//检查账户有足够余额
const balance = await account.getBalance();
if (balance.lt(value)) {
const balanceFormatted = ethers.utils.formatEther(balance);
console.error(
`Insufficient balance to send ${valueFormatted} (You have ${balanceFormatted})`
);
process.exit(1);
}
console.log(Transferring ${valueFormatted} ETH to ${to}...
);
// 提交转账
const tx = await account.sendTransaction({ to, value, gasPrice: 20e9 });
console.log(Transaction hash: ${tx.hash}
);
const receipt = await tx.wait();
console.log(Transaction confirmed in block ${receipt.blockNumber}
);
}
main(process.argv.slice(2));
这段代码虽然比前面的代码长,但实际上只是将之前所学的代码组合起来。这段代码中要有两个命令行参数。第一个是接收者地址,第二个是要发送的金额。然后确保提供的地址是有效的,提供的金额不是负数,并且帐户有足够的余额能够发送请求的金额。然后,提交交易并等待它被确认。
用之前的createWallet.js创建一个新账户,然后尝试向这个地址转些ETH
$ node src/createWallet.js
Mnemonic: napkin invite special reform cheese hunt refuse ketchup arena bag love caution
Address: 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13
$ node src/transferETH.js 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13 0.1
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Transferring 0.1 ETH to 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13…
Transaction hash: 0xa9f159fa8a9509ec8f8afa8ebb1131c3952cb3b2526471605fd84e8be408cebf
Transaction confirmed in block 8162896
你可以在Etherscan[47]看到结果,我们再来测试验证逻辑是有效的。
$ node src/transferETH.js foo
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Invalid address: foo
$ node src/transferETH.js 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13 0.1.2
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Invalid amount: 0.1.2
$ node src/transferETH.js 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13 -0.1
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Invalid amount: -0.1
$ node src/transferETH.js 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13 100
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Insufficient balance to send 100.0 (You have 0.89328474)
USDC转账
上面很大一部的代码可以用到这里,主要的区别是USDC是精确到6位,还有你是使用ERC20 规范中的transfer。入参依然是“to” 及 “value”,然后调用智能合约的transfer 方法。
在同一文件下创建transferUSDC.js文件
const ethers = require(“ethers”);
const wallet = require("./wallet");
const provider = require("./provider");
async function main(args) {
const account = wallet.connect(provider);
// 在合约中定义balanceOf和transfer方法
const usdc = new ethers.Contract(
“0x68ec573C119826db2eaEA1Efbfc2970cDaC869c4”,
[
“function balanceOf(address _owner) public view returns (uint256 balance)”,
“function transfer(address _to, uint256 _value) public returns (bool success)”,
],
account
);
let to, value;
// 生成第一个参数——接受地址
try {
to = ethers.utils.getAddress(args[0]);
} catch {
console.error(Invalid address: ${args[0]}
);
process.exit(1);
}
// 生成第二个参数——数量
try {
value = ethers.utils.parseUnits(args[1], 6);
if (value.isNegative()) {
throw new Error();
}
} catch {
console.error(Invalid amount: ${args[1]}
);
process.exit(1);
}
const valueFormatted = ethers.utils.formatUnits(value, 6);
//检查账户有足够余额
const balance = await usdc.balanceOf(account.address);
if (balance.lt(value)) {
const balanceFormatted = ethers.utils.formatUnits(balance, 6);
console.error(
`Insufficient balance to send ${valueFormatted} (You have ${balanceFormatted})`
);
process.exit(1);
}
console.log(Transferring ${valueFormatted} USDC to ${to}...
);
// 提交转账,调用transfer方法
const tx = await usdc.transfer(to, value, { gasPrice: 20e9 });
console.log(Transaction hash: ${tx.hash}
);
const receipt = await tx.wait();
console.log(Transaction confirmed in block ${receipt.blockNumber}
);
}
main(process.argv.slice(2));
试一试,你应该可以看到以下结果:
$ node src/transferUSDC.js 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13 1
Address: 0xB3512cF013F71598F359bd5CA3f53C1F4260956a
Transferring 1.0 USDC to 0xDdAC089Fe56F0a9C70e6a04C74DCE52F86a91e13…
Transaction hash: 0xc1b2157a83f29d6c04f960bc49e968a0cd2ef884761af7f95cc83880631fe4af
Transaction confirmed in block 8162963
恭喜
在本教程中,你学习了如何生成钱包、查询余额、转移代币和调用智能合约。你可能觉得自己还不太了解区块链,不过你已经有足够的知识,去构建自己加密钱包应用程序。为了保持简单,我们一直在编写命令行脚本,那么是否可以尝试构建一个图形界面的网页呢?
在本教程系列的下一部分中,我们将从头开始用solidity编写智能合约,并学习如何构建自己的硬币,可与USDC交换。我们还将使用今天学到的技术来与我们构建的合约进行互动。请继续关注。