Solidity学习-可升级合约(Transparent/UUPS/Beacon)
以太坊合约原生并不支持升级,目前的升级一般是采用代理模式实现的。代理模式在代理模式中,有2个合约:Proxy和Implementation,用户总是和Proxy进行交互。Proxy在收到用户的调用请求后,并不执行自身的代码,而是通过delegatecall去执行Implementation合约的代码。delegatecall的特殊之处就是,它并不切换上下文,因此Implementation的代码所处理的存储空间是Proxy合约的存储空间,而非自己的。 Proxy合约里存储了Implementation合约的地址,这个地址是可修改的,当我们需要进行合约升级的时候,只需要重新部署一个新的Implementation合约,同时把Proxy合约中存储的地址改成新合约的地址就行了。升级前后,合约的存储空间没有任何变化(依然是Proxy的存储空间),地址也没有变化(依然是Proxy的地址),因此升级过程对用户完全透明。 合约升级之后,要保证storage变量的兼容性。新版本可以在旧版本的变量之后增加新的变量,但是不可以删除或者修改旧版本的变量。因为自始自终,变量都只存储在Proxy合约里,升...
用golang开发ethereum
之前看到一个用golang开发以太坊的教程 https://goethereumbook.org/zh/ 这个教程非常详细,然而它太陈旧了,目前很多go-ethereum函数接口已经有所修改。最明显的是,EIP1559之后,交易的格式的已经大不一样。因此,我基于上述教程,依据最新的go-ethereum(v1.10.26)对代码demo进行了修改。 用golang开发以太坊的一个好处是,可以很方便的查看和调试geth的源码,可以帮助我们更深入地理解以太坊的底层实现。 代码库为 https://github.com/CryptoRbtree/goeth-client 下面对主要功能做简单介绍。1 账户首先需要调用ethclient.DialContext连接一个rpc,这个rpc可以是外部服务商提供的公链rpc,也可以是本地区块链的。var ( ctx = context.Background() url = "https://eth-mainnet.g.alchemy.com/v2/" + os.Getenv("ALCHEMY_ID") client, err = ethclie...
Solidity学习-交易、转账、合约
1 交易transaction交易是一个以太坊账户发给另一个以太坊账户的指令,例如我给你转账1ETH,我mint某个NFT,这些都是交易。交易是以太坊上非常重要的概念,实际上以太坊就是一个交易驱动的状态机,我们发出并被成功执行的每一个交易都是在改变以太坊的状态。那么一个交易包含哪些内容呢?recipient:交易的接收地址。可以是外部账户地址,例如我给你转账,这里就会写上你的地址;也可以是合约账户地址,例如我mint某个NFT,这里就需要填写NFT的合约地址。signature:发送者的签名,确切地说是发送者的私钥对交易内容的签名。该内容保证以太坊的安全性,如果有人想要模仿你发起一笔交易,ta可以伪造交易中的其他任何内容,但唯独无法生成签名。从这里大家也可以看出,为什么在不可信的网站随意用钱包签名是一件危险的事情,虽然签名这一操作并不会上链,但攻击者可以利用你的签名内容来发起交易。value:发送给接收者的ETH,单位是WEI,1 ETH = 10e18 WEIdata(payload):二进制数据,本项是可选的,主要在跟合约账户交互的时候使用,后面会详述。gasLimit:交易...
Solidity学习-可升级合约(Transparent/UUPS/Beacon)
以太坊合约原生并不支持升级,目前的升级一般是采用代理模式实现的。代理模式在代理模式中,有2个合约:Proxy和Implementation,用户总是和Proxy进行交互。Proxy在收到用户的调用请求后,并不执行自身的代码,而是通过delegatecall去执行Implementation合约的代码。delegatecall的特殊之处就是,它并不切换上下文,因此Implementation的代码所处理的存储空间是Proxy合约的存储空间,而非自己的。 Proxy合约里存储了Implementation合约的地址,这个地址是可修改的,当我们需要进行合约升级的时候,只需要重新部署一个新的Implementation合约,同时把Proxy合约中存储的地址改成新合约的地址就行了。升级前后,合约的存储空间没有任何变化(依然是Proxy的存储空间),地址也没有变化(依然是Proxy的地址),因此升级过程对用户完全透明。 合约升级之后,要保证storage变量的兼容性。新版本可以在旧版本的变量之后增加新的变量,但是不可以删除或者修改旧版本的变量。因为自始自终,变量都只存储在Proxy合约里,升...
用golang开发ethereum
之前看到一个用golang开发以太坊的教程 https://goethereumbook.org/zh/ 这个教程非常详细,然而它太陈旧了,目前很多go-ethereum函数接口已经有所修改。最明显的是,EIP1559之后,交易的格式的已经大不一样。因此,我基于上述教程,依据最新的go-ethereum(v1.10.26)对代码demo进行了修改。 用golang开发以太坊的一个好处是,可以很方便的查看和调试geth的源码,可以帮助我们更深入地理解以太坊的底层实现。 代码库为 https://github.com/CryptoRbtree/goeth-client 下面对主要功能做简单介绍。1 账户首先需要调用ethclient.DialContext连接一个rpc,这个rpc可以是外部服务商提供的公链rpc,也可以是本地区块链的。var ( ctx = context.Background() url = "https://eth-mainnet.g.alchemy.com/v2/" + os.Getenv("ALCHEMY_ID") client, err = ethclie...
Solidity学习-交易、转账、合约
1 交易transaction交易是一个以太坊账户发给另一个以太坊账户的指令,例如我给你转账1ETH,我mint某个NFT,这些都是交易。交易是以太坊上非常重要的概念,实际上以太坊就是一个交易驱动的状态机,我们发出并被成功执行的每一个交易都是在改变以太坊的状态。那么一个交易包含哪些内容呢?recipient:交易的接收地址。可以是外部账户地址,例如我给你转账,这里就会写上你的地址;也可以是合约账户地址,例如我mint某个NFT,这里就需要填写NFT的合约地址。signature:发送者的签名,确切地说是发送者的私钥对交易内容的签名。该内容保证以太坊的安全性,如果有人想要模仿你发起一笔交易,ta可以伪造交易中的其他任何内容,但唯独无法生成签名。从这里大家也可以看出,为什么在不可信的网站随意用钱包签名是一件危险的事情,虽然签名这一操作并不会上链,但攻击者可以利用你的签名内容来发起交易。value:发送给接收者的ETH,单位是WEI,1 ETH = 10e18 WEIdata(payload):二进制数据,本项是可选的,主要在跟合约账户交互的时候使用,后面会详述。gasLimit:交易...
Subscribe to rbtree
Subscribe to rbtree
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers
以太坊的签名算法是ECDSA-secp256k1,以下介绍的每一种签名都是基于该算法,只是用来签名的数据不同。
以太坊上,签名之前的交易结构如下。
let transaction = {
to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
value: ethers.utils.parseEther('1'),
data: '0xE0A293E08F72454CEd99E1769c3ebd21fD2C20a1',
gasLimit: '22000',
maxFeePerGas: ethers.utils.parseUnits('20', 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits('5', 'gwei'),
nonce: 1,
type: 2,
chainId: chainId, // 31337
}
各项目的含义不再介绍,有兴趣可以查阅https://ethereum.org/en/developers/docs/transactions/
我们可以看到这里有to地址,但是没有from地址。from地址并不必要写出来,因为在私钥签名之后,根据签名和用来签名的数据就可以计算出私钥对应的地址(from地址)。
在ECDSA签名之前,我们需要先把上面的交易数据序列化。以太坊采用的序列化方式是RLP
https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/
rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data])
ether.js有专门的函数完成这个功能
let serializedTransaction = await ethers.utils.serializeTransaction(transaction)
对于上面的例子,我们序列化之后得到:
0x02f847827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c0
然后需要对serializedTransaction进行keccak256哈希,然后就可以用私钥做签名了。
本例中用来签名的账户为0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
privateKey=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
let signingKey = new ethers.utils.SigningKey(privateKey)
let hash = ethers.utils.keccak256(serializedTransaction)
let signature = await signingKey.signDigest(hash)
let { v, r, s } = signature
我们可以得到签名的signature,signature有3个部分: v(1字节),r(32字节),s(32字节)
上面的例子中,我们可以得到
hash=0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093
r: '0x0d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4c',
s: '0x73460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5',
v: 28
其实这个签名的过程我们也可以使用小狐狸钱包完成。我们需要把privateKey导入,为了得到一样的结果,我们需要切换网络到本地区块链。在网页中,F12打开console。输入如下命令:
hash="0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093"
ethereum.request({method:"eth_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)
我们看到小狐狸出现如下弹窗:

我们可以注意到红字的提醒。eth_sign是一个非常危险的操作,因为它是对交易的签名。如果大家在交互网站的时候发现弹出这样的签名,如果不懂千万不要随意签名。因为不怀好意的攻击者可能会拿着任何一个交易让你签名,这个交易可以做任何事情,例如可以把你钱包里的所有资金转走。
小狐狸签名的结果如下:

和我们使用ether.js算出的signature是一样的。
我们把r,s,v和之前的交易信息放在一起,就得到了raw_transaction.
rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, v, r, s])
不过这里有一个trick,需要把v的值从28改成1(如果v是27则改成0)。v值的定义,在以太坊上修改过2次,第一次是The Dao事件之后为了防范重放攻击做出的修改,第二次是EIP1559,具体参见
https://eips.ethereum.org/EIPS/eip-155
https://eips.ethereum.org/EIPS/eip-1559
于是我们得到
0x02f88a827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c001a00d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4ca073460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5
上面这串数字就是我们在交易的时候,最终需要广播给全网矿工的东西。
如果你用ether.js的话,还有一个更简单的把交易进行签名和序列化的函数,应该会得到一样的结果。
let wallet = new ethers.Wallet(privateKey)
let signedTransaction = await wallet.signTransaction(transaction)
在以太坊中,签名不仅仅只会发生在发送交易的时候。
有时候,某个网站可能需要确认我们是否是某个账号的主人,那么它会拿一条消息出来,让我们签名,以便它确认我们的身份。
也有的智能合约是支持元交易(meta transaction),这样的设计可以帮助我们节省gas费。假设某个ERC20代币合约支持这样的交易,那么可能会有这样的操作。我打算给Bob付一笔钱,但是我不想支付gas费,那么我可以线下做个签名,让Bob拿着我的线下签名自己去发起交易领取。在智能合约的逻辑中,会通过ecrecover判断我签名的真实性,如果是真实有效的,Bob就能从我的账户里拿走钱财。
这样的设计虽然给我们带来了方便,但是也给我们带来了风险,如果在攻击者的诱导下,用我们的账号给支持这种功能的合约做了签名,那么我们的财产就不保了。opensea上发生的一些NFT零元购事件就是这样的原因https://mp.weixin.qq.com/s/XF1Zo_wWmrjRYsHykvR2Eg
下面我们具体看一下personal_sign,这个签名的标准在EIP-191里有描述
https://eips.ethereum.org/EIPS/eip-191
"\x19Ethereum Signed Message:\n" + len(message) + message
对上面的字符串进行keccak256,然后再用私钥签名即可。
我们可以看到有一个前缀“\x19Ethereum Signed Message:\n”,这就保证了这样消息不可能是第一节说的那种危险的交易签名,一定程度上提高了安全性。
我们依然用第1节里的账号进行实验,这次我们签名的消息是“I will pay Bob 1 ETH.”
我们可以得到签名的结果为:
r: '0xd82e945511655405916227caa6f22e4c886676ee94a24d6919809abc4006e1e5',
s: '0x27dc36778b38306c47c2102aab5e294bc0bc9f73be4d0c97a4762f5b37338136',
v: 28
同样这个功能小狐狸钱包实现:
message="I will pay Bob 1 ETH."
ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, message]}).then(console.log)


