ERC191是以太坊上的一个代币标准提案,全称是"EthereumRequestforComment191"。
ERC191是以太坊上的一个代币标准提案,全称是"Ethereum Request for Comment 191"。这个标准主要用于解决地址编码的问题,特别是在处理不同长度的地址时。
signed_data = 0x19 <version> <version specific data> <data to sign>
0x19
: 用于表示以太坊签名;0x45
: 用于表示 Ethereum Signed Message (标准消息签名);v
, r
, s
值,这些是标准的 ECDSA 签名值。0x19
前缀与实际消息数据拼接在一起。v
, r
, s
值。ecrecover
函数,根据 v
, r
, s
值和消息的哈希值,恢复出签名者的地址。下面是一个使用 Solidity 编写的示例合约,用于验证 ERC191 签名:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ERC191Verifier {
function getMessageHash(string memory _message) public pure returns (bytes32) {
return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", uint2str(bytes(_message).length), _message));
}
function verify(
address _signer,
string memory _message,
uint8 _v,
bytes32 _r,
bytes32 _s
) public pure returns (bool) {
bytes32 messageHash = getMessageHash(_message);
return recoverSigner(messageHash, _v, _r, _s) == _signer;
}
function recoverSigner(
bytes32 _messageHash,
uint8 _v,
bytes32 _r,
bytes32 _s
) public pure returns (address) {
return ecrecover(_messageHash, _v, _r, _s);
}
function uint2str(uint _i) internal pure returns (string memory _uintAsString) {
if (_i == 0) {
return "0";
}
uint j = _i;
uint len;
while (j != 0) {
len++;
j /= 10;
}
bytes memory bstr = new bytes(len);
uint k = len;
while (_i != 0) {
k = k-1;
uint8 temp = (48 + uint8(_i - _i / 10 * 10));
bytes1 b1 = bytes1(temp);
bstr[k] = b1;
_i /= 10;
}
return string(bstr);
}
}
在这个示例中:
getMessageHash
函数生成带有 0x19
前缀的消息哈希。verify
函数用于验证签名是否正确。recoverSigner
函数通过 ecrecover
恢复出签名者的地址。uint2str
函数将 uint
类型转换为字符串,用于生成哈希。通过这种方式,你可以在智能合约中验证 ERC191 标准的签名。
在实际应用中,签名是在客户端(例如使用 Web3.js 或 Ethers.js)生成的。下面我们演示整个步骤。
可以使用typescript来实现同样的功能,需要借助ethers库:
import { ethers } from 'ethers';
async function manualERC191Sign() {
console.log('ethers version:', ethers.version);
console.log('-------------------------------------------------------------');
// 1. 创建一个随机钱包(在实际应用中,你会使用真实的私钥)
const wallet = ethers.Wallet.createRandom();
// 2. 定义要签名的消息
const message = 'helloWorld';
// 3. 构造ERC191消息
// 3.1 计算消息长度
const messageLength = ethers.toUtf8Bytes(message).length;
// 3.2 构造前缀
const prefix = '\x19Ethereum Signed Message:\n' + messageLength;
// 3.3 将前缀和消息拼接
const prefixedMessage = prefix + message;
console.log('前缀化消息:', prefixedMessage);
// 4. 对拼接后的消息进行Keccak-256哈希
const messageHash = ethers.keccak256(ethers.toUtf8Bytes(prefixedMessage));
console.log('消息哈希:', messageHash);
// 5. 使用私钥对哈希进行签名
const signature = await wallet.signMessage(message);
console.log('ERC191签名:', signature);
// 6. 验证签名
const recoveredAddress = ethers.verifyMessage(message, signature);
console.log('签名地址:', wallet.address);
console.log('恢复的地址:', recoveredAddress);
console.log('签名验证:', recoveredAddress === wallet.address);
// 7. 手动签名
const msgHash = ethers.hashMessage(message);
const signingKey = new ethers.SigningKey(wallet.privateKey);
const signatureObject = signingKey.sign(msgHash);
const manualSignature = ethers.Signature.from(signatureObject).serialized;
console.log('手动生成的签名:', manualSignature);
console.log('签名匹配:', manualSignature === signature);
// 创建一个 Signature 对象
const sig = ethers.Signature.from(signature);
console.log('r:', sig.r);
console.log('s:', sig.s);
console.log('v:', sig.v);
// 如果需要十六进制字符串形式,可以这样转换:
console.log('r (hex):', ethers.hexlify(sig.r));
console.log('s (hex):', ethers.hexlify(sig.s));
console.log('v (hex):', `0x${sig.v.toString(16)}`); // 直接转换为十六进制字符串
}
export default manualERC191Sign;
const messageLength = ethers.toUtf8Bytes(message).length;
将字符串转换为UTF-8字节数组(通过 ethers.toUtf8Bytes())有几个重要原因:
<!---->
<!---->
<!---->
<!---->
<!---->
一个简单的示例:
const message = "Hello, 世界!"; // 混合 ASCII 和 Unicode 字符
const messageBytes = ethers.toUtf8Bytes(message);
console.log(messageBytes.length); // 14 (而不是 10,因为 "世界" 各占3个字节)
在这个例子中,如果我们只计算字符数,结果会是10。但实际的 UTF-8 字节长度是 14,因为中文字符每个占用 3 个字节。
// 4. 对拼接后的消息进行Keccak-256哈希
const messageHash = ethers.keccak256(ethers.toUtf8Bytes(prefixedMessage));
我们发现在第4个步骤的时候,对于我们拼接好的数据:prefixedMessage
,再次使用了ethers.toUtf8Bytes
进行了数据类型的转换,然后才调用 ethers.keccak256
进行hash运算。这个哈希函数需要字节数组作为输入。将 prefixedMessage
转换为 UTF-8 字节确保了整个消息(包括前缀)都被一致地编码,无论原始消息或前缀中包含什么字符。通过将整个 prefixedMessage
转换为字节,我们确保了在计算哈希时不会出现任何歧义或编码问题。
这部分的具体实现,在手动签名的部分会分步骤演示:
const signature = await wallet.signMessage(message);
// 7. 手动签名
const msgHash = ethers.hashMessage(message);
const signingKey = new ethers.SigningKey(wallet.privateKey);
const signatureObject = signingKey.sign(msgHash);
const manualSignature = ethers.Signature.from(signatureObject).serialized;
const msgHash = ethers.hashMessage(message);
我们首先看hashMessage
这个函数,我们打开源码看它的相关实现:
export function hashMessage(message: Uint8Array | string): string {
if (typeof(message) === "string") { message = toUtf8Bytes(message); }
return keccak256(concat([
toUtf8Bytes(MessagePrefix),
toUtf8Bytes(String(message.length)),
message
]));
}
其中:MessagePrefix,在 ethers
库中的定义是这样的:
/**
* A constant for the [[link-eip-191]] personal message prefix.
*
* (**i.e.** ``"\x19Ethereum Signed Message:\n"``)
*/
export const MessagePrefix: string = "\x19Ethereum Signed Message:\n";
concat
函数是这样定义的:
/**
* Returns a [[DataHexString]] by concatenating all values
* within %%data%%.
*/
export function concat(datas: ReadonlyArray<BytesLike>): string {
return "0x" + datas.map((d) => hexlify(d).substring(2)).join("");
}
这个函数会自动添加以太坊特定的前缀("\x19Ethereum Signed Message:\n"),然后计算整个预处理后消息的 Keccak256 哈希。这是为了确保签名的安全性,防止签名被用于其他目的。
这一步创建了一个 SigningKey 对象,它封装了与私钥相关的签名功能。wallet.privateKey 是钱包的私钥。
我们看下SigningKey
这个class的源码实现,初始化的时候,需要传入一个私钥,这样创建的对象就可以用用们传入的私钥进行签名操作了。
export class SigningKey {
#privateKey: string;
constructor(privateKey: BytesLike) {
assertArgument(dataLength(privateKey) === 32, "invalid private key", "privateKey", "[REDACTED]");
this.#privateKey = hexlify(privateKey);
}
get privateKey(): string { return this.#privateKey; }
get publicKey(): string { return SigningKey.computePublicKey(this.#privateKey); }
get compressedPublicKey(): string { return SigningKey.computePublicKey(this.#privateKey, true); }
sign(digest: BytesLike): Signature {
assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest);
const sig = secp256k1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), {
lowS: true
});
return Signature.from({
r: toBeHex(sig.r, 32),
s: toBeHex(sig.s, 32),
v: (sig.recovery ? 0x1c: 0x1b)
});
}
computeSharedSecret(other: BytesLike): string {
const pubKey = SigningKey.computePublicKey(other);
return hexlify(secp256k1.getSharedSecret(getBytesCopy(this.#privateKey), getBytes(pubKey), false));
}
static computePublicKey(key: BytesLike, compressed?: boolean): string {
let bytes = getBytes(key, "key");
// private key
if (bytes.length === 32) {
const pubKey = secp256k1.getPublicKey(bytes, !!compressed);
return hexlify(pubKey);
}
// raw public key; use uncompressed key with 0x04 prefix
if (bytes.length === 64) {
const pub = new Uint8Array(65);
pub[0] = 0x04;
pub.set(bytes, 1);
bytes = pub;
}
const point = secp256k1.ProjectivePoint.fromHex(bytes);
return hexlify(point.toRawBytes(compressed));
}
static recoverPublicKey(digest: BytesLike, signature: SignatureLike): string {
assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest);
const sig = Signature.from(signature);
let secpSig = secp256k1.Signature.fromCompact(getBytesCopy(concat([ sig.r, sig.s ])));
secpSig = secpSig.addRecoveryBit(sig.yParity);
const pubKey = secpSig.recoverPublicKey(getBytesCopy(digest));
assertArgument(pubKey != null, "invalid signautre for digest", "signature", signature);
return "0x" + pubKey.toHex(false);
}
static addPoints(p0: BytesLike, p1: BytesLike, compressed?: boolean): string {
const pub0 = secp256k1.ProjectivePoint.fromHex(SigningKey.computePublicKey(p0).substring(2));
const pub1 = secp256k1.ProjectivePoint.fromHex(SigningKey.computePublicKey(p1).substring(2));
return "0x" + pub0.add(pub1).toHex(!!compressed)
}
}
使用 SigningKey 对象的 sign 方法来签署消息哈希。这个方法使用 ECDSA(椭圆曲线数字签名算法)来生成签名。返回的 signatureObject 包含签名的各个组成部分(r, s, v)。
源码实现上,其实是调用的这个sign
方法:
sign(digest: BytesLike): Signature {
assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest);
const sig = secp256k1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), {
lowS: true
});
return Signature.from({
r: toBeHex(sig.r, 32),
s: toBeHex(sig.s, 32),
v: (sig.recovery ? 0x1c: 0x1b)
});
}
const manualSignature = ethers.Signature.from(signatureObject).serialized;
最后,我们使用 ethers.Signature.from 方法将签名对象转换为 Signature 实例,然后通过 .serialized 属性获取序列化的签名字符串。这个字符串是一个65字节的十六进制字符串,包含 r、s 和 v 值。
static from(sig?: SignatureLike): Signature {
function assertError(check: unknown, message: string): asserts check {
assertArgument(check, message, "signature", sig);
};
if (sig == null) {
return new Signature(_guard, ZeroHash, ZeroHash, 27);
}
if (typeof(sig) === "string") {
const bytes = getBytes(sig, "signature");
if (bytes.length === 64) {
const r = hexlify(bytes.slice(0, 32));
const s = bytes.slice(32, 64);
const v = (s[0] & 0x80) ? 28: 27;
s[0] &= 0x7f;
return new Signature(_guard, r, hexlify(s), v);
}
if (bytes.length === 65) {
const r = hexlify(bytes.slice(0, 32));
const s = bytes.slice(32, 64);
assertError((s[0] & 0x80) === 0, "non-canonical s");
const v = Signature.getNormalizedV(bytes[64]);
return new Signature(_guard, r, hexlify(s), v);
}
assertError(false, "invalid raw signature length");
}
if (sig instanceof Signature) { return sig.clone(); }
// Get r
const _r = sig.r;
assertError(_r != null, "missing r");
const r = toUint256(_r);
// Get s; by any means necessary (we check consistency below)
const s = (function(s?: string, yParityAndS?: string) {
if (s != null) { return toUint256(s); }
if (yParityAndS != null) {
assertError(isHexString(yParityAndS, 32), "invalid yParityAndS");
const bytes = getBytes(yParityAndS);
bytes[0] &= 0x7f;
return hexlify(bytes);
}
assertError(false, "missing s");
})(sig.s, sig.yParityAndS);
assertError((getBytes(s)[0] & 0x80) == 0, "non-canonical s");
// Get v; by any means necessary (we check consistency below)
const { networkV, v } = (function(_v?: BigNumberish, yParityAndS?: string, yParity?: Numeric): { networkV?: bigint, v: 27 | 28 } {
if (_v != null) {
const v = getBigInt(_v);
return {
networkV: ((v >= BN_35) ? v: undefined),
v: Signature.getNormalizedV(v)
};
}
if (yParityAndS != null) {
assertError(isHexString(yParityAndS, 32), "invalid yParityAndS");
return { v: ((getBytes(yParityAndS)[0] & 0x80) ? 28: 27) };
}
if (yParity != null) {
switch (getNumber(yParity, "sig.yParity")) {
case 0: return { v: 27 };
case 1: return { v: 28 };
}
assertError(false, "invalid yParity");
}
assertError(false, "missing v");
})(sig.v, sig.yParityAndS, sig.yParity);
const result = new Signature(_guard, r, s, v);
if (networkV) { result.#networkV = networkV; }
// If multiple of v, yParity, yParityAndS we given, check they match
assertError(sig.yParity == null || getNumber(sig.yParity, "sig.yParity") === result.yParity, "yParity mismatch");
assertError(sig.yParityAndS == null || sig.yParityAndS === result.yParityAndS, "yParityAndS mismatch");
return result;
}
这个手动过程实际上模拟了 wallet.signMessage 方法内部的工作原理。主要区别在于 wallet.signMessage 是一个高级方法,它在内部处理了所有这些步骤,而手动方法让您可以更细粒度地控制签名过程。
wallet.signMessage(message) 是一个简单的高级方法,内部处理所有细节。手动方法让您可以分步执行过程,可能用于特殊情况或更深入的理解。在大多数情况下,使用 wallet.signMessage 就足够了,因为它更简单且不容易出错。但是,理解手动过程对于深入了解以太坊签名机制很有帮助,特别是在需要自定义签名过程或进行底层操作时。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!