Skip to content

EIP-712的"结构化革命":让签名变得人类友好

EIP-712革命

从"天书"到"白话文"

还记得上一篇我们聊的EIP-191吗?它虽然解决了签名标准化的问题,但还有一个严重的痛点:用户根本不知道自己在签什么!

想象一下这样的场景:你打开MetaMask,屏幕上显示的是一串像天书一样的十六进制字符串:

text
0x1901f2d857b4a3edcb9731cdf7edb72424f123456789abcdef...
0x1901f2d857b4a3edcb9731cdf7edb72424f123456789abcdef...

然后钱包弹出提示:"请签名确认"。

你敢签吗?这就像有人递给你一份全是乱码的合同说"来,签个字"一样——谁知道这里面是什么内容!

EIP-712:签名界的"翻译官"

EIP-712就是为了解决这个问题而生的。它让复杂的结构化数据变得人类可读,就像给签名装上了一个"翻译官"。

核心思想:结构化+类型化+可读化

EIP-712的三大核心理念:

  1. 结构化(Structured):不再是一坨乱码,而是有层次的数据结构
  2. 类型化(Typed):每个字段都有明确的类型定义
  3. 可读化(Human-readable):钱包可以友好地显示签名内容

实际效果对比

没有EIP-712之前

text
MetaMask显示:
"Sign this message: 0x1901f2d857b4a3edcb9731cdf..."
用户心理:这是啥?敢签吗?🤔
MetaMask显示:
"Sign this message: 0x1901f2d857b4a3edcb9731cdf..."
用户心理:这是啥?敢签吗?🤔

有了EIP-712之后

text
MetaMask显示:
"向 张三 (0x8ba1f109551bD432803012645Hac136c22C501e1) 转账
代币:USDT
数量:100.00
手续费:0.1 ETH
截止时间:2025-08-05 15:30:00"
用户心理:这个我看得懂,可以签!✅
MetaMask显示:
"向 张三 (0x8ba1f109551bD432803012645Hac136c22C501e1) 转账
代币:USDT
数量:100.00
手续费:0.1 ETH
截止时间:2025-08-05 15:30:00"
用户心理:这个我看得懂,可以签!✅

差别很明显吧?

EIP-712的技术原理

1. 域分隔符(Domain Separator)

每个DApp都有自己的"身份证":

solidity
struct EIP712Domain {
    string name;                // DApp名称,比如"Uniswap V3"
    string version;             // 版本号,比如"1.0.0"
    uint256 chainId;           // 链ID,比如1(以太坊主网)
    address verifyingContract; // 验证合约地址
    bytes32 salt;              // 可选的盐值(用于去重)
}
struct EIP712Domain {
    string name;                // DApp名称,比如"Uniswap V3"
    string version;             // 版本号,比如"1.0.0"
    uint256 chainId;           // 链ID,比如1(以太坊主网)
    address verifyingContract; // 验证合约地址
    bytes32 salt;              // 可选的盐值(用于去重)
}

这就像每个DApp都有自己的"营业执照",确保签名不会被恶意网站冒用。

2. 类型哈希(Type Hash)

定义数据结构的"指纹":

solidity
// 转账操作的结构定义
struct Transfer {
    address from;     // 发送方
    address to;       // 接收方
    address token;    // 代币合约地址
    uint256 amount;   // 转账数量
    uint256 nonce;    // 防重放随机数
    uint256 deadline; // 截止时间
}

// 计算类型哈希
bytes32 TRANSFER_TYPEHASH = keccak256(
    "Transfer(address from,address to,address token,uint256 amount,uint256 nonce,uint256 deadline)"
);
// 转账操作的结构定义
struct Transfer {
    address from;     // 发送方
    address to;       // 接收方
    address token;    // 代币合约地址
    uint256 amount;   // 转账数量
    uint256 nonce;    // 防重放随机数
    uint256 deadline; // 截止时间
}

// 计算类型哈希
bytes32 TRANSFER_TYPEHASH = keccak256(
    "Transfer(address from,address to,address token,uint256 amount,uint256 nonce,uint256 deadline)"
);