实际上ether.js有一个更方便的函数:
let message = "I will pay Bob 1 ETH.";
let messageBytes = ethers.utils.toUtf8Bytes(message);
let sig = await signer.signMessage(messageBytes);
上面说的是对整个消息签名,不过有时候我们的消息会很长。我们在链上验证的时候,是需要把整个消息都传上去的,这就会有点不方便且会消耗更多gas。所有有些项目是对消息做了一次keccak256哈希,然后把hash作为消息再处理。因为hash过后固定为32字节,所以需要上传的消息也就固定为32字节。
我们仍然拿上面的message="I will pay Bob 1 ETH."作为例子,我们对这个消息进行keccak256哈希,得到0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52
消息前缀的添加方式和之前一样,只是消息长度固定为32字节了。
"\x19Ethereum Signed Message:\n32" + message
let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
let privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
let signingKey = new ethers.utils.SigningKey(privateKey);
let prefix = "\x19Ethereum Signed Message:\n32";
let hash = ethers.utils.keccak256(ethers.utils.solidityPack(
["string", "bytes32"],
[prefix, messageHash]));
let sig = await signingKey.signDigest(hash);
我们可以得到签名的结果为:
r: '0xedd6c4704b1ac676c8f13f96e54ae2dcb76d942f7bfaea178aaeaeff4033c2c0',
s: '0x2d9b6511fc1d9948fa8f5261ee290f507d25d4b2c0378cd316c1ccbc25d24bf7',
v: 27
同样我们也可以用小狐狸来完成签名:
hash="0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52"
ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)


