深入解析Safe多签钱包智能合约:代理部署与核心合约
Posted WongSSH
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入解析Safe多签钱包智能合约:代理部署与核心合约相关的知识,希望对你有一定的参考价值。
概述
读者可以前往我的博客获得更好的阅读体验
Safe
(或称Gnosis Safe
)是目前在以太坊中使用最为广泛的多签钱包。本文主要解析此钱包的逻辑设计和代码编写。
读者可以前往Safe Contracts获得源代码。
预备知识
Safe优势
作为智能合约钱包,Safe
支持多签名批准交易。这带来了以下优势:
-
更高的安全性。将资产放置在多签钱包内可以有效避免因为个人单一私钥的泄露而导致的资产丢失。用户可以将多签设置为
2-of-3
形式,个人保存两个私钥并将第三个私钥作为备份。当遭受黑客攻击时,泄露1个私钥对资产安全性没有影响。 -
更加高级的交易设置。相对于以太坊用户,智能合约具有可编程性,这意味着用户可以自行编辑一些交易逻辑,比如将多个交易聚合起来一起执行(
batched transactions
)。此部分由Safe Library contracts
提供。 -
更加灵活的访问管理。用户可以在钱包内加入具有特定功能的模块,比如限制单一用户每日最大可批准金额。这对于DAO是十分有用的。此部分由`Safe Modules提供。
上述仅仅是对Safe
优势的简单介绍。如果读者想了解更多关于此方面的介绍,请参考Gnosis Safe 官网
以太坊账户
在以太坊网络中,具有地址的账户被分为以下两类:
-
EOA(externally owned accounts) 我们平常使用的使用账户均属于这一类型。这一类型的账户具有公钥和私钥。
-
Contract accounts 合约账户。我们创建的合约也均有对应的区块地址,但没有私钥可以用于签名等操作,这一类型的账户被称为合约账户。与EOA相比,合约账户内存在代码逻辑,可以进行编写一些复杂操作。
值得注意的是,在以太坊中,EOA与合约账户是被同等对待的。合约账户可以发送交易,也可以接受ETH。
多签钱包
多签钱包是指需要使用多个私钥进行签名完成交易的钱包。它们的形式一般被标记为m-of-n
,即需要n
个签名人中的m
个签名人进行签名确认。在实际形式上,存在一些加密算法可以实现签名聚合等操作,比如schnorr
、BLS
等算法都可以实现原生上的多签。
但上述方法一般依赖于一些特定的密码学算法,构建基于这些算法的钱包具有一定的复杂性而且要求设计者具有较高的密码学造诣。而使用智能合约实现多签钱包较为简单,因为智能合约具有数据存储和处理功能,这大大降低了多签钱包智能合约的设计难度。
我们会在后文向读者介绍Gnosis Safe
的多签钱包的构造逻辑和代码。
中继商
在以太坊生态内,用户只能使用ETH作为Gas支付的货币。随着ERC20代币的日益繁荣,很多用户有了使用ERC20代币支付Gas的需求,在此需求刺激下,以太坊生态环境内出现了一种特殊的实体——中继商。它们运行用户向其支付ERC20代币,然后由中继商代替用户进行交互。
值得注意的是中继商进行上述操作需要合约支持,比较著名的有EIP2771 MetaTranscation
标准,具体可以参考EIP712的扩展使用。当然,Gnosis Safe
合约对于中继商进行交易进行了很好的支持,我们会在下文逐渐介绍。
代码准备
由于Github
仓库也用于Gnosis Safe
团队日常开发,在完成阶段性开发后进行审计,所以直接clone
仓库会获得未经审计的代码。一种更好的方法是前往Github Release下载源代码。
当我们下载并解压代码后,我们在项目目录中输入forge init foundry-safe
,然后我们将下载的代码中的contracts
文件夹中的合约文件转移到foundry-safe
项目中的src
目录中。
在后文中,我们将按照合约的生命周期逐渐分析源代码。
在此处,我们给出在Etherscan
网站中的各个合约地址:
代理工厂合约
当我们获取代码后,我们先研究合约的部署过程。参见下图:
这一部分的代码主要参考src/proxies/GnosisSafeProxyFactory.sol
合约。为了方便研究合约,我们也给出此合约在以太坊主网中的地址。
此流程的主要目的是使用工厂函数createProxy
创建逻辑合约的代理合约。使用代理合约的模式的目的是为了节省gas fee
。
最简核心实现
我们首先分析最简单的createProxy
函数。读者可以前往此网页查看一个真实的createProxy
交易。
createProxy
函数代码如下:
function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy)
proxy = new GnosisSafeProxy(singleton);
if (data.length > 0)
// solhint-disable-next-line no-inline-assembly
assembly
if eq(call(gas(), proxy, 0, add(data, 0x20), mload(data), 0, 0), 0)
revert(0, 0)
emit ProxyCreation(proxy, singleton);
通过natspec注释,我们可以得到各个参数的含义:
- singleton 为逻辑合约的地址,在以太坊主网上地址为
0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
- data 为调用逻辑合约(
GnosisSafe.sol
)的初始化calldata,我们会在后文介绍。
我们首先使用proxy = new GnosisSafeProxy(singleton);
创造了代理合约。此流程背后其实调用了create函数。
此处较难理解的是以下代码:
call(gas(), proxy, 0, add(data, 0x20), mload(data), 0, 0)
关于call
的参数可以参考此网页。此函数的形式为call(gas,addr,value,argsOffset,argsLength,retOffset,retLength)
,各参数含义如下:
- gas 进行
call
所需要的gas - addr 目标合约地址
- value 进行
call
操作转移的ETH - argsOffset 进行
call
操作发送的calldata
在内存中的开始位置 - argsLength 进行
call
操作发送的calldata
的长度 - retOffset 返回值写入内存的开始位置
- retLength 返回值的长度
在此处,我们使用add(data, 0x20)
获得calldata
在内存中的起始位置。其原理为在内存中存储的data
属于array
类型,此数据类型在第一个内存槽内存储有长度,其余地址槽内存储有真实的数据。我们通过add(data, 0x20)
获得真实数据的起始位置,然后通过mload(data)
获得data
的前 32 byte 中存储的长度。
上述内容可以参考Memory Management文档
完成上述操作,我们使用了if
判断call
的是否正确执行,call
正确执行会返回True
,在数值上等同于1
。
有了以上知识,我们可以分析此交易的Input Data
,我们点击Decode Input Data
以更加友好的方式分析变量,我们可以看到singleton
变量为0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
,data
为一个复杂用于合约初始化的bytes
,由于此初始化涉及到GnosisSafe
的核心实现,我们会在后文进行分析。
最后此代码释放ProxyCreation
事件,此事件的第一个参数为代理合约地址,第二个参数为复制的逻辑合约地址
读者可能会感觉上述流程极其奇怪,一是没有使用require
进行错误断言,二是在call
流程中没有使用solidity
抽象出的call
函数。出现上述的原因在于此部分代码是5年前写的,使用了solidity
的远古版本,因为一直可以正常运行,所以没有更新。
复杂核心实现
本小节介绍其他的合约部署实现。
我们首先研究deployProxyWithNonce
函数,此函数的作用是使用create2
部署合约,但不会调用代理合约初始化初始化函数(即没有进行上文给出的call
流程)。
此函数的核心使用了create2函数,该函数所需要的参数如下:
- value 转移给代理合约的ETH
- offset 合约初始化代码在内存中的偏移量
- size 初始化代码的长度
- salt 用于计算部署合约地址的参数
结合以上参数,我们可以获得确定的合约地址,计算方法如下:
keccak256(
0xff + sender_address + salt + keccak256(initialisation_code)
)[12:]
此函数源代码如下:
function deployProxyWithNonce(
address _singleton,
bytes memory initializer,
uint256 saltNonce
) internal returns (GnosisSafeProxy proxy)
// If the initializer changes the proxy address should change too. Hashing the initializer data is cheaper than just concatinating it
bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
// solhint-disable-next-line no-inline-assembly
assembly
proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
require(address(proxy) != address(0), "Create2 call failed");
首先,我们应该构建出可以部署的字节码。我们可以通过type(GnosisSafeProxy).creationCode
获得需要部署合约的创建字节码。
注意上述表述为创建代码而不是运行代码,具体请参考此文章
但我们发现一个问题,我们部署的合约包含一个构造器(src/proxies/GnosisSafeProxy.sol),代码如下:
constructor(address _singleton)
require(_singleton != address(0), "Invalid singleton address provided");
singleton = _singleton;
上述代码说明我们在构造对应字节码的过程中需要填入对应的参数。深入研究创建代码(可以通过proxyCreationCode()
获得),我们发现此代码总是在字节码的最后按照内存长度逐一读取参数。也就是说,我们需要在creationCode
后增加EVM标准内存长度( 32 byte )的代理合约的地址。在此处,我们使用了uint256(uint160(_singleton))
进行了转换,将合约地址转换为uint256
数据类型,此数据类型恰好占用 32 byte 。
在获得创建代码和构造器参数后,我们使用了abi.encodePacked
对参数进行合并,此过程的目的是生成符合EVM标准的字节码。此函数的作用是将各参数进行编码并非标准的合并,详细可以参考文档。
如果读者想观察最后生成的代码,请前往此网站观察和运行代码。
获得关键的deploymentData
参数后,我们可以非常简单的实现create2
。此处基本与上一节给出的call
类似,我们在此处不再赘述。对于最后结果使用了require
进行断言。
在目前,我们不建议仍使用此方法进行create2
。我个人更建议大家使用由solidity
抽象的create
语法。即下述语法:
proxy = new GnosisSafeProxysalt: salt(_singleton)
显然,只是用solidity
抽象语法更加简洁易懂。
上述函数仅仅作为合约构建过程中的中间函数,此函数是为了createProxyWithNonce
使用的。此函数较为简单,相当于在deployProxyWithNonce
基础上,增加了call
流程实现初始化。具体的call
流程与createProxy
类似,我们在此处不再赘述。
createProxyWithNonce
是目前使用最为广泛的创建代理合约的函数。读者可前往此网页查看。
createProxyWithCallback
是在createProxyWithNonce
基础上实现的一个极其不常见的函数。简单来说,此函数的作用是在创建完成代理合约后会向指定的合约地址进行proxyCreated
请求。
其核心代码如下:
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
此处要求callback
实现以下接口:
interface IProxyCreationCallback
function proxyCreated(
GnosisSafeProxy proxy,
address _singleton,
bytes calldata initializer,
uint256 saltNonce
) external;
使用此函数创建代理合约的交易极为罕见。
辅助函数
本节主要介绍GnosisSafeProxyFactory
中的三个辅助函数。这些函数也使用的比较少,不属于核心实现。
最为简单是以下两个辅助函数:
- proxyRuntimeCode() 获得
Runtime
代码 - proxyCreationCode() 获得
create
代码
如果读者无法理解两者的区别,请参考此文章
还有一个极其鸡肋的函数:
- calculateCreateProxyWithNonceAddress 此函数用于计算待部署的代理合约的地址
此合约通过revert
中断合约创建流程,并返回代理合约地址等信息。但使用此函数,需要提交一个from
为GnosisSafeProxyFactory
地址的交易,对于一般用户而言不是很好构建,而且上述计算过程也会消耗较多gas
,我建议读者通过相关公式在链下进行计算。
代理合约
此部分是工厂合约部署出的合约,与工厂合约相比,代理合约较为简单。此节介绍的代码位于src/proxies/GnosisSafeProxy.sol
。
此部分可以参考我之前写的使用多种方式编写可升级的智能合约(上)。
此处使用let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
获取逻辑合约地址。此流程使用and
操作使地址满足EVM要求。
另一段复杂的代码如下:
// 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000)
mstore(0, _singleton)
return(0, 0x20)
此处代码其实没有非常大的用途,相当于对IProxy
的实现。IProxy
定义如下:
interface IProxy
function masterCopy() external view returns (address);
此处实现了对代理合约调用masterCopy()
指令会返回逻辑合约的地址。当判断出来调用了函数masterCopy()
函数选择器时,我们使用mstore
将地址释放到内存中的第一个内存槽,然后使用return
返回前 32 byte ,即返回逻辑合约地址。
其余的代码主要实现了代理相关的逻辑,读者可自行参考我之前写的文章。
核心代码
我们在此节会进入核心代码GnosisSafe.sol
。此代码串联了各个模块,结构具有一定的复杂性。
由于此处涉及到大量外部模块,我们在此处不会详细介绍模块的实现仅会提及模块的功能,具体实现会在后文提及。
Setup
我们跳过了没有任何参数的构造器函数,直接讨论setUp
函数。在此处设计setUp
函数的原因在于此合约通过代理合约的形式部署,而代理合约部署时不会也无法调用构造器函数。此处给出的构造器函数几乎没有作用。
此处,我们也就使用之前的交易作为示例。我们可以在Input Data
获得输入的data
。如果读者还记得我们上文的讨论,就会知道此data
的作用正是初始化代理合约。
我们对此data
使用cast --calldata-decode
进行解析:
cast --calldata-decode "setup(address[],uint256,address,bytes,address,address,uint256,address)" 0xb63e800d000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000d85bf7de2a15fb2cf44f5beec271f804a0e6c881000000000000000000000000ab6647ad2a897d814d4c111a36d9fba6ed8ec28a00000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000
运行截图如下:
此交易进行了最简单的初始化,仅初始化了_owners
和_threshold
。这两个参数的含义如下:
- _owners 规定多签钱包的拥有者列表
- _threshold 规定单一交易需要签名的数量
如上述交易规定[0xd85bf7de2a15fb2cf44f5beec271f804a0e6c881, 0xab6647ad2a897d814d4c111a36d9fba6ed8ec28a]
为多签钱包拥有者,且单一交易需要两人均签名同意。
如果读者设计的多签钱包仅用于安全的存储资金,那么仅需要初始化这两个参数。
接下来我们介绍其他参数的作用:
to
用于初始化模块的地址data
用于初始化模块的calldata
fallbackHandler
应对fallback
情况的合约地址,可以设置为此地址payment
和paymentReceiver
此参数是为中继器等机构设计的参数
我们接下来逐行介绍setUp
函数代码及功能:
setupOwners(_owners, _threshold);
设置钱包拥有者和单一交易所需要签名的数量。我们会在后文介绍OwnerManager
时进行更加详细的介绍。
if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
设置fallback
函数以处理特殊情况。我们会在介绍FallbackManager
时进行介绍。
setupModules(to, data);
进行模块初始化的操作,我们会在介绍ModuleManager
时进行分析相关代码。
以下代码较难理解:
if (payment > 0)
handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
由于此处涉及到handlePayment
函数,我们在此处一并给出代码:
function handlePayment(
uint256 gasUsed,
uint256 baseGas,
uint256 gasPrice,
address gasToken,
address payable refundReceiver
) private returns (uint256 payment)
// solhint-disable-next-line avoid-tx-origin
address payable receiver = refundReceiver == address(0) ? payable(tx.origin) : refundReceiver;
if (gasToken == address(0))
// For ETH we will only adjust the gas price to not be higher than the actual used gas price
payment = gasUsed.add(baseGas).mul(gasPrice < tx.gasprice ? gasPrice : tx.gasprice);
require(receiver.send(payment), "GS011");
else
payment = gasUsed.add(baseGas).mul(gasPrice);
require(transferToken(gasToken, receiver, payment), "GS012");
简单阅读就可以发现此函数的作用为向refundReceiver
返还gas
费用。当然,我们可以选择使用任意的代币进行返还。
在了解handlePayment
函数的作用后,我们就可以理解初始化过程的代码。此代码的作用是为中继商设置的,实现用户可以委托中继商进行合约初始化的功能。当用户使用GnosisSafeProxyFactory
创建GnosisSafe
合约后,用户可以首先向未初始化的合约内转入资产,然后由中继商代为初始化。中继商在初始化过程中,通过设置paymentReceiver
等参数转移合约内的资产以覆盖自己的gas
成本。为了方便各位理解,我们进行合约调用测试。
我们首先需要进行一些步骤以保证foundry
可以成功编译和测试合约。首先删除src/test
,此文件夹内包含GnosisSafe
编写的辅助测试合约,这些合约对于我们进行测试是不需要的。然后修改src/interfaces/ISignatureValidator.sol
中的function isValidSignature(bytes memory _data, bytes memory _signature) public view virtual returns (bytes4);
修改为function isValidSignature(bytes calldata _data, bytes calldata _signature) public view virtual returns (bytes4);
。如果使用原代码会出现接口与实现不对应的情况。
我们需要在test/utils/MockERC20.sol
创建一个类似ERC20
的合约。代码如下:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract MockERC20
uint256 public transferAmount;
address public receiver;
function transfer(address refundReceiver, uint256 amount) public
transferAmount = amount;
receiver = refundReceiver;
receive() external payable
此合约只需要实现transfer
函数以保证handlePayment
可以正常运行。接下来,我们需要编写测试合约,我们在此处仅给出setUp
部分,完整的代码请参考Github仓库
代码如下:
function setUp() public
Token = new MockERC20();
SingletonTest = new GnosisSafe();
Safe = new GnosisSafeProxy(address(SingletonTest));
address[] memory ownerAddress = new address[](2);
uint256 threshold = 2;
ownerAddress[0] = address(0xd85bF7de2a15FB2Cf44f5beEc271F804A0E6C881);
ownerAddress[1] = address(0xaB6647aD2A897D814D4c111A36d9fba6ED8ec28A);
IGnosis(address(Safe)).setup(
ownerAddress,
threshold,
address(0),
"",
address(0),
address(Token),
10000,
payable(address(1))
);
初始化过程中的参数来自此交易,但在此处我们将paymentToken
等参数进行了设置,最后我们编写以下函数测试payment
是否成功:
function testTransfer() public
assertEq(Token.receiver(), address(1));
assertEq(Token.transferAmount(), 10000);
在测试中,我们发现receiver
获得了转移的代币。
当然,对于一般的用户而言,此功能并不是非常重要,主要实现了使用ERC20代币支付初始化Gas功能,即用户使用GnosisSafeProxyFactory
创建合约后向合约转移ERC20代币,与中继商协商价格后由中继商进行初始化,同时中继商使用handlePayment
函数在合约内收回等价值的代币。
execTransaction
根据natspec
提供的信息,我们可以得到execTransaction
所需要的参数:
- to 支付目标地址
- value 支付数量
- data 交易所携带的信息,即调用目标合约的
calldata
- operation 交易的类型,包括
Call
和DelegateCall
方式 - safeTxGas 设置交易的
gas
费用 - baseGas 与交易执行无关的
gas
费用,主要为中继商设置 - gasPrice 用于付款计算的gas价格,主要为中继商设置
- refundReceiver 提取资金的中继商地址
- signature 交易签名
在分析代码之前,我们首先给出一个在以太坊中的真实交易。此交易实现了提取多签钱包内的资金进行转账的功能,向目标用户转移了 8000 ETH。读者可以自行使用cast --calldata-decode
自行解码calldata
进行分析。
我们可以看到在函数体内大量使用了花括号进行分割代码,这是为了避免
Stack too deep
错误,具体可以参考这篇文章。
我们首先分析第一代码块中的代码,如下:
bytes32 txHash;
bytes memory txHashData =
encodeTransactionData(
// Transaction info
to,
value,
data,
operation,
safeTxGas,
// Payment info
baseGas,
gasPrice,
gasToken,
refundReceiver,
// Signature info
nonce
);
// Increase nonce and execute transaction.
nonce++;
txHash = keccak256(txHashData);
checkSignatures(txHash, txHashData, signatures);
此代码的作用主要为验证EIP712
签名。在此处使用了encodeTransactionData
将交易数据编码为EIP712
规定的结构化数据形式。我们会在后文对此函数进行介绍。然后,我们通过keccak256
计算哈希,完成EIP712
的结构化数据哈希流程。
读者可自行阅读我之前写的这两篇文章以理解上述流程:
在获得相关数据后,我们使用checkSignatures(txHash, txHashData, signatures);
进行验证签名是否正确。此函数我们会在下文进行介绍。
接下来,我们分析第二代码块:
address guard = getGuard();
if (guard != address(0))
Guard(guard).checkTransaction(
// Transaction info
to,
value,
data,
operation,
safeTxGas,
// Payment info
baseGas,
gasPrice,
gasToken,
refundReceiver,
// Signature info
signatures,
msg.sender
);
此代码主要涉及GuardManager
模块,在此处我们不详细分析其具体实现。它的功能是检测交易是否符合合约部署者所设置的其他条件.当然,这些条件需要用户自行编写合约并进行部署,然后调用setGuard(address guard)
进行设置。
在后面介绍GuardManager
模块时,我们会再次进行说明。
接下来介绍用于gas
相关设置的代码块,代码如下:
require(gasleft() >= ((safeTxGas * 64) / 63).max(safeTxGas + 2500) + 500, "GS010");
uint256 gasUsed = gasleft();
// If the gasPrice is 0 we assume that nearly all available gas can be used (it is always more than safeTxGas)
// We only substract 2500 (compared to the 3000 before) to ensure that the amount passed is still higher than safeTxGas
success = execute(to, value, data, operation, gasPrice == 0 ? (gasleft() - 2500) : safeTxGas);
gasUsed = gasUsed.sub(gasleft());
// If no safeTxGas and no gasPrice was set (e.g. both are 0), then the internal tx is required to be successful
// This makes it possible to use `estimateGas` without issues, as it searches for the minimum gas where the tx doesn't revert
require(success || safeTxGas != 0 || gasPrice != 0, "GS013");
// We transfer the calculated tx costs to the tx.origin to avoid sending it to intermediate contracts that have made calls
uint256 payment = 0;
if (gasPrice > 0)
payment = handlePayment(gasUsed, baseGas, gasPrice, gasToken, refundReceiver);
if (success) emit ExecutionSuccess(txHash, payment);
else emit ExecutionFailure(txHash, payment);
在进行具体交易代码前,我们可以看到合约使用require(gasleft() >= ((safeTxGas * 64) / 63).max(safeTxGas + 2500) + 500, "GS010");
检查了gasleft
的数值。
我们首先分析约束gasleft
大于(safeTxGas * 64) / 63
的原因,这一要求是基于EIP150。EIP150
规定:
63 / 64 * gas available = Call/DelegateCall gas
即call
操作传递的gas
应为当前可用gas
的63 / 64
。在此处,Call/DelegateCall gas
即我们为交易设置的safeTxGas
,而gas available
(可用gas)即合约的gasleft
。我们可用简单的计算得到gas left = (safeTxGas * 64) / 63
。当然,此时显示了gasleft
的可在交易中传递safeTxGas
的最小情况。如果gasleft < (safeTxGas * 64) / 63
情况出现,我们就无法满足向交易传递safeTxGas
的要求。
除了验证符合EIP150
的条件,我们还需要保证gas
费用足够合约抛出events
。此部分的成本为2500
。所以有了另一个限制条件gasleft > safeTxGas + 2500
。
最后,我们还需要在满足上述两个限制的基础上保留另一部分gas
保证代码运行,此部分数值为500
。
对上述条件进行组合,我们可以得到最终条件。
在完成gasleft
的校验后,我们进入了交易执行的核心模块。首先声明gasUsed
变量。然后,我们进入了交易执行的核心代码,如下:
success = execute(to, value, data, operation, gasPrice == 0 ? (gasleft() - 2500) : safeTxGas);
此处使用的execute
位于Executor
模块,主要用于进行交易,核心就是封装了Call
和DelegateCall
函数。我们会在后文为大家介绍此模块的实现。
在此处,我们给出execute
的函数的定义,如下:
function execute(
address to,
uint256 value,
bytes memory data,
Enum.Operation operation,
uint256 txGas
) internal returns (bool success)
我们关注个参数的含义:
- to 目标地址
- value 交易转移的ETH数量
- data 交易包含的
calldata
- operation 决定交易为
call
或delegatecall
- txGas 交易消耗的
gas
值
在此处,较为复杂的为txGas
参数为gasPrice == 0 ? (gasleft() - 2500) : safeTxGas)
。这显然是一个三目表达式,当设置gasPrice
为0时,交易发送的gas
为gasleft() - 2500
。而如果设置了gasPrice
,则交易发送safeTxGas
数量的gas
。进行如此设置的合理性在于,正如上文所述,gasPrice
参数一般由交易中继商设置,中继商会设置gasPrice
等变量,这些设置最终关乎中继商可在交易内获得的回报。所以当设置gasPrice
时,严格限制发送的gas
数量为safeTxGas
是有必要的。当然,对于普通用户而言,我们只需要交易可以正常进行而不关注交易过程的具体gas
消耗,所以如果用户没有设置gasPrice
参数,则会对交易设置gasleft() - 2500
的gas
。此处预留2500
是为了保证events
可以顺利抛出。
值得注意的是,虽然我们对交易设置了较高的
gas
,但并不意味着相较于safeTxGas
的消耗的gas
多,原因在于,交易执行方会将多余的gas
进行返还操作。
在进行交易执行后,我们通过gasUsed = gasUsed.sub(gasleft());
计算上述交易步骤消耗的具体gas
数量。着主要方便中继商在合约内提取手续费。
完成上述核心步骤后,我们接下来主要处理中继商提取手续费和抛出events
的过程。
首先,我们可用看到在此处进行了一系列条件检测,如下:
require(success || safeTxGas != 0 || gasPrice != 0, "GS013");
一旦不满足以下三个条件,合约会停止运行并抛出异常:
- 交易未成功
- 未设置
safeTxGas
- 未设置
gasPrice
交易未成功抛出异常可能对于大家而言比较好理解,但为什么同时要求满足未设置safeTxGas
和gasPrice
的条件呢?因为此参数主要由中继商设置,我们知道即使交易失败也会消耗gas
,所以我们需要在交易失败的条件下继续运行后面的中继商提取交易手续费的逻辑代码,避免中继商在失败交易中蒙受损失。
最后我们观察中继商提取手续费的代码块:
uint256 payment = 0;
if (gasPrice > 0)
payment = handlePayment(gasUsed, baseGas, gasPrice, gasToken, refundReceiver);
当交易设置的gasPrice
大于0
时,我们通过handlePayment
函数计算中继商手续费数量并将其返回给中继商。我们会在后文详细介绍handlePayment
函数。
手续费提取的具体交易可以参考这个交易,如下图:
在交易的最后代码,我们进行了抛出事件和调用Guard
合约中的checkAfterTransaction
进行监控。
handlePayment
在上文中,我们在setup
和execTransaction
中都使用了这一重要的参数。我们会在此处详细介绍此函数的参数和代码逻辑。由于此处使用到了gas
相关的基础知识,特别是EIP1559
相关的gas
机制,建议读者先行阅读此文
我们首先从参数分析,此函数需要以下参数:
- gasUsed 用于计算手续费的
gas
数值 - baseGas 类似
EIP1559
中的Base Fee
,具体可以参考此文 - gasPrice
gas
的价格 - gasToken 用于支付
gas
的代币,即中继商提取手续费时提取的代币种类 - refundReceiver 提取手续费资金的获得者,一般为中继商自身钱包地址
在此函数体中的第一行代码通过三目表达式定义了receiver
变量。当用户设置refundReceiver
参数时,即采用用户的设定参数; 否则使用tx.origin
作为提取手续费的接收者。
接下来我们进入一个分支结构,根据代币类型进行分支判断。我们首先分析gasToken == address(0)
的情况,即选择gasToken
为ETH的情况,在此处,我们使用以下公式计算中继商在合约内提取的手续费:
Gas Fee = (gasUsed + baseGas) * gasPrice
当然,此处的gasPrice
也通过一个三目表达式进行选择,要求gasPrice
为用户设置的gasPrice
和交易内含的tx.gasprice
中的较大值。当计算完成后,我们便将Gas Fee
数量的ETH通过send
发送给接收者。
用户可以在solidity 官方文档中查询到所有的
tx
结构体中的参数。
在其他代币分支,我们使用了类似公式进行计算。但由于以太坊交易tx
的数据结构中不包含以其他代币计费的情况,所以在此处我们无法使用tx.gasprice
。在此处,我们只能接受函数设置的gasPrice
变量。然后,我们通过位于src/common/SecuredTokenTransfer.sol
中transferToken
进行代币转移。
通过此函数,用户可以通过中继商使用任何代币支付gas
费用。
checkSignatures
我们在execTransaction
通过此函数检测交易多签是否符合签名者的数量限制。此函数需要以下参数:
- dataHash 交易参数的
EIP712
哈希值,具体可以参考此文章 - data 需要进行签名的数据
- signatures 需要进行检查的签名数据
此部分的核心代码为checkNSignatures(dataHash, data, signatures, _threshold);
,对于此函数我们会在下文进行介绍。
checkNSignatures
由于此函数所需要的参数与checkSignatures
有大量重叠,所以在此处我们不再进行相关的参数介绍。
在函数的起始位置,合约首先检查了signatures.length >= requiredSignatures.mul(65)
。这是为了保证signatures
聚合签名的长度符合预期。
此处的常数为
65
的原因是在最小签名(即仅包含v
、r
、s
)的长度为65 bytes
。具体可以参考此文章。
值得注意的是,GnosisSafe
为了满足多种签名方式并存的情况,修改了部分签名的定义。读者可以阅读相关文档进行学习。当然,我们也会在后文尽可能解释Gnosis
的签名格式。
我们使用for
循环和signatureSplit
函数进行签名分割。signatureSplit
被定义在src/common/SignatureDecoder.sol
合约中,我们会在后文进行分析。
GnosisSafe
支持多种签名方式,通过不同的v
值进行判断,包括以下几种类型:
v值 | 签名类型 |
---|---|
0 | 合约签名(EIP1271) |
1 | 预签名签名 |
v > 30 | eth_sign 签名 |
31 > v > 26 | ECSDA签名 |
在Gnosis
的签名规定中,签名包含两部分,分别是静态部分和动态部分。所有的签名类型都具有静态部分,只有合约签名具有动态部分。顾名思义,静态部分的程度都是已知的65 bytes
,且由v r s
三部分构成; 而动态部分的长度不固定,作为合约签名的附属部分存在。在多个签名最后聚合时,我们必须保证静态部分在前而动态部分在后,同时保证静态部分按升序排列。
合约签名(Contract Signature)
读者在阅读此部分时需要对EIP1271
标准有一定理解,如果读者对此没有了解,请先阅读此文章。简单来说,合约将签名权授予某拥有私钥的用户,由此用户进行签名。接受合约签名的合约使用合约签名对签名验证合约
调用isValidSignature
函数,获得此签名是否是正确的合约签名。
我们首先给出合约签名的静态格式:
32-bytes 签名验证合约 r 32-bytes 签名数据位置 s 0 v
在这里比较神奇的一点是由于签名有65 bytes
的长度限制,我们在此处无法完整将完整合约签名的编码,所以在此处我们设置了签名数据位置
参数,即合约签名数据在组合后的多签名中的位置。
注意合约签名数据的位置必须位于常规签名(即包含
v r s
字段的签名)的后面,否则会影响函数读取签名。上述表达都较为抽象,我们十分建议读者阅读文档中的示例以更好地理解上述表述。
合约签名的动态部分,即签名数据部分格式如下:
32-bytes signature lengthbytes signature data
在此处我们注意到signature data
没有具体长度,此签名的长度其实取决于签名验证合约
中的isValidSignature
的代码逻辑。
根据EIP1271
的相关流程,我们需要首先获得签名验证合约
的地址。根据上文给出的合约签名的格式
,我们通过对r
值的转换获得对应的地址,使用代码如下:
currentOwner = address(uint160(uint256(r)));
由上文我们给出的“静态部分在前,动态部分在后”的规则,我们需要校验指向合约动态部分的s
值是否在静态部分之外,使用require(uint256(s) >= requiredSignatures.mul(65), "GS021");
代码进行判断。
此处我们使用了每个签名的静态部分长度固定为
65 bytes
进行判断
接下来,我们检查签名数据是否位于多签名内。通过动态部分的格式,我们知道s + 32
即签名数据的起始位置,我们使用require(uint256(s).add(32) <= signatures.length, "GS022");
检查签名数据的起始位置是否位于多签名内。
上文给出的条件检查并不能完全保证合约签名中的s
指向的数据位于多签数据内,因为可能多签名由多于requiredSignatures
的签名组成,这导致使用require(uint256(s) >= requiredSignatures.mul(65), "GS021");
不能正确判断s
指向的数据是否在多签数据内。
我们需要通过一些更加复杂的手段判断s
指向的数据是否在多签数据内。如果需要更加精确的判断,我们需要获得多签数据的具体长度。具体来说,我们需要获取动态数据的长度。其核心函数为:
contractSignatureLen := mload(add(add(signatures, s), 0x20))
要理解此代码,读者需要对于EVM底层数据存储有所了解。signatures
属于bytes
,本质上属于动态类型,变量相当于指向底层数据在内存的指针。而我们需要先通过s
获得动态数据的起始位置。由于signatures
只是指向内存的指针,我们在此指针后增加s
就可以获得动态数据的起始内存地址。但需要注意signatures
属于动态类型,根据solidity
的规范,此数据的头部32 bytes
为数据长度,所以我们需要在signatures + s
的基础上增加0x20
实现跳过signatures
的长度数据的作用获取的真正的动态数据起始位置。
建议阅读Layout of State Variables in Storage以进一步了解上述步骤。
当我们获得到动态数据在内存中的起始位置后,我们可以通过mload(offset)
直接获取到动态数据的前 32 bytes ,即动态数据长度。
接下来我们需要具体计算判断s
指向的数据是否在多签数据内,通过计算签名长度确定,代码如下:
require(uint256(s).add(32).add(contractSignatureLen) <= signatures.length, "GS023");
在经过一系列检查后,我们终于进行提取签名数据的流程,代码如下:
contractSignature := add(add(signatures, s), 0x20)
非常简单粗暴的将动态数据整体提取出来。为什么需要将长度和签名数据同时提取?正如上文所述,前32 bytes作为长度,后面数据作为数据是solidity
中动态数据类型的基本形式。为了保证与solidity
规定相符,我们在此处也使用了此种数据格式进行数据提取。
在获取到完整的签名数据后,我们只需要对签名验证合约(r
)发送isValidSignature
请求即可,代码如下:
require(ISignatureValidator(currentOwner).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "GS024");
使用接口进行函数调用,较为简单。具体的接口实现由签名验证合约决定。如果验证正确,合约会返回0x20c13b0b
已证明签名正确。
预认证签名(Pre-Validated Signatures)
预认证签名的具体形式如下:
32-bytes hash validator32-bytes ignored1
从前之后依次由r
、s
、v
变量表示。
此函数依赖于合约中的映射:
mapping(address => mapping(bytes32 => uint256)) public approvedHashes;
此映射反映了某用户是否对特定的信息进行了预签名。如果进行了预签名,则uint256
会被置为1
。此过程通过approveHash
实现,此函数代码如下:
function approveHash(bytes32 hashToApprove) external
require(owners[msg.sender] != address(0), "GS030");
approvedHashes[msg.sender][hashToApprove] = 1;
emit ApproveHash(hashToApprove, msg.sender);
此函数较为简单,我们在后文不再进行介绍。
而对于此签名的检查是极其简单的,代码如下:
currentOwner = address(uint160(uint256(r)));
require(msg.sender == currentOwner || approvedHashes[currentOwner][dataHash] != 0, "GS025");
符合条件的交易需要满足以下条件:
- 发送者为签名中的
r
,此时发送的任何签名都被认可 - 发送者对于
dataHash
已进行过授权
上述条件为或
的关系,满足任一一点即证明签名有效。
Eth_sign签名
签名的具体形式如下:
32-bytes r32-bytes s1-byte v
这与传统的ECSDA
签名基本一致,但此处为了使用v
实现签名类型区分的作用,所以相比于正常的v
(即27或28),此处的v
值进行了+ 4
操作,即取值可以为31
或32
。
由于此处使用了传统签名方法,所以检验签名的方式极其简单,使用ecrecover
预编译函数,代码如下:
currentOwner = ecrecover(keccak256(abi.encodePacked("\\x19Ethereum Signed Message:\\n32", dataHash)), v - 4, r, s);
此处需要补充的一点是
eth_sign
接口会在签名信息前增加\\x19Ethereum Signed Message:\\n32
,这是为了防止签名被滥用,具体请参考Github
ECSDA签名
与其他签名验证相比,此签名验证较为简单,我们在此处不进行解释。
上述内容主要介绍了各种类的签名,我们处理验证签名外,我们还需要验证签名人是否符合要求。在介绍具体的判断代码前,我们需要简单了解一下用于存储签名人的映射,变量名为owners
。在此处。我们不加解释给出owners
中的映射情况(下图假设a
、b
、c
均为设置的签名人):
0x1 => a
a => b
c => 0x1
关于此映射情况的来源,我们会在介绍setupOwners
函数中进行解释。
所以在验证签名人身份时,我们可以使用owners[currentOwner] != address(0) && currentOwner != SENTINEL_OWNERS
进行判断。
而currentOwner > lastOwner
是为了保证签名人的地址按升序排列。
综合以上,我们使用下列代码进行判断:
require(currentOwner > lastOwner && owners[currentOwner] != address(0) && currentOwner != SENTINEL_OWNERS, "GS026");
requiredTxGas
此函数用于估计交易的gas
耗费,此函数使用的参数较为简单且多次使用过,所以我们不再具体介绍参数含义。
函数代码如下:
uint256 startGas = gasleft();
// We don't provide an error message here, as we use it to return the estimate
require(execute(to, value, data, operation, gasleft()));
uint256 requiredGas = startGas - gasleft();
// Convert response to string and return via error message
revert(string(abi.encodePacked(requiredGas)));
代码较为简单,值得注意的是为了避免我们在估计gas
消耗时完成不必要的交易,我们在代码的最后使用revert
函数直接抛出异常,达到交易中止的目的。当然,我们在revert
返回的错误信息中编码了requiredGas
,使用户可以通过错误信息获得交易的估计gas
。
encodeTransactionData
此函数用于编码交易数据,此函数使用的参数我们在前文都进行过相关介绍。对于此函数,我们不会进行详细介绍,读者可以参考我之前的博客基于链下链上双视角深入解析以太坊签名与验证
getTransactionHash
此函数主要依赖于encodeTransactionData
函数,仅进行了keccak256
哈希操作。值得注意的是,此函数的返回结果就是签名者用于签名的信息。
总结
我们完成了GnosisSafe
的代理相关合约和最为复杂的主合约的分析,相信读者也可以理解GnosisSafe
的基本运作模式。
在Proxy
相关合约中,src/proxies/GnosisSafeProxy.sol
提供具体的代理合约代码,而src/proxies/GnosisSafeProxy.sol
提供多种函数供用户进行代理合约部署。
在src/GnosisSafe.sol
合约中,核心函数为execTransaction
,其他函数基本都为此服务。读者可以以此函数为主线进行研究。当然,由于GnosisSafe
的野望,合约内存在大量为中继商设计的函数,这一部分由于很难看到交易实例,所以我个人给出的内容可以存在于现实不符的情况。当然此部分对于核心实现没有很大影响。
如果读者谋求较为简单的实现,可以前往此仓库。
以太坊智能合约项目-Token合约开发与部署
修订日期 | 姓名 | 邮箱 |
---|---|---|
2019-09-05 | brucefeng | [email protected] |
一. 钱包环境安装
以太坊钱包顾名思义,就是管理以太坊地址,存储以太坊Token的工具,再简单点说,任何区块链网络都需要我们有自己的账户,管理账户的软件可称之为钱包,无论是炒币的还是研究以太坊开发的,钱包都是必不可少的。
1.钱包分类
1.1 Mist
说到以太坊钱包,第一个要说的当然就是Ethereum官方钱包+浏览器 Mist。Mist是一个全节点钱包(全节点钱包通俗的来说就是同步了全部的以太坊区块信息的钱包)。也就是说打开钱包后,电脑会自动同步全部的以太坊区块信息,如果设备和网络的条件过关的情况下,大概需要半天左右的时间。
最新版下载地址:https://github.com/ethereum/mist/releases/tag/v0.11.1
1.2 MyEtherWallet
MyEtherWallet 是一个轻钱包,无需下载,在直接在网页上就可以完成所有的操作。
1.3 MetaMask
MetMask是一个以太坊钱包插件,目前支持Google跟FireFox浏览器,能够帮助用户方便地管理以太坊数字资产,支持所有的测试网络和私有链网络,是我们开发人员的必备钱包工具,下文将会讲解如何安装。
1.4 Parity
原以太坊基金会部分成员开发的钱包,也是一个全节点钱包。
下载地址: https://github.com/paritytech/parity-ethereum/releases
2.账户与网络
以太坊网络中的账户和典型的区块链账户没有太大区别,都由地址、公钥、私钥 3 部分构成,不论使用何种钱包创建的以太坊账户,在不同的以太网网络之间都是可以通用的,比如我在主网上创建了钱包账户,而切换到 任意 测试网络,如Kovan时仍然可以使用同样的账户,这种跨网络通用的账号机制实际上是内置在以太坊客户端之内的。
3.安装MetaMask钱包
3.1 钱包插件安装
创建以太坊账户的方式有很多种,上文就提到了多种以太坊钱包,但我们要开发跑在浏览器中的 DApp,钱包集成在浏览器中就非常方便了,所以我们选择MetaMask作为我们的开发环境的钱包工具。
官方地址:https://metamask.io/
点击GET CHROME EXTENSOIN跳转至安装界面
点击右上角ADD TO CHROME
点击添加扩展程序按钮,等待安装即可,安装成功后,在浏览器右上角可以有该图标显示!
3.2 钱包账户创建
打开MetaMask图标,点击TRY IT NOW按钮开始创建账户
点击CONTINUE继续
创建密码
点击NEXT继续
将协议文字拖到最后,点击ACCEPT继续
点击ACCEPT继续
重要: 点击
获取助记词,将这12个英文字母抄写在纸上,妥善保存!
按照顺序选择助记词进行确认,确认完毕
点击VIEW ACCOUNT查看账户地址与二维码信息
可以根据需要,对账户名进行修改
此时,钱包创建完毕,接下来,为了开发需要,我们需要为几套测试环境申请测试ETH进行开发使用。
二.申请测试ETH
1.测试环境说明
从上图可以看到我们钱包可以切换的环境,除了Main Ethereum Network之外的Network节点均为测试节点。
以太坊可以搭建私有的测试网络,不过由于以太坊是一个去中心化的平台,需要较多节点共同运作才能得到理想的测试效果,因此并不推荐自行搭建测试网络。
以太坊公开的测试网络共有4个,目前仍在运行的有3个。每个网络都有自己的创世区块和名字,按开始运行时间的早晚,依次为:
- Morden(已退役)
Morden是以太坊官方提供的测试网络,自2015年7月开始运行。到2016年11月时,由于难度×××已经严重影响出块速度,不得不退役,重新开启一条新的区块链。Morden的共识机制为PoW。
- Ropsten
Ropsten也是以太坊官方提供的测试网络,是为了解决Morden难度×××问题而重新启动的一条区块链,目前仍在运行,共识机制为PoW。测试网络上的以太币并无实际价值,因此Ropsten的挖矿难度很低,目前在755M左右,仅仅只有主网络的0.07%。这样低的难度一方面使一台普通笔记本电脑的CPU也可以挖出区块,获得测试网络上的以太币,方便开发人员测试软件,但是却不能阻止×××。
PoW共识机制要求有足够强大的算力保证没有人可以随意生成区块,这种共识机制只有在具有实际价值的主网络中才会有效。测试网络上的以太币没有价值,也就不会有强大的算力投入来维护测试网络的安全,这就导致了测试网络的挖矿难度很低,即使几块普通的显卡,也足以进行一次51%×××,或者用垃圾交易阻塞区块链,×××的成本及其低廉。
2017年2月,Ropsten便遭到了一次利用测试网络的低难度进行的×××,×××者发送了千万级的垃圾交易,并逐渐把区块Gas上限从正常的4,700,000提高到了90,000,000,000,在一段时间内,影响了测试网络的运行。×××者发动这些×××,并不能获得利益。
- Kovan
为了解决测试网络中PoW共识机制的问题,以太坊钱包Parity的开发团队发起了一个新的测试网络Kovan。Kovan使用了权威证明(Proof-of-Authority)的共识机制,简称PoA。
PoW是用工作量来获得生成区块的权利,必须完成一定次数的计算后,发现一个满足条件的谜题答案,才能够生成有效的区块。
PoA是由若干个权威节点来生成区块,其他节点无权生成,这样也就不再需要挖矿。由于测试网络上的以太币无价值,权威节点仅仅是用来防止区块被随意生成,造成测试网络拥堵,完全是义务劳动,不存在作恶的动机,因此这种机制在测试网络上是可行的。
Kovan与主网络使用不同的共识机制,影响的仅仅是谁有权来生成区块,以及验证区块是否有效的方式,权威节点可以根据开发人员的申请生成以太币,并不影响开发者测试智能合约和其他功能。
- Rinkeby
Rinkeby也是以太坊官方提供的测试网络,使用PoA共识机制。与Kovan不同,以太坊团队提供了Rinkeby的PoA共识机制说明文档,理论上任何以太坊钱包都可以根据这个说明文档,支持Rinkeby测试网络,目前Rinkeby已经开始运行。
2.测试ETH申请
2.1 Ropsten环境-测试币申请
切换测试环境,点击DEPOSIT
点击GET ETHER
点击获取1个ETH测试币,可以点击多次
目前共计是11个,超出后报错“User is greedy”,比较直白:用户太贪了:-),如果测试币花不掉,也不要浪费,可以继续返回给测试币发送方。
可以根据实际数量选择捐赠的数量
成功获取到11个ETH测试币
2.2 Kovan环境-测试币申请
https://gitter.im/kovan-testnet/faucet
打开了解跳转至gitter聊天室中,可以通过github账户或者twitter账户进行登录,加入faucet聊天室后,发送自己的账户地址到聊天列表中,笔者申请的账户地址为0x18850c9cE7B2274EbB0c78e6221844AC76715494
效果如下
笔者的github账户前几天申请过,所以第一次申请的时候提示我最近刚刚申请过,更换了登录账户后成功。
成功获取到3个ETH测试币
2.3 Rinkeby环境-测试币申请
这个环境的测试币申请稍微复杂一点,要求接受充值的账户持有人必须以太坊账户地址发送到自己的社交网络中(如 Twitter、Facebook、Google Plus),同样,该工具限制了充值的频率;
- 复制MetaMask账户地址:
0x18850c9cE7B2274EbB0c78e6221844AC76715494
- 打开google plus并登录:https://plus.google.com/
- 点击首页-右下角按钮
贴入账户地址,点击发布
点击按钮公开分享
复制链接地址
https://plus.google.com/100168130519914964665/posts/eqQ6iBMAhbJ
- 打开rinkeby的测试币获取地址
https://www.rinkeby.io/#faucet
按需选择
网站已经获取到请求,开始处理,等待片刻充值完毕
获取到18.75个ETH测试币
三套测试环境的测试币我们均已获得,现在我们可以开始代码设计工作了。
三.代码设计解读
1. Token是什么?
Token是区块链实践中的一个重要概念,有多重要呢?有观点认为,Token可能是比区块链更伟大的发明,是一个与“公司”比肩的伟大发明。区块链是一个技术概念,Token是一个经济概念;与“公司”一样,Token带来了全新的组织形式和协作形式。
Token早期的叫法为代币,但现在更适合的翻译应该是“通证”。“通证”的概念超过了“代币”。通证是一种可流通的、加密的数字权益证明,这个权益,可以是财产、×××、学历证书、钥匙、门票、积分、荣誉、使用权等等任何事物。代币更像是资产的数字化,而通证则在此基础上,同时包含了数字化的资产,将原本无法量化、无法记账的东西,给量化、记账。
Token目前有两种,一种是在区块链网络中内置的,用于用户之间的转账交易,并奖励矿工,这种被理解为数字加密货币,比特币就属于这一种;另一种是在DApp或智能合约上自行铸造的,用于内部交易,这种更接近“通证”这个含义。
看起来,Token跟股权有点类似,但其实这两者的差别还是比较大的。Token比股权,最重要的就是多了流通凭证。股权只是权益凭证,缺乏流动性,投资者如果对公司运作方式不认可,会通过董事会来施压,而Token模式下,投资者如果对公司运作方式不认可,可以直接卖了token退出。这一点,对于传统的投资模式,可能会有比较大的颠覆。VC行业也迅速做出了反应,市场上兴起了一波数字币基金,与传统VC在出资人、投资流程、项目管理和退出方面有明显的差异。
综上所述,Token作为一种权益证明,是数字的、加密的、可流通的。鉴于Token这么多的优点和想象力,区块链行业提出一个新概念,叫“通证经济”,要把Token通证充分用起来,用通证来盘活人力、资本、项目、信用等生产要素,用新的利益机制,造就新的生产关系。这事儿如果能成,会激发指数级的创新,给我们的生产生活方式带来巨大的改变。
2.规范简介
Token的走红,归功于以太坊及其订立的ERC20标准。基于这个标准,每一个人、每一家企业都可以在基于区块链的平台上发Token,这个Token是自定义的,可以代表任何权益和价值。
ERC-20最初作为一种尝试,旨在为以太坊(Ethereum)上的token合约提供一个特征与接口的共同标准,并且,它现在已经被证明是非常成功的了。ERC-20有很多好处,包括允许钱包显示数以百计不同token的账户余额;创建一个交易工具,只需提供token合约的地址就可以将更多token列入表中。创建ERC-20兼容token的好处很多,以至于在今天,很少有其他token合约用其他方式创建。
ERC是 Ethereum Request for Comment 的缩写,Request for Comment 是征求大家意见的意思,大家都希望 Ethereum 网络的未来技术走向不要集中在几个硏发人员的手里, 所以才开放给大家提出 Proposals (建议), 用来改进 Ethereum, 决定未来开发方向. 这就是每个人每件跟 Ethereum 未来发展有关的事情, 都能用 EIP 建议格式 (EIP template) 提出来. 只要被大家选上, 就会正式列入 EIPs 清单。
而且, 你会看到 EIP 定义或讨论 issues 里, 常常会看到它相关的 ERC, 也就是, 讨论过程中, 有一些要征求更多人意见时, 就会把它细节定义放在 ERC 里. 而且他们会用同一个号码, 比如 ERC-20就是对应到 EIP-20.
简单讲, 讨论项目, 一开始会用 EIP 提出建议, 结果与细节会定义在 ERC, 最后会 final (拍板定案), 放在 EIP 清单里 Finalized EIPs 区.
3.规范解读
关于EIP-20(ERC-20 Token Standard)的标准说明可以直接参考官方文档,英文能力强的直接看原文即可。
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-20.md
为了节约读者时间,本文直接在代码设计这部分对该标准进行解读,便于读者更好地理解如何使用标准去设计开发自己的Token合约。
4.代码设计
首先,我们看简单看一下现在主流的一些Token的项目介绍,我个人比较关注也比较看好火币Pro,所以姑且用HT给大家进行直观化的了解。
此处仅为学习交流,不代表任何炒币观点,如果读者有火币的人脉资源,倒是可以帮笔者引荐引荐。
HT(火币全球通用积分)是基于区块链发行和管理的积分系统,为火币官方发行的唯一积分。HT 将支持火币全球业务、全线产品,如:VIP 手续费折扣、认证商家保证金、火币积分专享活动、与热门币种交易、参与火币业务等。
以上信息中,我们姑且先关注一下总量: 500,000,000 HT,其他信息均为交易所信息,暂时不关注,现在,我们开始通过ERC20标准实现我们自己的Token合约。
4.1 创建合约接口 EIP20Interface
首先定义一个合约EIP20Interface,将ERC20标准中需要实现的所有方法与事件全部进行引用,作为Token合约的父合约,用于被其他合约集成使用。
pragma solidity ^0.4.24;
contract EIP20Interface{
/*
//获取token名字,比如"BruceFeng Coin"
function name() view returns (string name);
//获取Token简称,比如"BFC"
function symbol() view returns (string symbol);
//获取小数位,比如以太坊的decimals为18
function decimals() view returns (uint8 decimals);
//4.获取token发布的总量,比如HT 5亿
function totalSupply() view returns (uint256 totalSupply);
/*
//获取token发布的总量,比如HT 5亿
function totalSupply() view returns (uint256 totalSupply);
*/
//获取_owner地址的余额
function balanceOf(address _owner) public view returns (uint256 balance);
//主动转账:当前地址主动发起转账,从当前地址向_to地址转入_value个Token
function transfer(address _to, uint256 _value)public returns (bool success);
//被动转账:允许_to地址从_from(一般为当前地址)向_to转_value个Token
function transferFrom(address _from, address _to, uint256 _value) returns (bool success);
//允许_spender从自己(调用方)账户转走_value个Token
function approve(address _spender, uint256 _value) returns (bool success);
//自己_owner查询__spender地址可以转走自己多少个Token
function allowance(address _owner, address _spender) view returns (uint256 remaining);
//转账的时候必须要调用的事件,比如Tranfer,TransferFrom
event Transfer(address indexed _from, address indexed _to, uint256 _value);
//成功执行approve方法后调用的事件
event Approval(address indexed _owner, address indexed _spender, uint256 _value);
}
请严格参照3.规范解读中的英文网页进行对比查看,我们此处将网页中需要实现的方法全部贴入代码中,在下面会依次实现。
3.2 创建继承合约 BFCToken
contract BFCToken is EIP20Interface {
// 合约体
}
创建一个新合约名为BFCToken作为子类集成EIP20Interface合约中的所有方法。
3.3 设置Token合约参数
//1.获取token名字,比如"BruceFeng Coin"
string public name;
//2.获取Token简称,比如"BFC"
string public symbol;
//3.获取小数位,比如以太坊的decimals为18
uint8 public decimals;
//4.获取token发布的总量,比如HT 5亿
uint256 public totalSupply;
在合约标准中,通过function name() view returns (string name);
实现,由于这些变量是存储固定信息的变量,不参与任何运算,只为显示所需,所以,通过public类型进行声明即可自动提供变量值的获取方法,这是行业内的常规做法,大家可以先参考实现,所以,我们将获取这四个参数的函数都先注释掉。
3.4 定义存储变量
这两个变量非常重要,请仔细思考
//存储地址余额
mapping(address=>uint256) balances ;
//存储允许转出的金额
mapping(address=>mapping(address=>uint256)) allowances;
-
balances 存储账户余额
如balance["0xca35b7d915458ef540ade6068dfe2f44e8fa733c"]=10000
- allowances 允许转出的金额
key :0x18850c9cE7B2274EbB0c78e6221844AC76715494
value :
key : 0xca35b7d915458ef540ade6068dfe2f44e8fa733c
value : 100
value :
key : 0xxv3fe7d915458ef540ade6068dfe2f44e8fa34xb
value : 200
1.表示地址0x18850c9cE7B2274EbB0c78e6221844AC76715494允许地址0xca35b7d915458ef540ade6068dfe2f44e8fa733c从自己账户中转出100个TOKEN
map[0x18850c9cE7B2274EbB0c78e6221844AC76715494][0xca35b7d915458ef540ade6068dfe2f44e8fa733c]=100
2.表示地址0x18850c9cE7B2274EbB0c78e6221844AC76715494允许地址0xxv3fe7d915458ef540ade6068dfe2f44e8fa34xb从自己账户中转出200个TOKEN
map[0x18850c9cE7B2274EbB0c78e6221844AC76715494][0xxv3fe7d915458ef540ade6068dfe2f44e8fa34xb]=200
请仔细参考以上讲解说明,此处均为被动转账模式。
3.5 定义构造函数
function BFCToken(string _name,string _symbol, uint8 _decimals,uint256 _totalSupply) public{
name = _name;
symbol = _symbol;
decimals = _decimals;
totalSupply = _totalSupply;
balances[msg.sender] = _totalSupply;
}
通过该函数定义部署Token合约时的传入参数
3.5 查询地址余额 balanceOf
function balanceOf(address _owner) public view returns (uint256 balance){
return balances[_owner];
}
查询指定地址拥有该Token的数量
3.5 主动转账 transfer
function transfer(address _to, uint256 _value)public returns (bool success){
require(_value >0 && balances[_to] + _value > balances[_to] && balances[msg.sender] > _value);
balances[_to] += _value;
balances[msg.sender] -= _value;
Transfer(msg.sender, _to,_value);
return true;
}
转账:从自己账户向地址
_to
地址转入_value
个Token
3.6 被动转账
- 实现转账批准:approve
function approve(address _spender, uint256 _value) returns (bool success){
//定义依赖条件,转账金额>0 并且 被转账户余额>转账金额
require(_value >0 && balances[msg.sender] > _value);
//将转账金额存入allowances集合中,对应关系可参考···3.4定义存储变量···
allowances[msg.sender][_spender] = _value;
//触发Approval事件
Approval(msg.sender,_spender,_value);
return true;
}
允许
_spender
从自己(合约调用方msg.sender)账户转走_value
个Token
- 实现批准金额查看:allowance
function allowance(address _owner, address _spender) view returns (uint256 remaining){
return allowances[_owner][_spender];
}
查询当前地址
_owner
(msg.sender)可以被_spender
地址多少个Token
- 创建被动转账函数:transferFrom
function transferFrom(address _from, address _to, uint256 _value) returns (bool success){
//取出本次当前地址对中允许转账的金额
uint256 allowan = allowances[_from][_to];
/*定义依赖条件:
1. 允许转账的金额 > 转出的金额
2. 转出方地址的余额>=转出的金额
3. 转入方地址务必是当前账户地址
4. 转入方转账后地址务必大于原来余额
*/
require(allowan > _value && balances[_from] >= _value && _to == msg.sender && balances[_to] + _value>balances[_to]);
//将本次转账金额从允许转账金额中扣除
allowances[_from][_to] -= _value;
//将本次转账金额从转出方余额中扣除
balances[_from] -= _value;
//将本次转账金额加入到转入方余额中
balances[_to] += _value;
//触发Transfer事件
Transfer(_from,_to,_value);
return true;
}
5.最终代码
pragma solidity ^0.4.24;
contract EIP20Interface{
//获取_owner地址的余额
function balanceOf(address _owner) public view returns (uint256 balance);
//转账:从自己账户向_to地址转入_value个Token
function transfer(address _to, uint256 _value)public returns (bool success);
//转账:从_from向_to转_value个Token
function transferFrom(address _from, address _to, uint256 _value) returns (bool success);
//允许_spender从自己(调用方)账户转走_value个Token
function approve(address _spender, uint256 _value) returns (bool success);
//自己_owner查询__spender地址可以转走自己多少个Token
function allowance(address _owner, address _spender) view returns (uint256 remaining);
//转账的时候必须要调用的时间,比如Tranfer,TransferFrom
event Transfer(address indexed _from, address indexed _to, uint256 _value);
//成功执行approve方法后调用的事件
event Approval(address indexed _owner, address indexed _spender, uint256 _value);
}
contract BFCToken is EIP20Interface {
//1.获取token名字,比如"BruceFeng Coin"
string public name;
//2.获取Token简称,比如"BFC"
string public symbol;
//3.获取小数位,比如以太坊的decimals为18
uint8 public decimals;
//4.获取token发布的总量,比如HT 5亿
uint256 public totalSupply;
mapping(address=>uint256) balances ;
mapping(address=>mapping(address=>uint256)) allowances;
function BFCToken(string _name,string _symbol, uint8 _decimals,uint256 _totalSupply) public{
name = _name;
symbol = _symbol;
decimals = _decimals;
totalSupply = _totalSupply;
balances[msg.sender] = _totalSupply;
}
//获取_owner地址的余额
function balanceOf(address _owner) public view returns (uint256 balance){
return balances[_owner];
}
//转账:从自己账户向_to地址转入_value个Token
function transfer(address _to, uint256 _value)public returns (bool success){
require(_value >0 && balances[_to] + _value > balances[_to] && balances[msg.sender] > _value);
balances[_to] += _value;
balances[msg.sender] -= _value;
Transfer(msg.sender, _to,_value);
return true;
}
//转账:从_from向_to转_value个Token
function transferFrom(address _from, address _to, uint256 _value) returns (bool success){
uint256 allowan = allowances[_from][_to];
require(allowan > _value && balances[_from] >= _value && _to == msg.sender && balances[_to] + _value>balances[_to]);
allowances[_from][_to] -= _value;
balances[_from] -= _value;
balances[_to] += _value;
Transfer(_from,_to,_value);
return true;
}
//允许_spender从自己(调用方)账户转走_value个Token
function approve(address _spender, uint256 _value) returns (bool success){
require(_value >0 && balances[msg.sender] > _value);
allowances[msg.sender][_spender] = _value;
Approval(msg.sender,_spender,_value);
return true;
}
//自己_owner查询_spender地址可以转走自己多少个Token
function allowance(address _owner, address _spender) view returns (uint256 remaining){
return allowances[_owner][_spender];
}
}
四. 代码调试测试
调试环境: JavaScript VM
1.代码调试
在将代码正式部署到以太坊测试网络前,我们先在JavaScript VM环境进行部署并进行调试
填写Token初始化参数,需要注意,发行总量=
_TotalSupply/_decimals
,此处发行了100000个BFC
直接获取参数值
部署成功
2.代码测试
为了读者能够理顺后面的地址之间的关系,此处先进行说明
- 部署合约的账户地址(下文简称为地址A):
0xca35b7d915458ef540ade6068dfe2f44e8fa733c
- 生成的Token合约地址(下文简称为地址B):
0xdc04977a2078c8ffdf086d618d1f961b6c546222
-
用于转入Token的账户地址(下文简称为地址C):
0x14723a09acff6d2a60dcdf7aa4aff308fddc160c
(可以从JavaScript VM环境的账户中选择一个即可)
在代码测试过程中,这几个地址千万不要混淆,地址B目前不需要使用,但部署到以太坊网络后需要使用,此处先作个铺垫。
2.1 查询账户余额
查询地址A的Token余额
查询地址B的Token余额
2.2 主动转账
注意点: 务必保证转账账户需要选择正确
从地址A给地址B转账1888个Token
查询地址B的Token余额
查询地址A的Token余额
地址B给地址A转账88个Token
查询地址B的Token余额
查询地址A的Token余额
2.3 被动转账
其实被动转账这个概念是笔者自己定义的,让别人从自己这边取走东西,可以简单理解为被动,如此定义只是为了讲解的方便。
- 创建批准规则
地址A允许地址B转走800个Token
- 查看批准金额数量
allowance中填写: "0xca35b7d915458ef540ade6068dfe2f44e8fa733c","0x14723a09acff6d2a60dcdf7aa4aff308fddc160c"
查看地址B能从地址A中转出多少Token
此时地址A与地址B的余额分别为
地址A: 0: uint256: balance 9999999999999999998200
地址B: 0: uint256: balance 1800
- 执行转账操作
地址B从地址A中转出230个Token,注意执行合约的Account是地址B
此时,地址B能从地址A中转出的Token余额为570,地址B的Token余额增加了230
此时,Token合约代码测试基本完毕,可以部署到以太坊网络中。
五.代码部署转账
测试环境:本文选择使用Rinkeby环境进行合约部署
1.代码部署
选择对应的测试环境跟账户,填写对应的Token合约参数,总量为10万
确认部署
部署确认
合约部署成功
获取合约配置参数,复制合约地址:
0x79334c31893ca7c59dd0bcf2a69189dd0db609c9
2.转账测试
2.1 添加Token到钱包中
点击ADD Token
将合约地址粘贴进去,Token合约的简称跟位数都会直接显示
添加即可
添加成功
2.2 转账至其他账户地址
以上部署合约的账户地址(地址1)为: 0x18850c9cE7B2274EbB0c78e6221844AC76715494
要转入Token的账户地址(地址2)为:0x139f46dCb8DAE14dE0aE3F98B298A73393b7Cc43
地址2的账户信息
点击SEND执行转账操作
填写转入地址为地址2
信息确认
交易提交成功
交易确认成功
2.3 转账成功确认
进入到地址2中进行Token添加
转账成功
读者可以继续执行转账测试工作,如从地址2转入地址1中。
六.学习用途申明
本文所有内容均为学习交流使用,各位技术人在进行技术研究以及业务落地的过程中切记以遵守国家法律为前提,合法开展相关技术支撑的业务与市场活动。
最后,祝大家学习愉快!
以上是关于深入解析Safe多签钱包智能合约:代理部署与核心合约的主要内容,如果未能解决你的问题,请参考以下文章
web3 产品介绍: safe --多签钱包 多人审批更放心