3. 结构哈希(Struct Hash)

把具体数据打包成哈希:

solidity
function hashTransfer(Transfer memory transfer) internal pure returns (bytes32) {
    return keccak256(abi.encode(
        TRANSFER_TYPEHASH,
        transfer.from,
        transfer.to,
        transfer.token,
        transfer.amount,
        transfer.nonce,
        transfer.deadline
    ));
}
function hashTransfer(Transfer memory transfer) internal pure returns (bytes32) {
    return keccak256(abi.encode(
        TRANSFER_TYPEHASH,
        transfer.from,
        transfer.to,
        transfer.token,
        transfer.amount,
        transfer.nonce,
        transfer.deadline
    ));
}

4. 最终签名哈希

按照EIP-191的0x01版本格式组装:

solidity
function getTransferHash(Transfer memory transfer) public view returns (bytes32) {
    return keccak256(abi.encodePacked(
        "\x19\x01",                    // EIP-191 版本0x01前缀
        DOMAIN_SEPARATOR,              // 域分隔符
        hashTransfer(transfer)         // 结构哈希
    ));
}
function getTransferHash(Transfer memory transfer) public view returns (bytes32) {
    return keccak256(abi.encodePacked(
        "\x19\x01",                    // EIP-191 版本0x01前缀
        DOMAIN_SEPARATOR,              // 域分隔符
        hashTransfer(transfer)         // 结构哈希
    ));
}

实战案例:Permit的"免费转账"魔法

最经典的EIP-712应用就是ERC-20的Permit功能。传统的代币转账需要两步:

solidity
// 传统方式:需要两笔交易
token.approve(spender, amount);  // 第一笔:授权(需要gas费)
token.transferFrom(user, to, amount); // 第二笔:转账(需要gas费)
// 传统方式:需要两笔交易
token.approve(spender, amount);  // 第一笔:授权(需要gas费)
token.transferFrom(user, to, amount); // 第二笔:转账(需要gas费)

有了EIP-712的Permit,只需要一笔交易:

solidity
// EIP-712 Permit结构
struct Permit {
    address owner;     // 代币持有者
    address spender;   // 授权使用者
    uint256 value;     // 授权数量
    uint256 nonce;     // 防重放
    uint256 deadline;  // 过期时间
}
// EIP-712 Permit结构
struct Permit {
    address owner;     // 代币持有者
    address spender;   // 授权使用者
    uint256 value;     // 授权数量
    uint256 nonce;     // 防重放
    uint256 deadline;  // 过期时间
}
javascript
// 前端:用户签名(免费!)
const permit = {
    owner: userAddress,
    spender: contractAddress,
    value: amount,
    nonce: await token.nonces(userAddress),
    deadline: Math.floor(Date.now() / 1000) + 3600 // 1小时后过期
};

const signature = await signer._signTypedData(domain, types, permit);
// 前端:用户签名(免费!)
const permit = {
    owner: userAddress,
    spender: contractAddress,
    value: amount,
    nonce: await token.nonces(userAddress),
    deadline: Math.floor(Date.now() / 1000) + 3600 // 1小时后过期
};

const signature = await signer._signTypedData(domain, types, permit);
solidity
// 合约:一次性完成授权+转账
function transferWithPermit(
    Permit memory permit,
    bytes memory signature
) external {
    // 验证签名并授权
    token.permit(
        permit.owner,
        permit.spender,
        permit.value,
        permit.deadline,
        v, r, s  // 从signature解析出来
    );
    
    // 立即转账
    token.transferFrom(permit.owner, msg.sender, permit.value);
}
// 合约:一次性完成授权+转账
function transferWithPermit(
    Permit memory permit,
    bytes memory signature
) external {
    // 验证签名并授权
    token.permit(
        permit.owner,
        permit.spender,
        permit.value,
        permit.deadline,
        v, r, s  // 从signature解析出来
    );
    
    // 立即转账
    token.transferFrom(permit.owner, msg.sender, permit.value);
}

好处显而易见

  • 用户只需要签名(免费)
  • 合约支付所有gas费
  • 体验丝滑,一步到位