ether.js签名函数也可以实现这个功能:
let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
let sig = await signer.signMessage(ethers.utils.arrayify(messageHash));
这种签名方式其实是更加危险的,因为hash后的数据已经不可读了,当钱包弹出这样一个签名的时候,我们无从判断到底在签名什么。
第2节中介绍的message并没有固定的格式,每个项目可以自行签名的标准。但是这只适合简单的情形,例如验证账户拥有者,此时签名的消息内容如何是无关紧要的。但是如果是涉及代币、NFT资产的转移,就需要比较小心了,如果设计不好很容易被攻击者利用。
于是发展出了标准的结构化签名标准EIP-712
https://eips.ethereum.org/EIPS/eip-712
这种签名的消息格式为:
"\x19\x01" + domainSeparator + structHash
其中domainSeprator是域信息,有这样一些可选项目:域名称,版本号,链ID,合约地址,盐值。
structHash是消息的具体信息,是一个自定义的结构类型名称 + 一系列的自定义参数。
我们来看一下如何用代码实现,这个代码模拟了一个代币Permit。
const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const chainId = await signer.getChainId(); // 31337
const nonce = 0;
const name = "Gold";
const version = "1";
const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = 1000;
const deadline = 100;
const abi = ethers.utils.defaultAbiCoder;
const _PERMIT_TYPEHASH = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"));
const structHash = ethers.utils.keccak256(abi.encode(
["bytes32", "address", "address", "uint256", "uint256", "uint256"],
[_PERMIT_TYPEHASH, signer.address, spender, value, nonce, deadline]));
const typeHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
const nameHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(name));
const versionHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(version));
const domainSeparator = ethers.utils.keccak256(abi.encode(
["bytes32", "bytes32", "bytes32", "uint256", "address"],
[typeHash, nameHash, versionHash, chainId, token]
));
const typedDataHash = ethers.utils.keccak256(ethers.utils.solidityPack(
["string", "bytes32", "bytes32"],
["\x19\x01", domainSeparator, structHash]));
const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const signingKey = new ethers.utils.SigningKey(privateKey); // without prefix "\x19Ethereum Signed Message:\n32"
const signature = await signingKey.signDigest(typedDataHash);
最终结果:
r: '0x967cf9274943d2048f132ca12e491bd730d00a5ddae5fad3979d8fa535a17d05',
s: '0x3f931eb53f53d7fe38f25a08157c14585903304850d334fb79854cc88943f435',
v: 27
我们用小狐狸来做一下交叉验证,
let domain = [
{name: "name", type: "string" },
{name: "version", type: "string"},
{name: "chainId", type: "uint256"},
{name: "verifyingContract", type: "address"}
]
let domainData = {
name: "Gold",
version: "1",
chainId: ethereum.chainId,
verifyingContract: "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1"
}
let permit = [
{"name": 'owner', "type": 'address'},
{"name": 'spender', "type": 'address'},
{"name": 'value', "type": 'uint256'},
{"name": 'nonce', "type": 'uint256'},
{"name": 'deadline', "type": 'uint256'},
]
let message = {
owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
spender: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
value: 1000,
nonce: 0,
deadline: 100
}
let eip712TypedData = {
types: {
EIP712Domain: domain,
Permit: permit
},
domain: domainData,
primaryType: "Permit",
message: message
}
let data = JSON.stringify(eip712TypedData)
ethereum.request({method:"eth_signTypedData_v4", params: [ethereum.selectedAddress, data]}).then(console.log)


