一个例子
Solidity是以太坊中最常用的编程语言,这是一个公开拍卖智能合约的例子。
// SPDX-License-Identifier: MIT
// 告诉编译器我们用的是哪个版本的Solidity
pragma solidity ^0.8.20;
contract SimpleAuction {
// --- 状态变量 (State Variables) ---
// 这些是永久存储在区块链上的数据
// 受益人,也就是卖东西收钱的人
address public beneficiary;
// 拍卖结束的时间点(一个Unix时间戳)
uint public auctionEndTime;
// 当前最高出价者
address public highestBidder;
// 当前最高出价的金额
uint public highestBid;
// 一个映射,用于存储每个出价人被超过的退款。
// 如果A出价1ETH,B出价2ETH,那么A的1ETH就会暂存在这里等待他取回。
mapping(address => uint) private pendingReturns;
// 拍卖是否已结束的标志
bool public ended;
// --- 事件 (Events) ---
// 用于向外界(如App前端)发送通知
// 当有新的最高出价时触发
event HighestBidIncreased(address bidder, uint amount);
// 当拍卖成功结束时触发
event AuctionEnded(address winner, uint amount);
// --- 函数 (Functions) ---
// 构造函数:在部署合约时仅运行一次
constructor(uint _biddingTimeInSeconds) {
// 合约的部署者就是受益人
beneficiary = msg.sender;
// 设定结束时间 = 当前时间 + 传入的竞拍时长
auctionEndTime = block.timestamp + _biddingTimeInSeconds;
}
// 出价函数
// payable: 这是一个关键词,表示这个函数可以接收ETH
function bid() public payable {
// 1. 检查条件是否满足 (用 require)
require(block.timestamp < auctionEndTime, "Auction already ended.");
require(msg.value > highestBid, "There is already a higher bid.");
// 2. 如果之前有最高出价者,把他们的出价金额记录到待退款中
if (highestBidder != address(0)) {
pendingReturns[highestBidder] += highestBid;
}
// 3. 更新最高出价者和最高出价金额
highestBidder = msg.sender; // msg.sender: 调用这个函数的人
highestBid = msg.value; // msg.value: 随函数调用发送的ETH金额
// 4. 触发事件,通知外界有新的最高价
emit HighestBidIncreased(msg.sender, msg.value);
}
// 取回被超过的出价
function withdraw() public returns (bool) {
uint amount = pendingReturns[msg.sender];
if (amount > 0) {
// 在实际转账前,先把待退款金额清零,防止重入攻击
pendingReturns[msg.sender] = 0;
// 把钱退还给调用者
if (!payable(msg.sender).send(amount)) {
// 如果发送失败,把金额退回到待退款中
pendingReturns[msg.sender] = amount;
return false;
}
}
return true;
}
// 结束拍卖函数
function auctionEnd() public {
// 1. 检查条件
require(block.timestamp >= auctionEndTime, "Auction not yet ended.");
require(!ended, "auctionEnd has already been called.");
// 2. 标记拍卖已结束,并触发事件
ended = true;
emit AuctionEnded(highestBidder, highestBid);
// 3. 将最高出价的钱转给受益人
payable(beneficiary).transfer(highestBid);
}
}
Solidity 的核心在于:
- 定义状态 (State): 用状态变量(如
uint
,string
,address
)来定义合约需要存储的数据。 - 定义规则 (Rules): 用函数、修饰符和
require
语句来定义谁可以在什么条件下改变这些状态。 - 提供透明度 (Transparency): 用
public
和view
函数让外部可以读取数据,用event
将重要活动记录下来。
把上面的代码拆开来看会更容易理解。
A. 状态变量和事件
这部分是合约的“记忆”。
beneficiary
和auctionEndTime
: 在合约创建时就定好了,定义了谁收钱以及拍卖何时结束。block.timestamp
是一个全局变量,代表当前区块的时间。highestBidder
和highestBid
: 这是拍卖的核心动态数据,记录着当前的领先者和价格。pendingReturns
: 这是一个mapping
(映射),你可以把它想象成一个字典或哈希表。它的键(Key)是出价人的地址,值(Value)是他们应该被退还的金额。这是为了处理被别人超过出价后的退款问题。ended
: 这是一个bool
(布尔值),像一个开关,防止auctionEnd
函数被多次调用。event
: 事件就像合约的“广播系统”,它本身不影响合约逻辑,但能让外部应用(比如网站前端)监听到合约内部发生了什么重要事情。
B. constructor
(构造函数)
这个函数非常特殊,只在部署合约的那一刻被调用一次。它的作用是完成初始化设置。 在这里,它把部署合约的你(msg.sender
)设置为受益人,并根据你传入的参数计算出拍卖的精确结束时间。
在较新的 Solidity 版本(0.4.22
及以后)中,构造函数使用 constructor
关键字来声明。在旧版本中,构造函数的名称必须与合约名称完全相同。一个合约可以没有构造函数。如果没有定义,合约会使用一个默认的空构造函数。一个合约只能有一个构造函数。
C. bid()
(出价函数)
这是合约最核心的交互函数。
payable
: 这个关键字至关重要!它告诉以太坊虚拟机(EVM),“这个函数有能力接收以太币”。如果没有它,别人向这个函数发送ETH的交易会失败。require(...)
: 这是合约的“门卫”。require
接受两个参数:一个条件和一个失败时的提示信息。如果条件不为true
,整个函数调用就会失败,所有状态改动都会被回滚,Gas费也会被消耗但不会退还。require(block.timestamp < auctionEndTime, ...)
确保拍卖还没结束。require(msg.value > highestBid, ...)
确保你的出价是目前最高的。
msg.sender
和msg.value
: 这两个是所有public
和external
函数都能访问的全局变量。msg.sender
: 调用当前函数的账户地址(就是出价人的你)。msg.value
: 在这次函数调用中,你发送了多少以太币(单位是wei,ETH的最小单位)。
D. withdraw()
(取款函数)
如果你的出价被别人超过了,你的钱并不会自动原路返回,因为这在以太坊上是不安全的设计模式。最佳实践是让用户自己来“取回”退款。这个函数就是做这个的。它会检查 pendingReturns
中你是否有待领的退款,然后安全地发还给你。
E. auctionEnd()
(结束拍卖函数)
当拍卖时间到了之后,任何人都可以调用这个函数来终结拍卖。
- 它首先检查时间是否真的到了,以及拍卖是否已经被结束了。
- 然后,它把
ended
标志位设为true
,防止其他人再次调用。 - 最后,也是最关键的一步:
payable(beneficiary).transfer(highestBid)
。这行代码将合约中汇集的最高出价金额,安全地转移给受益人(beneficiary
)。
payable
关键字
payable
是一个关键字,用于修饰地址(address)和函数(function),使其能够接收和处理以太币(Ether)。如果一个函数或地址没有被标记为 payable
,那么它在默认情况下会拒绝所有发送给它的以太币。
payable
的两种主要用途
1. 修饰函数 (function
)
当 payable
用于修饰一个函数时,它意味着这个函数可以接收伴随交易一同发送过来的以太币。
关键特性:
- 接收 Ether:只有
payable
函数才能在被调用时接收 Ether。如果你试图向一个非payable
函数发送 Ether,交易将会失败并回滚。 - 访问
msg.value
:在一个payable
函数内部,你可以通过全局变量msg.value
来获取随交易发送过来的 Ether 数量(以 Wei 为单位)。 - 合约余额增加:当一个
payable
函数成功执行后,收到的 Ether 会被存入合约的地址中,增加合约的总余额。
2. 修饰地址 (address
)
当 payable
用于修饰一个地址类型的变量时,它创建了一个新的类型:address payable
。这种类型的地址变量拥有普通 address
类型不具备的成员函数,即 transfer
和 send
,专门用于向该地址发送 Ether。
关键特性:
- 发送 Ether 的能力:只有
address payable
类型的变量才能使用.transfer()
和.send()
方法来向其转账。一个普通的address
变量无法直接使用这些方法。 - 类型转换:你可以将一个
address
类型的变量显式转换为address payable
,语法是payable(address_variable)
。
fallback()函数
fallback()
是 Solidity 智能合约中的一个特殊函数,它充当着“默认”或“后备”的角色。当一个合约收到一个函数调用,但在其代码中找不到与调用指令相匹配的函数时,fallback()
函数就会被自动执行。
fallback()
的执行主要有两种情况:
- 调用了不存在的函数: 这是最主要的情况。当外部账户或另一个合约尝试调用当前合约的一个函数,但函数签名(
calldata
的前4字节)与合约中任何一个public
或external
函数都不匹配时,fallback()
会被触发。 - 向合约发送以太币 (ETH): 当一个交易直接向合约地址发送ETH,并且满足以下任一条件时,
fallback()
会被执行:- 交易中包含了数据 (
calldata
不为空),但这个数据不匹配任何函数(同情况1)。 - 交易中不包含任何数据 (
calldata
为空),并且合约中没有定义receive()
函数。
- 交易中包含了数据 (
在现代 Solidity (版本 0.6.x
及以上) 中,专门引入了 receive()
函数来处理“只接收ETH”的场景,这使得 fallback()
的职责更加清晰。
receive() external payable { ... }
- 唯一职责:当合约收到一笔不带任何数据 (
calldata
为空) 的纯ETH转账时,这个函数被触发。 - 设计目的:专门用来接收以太币。
- 唯一职责:当合约收到一笔不带任何数据 (
fallback() external [payable] { ... }
- 主要职责:处理所有与函数签名不匹配的调用。
- 次要职责:如果合约中没有定义
receive()
函数,那么fallback()
也会兼职处理不带数据的纯ETH转账(此时它必须被标记为payable
)。
执行优先级规则: 当一笔交易发往合约时:
- 有数据 (
calldata
不为空)?- 匹配到函数? -> 执行该函数。
- 未匹配到函数? -> 执行
fallback()
。
- 无数据 (
calldata
为空)?- 存在
receive()
函数? -> 执行receive()
。 - 不存在
receive()
函数,但存在payable fallback()
函数? -> 执行fallback()
。 - 以上两者都不存在? -> 交易失败。
- 存在
合约调用
一个合约可以通过两种主要方式调用另一个合约中的函数:一种是高级、直接的调用,另一种是低级、通用的 call
调用。
方法一:直接调用 (通过接口或合约实例)
这是最常见和最推荐的方式。它就像在程序中调用一个已知对象的函数一样。需要让你的合约“知道”目标合约长什么样,这通常通过接口 (Interface) 或直接导入目标合约的源代码来实现。
工作原理:
- 定义接口:接口就像一个合约的“菜单”或“功能列表”,它只声明函数,不包含具体实现。
- 创建实例:在你的合约中,使用目标合约的地址来创建一个指向它的实例。
- 直接调用:像调用自己合约内的函数一样,通过实例来调用目标函数。
代码示例:
假设我们有一个简单的计数器合约 Counter
。
// --- 目标合约:Counter.sol ---
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Counter {
uint public count;
function increment() public {
count += 1;
}
function getCount() public view returns (uint) {
return count;
}
}
现在,我们创建另一个合约 Caller
来调用 Counter
中的函数。
// --- 调用合约:Caller.sol ---
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 导入或定义Counter的接口,让本合约知道Counter有哪些函数
interface ICounter {
function increment() external;
function getCount() external view returns (uint);
}
contract Caller {
// 创建一个ICounter类型的状态变量,用于指向目标合约
ICounter public counterContract;
// 构造函数,在部署时传入Counter合约的地址
constructor(address _counterAddress) {
// 将地址转换为ICounter实例
counterContract = ICounter(_counterAddress);
}
// 调用Counter的写操作函数
function incrementCounterInB() public {
// 直接像调用普通函数一样调用
counterContract.increment();
}
// 调用Counter的读操作函数
function readCountFromB() public view returns (uint) {
// 直接调用并返回值
return counterContract.getCount();
}
}
优点:
- 类型安全:编译器会检查你调用的函数是否存在,以及参数类型是否正确。
- 代码清晰:可读性非常高,意图明确。
- 简单易用:是与已知合约交互的首选。
方法二:低级调用 (使用 address.call
)
这是一种更底层、更灵活但也更危险的方式。任何地址变量都可以使用 .call()
方法。不需要知道目标合约的任何信息,只需要它的地址。
工作原理: 手动构建需要发送的数据载荷 (payload),这个载荷精确地描述了你想调用哪个函数和使用什么参数(这与外部账户调用合约的原理完全相同)。然后通过 .call()
将这个载荷发送到目标地址。
代码示例:
我们仍然使用上面的 Counter
合约作为目标。
// --- 调用合约:CallerWithCall.sol ---
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CallerWithCall {
address counterAddress;
constructor(address _counterAddress) {
counterAddress = _counterAddress;
}
// 使用 .call() 调用 Counter 的 increment() 函数
function incrementWithCall() public {
// 手动编码函数签名和参数。因为increment没有参数,所以更简单。
// "increment()" 的函数选择器是 bytes4(keccak256("increment()"))
(bool success, bytes memory returnData) = counterAddress.call(
abi.encodeWithSignature("increment()")
);
// 极其重要:必须检查调用是否成功!
require(success, "Failed to call increment function");
}
// 使用 .call() 调用 Counter 的 getCount() 函数
function getCountWithCall() public view returns (uint) {
(bool success, bytes memory returnData) = counterAddress.call(
abi.encodeWithSignature("getCount()")
);
require(success, "Failed to call getCount function");
// 手动解码返回的数据
return abi.decode(returnData, (uint));
}
}
优点:
- 高度灵活:可以与任何合约交互,即使在编写时你并不知道它的具体类型。这是实现代理模式 (Proxy Pattern) 和可升级合约的关键。
缺点/风险:
- 类型不安全:编译器无法帮你检查。如果把函数名写错了(
"incremen()"
),交易在运行时会失败,但编译时不会报错。 - 代码复杂:需要手动进行编码和解码,可读性差。
- 安全风险:
.call()
在转发ETH时,如果处理不当,容易引发重入攻击 (Re-entrancy Attacks)。
另外的调用:delegatecall()
delegatecall()
的核心特点是:借用别人的代码,在自己的环境里执行。其执行目标合约的代码,但所有的状态变更、msg.sender
和 msg.value
都维持在调用合约的上下文中。
它的主要用途是实现代理模式 (Proxy Pattern),从而让不可变的智能合约变得“可升级”。
工作原理如下:
- 部署两个合约:
- Proxy 合约 (代理):这个合约非常简单,它负责存储所有数据(状态变量),并且是用户唯一交互的地址。它的核心逻辑就是将所有收到的调用都通过
delegatecall
转发给一个“逻辑合约”。 - Logic 合约 (逻辑):这个合约包含了所有复杂的业务逻辑,但它不存储任何重要状态。
- Proxy 合约 (代理):这个合约非常简单,它负责存储所有数据(状态变量),并且是用户唯一交互的地址。它的核心逻辑就是将所有收到的调用都通过
- 用户交互:用户调用 Proxy 合约的函数。Proxy 合约通过
delegatecall
把调用转发给 Logic 合约。Logic 合约的代码被执行,但因为它是在 Proxy 的上下文中执行的,所以所有状态都保存在了 Proxy 合约里。 - 升级过程:
- 当你发现 Logic 合约有 bug 或需要添加新功能时,只需部署一个新的 Logic V2 合约。
- 然后,调用 Proxy 合约中的一个特殊函数,将它指向的 Logic 合约地址从 V1 改为 V2。
- 用户的交互地址(Proxy)不变,所有历史数据(在Proxy中)都得以保留,但底层的业务逻辑已经被悄悄替换掉了。
以太坊虚拟机
EVM 不是一台物理存在的机器,而是一个虚拟的、沙盒化的运行时环境,它存在于每一个参与以太坊网络的节点之上。它负责执行代码、处理计算,并确保网络中的每一个节点都能得到完全相同的结果,从而达成共识。
EVM 的核心作用和特性
1. 智能合约的执行环境
这是 EVM 最核心的职责。开发者使用 Solidity 等高级语言编写智能合约,这些代码在部署前会被编译成 EVM 能直接理解的低级指令——字节码(Bytecode)。当用户发起一笔交易来调用智能合约的函数时,网络中的每一个节点都会启动自己的 EVM 实例,来执行这段字节码。
- 从 Solidity 到 EVM 执行的流程:
- 编写:开发者用 Solidity 编写
MyContract.sol
。 - 编译:Solidity 编译器将代码转换为 EVM 字节码。
- 部署:将字节码通过一笔交易发送到以太坊网络上,存储在区块链中。
- 执行:当有人调用该合约的函数时,所有节点上的 EVM 会加载并逐条执行这段字节码,计算最终结果。
- 编写:开发者用 Solidity 编写
2. 保证确定性(Deterministic)
这是区块链共识的基础。一个操作无论在哪个国家的哪个节点上、在什么时间执行,只要输入是相同的,EVM 执行后得到的结果必须是完全一致的。这种确定性确保了所有节点都能就账本的新状态(比如谁的余额减少了,谁的增加了)达成共识。为了实现这一点,EVM 的环境是高度受限的,它不能进行非确定性的调用(如调用外部世界的网络 API 或生成随机数)。
3. 状态管理(State Management)
以太坊可以被看作一个巨大的“状态机”。“状态”就是所有账户余额和智能合约数据的快照。每当一笔交易被 EVM 执行,它就会从当前状态转换到一个新的状态。EVM 的工作就是根据交易和智能合约的逻辑,准确无误地计算出这个“新状态”。
4. 隔离与安全(Isolation)
EVM 是一个沙盒环境。这意味着在 EVM 中运行的智能合约代码与节点计算机的其他部分是完全隔离的。合约代码不能访问节点的文件系统、网络或其他进程。这种隔离机制极大地增强了安全性,防止了恶意合约攻击运行它的计算机。
5. 图灵完备性(Turing Completeness)
EVM 被设计为“准图灵完备”的。理论上,“图灵完备”的机器可以解决任何可计算的问题。这意味着 EVM 能够执行任意复杂的算法和逻辑,使得开发者可以构建出功能极其丰富的去中心化应用(DApps),从简单的转账到复杂的去中心化金融(DeFi)协议。
6. Gas 机制:防止滥用
既然 EVM 是图灵完备的,就存在一个理论上的风险——“停机问题”(Halting Problem),即代码可能陷入无限循环,导致整个网络瘫痪。
为了解决这个问题,以太坊引入了 Gas(燃料) 机制:
- 计算成本:EVM 中的每一个操作指令(称为 Opcode,如加法、乘法、存储数据等)都被设定了一个明确的 Gas 消耗量。复杂的操作消耗更多的 Gas。
- 付费执行:用户在发起交易时,必须提供一定数量的 Ether 作为 Gas 费,为他们的计算“买单”。
- 防止无限循环:如果一个合约的执行消耗的 Gas 超过了用户提供的上限,EVM 会强制停止执行,并回滚所有已做的状态更改,但已经消耗的 Gas 费不会退还。这有效阻止了恶意或低效的代码拖垮整个网络。
汽油费
在以太坊里,汽油费是用户为了在网络上执行操作而支付的交易成本。无论是发送ETH、交易代币,还是与智能合约进行交互(例如铸造NFT或参与DeFi),都必须支付这笔费用。
目的
汽油费的存在有两大核心目的:
- 激励网络验证者 (Validators):以太坊网络由成千上万的计算机(验证者)共同维护。验证者需要贡献自己的计算资源来处理交易并将其打包到区块中,以确保网络的安全和运行。汽油费就是对他们辛勤工作的奖励和补偿。没有这笔费用,就没有人有动力去维护这个去中心化的网络。
- 防止网络滥用与垃圾信息:如果执行操作是免费的,恶意行为者就可以通过发送无数个无意义的交易来发起垃圾信息攻击,从而导致网络堵塞瘫痪。通过为每一次计算都设定一个成本,汽油费有效地阻止了这种滥用行为,确保了网络资源的有效利用。
计算
自2021年8月的“伦敦升级”(EIP-1559)后,以太坊的汽油费计算方式变得更加复杂但可预测。总费用由三个关键部分组成:
- 基础费 (Base Fee):这是由协议本身设定的必须支付的费用。它会根据网络的繁忙程度自动调整:如果网络使用率超过50%,基础费就会上升;如果低于50%,则会下降。这部分费用会被销毁(Burn),而不是支付给验证者,这会给ETH带来通缩效应。
- 优先费 (Priority Fee) / 小费 (Tip):这是用户额外支付给验证者的费用,目的是激励验证者优先处理你的交易。在网络拥堵时,支付更高的小费可以让你的交易更快被打包。
- Gas 使用量 (Gas Used):指完成一笔交易实际消耗的计算资源量。简单的ETH转账消耗的Gas固定为21,000单位,而复杂的智能合约交互(如DEX交易)则需要更多的计算步骤,因此会消耗更多的Gas。
总汽油费的计算公式如下:
总费用=(基础费+优先费)×Gas使用量
汽油费通常用 Gwei 来计价。
举例说明: 假设你想进行一笔交易:
- 当前网络的基础费是
20 Gwei
。 - 你愿意支付
2 Gwei
的优先费来加快处理速度。 - 这笔交易预计的 Gas 使用量 是
21,000
。
那么你的总汽油费大约是: (20 Gwei + 2 Gwei) * 21,000 = 462,000 Gwei = 0.000462 ETH
用户在提交交易时,钱包(如MetaMask)还会要求设定一个 Gas 上限 (Gas Limit),这是你愿意为这笔交易支付的最大Gas单位数。这是一个安全措施,防止因合约执行出错而耗尽你钱包里所有的ETH。如果交易实际消耗的Gas低于上限,未使用的部分会自动退还。
波动
以太坊的汽油费是动态变化的,有时非常便宜,有时则极其昂贵。主要影响因素是:
- 网络拥堵:这是最主要的原因。当有大量用户同时试图在以太坊上进行交易时(例如,一个热门的NFT项目开始铸造,或者市场剧烈波动时),区块空间就成了一种稀缺资源。用户为了让自己的交易能被优先处理,会提高“小费”出价,从而推高了整体的汽油费水平。这就像高峰时期的打车软件,需要加价才能叫到车。
- 交易复杂度:与复杂的智能合约交互比简单的转账需要更多的计算步骤,因此消耗的Gas更多,总费用自然也更高。
停机问题
当你发起一个对智能合约的调用时,必须设定一个 Gas 上限 (Gas Limit)。当节点开始执行交易时,EVM 会像一个计价器一样,为合约中的每一个操作步骤都收取一笔微小的 Gas 费用。如果合约代码中存在一个死循环,累计消耗的 Gas 总量会达到最初设定的Gas 上限,在 Gas 耗尽的那一刻,EVM 会立即强制停止执行。这会触发一个 “Out of Gas” 的异常。
一旦发生“Out of Gas”异常:
- 交易失败:这笔交易被标记为失败。
- 状态回滚:合约在此次交易中对区块链状态所做的任何修改(比如更改了某个变量的值)都会被完全撤销,就好像这笔交易从未发生过一样。
- 费用照付:尽管交易失败了,但你仍然需要支付已经消耗掉的 Gas 费用。这笔钱会作为补偿支付给验证者,因为他们确实为你付出了计算资源。
错误处理
在以太坊中,一笔交易(Transaction)是原子性的。这意味着交易要么完全成功,要么完全失败,不存在“部分成功”的中间状态。
当一个交易在执行过程中遇到无法继续的错误时,一个称为状态回滚 (State Reversion) 的机制会被触发。
- 状态回滚:交易中所有已经发生的状态变更(例如修改变量的值、转移代币)都会被完全撤销,区块链的状态会恢复到该交易开始之前的那一刻。
Solidity 语言为开发者提供了三种主要的工具来主动触发错误和状态回滚。
1. require()
require()
是最常用、最重要的错误处理函数。它用于在函数执行的最开始检查条件是否满足,充当着“守门员”的角色。
- 用途:验证外部输入、检查权限、或确保合约处于正确的状态。
- 例:检查调用者是不是合约的拥有者 (
msg.sender == owner
)。 - 例:检查转账金额是否大于0 (
amount > 0
)。
- 例:检查调用者是不是合约的拥有者 (
- 语法:
require(bool condition, string memory message);
- 如果
condition
为false
,交易就会回滚,并向用户返回message
作为错误原因。
- 如果
- Gas 行为:失败时,会退还剩余的全部 Gas。
function withdraw(uint amount) public {
// 检查条件:确保用户有足够的余额
require(balances[msg.sender] >= amount, "Insufficient balance.");
// ... 如果条件满足,继续执行取款逻辑
}
2. revert()
revert()
的作用与 require()
失败时一样,都是触发回滚。但它更加灵活,通常用在复杂的 if-else
逻辑分支中。
- 用途:当你通过复杂的逻辑判断出一个错误条件,需要手动停止执行并回滚时使用。
- 语法:
revert(string memory message);
- Gas 行为:与
require
相同,失败时退还剩余 Gas。
if (tx.origin != msg.sender) {
// 不允许合约调用此函数,手动触发回滚
revert("Contracts are not allowed to call this function.");
}
现代用法:自定义错误 (Custom Errors) 为了节省 Gas,现代 Solidity 推荐使用自定义错误来代替字符串消息。
// 在合约顶部定义错误
error InsufficientBalance(uint required, uint available);
// 在函数中触发
if (balances[msg.sender] < amount) {
revert InsufficientBalance({
required: amount,
available: balances[msg.sender]
});
}
3. assert()
assert()
与前两者有本质区别。它不应该被用来检查外部输入,而应该用来检查代码内部的、理论上永远不应该发生的错误。
- 用途:检查代码不变量 (Invariants),比如算术溢出(在旧版Solidity中)或者一些关键的内部状态是否被意外破坏。如果
assert()
失败了,通常意味着你的合约代码本身存在一个严重的 Bug。 - 语法:
assert(bool condition);
(没有错误消息) - Gas 行为:这是一个惩罚性的工具! 如果
assert()
失败,它会消耗掉所有剩余的 Gas,不会退还
function applyRewards() internal {
uint initialBalance = address(this).balance;
// ... 一些复杂的奖励计算逻辑 ...
// 断言:合约的总余额在计算后不应该减少
assert(address(this).balance >= initialBalance);
}
函数 | 用途 | 检查对象 | 失败时 Gas 行为 |
---|---|---|---|
require() |
条件守卫 | 外部输入、权限、状态 | 退还剩余 Gas |
revert() |
手动回滚 | 复杂的内部逻辑判断 | 退还剩余 Gas |
assert() |
内部断言 | 代码不变量、内部错误 | 消耗所有剩余 Gas |
try/catch
当你调用另一个合约的函数时,如果那个函数 revert
了,你的整个交易也会跟着回滚。但在某些情况下,你可能希望“捕捉”这个外部错误,并执行备用逻辑,而不是让自己的函数也失败。这时就需要 try/catch
。
- 用途:安全地调用外部合约,即使外部调用失败,也能继续执行你自己的函数逻辑。
interface IOracle {
function getPrice() external returns (uint);
}
contract PriceReader {
IOracle public oracleA;
IOracle public oracleB;
function getBestPrice() public returns (uint) {
try oracleA.getPrice() returns (uint price) {
// 如果成功,直接返回价格
return price;
} catch {
// 如果 oracleA 调用失败,则尝试调用 oracleB
// 不会让整个 getBestPrice() 函数失败
return oracleB.getPrice();
}
}
}
嵌套调用
嵌套调用(Nested Call) 指的是一个智能合约(我们称之为合约A)调用了另一个智能合约(合约B)的函数,而在合约B的这个函数执行过程中,它又调用了第三个智能合约(合约C)的函数。
这种 A → B → C 的调用链就构成了一次嵌套调用。理论上,这个链条可以更长(A → B → C → D ...)。
嵌套调用的关键特征与影响
1. 共享同一笔交易上下文
整个 A → B → C 的调用链都发生在同一笔以太坊交易中,这意味着:
- 原子性:如果调用链中的任何一步失败(例如,合约C的调用
revert
了),那么整笔交易都会失败,所有已经发生的状态变更(包括合约A和B的)都会被完全回滚。 msg.sender
的变化:msg.sender
记录的是直接调用者。- 在合约B看来,
msg.sender
是合约A的地址。 - 在合约C看来,
msg.sender
是合约B的地址。 - 真正发起这笔交易的外部用户地址可以通过
tx.origin
来获取,但出于安全原因,强烈不推荐使用tx.origin
进行身份验证。
- 在合约B看来,
2. Gas 的传递与消耗
Gas(汽油费)会像“燃料”一样在调用链中被传递下去。
- 用户在发起交易时设定一个总的 Gas 上限 (Gas Limit)。
- 当合约A调用合约B时,EVM会把剩余 Gas 的一大部分(通常是63/64)传递给合约B。
- 同样,当合约B调用合约C时,也会把其剩余 Gas 的一大部分传递给合约C。
- 如果在任何一步中,传递下去的 Gas 不足以完成后续操作,就会导致“Out of Gas”错误,并使整个交易失败回滚。
3. 调用深度限制 (Call Depth Limit)
为了防止因无限递归调用而耗尽网络资源,以太坊设置了一个1024层的调用深度限制。如果 A → B → C ... 的链条超过了这个深度,交易会自动失败。在正常的业务逻辑中,极少会触及这个限制。
重入攻击(Re-entrancy)
当合约A调用外部(可能怀有恶意的)合约B时,如果合约A在完成自身状态更新之前就进行了外部调用,那么恶意的合约B就有可能“重入”(Re-enter),即回头反向调用合约A的函数,从而在合约A不知情的情况下,多次执行对自己有利的操作。
经典案例(一个有漏洞的银行合约):
- 受害者合约A有一个
withdraw()
函数,用于取款。 - 攻击者部署一个恶意合约B,并向合约A存入一些ETH。
- 攻击者调用合约A的
withdraw()
函数。 - 合约A的
withdraw()
函数先检查余额,然后向合约B发送ETH(这是一个嵌套调用),最后才更新余额。 - 漏洞触发:当合约B收到ETH时,其
receive()
或fallback()
函数被触发。在这个函数里,合约B立即回头再次调用合约A的withdraw()
函数。 - 由于合约A还没有来得及把合约B的余额更新为0,所以第二次
withdraw
的余额检查依然通过,合约B成功取走了第二笔钱。这个过程会循环,直到耗尽合约A的资金。
区块头中的gasUsed
和 gasLimit
gasLimit
(区块Gas上限)
gasLimit
是一个区块允许包含的所有交易能够消耗的 Gas 总量的上限。
它本质上定义了一个区块的最大容量或最大计算负载。这个值不是针对单笔交易的,而是针对整个区块的。
- 功能和目的:
- 决定区块大小:
gasLimit
直接决定了一个区块内可以打包多少笔交易,以及这些交易能有多复杂。gasLimit
越高,能容纳的交易就越多,网络的交易吞吐量(TPS)也就越高。 - 维护网络安全与去中心化: 设置一个上限可以防止区块变得过大。如果区块过大(包含了过多的计算),会导致普通配置的节点难以在规定时间内处理和验证区块,从而降低网络的同步速度和稳定性。这会迫使只有硬件配置极高的节点才能参与,损害网络的去中心化。
- 决定区块大小:
- 如何设定:
gasLimit
并非一个固定不变的常量。它是一个动态调整的值。网络中的验证者(以前的矿工)在发布新区块时,可以对上一个区块的gasLimit
进行微调。- 调整规则:验证者可以将新区块的
gasLimit
在上一区块gasLimit
的1/1024
范围内进行上调或下调。 - 市场驱动:这种机制允许网络容量根据需求进行有机的、缓慢的调整。如果验证者们认为网络需要更高的吞吐量,他们会逐渐投票提高
gasLimit
;反之则会降低。
- 调整规则:验证者可以将新区块的
自“伦敦升级”(EIP-1559)后,虽然区块的 gasLimit
硬上限是 3000万 Gas,但协议设定了一个 1500万 Gas 的“目标值 (Target)”。网络会通过调整基础费(Base Fee)来激励验证者尽量使区块的实际使用量维持在这个目标值附近。
gasUsed
(区块Gas使用量)
gasUsed
指的是一个区块内所有被成功打包的交易实际消耗的 Gas 总量之和。
它代表了这个区块实际承载的计算工作量,也就是货车实际装载的货物重量。
- 功能和目的:
- 衡量网络活动:
gasUsed
是一个关键的链上指标,直接反映了当时网络的繁忙程度。当gasUsed
持续接近gasLimit
时,说明网络非常拥堵。 - 影响基础费 (Base Fee):
gasUsed
的值是 EIP-1559 费用机制的核心。- 如果一个区块的
gasUsed
高于 1500万的目标值,下一个区块的base fee
就会上涨。 - 如果一个区块的
gasUsed
低于 1500万的目标值,下一个区块的base fee
就会下降。 这创造了一个可预测的费用市场,并通过供需关系来调节网络拥堵。
- 如果一个区块的
- 衡量网络活动:
两者的关系与流程
- 验证者构建区块:
- 验证者从交易池中挑选交易来打包进新的区块。
- 每一笔交易都有自己的
gasLimit
(用户设定的单笔交易Gas上限)。 - 验证者会进行计算,确保所有被选中交易的 Gas 总和不会超过当前区块的
gasLimit
。
- 执行交易:
- 验证者按顺序执行区块中的交易。
- 每笔交易实际消耗的 Gas 被记录下来。例如,一笔简单的ETH转账消耗21,000 Gas;一笔复杂的DeFi交易可能消耗200,000 Gas。
- 记录
gasUsed
:- 所有交易执行完毕后,验证者会将这些交易实际消耗的 Gas 全部加起来,得到一个总和。
- 这个总和就是该区块的
gasUsed
,它会被记录在新区块的区块头中。
以太坊中执行和挖矿的顺序
无论是过去的矿工,还是现在的验证者,区块的创建者都必须先执行智能合约,然后才能进行“挖矿”或“提议”,因为执行的结果是打包成区块的关键部分。
工作量证明 (PoW) 时代
第一步:打包交易 (Bundling)
- 矿工的电脑上有一个“内存池 (Mempool)”,里面充满了等待被处理的用户交易。
- 矿工会从这个池子里挑选交易。通常,他们会优先选择支付了更高 Gas 费的交易,因为这能让他们赚取更多的手续费。
- 矿工将这些挑选出来的交易集合在一起,形成一个“候选区块”的内容。这个过程就是打包。
第二步:执行智能合约并计算状态 (Execution)
- 为了生成一个有效的区块,矿工必须知道这个区块被全网接受后,以太坊的“世界状态”会变成什么样。
- 因此,矿工在自己的节点上,按顺序执行他刚刚打包的所有交易中的智能合约调用。
- 执行完毕后,他的节点会计算出一个最终的“状态根”,这是一个用来代表所有账户、余额和合约数据最终状态的加密哈希值。这个状态根是区块头里至关重要的一部分。
第三步:挖矿 (Mining)
- 现在,矿工有了一个完整的候选区块,包括交易列表、状态根以及其他数据。
- 他开始进行“挖矿”:这是一个纯粹的、消耗巨大计算能力的数学竞赛。他需要不断尝试不同的随机数 (Nonce),直到找到一个值,使得整个区块头的哈希值小于当前网络设定的一个极低的目标值。
- 这个过程与智能合约的内容无关,只是为了获得记账权。
第四步:广播与全网验证
- 第一个找到正确随机数的矿工,会立刻向全网广播他“挖”出的完整区块。
- 其他节点收到这个区块后,为了验证其有效性,会重新独立地执行一遍区块中所有的智能合约,然后比较自己计算出的“状态根”是否与广播来的区块头中的“状态根”一致。
- 如果一致,并且“工作量证明”也有效,其他节点就会接受这个区块,并将其添加到自己的区块链副本上。
PoW 顺序:对于矿工来说,是 打包 → 执行 → 挖矿。对于网络其他节点来说,是 接收区块 → 执行并验证。
权益证明 (PoS) 时代
以太坊现在使用 PoS 机制,虽然没有了“挖矿”,但核心逻辑相似。
第一步:打包交易 (Bundling)
- 系统会伪随机地从所有质押了 ETH 的验证者中选出一位,作为下一个“区块提议者 (Block Proposer)”。
- 这位提议者同样从内存池中挑选交易进行打包。
第二步:执行智能合约并计算状态 (Execution)
- 与矿工一样,这位提议者必须在本地节点上执行所有打包的交易,来计算出最终的“状态根”。
第三步:提议区块 (Proposing)
- 提议者将区块内容、状态根等数据组装成一个完整的区块,然后用自己的私钥对其进行签名,并广播给网络。这个过程取代了 PoW 中的“挖矿”,它不消耗大量算力,只是一个“我提议这个区块”的声明。
第四步:验证与共识 (Attestation & Consensus)
- 网络中的其他验证者(被组织成一个“委员会”)会收到这个提议的区块。
- 他们同样会重新执行区块中的所有智能合约,来验证状态根是否正确。
- 验证通过后,他们会广播一个“证明 (Attestation)”消息,相当于投了赞成票。当区块获得了足够多的赞成票后,它就会被最终确定下来,成为区块链上不可篡改的一部分。
对于整个网络而言,智能合约的执行发生了两次:
- 第一次由区块创建者在打包时执行,目的是为了生成区块。
- 第二次由网络中的所有其他节点在验证时执行,目的是为了确认区块的有效性。
失败交易上链
执行失败的交易不仅需要,而且必须被打包到区块并发布到链上。它揭示了区块链作为“不可篡改的公共账本”的核心本质。简单来说,**区块链记录的不仅仅是成功的结果,更是所有“已发生并已验证”的行为及其最终状态。
以下是为什么失败的交易必须上链的几个关键原因:
1. 记录已经发生的计算
即使一笔交易最终失败了,验证者(或过去的矿工)为了得出“失败”这个结论,已经付出了真实的计算资源(电力、CPU时间)。
- 执行过程: 节点接收到交易后,会按照指令去执行智能合约。它可能执行了很多步骤,直到遇到一个
require
失败、一个revert
指令,或者耗尽了所有Gas。 - 结果是“失败”: “失败”本身就是一个有效的、经过计算得出的最终结果。
- 记录事实: 区块链需要忠实地记录下“账户
0xabc
在这个时间点,尝试调用合约0x123
的doSomething()
函数,并最终导致了一个失败状态”。这保证了历史记录的完整性和可审计性。
2. Gas费的合法性与对验证者的补偿
这是最直接的经济原因。
- 工作必须有报酬: 验证者付出了计算资源来处理你的交易,无论成功与否,他们都理应得到补偿。这个补偿就是你支付的 Gas 费。
- 链上证据: 为了让这笔 Gas 费的扣除变得合理合法,链上必须有一条记录来证明“这笔钱是因执行这笔交易而被消耗的”。如果失败的交易被简单地丢弃,那么用户的Gas费就会“凭空消失”,而没有任何链上证据来解释原因。这会破坏整个经济模型的信任基础。
3. 保证状态一致性与防止重放攻击
每个账户发出的交易都有一个唯一的、递增的序列号,称为 Nonce。
- Nonce 的作用: Nonce 确保了交易按顺序执行,且每笔交易只能被执行一次。你的第一笔交易 Nonce 是 0,第二笔是 1,以此类推。
- 失败交易消耗 Nonce: 假设当前账户的 Nonce 是 5。发起了一笔 Nonce 为 5 的交易,但它执行失败了。这笔失败的交易必须被打包上链,从而将账户的下一个可用 Nonce “推进”到 6。
- 如果这笔失败的 Nonce 5 交易被丢弃,那么你账户的下一个可用 Nonce 仍然是 5。这会导致状态混乱,并可能允许你或其他人重新广播一笔旧的、具有相同 Nonce 的交易,造成所谓的“重放攻击”。
需要区分的“失败”类型
值得注意的是,有一种“失败”的交易是不会上链的:
- 根本无效的交易 (Invalid Transaction): 这类交易在被广播到网络时,就存在根本性的格式错误。例如:
- 错误的数字签名(无法证明是你发起的)。
- 账户余额不足以支付可能的最大Gas成本 (
gas limit * max fee per gas
)。 - 使用了已经被用过的 Nonce。
这些交易在一开始就会被节点拒绝,甚至没有资格进入“内存池 (Mempool)”去等待被打包,因此它们自然也不会出现在区块里。
智能合约的全局变量和特殊对象
智能合约在一个封闭、确定性的环境(EVM)中运行,但它可以访问到触发其执行的当前调用和其所在的当前区块的特定信息。这些信息是通过Solidity内置的全局变量和特殊对象(主要是 block
、msg
和 tx
)来获取的。
1. 可获得的区块信息 (Block Information)
这些信息描述了当前交易被打包进去的那个区块的属性。主要通过 block
对象来访问。
变量 | 类型 | 描述 |
---|---|---|
block.number |
uint |
当前区块的高度。这是一个不断递增的数字,代表了区块链的长度。 |
block.timestamp |
uint |
当前区块的时间戳。由验证者设定,是自Unix纪元(1970年1月1日)以来的秒数。注意:这个时间戳可以被验证者在一定范围内(通常是几秒)操纵,因此不应用于生成精确的时间或随机数。 |
block.coinbase |
address payable |
打包当前区块的验证者的地址。这个地址会接收到区块奖励和交易的优先费(小费)。在PoS下,也常被称为 block.beneficiary 。 |
block.gaslimit |
uint |
当前区块的Gas上限。它定义了这个区块能容纳的总计算量。 |
block.basefee |
uint |
当前区块的基础费 (Base Fee)。这是由EIP-1559引入的,是每单位Gas必须支付的、会被销毁的费用部分。 |
block.chainid |
uint |
当前所在的区块链ID。例如,以太坊主网是 1 ,Sepolia测试网是 11155111 。用于防止跨链重放攻击。 |
block.prevrandao |
bytes32 |
PoS机制下的随机性信标。在合并升级(The Merge)后,它取代了过去的 block.difficulty 。它是由验证者签名和网络共识产生的伪随机数,但对于关键应用(如抽奖)来说,它仍然不够安全,可能被验证者影响。 |
2. 可获得的调用和交易信息 (Call and Transaction Information)
这些信息描述了直接触发合约执行的那一次调用 (Call),以及发起这次调用的整笔交易 (Transaction)。主要通过 msg
和 tx
对象访问。
变量 | 类型 | 描述 |
---|---|---|
msg.sender |
address |
直接调用者的地址。这是最重要、最常用的变量之一,主要用于身份验证和权限控制。如果一个外部账户调用合约A,那么在A中msg.sender 就是这个外部账户;如果合约A再调用合约B,那么在B中msg.sender 就是合约A的地址。 |
msg.value |
uint |
随本次调用一同发送的ETH数量(单位是wei)。如果函数不是 payable 的,而 msg.value 大于0,交易会失败。 |
msg.data |
bytes |
完整的调用数据 (Calldata)。包含了函数选择器和所有编码后的参数。 |
msg.sig |
bytes4 |
调用数据的前4个字节,即函数选择器。可以用来判断调用的是哪个函数。 |
tx.origin |
address |
发起整笔交易的原始外部账户 (EOA)。无论中间有多少层嵌套调用(A→B→C),tx.origin 始终是那个最初签名并发起交易的用户钱包地址。安全警告:强烈不推荐使用 tx.origin 进行身份验证 (见下文解释) |
tx.gasprice |
uint |
发起交易时设定的Gas价格。它代表了用户愿意为单位Gas支付的价格。在EIP-1559后,它通常反映了实际的有效Gas价格。 |
msg.sender
vs. tx.origin
的重要区别
这是Solidity开发中最关键的安全概念之一。
msg.sender
: 是你的直接邻居。tx.origin
: 是整个调用链的源头。
场景: 用户A (外部账户) → 调用合约B → 合约B再调用合约C
在... | msg.sender 的值是 |
tx.origin 的值是 |
---|---|---|
合约B中 | 用户A的地址 | 用户A的地址 |
合约C中 | 合约B的地址 | 用户A的地址 |
为什么不能用 tx.origin
做身份验证?
假设你的合约C有一个 transferOwner
函数,并且你用 require(tx.origin == owner)
来做权限检查。 攻击者可以创建一个恶意的中间合约D,然后诱骗你(真正的owner)与合约D进行任意交互(例如,领取一个空投)。当你调用合约D时,tx.origin
是你。然后,恶意的合约D就可以用你的名义去调用合约C的 transferOwner
函数。因为检查的是 tx.origin
,这个检查会通过,你的合约所有权就会被盗走。
永远使用 msg.sender
进行身份验证,因为它无法被中间合约伪造。
3. 智能合约无法获得的信息
同样重要的是,要知道哪些信息是合约无法获取的,以避免错误的设计。
- 未来的区块信息: 合约无法预知未来区块的哈希、时间戳等。
- 关于其他交易的信息: 合约无法获取同一区块中、在它之前或之后执行的其他交易的任何信息。
- 用户的私钥或真实身份: 合约只能看到地址,无法知道地址背后的人是谁或其私钥。
- 真正安全的随机数: 如前所述,链上变量(如
block.timestamp
,block.prevrandao
)都不能作为安全的随机源。需要安全的随机数通常依赖于预言机(Oracle)服务,如 Chainlink VRF。 - 外部世界的API或数据: 合约不能直接调用现实世界的网站API。这也需要通过预言机来实现。
地址(Address)
地址(Address)是 Solidity 中最核心、最基础的数据类型之一。它是一个20字节(40个十六进制字符)的值,是以太坊区块链上账户的唯一标识符。在 Solidity 中,地址类型被明确地分为两种,它们的唯一区别在于能否直接接收以太币(ETH)。
1.核心地址分类
address
这是标准的、最常用的地址类型。
- 功能:
- 它可以持有和发送各种代币(如ERC-20, ERC-721)。
- 它可以作为合约的所有者或被授权的用户。
- 它可以被调用,也可以调用其他合约。
- 限制:你不能直接通过
.transfer()
或.send()
方法向一个普通的address
类型的变量发送ETH。
address payable
这是一个特殊的、可支付的地址类型。
- 功能:
- 它拥有
address
类型的所有功能。 - 额外地,它可以作为ETH转账的目标,能够通过
.transfer()
和.send()
方法接收ETH。
- 它拥有
- 应用场景:任何需要接收ETH的地址变量、函数参数或返回值,都必须被声明为
address payable
。例如,一个合约如果定义了receive()
或payable fallback()
函数,那么它自身的地址address(this)
就是address payable
类型的。
2.成员(属性与方法)
一个地址类型的变量,拥有一些内置的成员(属性和方法),可以让你获取信息或执行操作。
属性 (Properties)
.code
(bytes memory
) 返回指定地址账户的合约字节码。如果地址是一个外部账户(EOA),则返回空字节数组。.codehash
(bytes32
) 返回指定地址账户的合约字节码的哈希值。
.balance
(uint256
) 返回指定地址的ETH余额,单位是 wei (1 ETH = 1018 wei)。
address user = 0xAbc...;
uint256 userBalance = user.balance;
操作方法 (Methods for address payable
)
以下方法专门用于发送ETH,因此它们只能被 address payable
类型的变量调用。
.transfer(uint256 amount)
- 行为:向目标地址发送指定数量
amount
的ETH。如果发送失败(例如,对方合约的fallback
函数执行失败),它会立即revert
,中断整个交易的执行。 - Gas 限制:它只转发 2,300 Gas 的固定津贴。这点Gas只够用来执行一个简单的日志事件,不足以执行复杂的状态更改。这个限制主要是为了防止重入攻击。
- 曾经被认为是发送ETH最安全的方式,因为它的失败会立即中止一切。
- 行为:向目标地址发送指定数量
.send(uint256 amount)
- 行为:与
.transfer
类似,也是发送ETH。但如果发送失败,它不会revert
,而是会返回一个布尔值false
。 - Gas 限制:同样只转发 2,300 Gas。
- 现在强烈不推荐使用 因为开发者很容易忘记检查其
false
的返回值,导致ETH发送失败却浑然不知,合约逻辑继续执行下去,造成潜在的漏洞。
- 行为:与
.call{value: uint256 amount}("")
- 行为:这是目前最推荐的发送ETH的方式。它是一种低级调用,功能强大且灵活。如果发送失败,它和
.send
一样,不会revert
,而是会返回(false, bytes_data)
。 - Gas 限制:默认情况下,它会转发所有可用的Gas。这使得接收方合约可以执行更复杂的
receive()
或fallback()
逻辑。 - 评价:最灵活、最通用的方式,但使用时必须检查返回的
bool success
值,并遵循“检查-生效-交互”模式来防范重入攻击。
- 行为:这是目前最推荐的发送ETH的方式。它是一种低级调用,功能强大且灵活。如果发送失败,它和
发送ETH的最佳实践:
// 现代推荐的方式
(bool success, ) = payable_address.call{value: amount}("");
require(success, "Failed to send Ether");
3. 地址类型之间的转换
- 获取合约自身地址
address(this)
:address(this)
会返回当前合约的地址。它的类型取决于合约是否能接收ETH。如果合约定义了receive() external payable
或payable fallback()
,那么address(this)
的类型就是address payable
;否则,它就是普通的address
类型。
从 address
到 address payable
: 这必须是显式转换,需要使用 payable(...)
来进行。
address user = 0xAbc...;
// 必须明确告诉编译器,你确认这个地址可以接收ETH
address payable receiver = payable(user);
这是一个安全措施,强制开发者思考并确认目标地址确实是可支付的。
从 address payable
到 address
: 这是隐式转换的,可以直接赋值,因为 address payable
是 address
的一个子集,这种转换总是安全的。
address payable sender = payable(msg.sender);
address user = sender; // 无需转换,直接可用
表格总结
成员 / 方法 (Member / Method) | 描述 (Description) | 关键点 / 注意事项 (Key Points / Notes) |
---|---|---|
核心类型 | ||
address |
标准地址类型,代表一个以太坊账户。 | 不能直接接收ETH。可持有代币,可作为函数参数和所有者。 |
address payable |
可支付的地址类型,address 的特殊版本。 |
可以通过 transfer 和 send 方法接收ETH。 |
地址属性 | ||
.balance |
获取一个地址的ETH余额。 | 返回 uint256 类型的值,单位是 wei。 |
.code |
获取一个地址的智能合约字节码。 | 对于外部账户(EOA),返回空。类型为 bytes memory 。 |
.codehash |
获取字节码的 Keccak-256 哈希值。 | 对于外部账户(EOA),返回的是空字节码的哈希。 |
发送ETH的方法 (只能由 address payable 调用) |
||
.transfer(amount) |
发送指定数量的ETH。 | 失败时立即 revert 。仅转发 2,300 Gas,安全性高但灵活性差。 |
.send(amount) |
发送指定数量的ETH。 | 不推荐。失败时不 revert ,仅返回 false ,容易因忘记检查返回值而出错。同样只转发 2,300 Gas。 |
.call{value: amount}("") |
(推荐) 发送指定数量的ETH的通用方式。 | 失败时不 revert ,返回 (false, data) 。必须检查返回值。默认转发所有可用Gas,灵活性最高。 |
类型转换与其他 | ||
payable(address) |
将一个 address 类型显式转换为 address payable 。 |
必须的转换操作,用于告知编译器该地址可以安全地接收ETH。 |
address(this) |
获取当前合约自身的地址。 | 如果合约有 receive 或 payable fallback 函数,则类型为 address payable ,否则为 address 。 |