EIP-712的安全特性

1. 域绑定防冒用

每个DApp都有独特的域分隔符,签名无法跨域使用:

solidity
// Uniswap的域
DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256("Uniswap V3"),
    keccak256("1.0.0"),
    1, // 以太坊主网
    address(this)
));

// 恶意网站无法伪造相同的域分隔符
// Uniswap的域
DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256("Uniswap V3"),
    keccak256("1.0.0"),
    1, // 以太坊主网
    address(this)
));

// 恶意网站无法伪造相同的域分隔符

2. 链绑定防重放

chainId确保签名只在特定链上有效:

javascript
const domain = {
    name: "MyDApp",
    version: "1.0.0",
    chainId: 1,  // 只在以太坊主网有效
    verifyingContract: contractAddress
};
const domain = {
    name: "MyDApp",
    version: "1.0.0",
    chainId: 1,  // 只在以太坊主网有效
    verifyingContract: contractAddress
};

在BSC(chainId: 56)上,这个签名就无效了。

3. 时间限制防滥用

通过deadline字段设置过期时间:

solidity
require(block.timestamp <= deadline, "Signature expired");
require(block.timestamp <= deadline, "Signature expired");

4. nonce防重放

每个用户都有递增的nonce:

solidity
mapping(address => uint256) public nonces;

function verify(address user, uint256 nonce, bytes memory signature) external {
    require(nonce == nonces[user], "Invalid nonce");
    nonces[user]++; // 使用后立即递增
    // 验证签名...
}
mapping(address => uint256) public nonces;

function verify(address user, uint256 nonce, bytes memory signature) external {
    require(nonce == nonces[user], "Invalid nonce");
    nonces[user]++; // 使用后立即递增
    // 验证签名...
}

钱包如何显示EIP-712签名

MetaMask等钱包是如何把复杂的数据结构变成用户友好的界面的?

1. 解析类型定义

javascript
const types = {
    EIP712Domain: [
        { name: "name", type: "string" },
        { name: "version", type: "string" },
        { name: "chainId", type: "uint256" },
        { name: "verifyingContract", type: "address" }
    ],
    Transfer: [
        { name: "from", type: "address" },
        { name: "to", type: "address" },
        { name: "token", type: "address" },
        { name: "amount", type: "uint256" },
        { name: "nonce", type: "uint256" },
        { name: "deadline", type: "uint256" }
    ]
};
const types = {
    EIP712Domain: [
        { name: "name", type: "string" },
        { name: "version", type: "string" },
        { name: "chainId", type: "uint256" },
        { name: "verifyingContract", type: "address" }
    ],
    Transfer: [
        { name: "from", type: "address" },
        { name: "to", type: "address" },
        { name: "token", type: "address" },
        { name: "amount", type: "uint256" },
        { name: "nonce", type: "uint256" },
        { name: "deadline", type: "uint256" }
    ]
};

2. 渲染用户界面

钱包会把types和实际数据结合,生成类似这样的界面:

text
🔒 签名请求

📱 DApp: Uniswap V3 (v1.0.0)
🌐 网络: 以太坊主网
📄 合约: 0x1234...5678

📝 操作详情:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
发送方:   0x8ba1...501e1 (你的地址)
接收方:   0x742d...4E4A2
代币:     USDT (0xdAC1...35Ec7)
数量:     100.00 USDT
随机数:   42
截止时间: 2025-08-05 15:30:00
━━━━━━━━━━━━━━━━━━━━━━━━━━━━

⚠️  只在授权可信的DApp时才签名
✅ 取消    ✅ 签名
🔒 签名请求

📱 DApp: Uniswap V3 (v1.0.0)
🌐 网络: 以太坊主网
📄 合约: 0x1234...5678

📝 操作详情:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
发送方:   0x8ba1...501e1 (你的地址)
接收方:   0x742d...4E4A2
代币:     USDT (0xdAC1...35Ec7)
数量:     100.00 USDT
随机数:   42
截止时间: 2025-08-05 15:30:00
━━━━━━━━━━━━━━━━━━━━━━━━━━━━

