Solidity 中级面试题
以下是 Solidity 中级面试题 的详细解答:
1. transfer 和 send 之间有什么区别?为什么不应该使用它们?
| 函数 | Gas 限制 | 失败处理 |
|---|---|---|
send | 2300 | 返回 bool false,不自动回滚 |
transfer | 2300 | 失败时自动 revert |
为什么不推荐使用?
- 两者都固定 2300 gas,这对于接收方合约的
fallback/receive函数可能不够(例如执行复杂逻辑或存储写入)。 - 随着以太坊升级(如 Istanbul 后 EIP-2929 提高了某些操作的 gas 成本),2300 gas 可能不足以完成简单的状态访问。
- 推荐替代:使用
call{value: amount}(""),可手动设置 gas(或转发全部剩余 gas),并处理返回的 bool。
2. 如何在 Solidity 中编写高效的 gas 循环?
- 减少循环次数:能 off-chain 计算的就不要上链。
- 提前终止:使用
break或条件判断。 - 避免在循环内频繁修改存储:将中间变量缓存到
memory,最后一次性写回。 - 使用
unchecked块:如果循环索引不会溢出(如for (uint i=0; i<n; i++)中i++可放入unchecked)。 - 尽量使用固定长度数组(比动态数组 gas 略低)。
- 循环内不要调用外部合约(昂贵且危险)。
示例:
uint sum = 0;
for (uint i = 0; i < n; i++) {
sum += arr[i]; // 假设 arr 是 memory 数组
}
3. 代理合约中的存储冲突是什么?
代理合约通常将逻辑合约的存储布局继承到代理的存储空间。存储冲突发生在:
- 变量声明顺序不一致:代理合约和逻辑合约的状态变量顺序或类型不同,导致数据覆盖。
- 代理自身的变量(如
implementation地址)存储在一个固定槽位(如keccak256("eip1967.proxy.implementation") - 1),如果逻辑合约的变量意外占用了该槽位,就会冲突。
解决方案:使用 EIP-1967 标准存储槽,逻辑合约避免使用这些特殊槽位;或使用 透明代理、UUPS 模式确保存储布局一致。
4. 什么是闪电贷?
闪电贷是一种无抵押贷款,允许借款人在同一区块内借入任意数额的资产,前提是必须在交易结束前归还本金 + 手续费。若未能归还,整个交易回滚。
用途:套利、抵押品更换、自清算等。
风险:闪电贷攻击(利用价格预言机操纵或重入漏洞)曾导致多个 DeFi 协议损失。
5. 在权益证明之前后,block.timestamp 发生了什么变化?
- PoW(工作量证明):
block.timestamp由矿工设置,允许有一定的偏差(通常不超过未来 15 秒),但受难度调整约束,相对不可预测。 - PoS(权益证明):验证者提出区块时
timestamp必须严格单调递增,并且不能偏离前一个区块时间太长(以太坊 2.0 后规范更严)。虽然仍可由验证者轻微影响,但整体安全性更高,对智能合约而言使用block.timestamp做随机数仍不可靠(仍可被操纵 ± 几秒)。
6. 什么是抢跑(frontrunning)?
抢跑是指监控 mempool 中的待处理交易,并在同一交易之前插入自己的交易,以获取利益。常见于:
- 去中心化交易所(DEX):发现大额买单后,抢先买入再卖出。
- 清算:抢先清算别人的仓位获取奖励。
- 抢购 NFT 或白名单。
防御措施:使用提交-揭示、批量拍卖、门槛加密(如 Flashbots 隐私交易)等。
7. 什么是提交-揭示方案,何时使用它?
提交-揭示(Commit-Reveal) 是一种两阶段协议:
- 提交阶段:用户发送哈希后的承诺(如
keccak256(choice + salt)),不暴露原始值。 - 揭示阶段:用户发送原始值(choice 和 salt),合约验证哈希匹配。
何时使用:
- 防止抢跑(如链上投票、竞拍、石头剪刀布游戏)。
- 任何需要隐藏用户输入直到某个时刻的场景。
8. 在什么情况下,abi.encodePacked 可能会产生漏洞?
abi.encodePacked 是紧打包,不会填充零。当多个变量拼接时可能产生哈希碰撞。
例如:
keccak256(abi.encodePacked("a", "bc")) == keccak256(abi.encodePacked("ab", "c"))
因为 "a"+"bc" 和 "ab"+"c" 最终字节序列相同。
漏洞场景:使用 encodePacked 生成签名或验证身份时,攻击者可以构造不同输入得到相同哈希,绕过校验。
修复:使用 abi.encode(填充到 32 字节)或明确分隔符(如 abi.encodePacked(addr, nonce) 中 addr 是固定长度则安全)。
9. 以太坊如何确定 EIP-1559 中的 BASEFEE?
BASEFEE 是每区块的基础费用,由协议根据前一个区块的 gas 使用量与目标 gas 量(15M)的比值动态调整:
- 如果前块 gas 使用量 > 目标,
BASEFEE最多增加 12.5%。 - 如果前块 gas 使用量 < 目标,
BASEFEE最多减少 12.5%。 - 调整公式:
base_fee_new = base_fee_old * (1 + (gas_used - gas_target) / gas_target * 1/8)
BASEFEE 不归矿工/验证者,而是被燃烧。用户需支付 priority fee(小费)给验证者。
10. 冷读(cold read)和热读(warm read)之间有什么区别?
- 冷读:首次访问某个存储槽或账户,需要额外 gas(EIP-2929 后,首次访问存储槽花费 2100 gas)。
- 热读:在同一交易中再次访问同一个存储槽或账户,仅花费 100 gas(地址访问 2600→100)。
区别在于是否已被本交易访问过。合约优化时可通过提前访问(如 SLOAD)来预热,降低后续访问成本。
11. AMM 如何定价资产?
自动做市商(AMM)使用恒定乘积公式 x * y = k(如 Uniswap V2)。
x和y为两种代币的储备量。- 交易
Δx换取Δy需满足(x + Δx) * (y - Δy) = k。 - 价格由储备量决定:
price = y / x(不考虑手续费)。 - 大额交易会产生滑点(偏离市场价格)。
12. 代理中的函数选择器冲突是什么,它是如何发生的?
函数选择器冲突:两个不同的函数签名(名称和参数类型)却得到相同的 keccak256 前 4 字节。虽然概率极低,但理论上可能发生。
在代理中:
- 代理合约本身有
fallback转发给逻辑合约。 - 如果代理合约的定义了一个函数(如
upgradeTo),而逻辑合约也有一个同选择器的函数,代理自己的函数会优先匹配,导致逻辑合约的函数永远无法被调用。 - 透明代理通过判断
msg.sender是管理员还是普通用户来解决:管理员调用代理函数,用户调用被转发。
13. 什么是签名重放攻击?
签名重放攻击是指同一个有效的数字签名被多次提交,从而重复执行相同操作(如提款、转移所有权)。
场景:用户签署了一条消息(如“授权转账 100 DAI”),攻击者捕获该签名后不断向合约提交,导致多次扣款。
防御:
- 在签名中包含
nonce(单调递增)或deadline。 - 合约记录已使用过的签名(
mapping(bytes => bool) used)。 - 使用 EIP-712 结构化签名,增加链 ID 和合约地址域分隔符。
14. 什么是 gas griefing(恶意破坏)?
Gas griefing 是指攻击者通过使目标交易消耗大量 gas,导致交易失败或耗尽 gas 而无法执行。常见方式:
- 在被调用合约中,通过
require失败前消耗大量 gas(如循环)。 - 利用
call转发所有剩余 gas,然后被调用方故意消耗到接近gasleft(),导致外层交易失败。 - 防御:对外部调用设置固定的
gas限制(call{gas: 50000}),不使用gasleft()直接转发全部。
15. 如何设计一个石头-剪刀-布的智能合约游戏,使玩家无法作弊?
使用提交-揭示机制:
- 玩家 A 提交:
keccak256(choice + salt)(choice: 0=石头,1=剪刀,2=布)。 - 玩家 B 提交:同样提交哈希。
- 双方揭示:发送原始
choice和salt,合约验证哈希匹配。 - 结算:根据规则判断胜负,转移押金。
防止作弊的措施:
- 设置提交截止时间和揭示截止时间。
- 若一方不揭示,则退还对方押金或判定对方赢。
- 使用链上随机数?不需要,因为选择隐藏了。
- 确保
salt足够长(如uint256),避免暴力破解。 - 可引入
commit-reveal的顺序:玩家 A 先提交,玩家 B 后提交,但是 B 可以看到 A 的哈希?注意哈希不可逆,但 B 仍可在提交时选择针对 A 的已知选择?因为 A 的 choice 仍未知,所以公平。更安全的做法是同时提交(通过区块边界或使用类似 RANDAO 的方式)。 以下是 16–30 题 的解答:
16. 自由内存指针是什么,它存储在哪里?
自由内存指针(free memory pointer)指向当前未使用的内存起始位置,初始值为 0x40(即内存中第 64 字节处)。
它存储在内存地址 0x40 到 0x5f(32 字节大小)。
当需要动态分配内存(如 new 字节数组、abi.encode 等)时,会读取该指针,然后将其增加分配的大小,并将新的指针写回 0x40。
注意:内联汇编中应手动维护该指针,避免覆盖。
17. 接口中有效的函数修饰符有哪些?
接口中只能定义函数声明,不能有实现。因此允许的修饰符非常有限:
- 无
private、internal(接口函数都是隐式external) - 不能有
view、pure等修饰符?实际上可以写view或pure,因为它们只影响函数行为而不涉及实现,但接口中的函数默认可以没有这些修饰符。 - 不能有
virtual、override(因为接口没有实现) - 不能有
payable?接口函数可以标记payable,允许接收以太币。
简单说:接口中函数只能是
external类型,可加view/pure/payable,无其他修饰符。
18. 函数参数中的 memory 和 calldata 有什么区别?
| 关键字 | 存储位置 | 可修改性 | Gas 成本 | 适用场景 |
|---|---|---|---|---|
calldata | 调用数据区 | 只读 | 极低(直接读取) | 外部函数的参数(默认) |
memory | 内存 | 可修改 | 较高(复制或分配内存) | 内部函数参数或需要修改的数据 |
calldata仅用于external函数,避免复制数据从而节省 gas。memory可用于所有函数可见性,数组/结构体等可被修改。
19. 描述三种存储 gas 成本类型。
以太坊存储相关 gas 成本(基于当前 EVM):
SLOAD(读存储):冷读 2100 gas,热读 100 gas。SSTORE(写存储):- 从 0 → 非 0(新增):22100 gas。
- 从 非 0 → 0(删除):4800 gas(并返还约 15000 gas 的退款)。
- 从 非 0 → 非 0(修改):5000 gas。
- 冷地址/账户访问:首次访问外部账户或合约(
EXTCODESIZE等)需 2600 gas,热访问 100 gas。
20. 为什么可升级合约不应该使用构造函数?
因为代理模式中,逻辑合约的构造函数只在部署时执行,但代理合约的存储初始化通常需要在初始化函数中完成。如果逻辑合约使用构造函数,代理无法调用它,导致状态未初始化。
替代:使用 initialize 函数(通常标记 initializer 修饰符),由代理通过 delegatecall 调用。
21. UUPS 和 Transparent Upgradeable Proxy 模式之间有什么区别?
| 特性 | 透明代理 (Transparent Proxy) | UUPS (Universal Upgradeable Proxy Standard) |
|---|---|---|
| 升级逻辑所在 | 代理合约本身包含 upgradeTo 函数 | 逻辑合约中实现 upgradeTo 函数 |
| 存储冲突 | 通过特殊槽位(EIP-1967)和权限区分避免 | 同样使用 EIP-1967 槽位 |
| 管理员调用升级 | 管理员调用代理直接升级 | 管理员调用逻辑合约的 upgradeTo,经由 delegatecall |
| 构造函数 | 无影响 | 逻辑合约不能有构造函数,需初始化函数 |
| 风险 | 代理稍大,但升级逻辑独立 | 若逻辑合约错误覆盖 upgradeTo,将无法升级 |
22. 如果合约通过 delegatecall 调用一个空地址或之前已自毁的实现,会发生什么?如果是常规调用而不是 delegatecall 呢?
delegatecall到空地址(地址无代码):delegatecall不会抛出异常,而是返回success = false(因为目标地址没有代码,EVM 会静默失败)。delegatecall到已自毁的合约地址:自毁后代码被清除,同样视为空地址,返回false。- 常规
call到空地址或自毁地址:也会返回false(无代码,没有目标函数执行)。但注意:转账call{value: x}("")如果目标地址无代码但接收 ETH,会成功(因为不需要代码执行)。
23. ERC777 代币存在什么危险?
ERC777 扩展了 ERC20,引入了 tokensReceived 钩子(允许接收方合约实现复杂逻辑),但该机制曾被用来实施重入攻击(例如 Uniswap 上的 imBTC 事件)。攻击者在转账过程中回调代币合约,在余额更新之前再次触发转账,导致双重记账。
教训:任何带钩子的代币都需要转账实现遵循“检查-效果-交互”模式,否则易受攻击。
24. 什么是债券曲线(bonding curve)?
债券曲线是一种价格与代币供应量相关的数学曲线。购买代币时,价格沿曲线上升;卖出时价格下降。通常用于连续流动性模型(如 Bancor、Friends With Benefits)。
常见形式:price = m * supply + b(线性)或 price = a * supply^2(指数)。
智能合约根据曲线公式计算买入/卖出时需支付或收到的代币数量,无需订单簿。
25. OpenZeppelin ERC721 实现中的 safeMint 与 mint 有何不同?
mint:直接铸造代币,不检查接收方是否能处理 ERC721 代币。若接收方是合约,该合约可能无法正确管理代币(如缺少onERC721Received)。safeMint:铸造后调用接收方的onERC721Received函数,要求返回0x150b7a02魔术值,否则回滚。这防止代币被发送到未实现标准接口的合约中导致永久锁定。
26. 什么是三明治(sandwich)攻击?
一种 MEV 抢跑策略,攻击者在大额交易前后分别买入和卖出同一资产,以赚取差价。
步骤:
- 发现 mempool 中待处理的大额买单(例如买入代币 A)。
- 攻击者抢先买入 A(推高价格)。
- 大额交易被执行(以更高价格买入)。
- 攻击者立即卖出 A(获利离场)。
防御:使用限价单、批量拍卖、或通过隐私交易(如 Flashbots)避免被抢跑。
27. 如果向一个会回滚的函数进行 delegatecall,delegatecall 会怎么做?
delegatecall 会将执行环境切换到目标合约,如果目标函数内部 revert 或抛出异常,整个 delegatecall 会回滚当前交易(如同普通 call 的 revert)。delegatecall 返回 false(如果使用了低级别调用),或者直接终止执行(若用 address.delegatecall(...) 会返回 bool 和返回数据)。
28. 乘以和除以二的倍数的 gas 高效替代方法是什么?
使用移位操作:
- 乘以 2:
x << 1(比x * 2稍省 gas) - 除以 2:
x >> 1(仅适用于整数除法,向零截断) - 乘以 2 的 n 次方:
x << n - 除以 2 的 n 次方:
x >> n
但在 Solidity 0.8+ 中,编译器已对常量乘法/除法做了优化,移位带来的优势微乎其微,但可读性更好。在 unchecked 块中使用可避免溢出检查。
29. 多大 uint 可以与一个地址在一个槽中?
一个存储槽是 32 字节(256 位)。
地址(address)占用 20 字节(160 位)。
因此剩余 12 字节(96 位)可以存放一个 uint96(最大 2^96-1)。
例如 struct Packed { address addr; uint96 value; } 可存放在一个槽中。
Solidity 会自动尝试将连续的小类型变量打包到同一槽中。
30. 哪些操作会部分退还 gas?
EVM 在执行某些操作后会退还部分 gas,常见的有:
- 清除存储(将非零值改为零):退还 4800 gas(实际获得约 15000 gas 的净退款?具体:写零时扣除 5000,但事后退还 15000,所以净赚 10000?实际机制复杂,最终交易有最大退款上限为 gas 用量的 1/2)。
- 自毁合约(
selfdestruct):退还 24000 gas(但已被 EIP-4758 提议弃用,目前仍可用)。 - 删除映射或数组元素(
delete):根据清除操作退还部分 gas。
注意:退款不会在交易执行中即时返还,而是在交易结束后累加到剩余 gas 中,且总退款不超过交易 gas 使用量的一半。 以下是 31–41 题 的解答:
31. ERC165 作用于什么?
ERC165 用于接口检测(Interface Detection)。合约可以通过 supportsInterface(interfaceId) 函数声明自己实现了哪些标准接口(如 ERC721、ERC20 等)。这使得外部调用者能够查询合约是否支持特定接口,从而安全地进行交互(例如在 safeMint 前检查接收方是否支持 ERC721)。
32. 如果代理对 A 进行 delegatecall,而 A 执行 address(this).balance,返回的是代理的余额还是 A 的余额?
关于第 32 题:如果代理对 A 进行 delegatecall,而 A 执行 address(this).balance,返回的是代理的余额还是 A 的余额?
结论:返回的是代理合约的余额。下面深入解释原因。
1. delegatecall 的核心语义
delegatecall 是一种特殊的调用方式,它只借用目标合约的代码,但完全保留调用合约的上下文:
- 存储:读写操作作用于调用方(代理)的存储槽。
msg.sender:保持为最初调用代理的外部账户地址。msg.value:保持原始转账金额。address(this):指向当前执行的合约,即代理合约本身(因为代码从 A 复制到代理的上下文中执行)。balance:与address(this)绑定,自然也是代理的余额。
可以理解为:
delegatecall把目标合约的代码“粘贴”到调用合约中执行,就像那段代码本来就写在调用合约里一样。
2. 代码示例验证
contract Proxy {
address public implementation;
constructor(address _impl) { implementation = _impl; }
function getBalanceViaDelegatecall() external view returns (uint) {
(bool ok, bytes memory data) = implementation.delegatecall(
abi.encodeWithSignature("getThisBalance()")
);
require(ok);
return abi.decode(data, (uint));
}
}
contract LogicA {
function getThisBalance() external view returns (uint) {
return address(this).balance; // 这里 this 是谁?
}
}
假设 Proxy 余额为 10 ETH,LogicA 余额为 0 ETH。
调用 Proxy.getBalanceViaDelegatecall() 返回 10 ETH,而不是 0。
3. 为什么不是 A 的余额?
- 如果使用普通
call,address(this)会指向被调用的合约 A,返回 A 的余额。 - 但
delegatecall故意不改变执行环境,只改变代码。address(this)始终是执行合约,在delegatecall执行期间“执行合约”仍然是代理(因为代理是实际运行代码的合约)。
4. 开发中的实际影响
这一特性在可升级代理中至关重要:
- 逻辑合约中若使用
address(this).balance或address(this)进行任何权限判断,它们操作的都是代理的余额和地址。 - 如果逻辑合约期望访问自己的存储变量(如
owner),但通过delegatecall执行时,实际读写的是代理的存储 —— 因此需要存储布局一致。 - 这也意味着代理合约可以持有全部资金,而逻辑合约无需持有任何资金(逻辑合约的余额通常为 0,除非直接向其转账,但不推荐)。
5. 如何获取逻辑合约自身的余额?
如果确实需要获取逻辑合约(即被 delegatecall 的合约)的余额,必须在逻辑合约中使用类似 address(this) 是无法做到的,因为 this 总是指向代理。一种方法是:将逻辑合约地址作为参数传递(例如通过代理预先存储),然后显式调用 address(logicContract).balance。但通常这么做意义不大,因为代理模式中资金应完全由代理管理。
33. 滑点参数有什么用?
滑点参数(slippage)用于去中心化交易所交易,保护用户免受价格不利波动的影响。
用户在提交交易时指定允许的最大价格偏差(如 minOutputAmount 或 maxInputAmount)。如果交易实际成交价格超出该范围,交易会自动回滚,防止因抢跑或市场剧烈波动导致意外损失。
34. ERC721A 如何减少铸造成本?有什么权衡?
原理:将批量铸造时的所有权存储从每个 token 一个存储槽优化为仅记录范围(例如从 startTokenId 到 endTokenId 属于同一所有者),从而大幅减少 SSTORE 次数。
权衡:
- 转移单个 token 的 gas 成本略高于标准 ERC721。
- 查询特定 token 的所有者需要计算(遍历范围),增加了一些计算开销。
- 代码更复杂,容易引入边缘情况 bug。
35. 什么是 TWAP?
TWAP = Time-Weighted Average Price(时间加权平均价格)。
它计算一个资产在特定时间窗口内的平均价格(通常来自 AMM 或预言机),能够抵抗短期价格操纵。
例如 Uniswap V2 的 priceCumulativeLast 可用于实现 TWAP。许多借贷协议使用 TWAP 作为清算参考价。
36. Compound Finance 如何计算利用率?
利用率 U 表示借贷市场中借出资金的占用比例:
U = totalBorrows / (totalCash + totalBorrows - reserves)
或者简化公式:U = borrowed / totalSupply。
利用率影响借贷利率:当 U 越高,借款利率上升,存款利率也上升,通过市场调节供需平衡。
37. 为什么大量合约字节码以 6080604052 开头?这个字节码序列是做什么的?
6080604052 是 Solidity 编译器生成的合约部署代码(init code)的常见前缀,对应的汇编指令是:
60 80–PUSH1 0x8060 40–PUSH1 0x4052–MSTORE
作用:在内存地址 0x40 处存储 0x80(即初始化自由内存指针为 0x80)。这是 Solidity 内存布局的固定开头,所有合约几乎都从这段代码开始。
38. 内存中的 bytes 和 bytes1[] 之间有什么区别?
bytes:动态字节数组,长度编码在前 32 字节(存放数组长度),后续每个字节占 1 字节,且内存中紧密排列。bytes1[]:bytes1类型的动态数组,每个元素占用 32 字节(因为 EVM 内存分配以 32 字节为单元,虽然bytes1只需 1 字节,但为了对齐,每个元素会单独占用一个 word,导致巨大内存浪费)。
因此,处理字节序列时始终使用 bytes 而非 bytes1[]。
39. 以太坊预编译合约的地址是什么?
预编译合约位于地址 0x01 到 0x09(以及后续添加的 0x0a 等):
0x01:ecrecover(椭圆曲线签名恢复)0x02:SHA2560x03:RIPEMD1600x04:identity(数据复制)0x05:modExp(模幂运算)0x06:BN128 加法0x07:BN128 标量乘法0x08:BN128 配对检查0x09:Blake2b 压缩
(某些链可能更多,如0x0a用于 BLS 签名等)
40. uint64 和 uint256 在 calldata 中的 ABI 编码有何不同?
uint64:在 ABI 编码时占据 32 字节,高位补零(因为 ABI 要求所有基本类型都编码为 32 字节倍数)。uint256:同样占据 32 字节,但不会补零(直接写完整 32 字节)。
两者在 calldata 中的字节长度相同(都是 32 字节),区别仅在于高 192 字节是否为全零。
注意:动态类型(如uint[])会因长度和偏移量额外占用空间。
41. 为什么 calldata 中的负数会消耗更多的 gas?
在 Solidity 中,int 类型使用二进制补码表示。负数在 calldata 中高位字节非零(例如 -1 有 32 个 0xff 字节),而正数通常有许多前导零字节。
Calldata 的 gas 成本是按每字节非零数据计算的(EIP-2028 后:零字节 4 gas,非零字节 16 gas)。负数包含大量非零字节(0xff),因此 calldata 大小相同但 gas 成本更高。
如果使用
uint或优化编码可避免该开销。