对于这种EIP-712格式的签名,ether.js当然本身就有相应的函数。上面写了那么多代码只是想要让大家直观看一下EIP-712的消息是如何组织的。
const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const chainId = await signer.getChainId(); // 31337
const nonce = 0;
const name = "Gold";
const version = "1";
const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = 1000;
const deadline = 100;
let sig = ethers.utils.splitSignature(
await signer._signTypedData(
{
name,
version,
chainId,
verifyingContract: token
},
{
Permit: [
{
name: "owner",
type: "address",
},
{
name: "spender",
type: "address",
},
{
name: "value",
type: "uint256",
},
{
name: "nonce",
type: "uint256",
},
{
name: "deadline",
type: "uint256",
},
],
},
{
owner: signer.address,
spender,
value,
nonce,
deadline,
}
)
)
目前大部分项目的签名都是这种EIP-712格式的签名,它的优点就是最大程度地做到了“所见即所签”,如果对代币和NFT比较熟悉的朋友一眼就能看出这个签名是在做什么样的授权。
不过这个签名本身并不能保证安全性,还是需要用户对这些消息内容有一定程度的了解。例如攻击者故意让你做一个NFT合约的签名,依然可能会让很多小白用户在不知不觉中丢失资产。
以太坊的签名算法是ECDSA-secp256k1,以下介绍的每一种签名都是基于该算法,只是用来签名的数据不同。
以太坊上,签名之前的交易结构如下。
let transaction = {
to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
value: ethers.utils.parseEther('1'),
data: '0xE0A293E08F72454CEd99E1769c3ebd21fD2C20a1',
gasLimit: '22000',
maxFeePerGas: ethers.utils.parseUnits('20', 'gwei'),
maxPriorityFeePerGas: ethers.utils.parseUnits('5', 'gwei'),
nonce: 1,
type: 2,
chainId: chainId, // 31337
}
各项目的含义不再介绍,有兴趣可以查阅https://ethereum.org/en/developers/docs/transactions/
我们可以看到这里有to地址,但是没有from地址。from地址并不必要写出来,因为在私钥签名之后,根据签名和用来签名的数据就可以计算出私钥对应的地址(from地址)。
在ECDSA签名之前,我们需要先把上面的交易数据序列化。以太坊采用的序列化方式是RLP
https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/
rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data])
ether.js有专门的函数完成这个功能
let serializedTransaction = await ethers.utils.serializeTransaction(transaction)
对于上面的例子,我们序列化之后得到:
0x02f847827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c0
然后需要对serializedTransaction进行keccak256哈希,然后就可以用私钥做签名了。
本例中用来签名的账户为0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
privateKey=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
let signingKey = new ethers.utils.SigningKey(privateKey)
let hash = ethers.utils.keccak256(serializedTransaction)
let signature = await signingKey.signDigest(hash)
let { v, r, s } = signature
我们可以得到签名的signature,signature有3个部分: v(1字节),r(32字节),s(32字节)
上面的例子中,我们可以得到
hash=0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093
r: '0x0d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4c',
s: '0x73460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5',
v: 28
其实这个签名的过程我们也可以使用小狐狸钱包完成。我们需要把privateKey导入,为了得到一样的结果,我们需要切换网络到本地区块链。在网页中,F12打开console。输入如下命令:
hash="0xdd54f25d0bbeae343a1dec3acce8dc5b3a0886bdb9c5e667f65d5bbbe11bc093"
ethereum.request({method:"eth_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)
我们看到小狐狸出现如下弹窗:

我们可以注意到红字的提醒。eth_sign是一个非常危险的操作,因为它是对交易的签名。如果大家在交互网站的时候发现弹出这样的签名,如果不懂千万不要随意签名。因为不怀好意的攻击者可能会拿着任何一个交易让你签名,这个交易可以做任何事情,例如可以把你钱包里的所有资金转走。
小狐狸签名的结果如下:

和我们使用ether.js算出的signature是一样的。
我们把r,s,v和之前的交易信息放在一起,就得到了raw_transaction.
rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, v, r, s])
不过这里有一个trick,需要把v的值从28改成1(如果v是27则改成0)。v值的定义,在以太坊上修改过2次,第一次是The Dao事件之后为了防范重放攻击做出的修改,第二次是EIP1559,具体参见
https://eips.ethereum.org/EIPS/eip-155
https://eips.ethereum.org/EIPS/eip-1559
于是我们得到
0x02f88a827a690185012a05f2008504a817c8008255f09470997970c51812dc3a010c7d01b50e0d17dc79c8881bc16d674ec8000094e0a293e08f72454ced99e1769c3ebd21fd2c20a1c001a00d254c2662f6b1db272520fdb6b64d2402849058e89deab6c64d3b6cf2ebea4ca073460b644f90d5ccc59b3737f6e514b4ff91053756846bfbff16fa38d017a1b5
上面这串数字就是我们在交易的时候,最终需要广播给全网矿工的东西。
如果你用ether.js的话,还有一个更简单的把交易进行签名和序列化的函数,应该会得到一样的结果。
let wallet = new ethers.Wallet(privateKey)
let signedTransaction = await wallet.signTransaction(transaction)
在以太坊中,签名不仅仅只会发生在发送交易的时候。
有时候,某个网站可能需要确认我们是否是某个账号的主人,那么它会拿一条消息出来,让我们签名,以便它确认我们的身份。
也有的智能合约是支持元交易(meta transaction),这样的设计可以帮助我们节省gas费。假设某个ERC20代币合约支持这样的交易,那么可能会有这样的操作。我打算给Bob付一笔钱,但是我不想支付gas费,那么我可以线下做个签名,让Bob拿着我的线下签名自己去发起交易领取。在智能合约的逻辑中,会通过ecrecover判断我签名的真实性,如果是真实有效的,Bob就能从我的账户里拿走钱财。
这样的设计虽然给我们带来了方便,但是也给我们带来了风险,如果在攻击者的诱导下,用我们的账号给支持这种功能的合约做了签名,那么我们的财产就不保了。opensea上发生的一些NFT零元购事件就是这样的原因https://mp.weixin.qq.com/s/XF1Zo_wWmrjRYsHykvR2Eg
下面我们具体看一下personal_sign,这个签名的标准在EIP-191里有描述
https://eips.ethereum.org/EIPS/eip-191
"\x19Ethereum Signed Message:\n" + len(message) + message
对上面的字符串进行keccak256,然后再用私钥签名即可。
我们可以看到有一个前缀“\x19Ethereum Signed Message:\n”,这就保证了这样消息不可能是第一节说的那种危险的交易签名,一定程度上提高了安全性。
我们依然用第1节里的账号进行实验,这次我们签名的消息是“I will pay Bob 1 ETH.”
我们可以得到签名的结果为:
r: '0xd82e945511655405916227caa6f22e4c886676ee94a24d6919809abc4006e1e5',
s: '0x27dc36778b38306c47c2102aab5e294bc0bc9f73be4d0c97a4762f5b37338136',
v: 28
同样这个功能小狐狸钱包实现:
message="I will pay Bob 1 ETH."
ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, message]}).then(console.log)