⚠️  只在授权可信的DApp时才签名
✅ 取消    ✅ 签名

3. 特殊字段处理

钱包还会对特定类型做友好化处理:

javascript
// 地址字段:显示ENS名称或简化地址
"0x8ba1f109551bD432803012645Hac136c22C501e1""vitalik.eth"

// 时间戳:转换为可读时间
1722870600"2025-08-05 15:30:00"

// 大数字:添加单位和格式化
"100000000000000000000""100.00 TOKEN"

// 合约地址:显示已知合约名称
"0xA0b86a33E6441d8178E72C34b0A8d9227071a7A8""USDT Token Contract"
// 地址字段:显示ENS名称或简化地址
"0x8ba1f109551bD432803012645Hac136c22C501e1""vitalik.eth"

// 时间戳:转换为可读时间
1722870600"2025-08-05 15:30:00"

// 大数字:添加单位和格式化
"100000000000000000000""100.00 TOKEN"

// 合约地址:显示已知合约名称
"0xA0b86a33E6441d8178E72C34b0A8d9227071a7A8""USDT Token Contract"

EIP-712在DeFi中的广泛应用

1. DEX的订单簿

solidity
struct Order {
    address trader;        // 交易者
    address baseToken;     // 基础代币
    address quoteToken;    // 计价代币
    uint256 baseAmount;    // 基础代币数量
    uint256 quoteAmount;   // 计价代币数量
    uint256 expiry;        // 过期时间
    uint256 salt;          // 防重复盐值
}
struct Order {
    address trader;        // 交易者
    address baseToken;     // 基础代币
    address quoteToken;    // 计价代币
    uint256 baseAmount;    // 基础代币数量
    uint256 quoteAmount;   // 计价代币数量
    uint256 expiry;        // 过期时间
    uint256 salt;          // 防重复盐值
}

2. 借贷协议的授权

solidity
struct LendingPermit {
    address borrower;      // 借款人
    address lender;        // 出借人
    address asset;         // 资产
    uint256 amount;        // 金额
    uint256 interestRate;  // 利率
    uint256 duration;      // 借款期限
    uint256 nonce;
}
struct LendingPermit {
    address borrower;      // 借款人
    address lender;        // 出借人
    address asset;         // 资产
    uint256 amount;        // 金额
    uint256 interestRate;  // 利率
    uint256 duration;      // 借款期限
    uint256 nonce;
}

3. NFT的批量操作

solidity
struct BatchTransfer {
    address from;
    address to;
    uint256[] tokenIds;    // 批量NFT ID
    uint256 deadline;
    uint256 nonce;
}
struct BatchTransfer {
    address from;
    address to;
    uint256[] tokenIds;    // 批量NFT ID
    uint256 deadline;
    uint256 nonce;
}

安全陷阱与防范

虽然EIP-712让签名变得更安全、更友好,但还是有一些需要注意的安全陷阱:

1. 假冒的"友好"界面

恶意DApp可能会在类型定义中撒谎:

javascript
// 恶意类型定义:把转账伪装成查询
const maliciousTypes = {
    BalanceQuery: [  // 看起来像查询余额
        { name: "user", type: "address" },
        { name: "token", type: "address" },
        { name: "amount", type: "uint256" }  // 实际上是转账金额!
    ]
};
// 恶意类型定义:把转账伪装成查询
const maliciousTypes = {
    BalanceQuery: [  // 看起来像查询余额
        { name: "user", type: "address" },
        { name: "token", type: "address" },
        { name: "amount", type: "uint256" }  // 实际上是转账金额!
    ]
};

防范方法

  • 只在信任的DApp上签名
  • 仔细检查签名内容,特别是金额字段
  • 使用知名钱包,它们通常有额外的安全检查

2. 钓鱼网站的域名欺骗

恶意网站可能使用相似的域名:

javascript
// 正版Uniswap
const legitimateDomain = {
    name: "Uniswap V3",
    verifyingContract: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
};

