往期传送门:
学习完所有的 Solidity 基础知识,现在可以正式编写我们的第一个智能合约了。
需求分析
现在需要编写一个合约,允许任何人在这个合约上发布募捐项目,也可以给其他项目捐款。
项目发起者可以随时提取收到的捐款,同时也需要支付一定的收益给合约所有者。
项目发起者可以创建或取消自己发起的募捐项目,但合约所有者不能随意提取其他发起者接收到的捐款。
合约编写
现在正式开始合约的编写,可以大概把开发过程分成下面几个部分:
Ownable 合约
看过我的 Solidity智能合约基础 #3 的朋友应该对这个很熟悉了,也就是先定义一个抽象合约 Ownable 使得合约可以拥有一个合约所有者,并且所有者可以转让给其他人。
事件与错误
首先定义几个错误和事件以及保存合约所有者的变量:
abstract contract Ownable {
// 非合约所有者
error NotContractOwner();
// 拒绝变更合约所有者
error ContractOwnerChangeRefused();
// 合约所有者变更
event ContractOwnerChanged(address originalOwner, address newOwner);
// 合约所有者
address internal contractOwner;
}
构造函数
constructor(address initialOwner) {
if (initialOwner != address(0)) {
contractOwner = initialOwner;
} else {
contractOwner = msg.sender;
}
}
允许合约部署时手动指定一个所有者,如果指定的是一个无效的地址 0x00,则合约所有者默认设置为创建合约的账户。
自定义修饰符
定义一个 contractOwnerOnly 函数修饰符供子合约使用。
/**
* 仅合约所有者可用
*/
modifier contractOwnerOnly {
if (msg.sender != contractOwner) {
revert NotContractOwner();
}
_;
}
转让合约函数
function transferOwnership(address newOwner) contractOwnerOnly public {
if (!preTranferOwnership(newOwner)) {
revert ContractOwnerChangeRefused();
}
emit ContractOwnerChanged(contractOwner, newOwner);
contractOwner = newOwner;
}
function preTranferOwnership(address newOwner) internal pure virtual returns (bool) {
return newOwner != address(0);
}
完整代码
abstract contract Ownable {
// 非合约所有者
error NotContractOwner();
// 拒绝变更合约所有者
error ContractOwnerChangeRefused();
// 合约所有者变更
event ContractOwnerChanged(address originalOwner, address newOwner);
// 合约所有者
address internal contractOwner;
constructor(address initialOwner) {
if (initialOwner != address(0)) {
contractOwner = initialOwner;
} else {
contractOwner = msg.sender;
}
}
function transferOwnership(address newOwner) contractOwnerOnly public {
if (!preTranferOwnership(newOwner)) {
revert ContractOwnerChangeRefused();
}
emit ContractOwnerChanged(contractOwner, newOwner);
contractOwner = newOwner;
}
function preTranferOwnership(address newOwner) internal pure virtual returns (bool) {
return newOwner != address(0);
}
function getContractOwner() public view returns (address) {
return contractOwner;
}
/**
* 仅合约所有者可用
*/
modifier contractOwnerOnly {
if (msg.sender != contractOwner) {
revert NotContractOwner();
}
_;
}
}
定义合约接口
定义一个 Donation 合约的接口,里面再定义一些合约需要实现的函数,例如:创建项目、捐款、提取捐款、取消项目、查询项目发起者、查询项目余额以及总捐款额等。
interface IDonation {
/**
* 给某个项目捐赠
*
* @param donationName 捐赠项目名称
*/
function donate(string calldata donationName) external payable;
/**
* 创建一个新的捐赠项目
*
* @param donationName 捐赠项目名称
*/
function createDonation(string calldata donationName) external;
/**
* 提取捐赠金, 仅项目发起者可调用
*
* @param donationName 捐赠项目名称
*/
function withdraw(string calldata donationName) external;
/**
* 取消捐赠项目, 仅项目发起者可调用
*
* @param donationName 捐赠项目名称
*/
function cancelDonation(string calldata donationName) external;
/**
* 获取某个捐赠项目的总金额
*
* @param donationName 捐赠项目名称
*/
function getDonationAmount(string calldata donationName) external view returns (uint);
/**
* 获取某个捐赠项目的余额
*
* @param donationName 捐赠项目名称
*/
function getDonationBalance(string calldata donationName) external view returns (uint);
/**
* 获取某个项目的发起者
*
* @param donationName 捐赠项目名称
*/
function getDonationOwner(string calldata donationName) external view returns (address);
}
合约基本框架
接下来逐个实现这些接口方法,首先让 Donation 合约继承 Ownable 合约和 IDonation 接口。
contract Donation is Ownable, IDonation {
constructor(address initialOwner) Ownable(initialOwner) {
}
}
事件与错误
现在的代码应该是报错的,暂时忽略它们,下面先提前定义几个错误和事件以便后续使用:
contract Donation is Ownable, IDonation {
// 捐赠项目已存在
error DonationAlreadyExists();
// 捐赠项目不存在
error DonationNotExist();
// 非捐赠项目发起者
error NotDonationOwner();
// 余额不足以支付提取费
error InsufficientWithdrawFee();
// 项目仍有可提取余额
error DonationStillHasBalance();
// 捐赠事件
event DonateEvent(address donor, uint amount);
// 提取捐赠金事件
event WithdrawEvent(address owner, uint amount);
// 捐赠项目取消事件
event DonationCancelEvent(address ownner);
constructor(address initialOwner) Ownable(initialOwner) {
}
}
余额提取费用
上面提到项目发起人在提取项目余额的时候需要向合约所有者支付一笔费用,具体的费用应该用一个变量来保存,并且这个变量只能被合约所有者修改。
代码如下所示:
// 1 ETH = 1000000000000000000 Wei
// 1 GWei = 1000000000 Wei
uint public withdrawFee = 1000000000000000;
/**
* 修改项目余额提取费用
*
* @param newAmount 新项目余额提取费用
*/
function changeWithdrawFee(uint newAmount) public contractOwnerOnly {
withdrawFee = newAmount;
}
自定义项目结构
既然有捐赠项目,那就需要定义一个结构来保存它们。
捐赠项目应该有它自己的名字,还需要记录发起者的地址以及捐赠人的信息等。
struct SingleDonation {
// 捐赠项目名称
string donationName;
// 发起者账户地址
address ownerAddress;
// 捐赠者
address[] donors;
// 捐赠者对应的捐赠金额
uint[] donorAmounts;
// 共计收到的捐赠金额
uint receivedAmount;
// 当前剩余可提取的余额
uint balance;
}
这里解释一下 donors
和 donorAmounts
,这两个数组应该是等长的。
当这个项目接收到一笔捐赠金时,donors
数组应该添加一个元素,这个元素就是捐赠者的地址。同时 donorAmounts
也要新增一个元素,这个元素是对应捐赠的金额。
Q:为什么不设计成映射(mapping)?
A:如果想要实例化这个结构,那么这个结构内就不允许出现 mapping。
然后在合约内添加一个成员变量来保存发起者发起的项目:
// 捐赠项目映射
mapping(string => SingleDonation) private donations;
注意这个映射是由 string 映射到 SingleDonation 的,也就是根据项目名称才可以找到对应的项目。
添加一个判断项目是否存在的函数以及一个自定义的修饰符:
/**
* 判断某个捐赠项目是否存在
*
* @param donationName 捐赠项目名称
*/
function isDonationExists(string calldata donationName) internal view returns (SingleDonation storage, bool) {
SingleDonation storage t = donations[donationName];
return (t, t.ownerAddress != address(0));
}
/**
* 仅项目发起者可用 同时判断项目是否存在
*/
modifier donationOwnerOnly(string calldata donationName) {
(SingleDonation storage donation, bool exists) = isDonationExists(donationName);
if (!exists) {
revert DonationNotExist();
}
if (donation.ownerAddress != msg.sender) {
revert NotDonationOwner();
}
_;
}
合约接口实现
下面就可以正式开始合约的接口实现了:
创建项目
function createDonation(string calldata donationName) external override {
(, bool exists) = isDonationExists(donationName);
if (exists) {
revert DonationAlreadyExists();
}
donations[donationName] = SingleDonation(donationName, msg.sender, new address[](0), new uint[](0), 0, 0);
}
这里的代码比较简单,首先判断项目是否存在,存在则回滚交易抛出异常,不存在则实例化一个结构保存在映射的对应位置上。
捐赠
function donate(string calldata donationName) public payable override {
(SingleDonation storage donation, bool exists) = isDonationExists(donationName);
if (!exists) {
revert DonationNotExist();
}
uint amount = msg.value;
// donors 与 donorAmounts 必须配对
donation.donors.push(msg.sender);
donation.donorAmounts.push(amount);
donation.receivedAmount += amount;
donation.balance += amount;
emit DonateEvent(msg.sender, amount);
}
提取余额
function withdraw(string calldata donationName) external donationOwnerOnly(donationName) override {
(SingleDonation storage donation,) = isDonationExists(donationName);
uint originalBalance = donation.balance;
// 项目余额必须大于提取费
if (originalBalance <= withdrawFee) {
revert InsufficientWithdrawFee();
}
// 转账前先将余额设置为 0, 保证安全性
donation.balance = 0;
// 分红提取费给合约所有者
payable(contractOwner).transfer(withdrawFee);
uint lastAmount = originalBalance - withdrawFee;
// 将所有剩余余额转账给项目发起者
payable(msg.sender).transfer(lastAmount);
// 发送提取事件
emit WithdrawEvent(msg.sender, lastAmount);
}
取消项目
function cancelDonation(string calldata donationName) public donationOwnerOnly(donationName) override {
(SingleDonation storage donation,) = isDonationExists(donationName);
uint originalBalance = donation.balance;
int availableBalance = int(originalBalance) - int(withdrawFee);
// 若仍有可提取的余额 拒绝本次交易
if (availableBalance > 0) {
revert DonationStillHasBalance();
}
// 若仍有余额但不足以支付提取费 则将所有余额转账给合约所有者
if (originalBalance > 0) {
payable(contractOwner).transfer(originalBalance);
}
// 将此项目从映射中移除
donations[donationName] = SingleDonation("", address(0), new address[](0), new uint[](0), 0, 0);
// 发送事件
emit DonationCancelEvent(msg.sender);
}
其他方法实现
function getDonationAmount(string calldata donationName) external view override returns (uint) {
return donations[donationName].receivedAmount;
}
function getDonationBalance(string calldata donationName) external view override donationOwnerOnly(donationName) returns (uint) {
return donations[donationName].balance;
}
function getDonationOwner(string calldata donationName) external view override returns (address) {
SingleDonation storage t = donations[donationName];
return t.ownerAddress;
}
合约回调函数
/**
* 拒绝任何向本合约直接转账的操作
*/
receive() external payable {
revert();
}
/**
* 拒绝任何调用不存在的函数的操作
*/
fallback() external {
revert();
}
完整代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDonation {
/**
* 给某个项目捐赠
*
* @param donationName 捐赠项目名称
*/
function donate(string calldata donationName) external payable;
/**
* 创建一个新的捐赠项目
*
* @param donationName 捐赠项目名称
*/
function createDonation(string calldata donationName) external;
/**
* 提取捐赠金, 仅项目发起者可调用
*
* @param donationName 捐赠项目名称
*/
function withdraw(string calldata donationName) external;
/**
* 取消捐赠项目, 仅项目发起者可调用
*
* @param donationName 捐赠项目名称
*/
function cancelDonation(string calldata donationName) external;
/**
* 获取某个捐赠项目的总金额
*
* @param donationName 捐赠项目名称
*/
function getDonationAmount(string calldata donationName) external view returns (uint);
/**
* 获取某个捐赠项目的余额
*
* @param donationName 捐赠项目名称
*/
function getDonationBalance(string calldata donationName) external view returns (uint);
/**
* 获取某个项目的发起者
*
* @param donationName 捐赠项目名称
*/
function getDonationOwner(string calldata donationName) external view returns (address);
}
abstract contract Ownable {
// 非合约所有者
error NotContractOwner();
// 拒绝变更合约所有者
error ContractOwnerChangeRefused();
// 合约所有者变更
event ContractOwnerChanged(address originalOwner, address newOwner);
// 合约所有者
address internal contractOwner;
constructor(address initialOwner) {
if (initialOwner != address(0)) {
contractOwner = initialOwner;
} else {
contractOwner = msg.sender;
}
}
function transferOwnership(address newOwner) contractOwnerOnly public {
if (!preTranferOwnership(newOwner)) {
revert ContractOwnerChangeRefused();
}
emit ContractOwnerChanged(contractOwner, newOwner);
contractOwner = newOwner;
}
function preTranferOwnership(address newOwner) internal pure virtual returns (bool) {
return newOwner != address(0);
}
function getContractOwner() public view returns (address) {
return contractOwner;
}
/**
* 仅合约所有者可用
*/
modifier contractOwnerOnly {
if (msg.sender != contractOwner) {
revert NotContractOwner();
}
_;
}
}
contract Donation is Ownable, IDonation {
// 捐赠项目已存在
error DonationAlreadyExists();
// 捐赠项目不存在
error DonationNotExist();
// 非捐赠项目发起者
error NotDonationOwner();
// 余额不足以支付提取费
error InsufficientWithdrawFee();
// 项目仍有可提取余额
error DonationStillHasBalance();
// 捐赠事件
event DonateEvent(address donor, uint amount);
// 提取捐赠金事件
event WithdrawEvent(address owner, uint amount);
// 捐赠项目取消事件
event DonationCancelEvent(address ownner);
// 1 ETH = 1000000000000000000 Wei
// 1 GWei = 1000000000 Wei
uint public withdrawFee = 1000000000000000;
constructor(address initialOwner) Ownable(initialOwner) {
}
/**
* 修改项目余额提取费用
*
* @param newAmount 新项目余额提取费用
*/
function changeWithdrawFee(uint newAmount) public contractOwnerOnly {
withdrawFee = newAmount;
}
struct SingleDonation {
// 捐赠项目名称
string donationName;
// 发起者账户地址
address ownerAddress;
// 捐赠者
address[] donors;
// 捐赠者对应的捐赠金额
uint[] donorAmounts;
// 共计收到的捐赠金额
uint receivedAmount;
// 当前剩余可提取的余额
uint balance;
}
// 捐赠项目映射
mapping(string => SingleDonation) private donations;
function createDonation(string calldata donationName) external override {
(, bool exists) = isDonationExists(donationName);
if (exists) {
revert DonationAlreadyExists();
}
donations[donationName] = SingleDonation(donationName, msg.sender, new address[](0), new uint[](0), 0, 0);
}
function donate(string calldata donationName) public payable override {
(SingleDonation storage donation, bool exists) = isDonationExists(donationName);
if (!exists) {
revert DonationNotExist();
}
uint amount = msg.value;
// donors 与 donorAmounts 必须配对
donation.donors.push(msg.sender);
donation.donorAmounts.push(amount);
donation.receivedAmount += amount;
donation.balance += amount;
emit DonateEvent(msg.sender, amount);
}
function withdraw(string calldata donationName) external donationOwnerOnly(donationName) override {
(SingleDonation storage donation,) = isDonationExists(donationName);
uint originalBalance = donation.balance;
// 项目余额必须大于提取费
if (originalBalance <= withdrawFee) {
revert InsufficientWithdrawFee();
}
// 转账前先将余额设置为 0, 保证安全性
donation.balance = 0;
// 分红提取费给合约所有者
payable(contractOwner).transfer(withdrawFee);
uint lastAmount = originalBalance - withdrawFee;
// 将所有剩余余额转账给项目发起者
payable(msg.sender).transfer(lastAmount);
// 发送提取事件
emit WithdrawEvent(msg.sender, lastAmount);
}
function cancelDonation(string calldata donationName) public donationOwnerOnly(donationName) override {
(SingleDonation storage donation,) = isDonationExists(donationName);
uint originalBalance = donation.balance;
int availableBalance = int(originalBalance) - int(withdrawFee);
// 若仍有可提取的余额 拒绝本次交易
if (availableBalance > 0) {
revert DonationStillHasBalance();
}
// 若仍有余额但不足以支付提取费 则将所有余额转账给合约所有者
if (originalBalance > 0) {
payable(contractOwner).transfer(originalBalance);
}
// 将此项目从映射中移除
donations[donationName] = SingleDonation("", address(0), new address[](0), new uint[](0), 0, 0);
// 发送事件
emit DonationCancelEvent(msg.sender);
}
function getDonationAmount(string calldata donationName) external view override returns (uint) {
return donations[donationName].receivedAmount;
}
function getDonationBalance(string calldata donationName) external view override donationOwnerOnly(donationName) returns (uint) {
return donations[donationName].balance;
}
/**
* 判断某个捐赠项目是否存在
*
* @param donationName 捐赠项目名称
*/
function isDonationExists(string calldata donationName) internal view returns (SingleDonation storage, bool) {
SingleDonation storage t = donations[donationName];
return (t, t.ownerAddress != address(0));
}
function getDonationOwner(string calldata donationName) external view override returns (address) {
SingleDonation storage t = donations[donationName];
return t.ownerAddress;
}
/**
* 仅项目发起者可用 同时判断项目是否存在
*/
modifier donationOwnerOnly(string calldata donationName) {
(SingleDonation storage donation, bool exists) = isDonationExists(donationName);
if (!exists) {
revert DonationNotExist();
}
if (donation.ownerAddress != msg.sender) {
revert NotDonationOwner();
}
_;
}
/**
* 拒绝任何向本合约直接转账的操作
*/
receive() external payable {
revert();
}
/**
* 拒绝任何调用不存在的函数的操作
*/
fallback() external {
revert();
}
}
合约测试
打开 Remix:https://remix.ethereum.org/,把合约代码复制进去,然后编译。
先来看看 Remix 为我们提供了哪些账户:
下面将以第一个 0x5B3…eddC4 的账户来创建合约并发起一个项目。
合约初始化
部署时需要提供构造函数的参数,也就是合约所有者,按之前的逻辑,如果设置为 0x00,则合约所有者默认是合约部署者的地址。因此传入 0x0000000000000000000000000000000000000000
即可。
部署成功后,在下面就会出现刚刚部署好的合约:
首先来创建一个名为 HelloWorld 的捐赠项目:
然后测试一下相应的函数看看返回值是否正确:
发现合约所有者和项目发起者确实是 0x5B3…ddC4,由于没有任何捐款,因此项目余额与共计金额都是0。
捐赠测试
现在切换到第二个账户,向这个项目捐款 11 ETH 试试看。
注意在执行 donate()
方法之前要提前设定好 value
,这里我设置成了 11 Ether,也就是下次执行某个函数的时候会带上这 11 ETH。
然后输入项目名称,点击 transact 即可。
在控制台可以看到具体的日志,发现之前预设的 DonateEvent
也被成功发出了,再次查询项目余额,这 11 ETH 就已经被记录了。
取消项目
按逻辑来看,由于现在这个 HelloWorld 项目内还有 11 ETH,因此发起者需要先提取然后才能取消这个项目。
可以看到对应的自定义错误也被返回了,这样就可以知道,这个项目里还有捐款没有被完全提取。
提取余额
现在切换回第一个账户,也就是项目发起者的账户,然后尝试提取余额。
同样这个事件也会被发出,这里的 amount 比实际的 11 ETH 少了 0.001 ETH,因为项目发起者需要向合约所有者支付设定的 0.001 ETH,所以提取的实际金额会少一些。
由于第一个账户既是合约所有者也是项目发起者,所以实际上这 0.001 ETH 也转给了这个账户。
现在这个项目的余额应该是 0,而总计金额仍然是 11 ETH。
结束语
至此我们的第一个合约就写完了,经过测试功能都没有什么问题。
但是现在只能通过 ABI 来调用合约函数,而 DApp 也是需要图形化界面来简化操作的。后续将会继续学习智能合约与前端的结合。