实际上ether.js有一个更方便的函数:
let message = "I will pay Bob 1 ETH.";
let messageBytes = ethers.utils.toUtf8Bytes(message);
let sig = await signer.signMessage(messageBytes);
上面说的是对整个消息签名,不过有时候我们的消息会很长。我们在链上验证的时候,是需要把整个消息都传上去的,这就会有点不方便且会消耗更多gas。所有有些项目是对消息做了一次keccak256哈希,然后把hash作为消息再处理。因为hash过后固定为32字节,所以需要上传的消息也就固定为32字节。
我们仍然拿上面的message="I will pay Bob 1 ETH."作为例子,我们对这个消息进行keccak256哈希,得到0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52
消息前缀的添加方式和之前一样,只是消息长度固定为32字节了。
"\x19Ethereum Signed Message:\n32" + message
let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
let privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
let signingKey = new ethers.utils.SigningKey(privateKey);
let prefix = "\x19Ethereum Signed Message:\n32";
let hash = ethers.utils.keccak256(ethers.utils.solidityPack(
["string", "bytes32"],
[prefix, messageHash]));
let sig = await signingKey.signDigest(hash);
我们可以得到签名的结果为:
r: '0xedd6c4704b1ac676c8f13f96e54ae2dcb76d942f7bfaea178aaeaeff4033c2c0',
s: '0x2d9b6511fc1d9948fa8f5261ee290f507d25d4b2c0378cd316c1ccbc25d24bf7',
v: 27
同样我们也可以用小狐狸来完成签名:
hash="0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52"
ethereum.request({method:"personal_sign", params: [ethereum.selectedAddress, hash]}).then(console.log)