// 钓鱼网站
const phishingDomain = {
    name: "Uniswap V3",  // 名字一样!
    verifyingContract: "0x1234567890123456789012345678901234567890"  // 但合约不同
};
// 正版Uniswap
const legitimateDomain = {
    name: "Uniswap V3",
    verifyingContract: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
};

// 钓鱼网站
const phishingDomain = {
    name: "Uniswap V3",  // 名字一样!
    verifyingContract: "0x1234567890123456789012345678901234567890"  // 但合约不同
};

防范方法

  • 检查verifyingContract地址是否正确
  • 使用书签访问DApp,不要点击可疑链接
  • 在签名前确认当前网站URL

3. 过长的截止时间

某些恶意DApp可能设置很长的deadline:

javascript
const maliciousData = {
    deadline: 9999999999  // 2286年才过期!
};
const maliciousData = {
    deadline: 9999999999  // 2286年才过期!
};

这意味着如果你的私钥泄露,攻击者可以在很长时间内使用这个签名。

防范方法

  • 检查deadline字段,确保过期时间合理
  • 对于重要操作,选择较短的有效期

EIP-712的后续发展

EIP-712成功后,社区又基于它开发了很多扩展:

1. EIP-5267:域信息检索

允许合约动态返回自己的EIP-712域信息:

solidity
interface IERC5267 {
    function eip712Domain() external view returns (
        bytes1 fields,
        string memory name,
        string memory version,
        uint256 chainId,
        address verifyingContract,
        bytes32 salt,
        uint256[] memory extensions
    );
}
interface IERC5267 {
    function eip712Domain() external view returns (
        bytes1 fields,
        string memory name,
        string memory version,
        uint256 chainId,
        address verifyingContract,
        bytes32 salt,
        uint256[] memory extensions
    );
}

2. EIP-7730:清晰签名格式

进一步改进签名显示,让用户更容易理解:

json
{
    "message": "Transfer 100 USDT to Alice",
    "risk_level": "medium",
    "gas_estimate": "21000",
    "equivalent_usd": "$100.00"
}
{
    "message": "Transfer 100 USDT to Alice",
    "risk_level": "medium",
    "gas_estimate": "21000",
    "equivalent_usd": "$100.00"
}

3. EIP-7739:智能账户可读签名

为智能钱包优化的签名格式,支持更复杂的签名逻辑。

实际开发中的最佳实践

1. 合理设计数据结构

solidity
// ✅ 好的设计:语义清晰
struct SwapOrder {
    address trader;
    address tokenIn;
    address tokenOut;
    uint256 amountIn;
    uint256 minAmountOut;  // 明确表示最小接收量
    uint256 deadline;
    uint256 nonce;
}

// ❌ 不好的设计:容易混淆
struct Order {
    address user;
    address token1;  // 哪个是输入,哪个是输出?
    address token2;
    uint256 amount1; // 到底是多少?
    uint256 amount2;
}
// ✅ 好的设计:语义清晰
struct SwapOrder {
    address trader;
    address tokenIn;
    address tokenOut;
    uint256 amountIn;
    uint256 minAmountOut;  // 明确表示最小接收量
    uint256 deadline;
    uint256 nonce;
}

// ❌ 不好的设计:容易混淆
struct Order {
    address user;
    address token1;  // 哪个是输入,哪个是输出?
    address token2;
    uint256 amount1; // 到底是多少?
    uint256 amount2;
}

2. 提供清晰的类型描述

javascript
// ✅ 好的命名
const types = {
    SwapOrder: [
        { name: "trader", type: "address" },
        { name: "tokenIn", type: "address" },
        { name: "tokenOut", type: "address" },
        { name: "amountIn", type: "uint256" },
        { name: "minAmountOut", type: "uint256" },
        { name: "deadline", type: "uint256" },
        { name: "nonce", type: "uint256" }
    ]
};

// ❌ 不好的命名
const types = {
    Order: [
        { name: "a", type: "address" },
        { name: "b", type: "address" },
        { name: "c", type: "uint256" }
    ]
};
// ✅ 好的命名
const types = {
    SwapOrder: [
        { name: "trader", type: "address" },
        { name: "tokenIn", type: "address" },
        { name: "tokenOut", type: "address" },
        { name: "amountIn", type: "uint256" },
        { name: "minAmountOut", type: "uint256" },
        { name: "deadline", type: "uint256" },
        { name: "nonce", type: "uint256" }
    ]
};

