Skip to main content

Solidity 中级面试题

以下是 Solidity 中级面试题 的详细解答:


1. transfer 和 send 之间有什么区别?为什么不应该使用它们?

函数Gas 限制失败处理
send2300返回 bool false,不自动回滚
transfer2300失败时自动 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) 是一种两阶段协议:

  1. 提交阶段:用户发送哈希后的承诺(如 keccak256(choice + salt)),不暴露原始值。
  2. 揭示阶段:用户发送原始值(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)。

  • xy 为两种代币的储备量。
  • 交易 Δ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. 如何设计一个石头-剪刀-布的智能合约游戏,使玩家无法作弊?

使用提交-揭示机制:

  1. 玩家 A 提交keccak256(choice + salt)(choice: 0=石头,1=剪刀,2=布)。
  2. 玩家 B 提交:同样提交哈希。
  3. 双方揭示:发送原始 choicesalt,合约验证哈希匹配。
  4. 结算:根据规则判断胜负,转移押金。

防止作弊的措施

  • 设置提交截止时间和揭示截止时间。
  • 若一方不揭示,则退还对方押金或判定对方赢。
  • 使用链上随机数?不需要,因为选择隐藏了。
  • 确保 salt 足够长(如 uint256),避免暴力破解。
  • 可引入 commit-reveal 的顺序:玩家 A 先提交,玩家 B 后提交,但是 B 可以看到 A 的哈希?注意哈希不可逆,但 B 仍可在提交时选择针对 A 的已知选择?因为 A 的 choice 仍未知,所以公平。更安全的做法是同时提交(通过区块边界或使用类似 RANDAO 的方式)。 以下是 16–30 题 的解答:

16. 自由内存指针是什么,它存储在哪里?

自由内存指针(free memory pointer)指向当前未使用的内存起始位置,初始值为 0x40(即内存中第 64 字节处)。
存储在内存地址 0x400x5f(32 字节大小)。
当需要动态分配内存(如 new 字节数组、abi.encode 等)时,会读取该指针,然后将其增加分配的大小,并将新的指针写回 0x40
注意:内联汇编中应手动维护该指针,避免覆盖。


17. 接口中有效的函数修饰符有哪些?

接口中只能定义函数声明,不能有实现。因此允许的修饰符非常有限:

  • privateinternal(接口函数都是隐式 external
  • 不能有 viewpure 等修饰符?实际上可以viewpure,因为它们只影响函数行为而不涉及实现,但接口中的函数默认可以没有这些修饰符。
  • 不能有 virtualoverride(因为接口没有实现)
  • 不能有 payable?接口函数可以标记 payable,允许接收以太币。

简单说:接口中函数只能是 external 类型,可加 view/pure/payable,无其他修饰符。


18. 函数参数中的 memory 和 calldata 有什么区别?

关键字存储位置可修改性Gas 成本适用场景
calldata调用数据区只读极低(直接读取)外部函数的参数(默认)
memory内存可修改较高(复制或分配内存)内部函数参数或需要修改的数据
  • calldata 仅用于 external 函数,避免复制数据从而节省 gas。
  • memory 可用于所有函数可见性,数组/结构体等可被修改。

19. 描述三种存储 gas 成本类型。

以太坊存储相关 gas 成本(基于当前 EVM):

  1. SLOAD(读存储):冷读 2100 gas,热读 100 gas。
  2. SSTORE(写存储)
    • 从 0 → 非 0(新增):22100 gas
    • 从 非 0 → 0(删除):4800 gas(并返还约 15000 gas 的退款)。
    • 从 非 0 → 非 0(修改):5000 gas
  3. 冷地址/账户访问:首次访问外部账户或合约(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 抢跑策略,攻击者在大额交易前后分别买入和卖出同一资产,以赚取差价。
步骤:

  1. 发现 mempool 中待处理的大额买单(例如买入代币 A)。
  2. 攻击者抢先买入 A(推高价格)。
  3. 大额交易被执行(以更高价格买入)。
  4. 攻击者立即卖出 A(获利离场)。
    防御:使用限价单、批量拍卖、或通过隐私交易(如 Flashbots)避免被抢跑。

27. 如果向一个会回滚的函数进行 delegatecall,delegatecall 会怎么做?

delegatecall将执行环境切换到目标合约,如果目标函数内部 revert 或抛出异常,整个 delegatecall回滚当前交易(如同普通 callrevert)。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 的余额?

  • 如果使用普通 calladdress(this) 会指向被调用的合约 A,返回 A 的余额。
  • delegatecall 故意不改变执行环境,只改变代码。address(this) 始终是执行合约,在 delegatecall 执行期间“执行合约”仍然是代理(因为代理是实际运行代码的合约)。

4. 开发中的实际影响

这一特性在可升级代理中至关重要:

  • 逻辑合约中若使用 address(this).balanceaddress(this) 进行任何权限判断,它们操作的都是代理的余额和地址。
  • 如果逻辑合约期望访问自己的存储变量(如 owner),但通过 delegatecall 执行时,实际读写的是代理的存储 —— 因此需要存储布局一致
  • 这也意味着代理合约可以持有全部资金,而逻辑合约无需持有任何资金(逻辑合约的余额通常为 0,除非直接向其转账,但不推荐)。

5. 如何获取逻辑合约自身的余额?

如果确实需要获取逻辑合约(即被 delegatecall 的合约)的余额,必须在逻辑合约中使用类似 address(this) 是无法做到的,因为 this 总是指向代理。一种方法是:将逻辑合约地址作为参数传递(例如通过代理预先存储),然后显式调用 address(logicContract).balance。但通常这么做意义不大,因为代理模式中资金应完全由代理管理。


33. 滑点参数有什么用?

滑点参数(slippage)用于去中心化交易所交易,保护用户免受价格不利波动的影响。
用户在提交交易时指定允许的最大价格偏差(如 minOutputAmountmaxInputAmount)。如果交易实际成交价格超出该范围,交易会自动回滚,防止因抢跑或市场剧烈波动导致意外损失。


34. ERC721A 如何减少铸造成本?有什么权衡?

原理:将批量铸造时的所有权存储从每个 token 一个存储槽优化为仅记录范围(例如从 startTokenIdendTokenId 属于同一所有者),从而大幅减少 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 80PUSH1 0x80
  • 60 40PUSH1 0x40
  • 52MSTORE

作用:在内存地址 0x40 处存储 0x80(即初始化自由内存指针为 0x80)。这是 Solidity 内存布局的固定开头,所有合约几乎都从这段代码开始。


38. 内存中的 bytes 和 bytes1[] 之间有什么区别?

  • bytes:动态字节数组,长度编码在前 32 字节(存放数组长度),后续每个字节占 1 字节,且内存中紧密排列
  • bytes1[]bytes1 类型的动态数组,每个元素占用 32 字节(因为 EVM 内存分配以 32 字节为单元,虽然 bytes1 只需 1 字节,但为了对齐,每个元素会单独占用一个 word,导致巨大内存浪费)。

因此,处理字节序列时始终使用 bytes 而非 bytes1[]


39. 以太坊预编译合约的地址是什么?

预编译合约位于地址 0x010x09(以及后续添加的 0x0a 等):

  • 0x01:ecrecover(椭圆曲线签名恢复)
  • 0x02:SHA256
  • 0x03:RIPEMD160
  • 0x04: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 或优化编码可避免该开销。