ether.js签名函数也可以实现这个功能:
let messageHash = "0x22658f1dab0720e8bf599551cc6dd75230ed4efefc7f6694f0b389e0807a0e52";
let sig = await signer.signMessage(ethers.utils.arrayify(messageHash));
这种签名方式其实是更加危险的,因为hash后的数据已经不可读了,当钱包弹出这样一个签名的时候,我们无从判断到底在签名什么。
第2节中介绍的message并没有固定的格式,每个项目可以自行签名的标准。但是这只适合简单的情形,例如验证账户拥有者,此时签名的消息内容如何是无关紧要的。但是如果是涉及代币、NFT资产的转移,就需要比较小心了,如果设计不好很容易被攻击者利用。
于是发展出了标准的结构化签名标准EIP-712
https://eips.ethereum.org/EIPS/eip-712
这种签名的消息格式为:
"\x19\x01" + domainSeparator + structHash
其中domainSeprator是域信息,有这样一些可选项目:域名称,版本号,链ID,合约地址,盐值。
structHash是消息的具体信息,是一个自定义的结构类型名称 + 一系列的自定义参数。
我们来看一下如何用代码实现,这个代码模拟了一个代币Permit。
const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const chainId = await signer.getChainId(); // 31337
const nonce = 0;
const name = "Gold";
const version = "1";
const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = 1000;
const deadline = 100;
const abi = ethers.utils.defaultAbiCoder;
const _PERMIT_TYPEHASH = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"));
const structHash = ethers.utils.keccak256(abi.encode(
["bytes32", "address", "address", "uint256", "uint256", "uint256"],
[_PERMIT_TYPEHASH, signer.address, spender, value, nonce, deadline]));
const typeHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
const nameHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(name));
const versionHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(version));
const domainSeparator = ethers.utils.keccak256(abi.encode(
["bytes32", "bytes32", "bytes32", "uint256", "address"],
[typeHash, nameHash, versionHash, chainId, token]
));
const typedDataHash = ethers.utils.keccak256(ethers.utils.solidityPack(
["string", "bytes32", "bytes32"],
["\x19\x01", domainSeparator, structHash]));
const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const signingKey = new ethers.utils.SigningKey(privateKey); // without prefix "\x19Ethereum Signed Message:\n32"
const signature = await signingKey.signDigest(typedDataHash);
最终结果:
r: '0x967cf9274943d2048f132ca12e491bd730d00a5ddae5fad3979d8fa535a17d05',
s: '0x3f931eb53f53d7fe38f25a08157c14585903304850d334fb79854cc88943f435',
v: 27
我们用小狐狸来做一下交叉验证,
let domain = [
{name: "name", type: "string" },
{name: "version", type: "string"},
{name: "chainId", type: "uint256"},
{name: "verifyingContract", type: "address"}
]
let domainData = {
name: "Gold",
version: "1",
chainId: ethereum.chainId,
verifyingContract: "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1"
}
let permit = [
{"name": 'owner', "type": 'address'},
{"name": 'spender', "type": 'address'},
{"name": 'value', "type": 'uint256'},
{"name": 'nonce', "type": 'uint256'},
{"name": 'deadline', "type": 'uint256'},
]
let message = {
owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
spender: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
value: 1000,
nonce: 0,
deadline: 100
}
let eip712TypedData = {
types: {
EIP712Domain: domain,
Permit: permit
},
domain: domainData,
primaryType: "Permit",
message: message
}
let data = JSON.stringify(eip712TypedData)
ethereum.request({method:"eth_signTypedData_v4", params: [ethereum.selectedAddress, data]}).then(console.log)