// ❌ 不好的命名
const types = {
    Order: [
        { name: "a", type: "address" },
        { name: "b", type: "address" },
        { name: "c", type: "uint256" }
    ]
};

3. 实现适当的安全检查

solidity
contract SecureContract {
    mapping(address => uint256) public nonces;
    
    function executeOrder(Order memory order, bytes memory signature) external {
        // 1. 验证签名
        bytes32 hash = getOrderHash(order);
        address signer = ECDSA.recover(hash, signature);
        require(signer == order.trader, "Invalid signature");
        
        // 2. 检查nonce防重放
        require(order.nonce == nonces[order.trader], "Invalid nonce");
        nonces[order.trader]++;
        
        // 3. 检查deadline防过期
        require(block.timestamp <= order.deadline, "Order expired");
        
        // 4. 执行业务逻辑
        // ...
    }
}
contract SecureContract {
    mapping(address => uint256) public nonces;
    
    function executeOrder(Order memory order, bytes memory signature) external {
        // 1. 验证签名
        bytes32 hash = getOrderHash(order);
        address signer = ECDSA.recover(hash, signature);
        require(signer == order.trader, "Invalid signature");
        
        // 2. 检查nonce防重放
        require(order.nonce == nonces[order.trader], "Invalid nonce");
        nonces[order.trader]++;
        
        // 3. 检查deadline防过期
        require(block.timestamp <= order.deadline, "Order expired");
        
        // 4. 执行业务逻辑
        // ...
    }
}

写在最后

EIP-712真正实现了签名的"结构化革命",它不仅让签名变得更安全,更重要的是让普通用户能够理解自己在签什么。这种"人类友好"的设计理念,正是区块链技术走向大众化的重要基石。

从EIP-191的"交通规则"到EIP-712的"翻译官",我们可以看到以太坊生态是如何一步步变得更加完善和用户友好的。每一个EIP的背后,都是无数开发者的智慧结晶和对用户体验的不懈追求。

如果你是开发者,强烈建议在项目中使用EIP-712标准——你的用户会因为清晰的签名界面而感谢你。如果你是普通用户,记住这个原则:看不懂的签名,绝对不要签!

关于作者们

作者基本来自井畅公司的技术工程师和社区热心小伙伴,热衷于探索前沿技术并分享实践经验。通过系统化的学习和多年项目实践,井畅希望能帮助更多开发者快速掌握核心技能,少走弯路。

加入技术社区

🔹 技术交流QQ群 : 568285439

🔹 GitHubjccdex - 关注获取示例代码和开源项目

🔹 公众号:[井畅] - 每周更新,分享行业动态与技术心得

有任何问题或建议,欢迎在评论区留言,我会尽快回复。

关于我们这群"代码考古学家"

我们基本来自井畅公司的技术工程师和社区热心小伙伴,平时就喜欢挖掘这些技术背后的故事。通过系统化学习和多年项目实践,我们希望能帮助更多开发者快速掌握核心技能,少走弯路(毕竟踩坑不好玩)。

加入我们的"考古队"

🔹 技术交流QQ群:568285439(欢迎来聊技术,也欢迎来吐槽)

🔹 GitHubjccdex - 代码示例和开源项目都在这里

🔹 公众号:[井畅] - 每周更新,分享行业动态与技术心得

下期预告:EIP-1271:当智能合约也想要"签名"

下一篇我们要聊EIP-1271:智能合约签名验证标准。既然合约没有私钥,它们是如何"签名"的?多签钱包、DAO投票、代理合约都是如何验证签名的?EIP-1271如何让合约也拥有了"签名"的能力?

敬请期待这场"合约签名"的技术探索!

有任何问题或建议,欢迎在评论区留言。我们承诺:技术问题必回复,吐槽也欢迎!

Released under the MIT License.