对于这种EIP-712格式的签名,ether.js当然本身就有相应的函数。上面写了那么多代码只是想要让大家直观看一下EIP-712的消息是如何组织的。
const [signer] = await ethers.getSigners(); // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
const chainId = await signer.getChainId(); // 31337
const nonce = 0;
const name = "Gold";
const version = "1";
const token = "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1";
const spender = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const value = 1000;
const deadline = 100;
let sig = ethers.utils.splitSignature(
await signer._signTypedData(
{
name,
version,
chainId,
verifyingContract: token
},
{
Permit: [
{
name: "owner",
type: "address",
},
{
name: "spender",
type: "address",
},
{
name: "value",
type: "uint256",
},
{
name: "nonce",
type: "uint256",
},
{
name: "deadline",
type: "uint256",
},
],
},
{
owner: signer.address,
spender,
value,
nonce,
deadline,
}
)
)
目前大部分项目的签名都是这种EIP-712格式的签名,它的优点就是最大程度地做到了“所见即所签”,如果对代币和NFT比较熟悉的朋友一眼就能看出这个签名是在做什么样的授权。
不过这个签名本身并不能保证安全性,还是需要用户对这些消息内容有一定程度的了解。例如攻击者故意让你做一个NFT合约的签名,依然可能会让很多小白用户在不知不觉中丢失资产。
No activity yet