合约:部署在链上的确定性程序
合约是后面"币怎么产生"的地基——先把它是什么、约束在哪,讲清楚。
合约 = 一个地址下的字节码 + 一块持久化存储
部署合约本质上也是一笔交易:to 留空,data 里放的是"创建代码"(constructor 逻辑 +
真正要长期运行的字节码)。这笔交易执行完,链上多出一个新地址,这个地址名下挂着两样东西:
不可变的字节码(部署后逻辑锁死,除非用代理模式故意留了可替换的口子)和
可变的 storage(合约自己的一组变量,每个变量占一个 32 字节的槽位)。
核心约束只有一条,但决定了合约能做什么、不能做什么:同样的输入 + 同样的链上前置状态, 全世界任何一个诚实节点执行同一段字节码必须得到完全相同的结果。所以合约代码里没有系统时钟、 没有随机数生成器、不能主动发 HTTP 请求——任何"不确定性"都得从外部以一笔交易的形式喂进来 (这是预言机存在的根本原因)。
链上"币"怎么产生:两种不同的铸造机制
"发行规则写在协议共识里"和"发行规则写在一份合约里",可信度完全不是一回事。
发行规则写死在共识客户端里,谁都不能单方面改
比特币:每挖出一个新块,协议规定性地"凭空"记一笔奖励给出块者(区块奖励,Coinbase 交易)。 初始 50 BTC/块,每 210,000 个块(约 4 年)减半一次,这个减半计划写在每一个全节点客户端的验证规则里—— 如果谁记的奖励不符合当前该给的数额,全网节点直接拒绝这个块。因为是等比数列极限收敛,总量趋近但 略低于 2,100 万枚(整数除法在减半到 0 之前有舍入,精确值约 20,999,999.9769 BTC)。
以太坊:没有预设总量上限。The Merge 后 PoS 的新增发行量由"当前有多少 ETH 被质押"决定 (近似与质押量的平方根成反比设计,鼓励参与又不过度稀释);同时 EIP-1559 把每笔交易的 base fee 直接 销毁(burn)掉。净发行 = 新增发行 − 销毁量,网络越繁忙销毁越多,历史上多次出现净通缩 (销毁 > 新增)——这是"ultra sound money"说法的由来。
"铸币"只是合约里一次普通的状态转移,只加不减
ERC-20 代币根本不是"链自带"的东西,只是一份合约自己维护的
mapping(address => uint256) balances + 一个 totalSupply 变量。
转账是"A 减、B 加";铸币(mint)就是只加不减——把某个地址的余额和 totalSupply 同时加上一个数,
凭空生成;销毁(burn)反过来,只减不加。这两个操作在 EVM 层面和普通加减法没有任何区别,
特殊的地方全在"谁被允许调用这个函数、调用时检查什么条件"——这些检查只存在于这份合约自己的代码里。
// 一个典型的、带access control + 供给上限的铸币函数(OpenZeppelin 风格) function mint(address to, uint256 amount) external onlyOwner { require(totalSupply() + amount <= CAP, "exceeds cap"); _mint(to, amount); // balances[to] += amount; totalSupply += amount; }
常见的几种约束模式,可信度层层递减:
| 模式 | 谁能铸 | 供给上限 | 可信度 |
|---|---|---|---|
| 一次性铸完,无 mint 函数 | 没人(部署后彻底没有铸币入口) | 硬编码在 constructor | 最强承诺 |
ERC20Capped | 被授权者,但每次都检查上限 | totalSupply()+amount≤cap,超了 revert |
上限只是一行 if |
AccessControl(MINTER_ROLE) | 持有该角色的地址(可以是多签/DAO) | 看代码有没有写 | 取决于角色分配 |
onlyOwner 单一管理员 | 一个私钥 | 可能压根没有 | 私钥泄露=无限铸币 |
require。如果合约是可升级代理(proxy),实现被换掉,这行检查可以直接消失——
供给规则的可信度等于这份合约代码(以及谁能升级它)的可信度,不是链本身赋予的。
这也是为什么审计报告、是否可升级、owner 是不是多签,比"白皮书写了多少总量"更重要。交易的一生:构造、竞价、打包、执行、确认
点击每一步,看这一步具体发生了什么、为什么会有"竞争"。
构造与签名:一笔交易本质是一条排队号码
钱包在本地组好字段 {nonce, to, value, data, gasLimit, maxFeePerGas, maxPriorityFeePerGas},
用私钥签名(私钥不出本机)。nonce 是这个地址发出过的交易计数,必须严格连续——
如果你已经发过 nonce=5,现在发 nonce=7,这笔交易会被搁进 mempool 的"queued"(排队等待)而不是
"pending"(可执行),直到 nonce=6 出现为止。这也是"一个账户的交易之间有严格先后顺序"的来源,
不同账户之间则完全没有这层约束,顺序全看下一步的竞价。
竞价广播:mempool 里真正在"竞争"的是什么
交易广播进 mempool(全网节点的待打包池),此刻公开可见但还没生效。EIP-1559 之后,
每笔交易的费用分两块:base fee(协议按上一块拥堵程度自动设定,每块最多变动 ±12.5%,
这部分会被销毁,不给任何人)+ priority fee / tip(你自己出的小费,这才是真正付给
打包者的钱)。你设一个 maxFeePerGas 当上限,实际扣的
effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)。
"竞争"竞的就是这笔 tip:出块者/builder 天然倾向优先打包 tip 更高的交易,因为区块空间有限 (下一节讲)。MEV searcher 竞争的不是"谁先广播",是"谁能出更高的价格/给 builder 更好的组合收益, 换取自己的交易被排在关键位置之前或之后"——这也是为什么大额交易常常走"私有 orderflow"直接发给 builder,不进公开 mempool,避免被抢跑。
打包:不是先来后到,是收益最大化排序
一个块的总 gas 有硬上限(以太坊主网约 3000 万 gas,协议目标是让平均用量落在 1500 万, base fee 就是照着"上一块用量相对目标高还是低"来调的杠杆)。builder 从 mempool (以及私有 orderflow)里挑一批交易,目标是让这个块的总收益(tip 总和 + 可提取的 MEV)最大化, 不是谁先来就排谁。这也是一个常见误区的真相:"打包"这个词听起来像排队窗口,实际排序逻辑更像 一场组合竞价。以太坊 PoS 之后,这个流程进一步拆成 proposer(出块权的持有者)和 builder (实际组装区块内容的专业角色),大多数区块通过 mev-boost 这类中继完成"proposer 盲选出价最高的 builder 方案"这一步(PBS,Proposer-Builder Separation)。
执行:EVM 逐条指令跑,也是"竞争"结果兑现的地方
区块被广播后,每个节点独立重放执行区块里的每笔交易——EVM 按操作码(opcode)一条条跑,
每条指令消耗固定或可变的 gas,直到用完 gasLimit 或正常结束。
被打包 ≠ 一定成功:如果你的交易在等待打包期间,链上状态被别人的交易先改变了
(比如你想买的价格被抢跑推高、你要清算的仓位已经被别人清算),你的交易依然会被执行,
但可能在执行到一半时触发 require 失败并 revert——revert 会撤销这笔交易造成的
所有状态改动,但已经消耗的 gas 不退(下一节详细算这笔账)。
确认:安全性是攒出来的,不是瞬间给的
新区块广播后,可能出现短暂的分叉(两个 builder 几乎同时产出候选区块),网络会按固定规则收敛到一条链 (PoW 是"累计工作量最大";PoS 是 fork-choice 规则)。确认数就是这笔交易之后又叠了几个块—— 叠得越多,被反转的代价越高。PoS 还多一层更强的保证:终局性(finality),大约每两个 epoch (约 12.8 分钟)会有一批区块被"最终确定",此后要反转它,攻击者必须让自己被罚没(slash)至少 1/3 的全网质押金,代价是数百亿美元级别——这不是"数学上不可能",是"经济上不划算"。
耗费与结果:gas 到底怎么算、成败怎么定
自己拖一下滑块,看一笔交易实际会被扣多少钱,以及"revert 也要付钱"是怎么回事。
gas 的三层账
- 起步价(intrinsic gas):每笔交易固定先收 21000 gas,外加 calldata 里每个零字节 4 gas、 每个非零字节 16 gas——这解释了为什么"调用参数越长的交易起步就越贵",跟合约逻辑本身还没关系。
- 逐指令计价(opcode gas):加法这类简单运算只要 3 gas;读一个从没读过的 storage 槽(cold access)要 2100 gas,同一笔交易里读过一次后再读(warm)只要 100 gas;把一个原本是 0 的槽第一次写入 非零值要 20000 gas(这是最贵的常规操作之一,也是为什么"减少 storage 写入"是 gas 优化的核心)。
- 退款(gas refund):把一个非零槽清零会退一部分 gas,但退款总额被硬性封顶在 "这笔交易实际耗费的 1/5"(EIP-3529)——防止有人靠"先占满 storage 再清空"骗退款套利。
gasLimit 只是你愿意
付的上限,实际扣费按 gasUsed(真正耗费)算,两者之间的差额从来没被收过,
不存在"退还"这回事。真正需要留意的是revert:交易执行到失败那一刻为止,之前消耗的 gas
是真金白银付出去的,只有失败点之后"没来得及花"的那部分 gas 不收——失败的交易依然要付费。试算一笔交易:拖动滑块看收据怎么变
数据与意义:交易结束后到底留下了什么
收据(receipt)和日志(log)是外部世界唯一能读到的"发生了什么"的凭证。
交易收据(Transaction Receipt)
| 字段 | 含义 |
|---|---|
status | 0x1 成功 / 0x0 失败——Byzantium 升级后才有这个字段,之前只能靠对比状态根间接判断 |
gasUsed / cumulativeGasUsed | 本笔实耗 / 这笔之前该区块累计耗了多少(算区块是否还有空间) |
effectiveGasPrice | 本笔实际按什么单价结算(见上面计算器) |
contractAddress | 如果这笔是部署交易,新合约的地址 |
logs[] | 合约执行过程中主动 emit 的事件,见下 |
logsBloom | 一个概率型过滤器(Bloom filter),不用扫描全部日志就能快速判断 "这个块里大概率/绝对没有我关心的某类事件" |
事件日志:外部世界"看懂"链上发生了什么的唯一入口
合约不会主动"通知"任何人。它能做的只有在执行时 emit 一条日志,写进这笔交易的 receipt 里。
以最常见的 Transfer(address indexed from, address indexed to, uint256 value) 为例:
topics[0] = keccak256("Transfer(address,address,uint256)") // 事件签名哈希,固定值 topics[1] = 0x000...<from 地址,左补零到32字节> // indexed 参数进 topics topics[2] = 0x000...<to 地址,左补零到32字节> data = 0x000...<amount,ABI 编码> // 非 indexed 参数进 data
钱包、区块浏览器、subgraph 索引器、还有你自己写的套利机器人——全都是靠"订阅特定 topics[0] 的日志"
来知道"哪笔转账发生了""哪个池子被 swap 了",而不需要逐块重放整条链的状态变化。
这也是为什么 indexed 关键字很重要:只有 indexed 的参数才会被塞进
topics,才能被节点用哈希索引快速过滤(最多 3 个),非 indexed 的参数虽然也在日志里,
但只能靠下载整条 data 再解码才能拿到,没法作为过滤条件。
底层解剖:一次调用到底怎么落地
前面五节讲的是"发生了什么";这一节把它摁到字节层面,看这些概念具体怎么变成 0 和 1,以及滑点、gas、竞价怎么在一次真实调用里绞在一起。
A. 从源码到一个地址:部署与构造函数到底做了什么
写完 Solidity 源码,编译器(solc)产出三样东西:运行时字节码(部署后长期挂在这个地址上、
真正响应调用的代码)、创建字节码(= 运行时字节码 + 一段只在部署那一刻跑一次的构造逻辑,
打包在一起)、以及 ABI(下面 B 讲)。部署交易的 to 留空,
data 就是这段创建字节码(如果构造函数有参数,参数按 ABI 规则编码后直接拼接在
创建字节码最后)。
构造函数在做的事:读出拼在末尾的构造参数,把它们写进 storage(比如把 msg.sender
记成 owner)或者直接**烧进** immutable 变量(`immutable` 不占 storage 槽,
编译器把值直接拼进运行时字节码本身,读取时不用 SLOAD,省一大笔 gas);
跑完之后用 RETURN 指令把"运行时字节码"这段吐出去交给 EVM 存起来——
构造函数自己那段逻辑从此不再是这个地址代码的一部分,它只活在部署那一笔交易里,
一次性用完即弃。
新地址怎么算出来:CREATE(普通部署)算法是
keccak256(rlp([发送者地址, 发送者当前nonce])) 取后 20 字节;
CREATE2 算法是 keccak256(0xff ++ 部署者 ++ salt ++ keccak256(创建字节码))
取后 20 字节——CREATE2 的关键价值是地址在真正部署之前就能算出来(只要创建字节码和 salt
不变),Uniswap V2 的交易对地址、ERC-4337 智能账户"先算出地址收币、真正用时才部署",用的都是这个。
B. ABI:链上从来不存在、但缺了它什么都读不懂的说明书
链上只有字节码,没有任何"函数叫什么名字、参数是什么类型"这类人类可读信息——这些全部记在 一份链下的 JSON 文件里,就是 ABI(Application Binary Interface)。钱包、ethers.js/web3.py 这类库、 区块浏览器,全靠这份 ABI 才知道"调用这个合约的转账函数要拼什么字节、返回的裸字节该怎么解读成人话"。 这也是为什么 Etherscan 上一个未验证的合约只能看到十六进制字节码、读写面板是灰的—— 没人告诉它 ABI 是什么,它没法帮你拼调用。
ABI 编码的核心规则:定长类型(uint256、address、bool)
直接补零对齐到 32 字节;变长类型(string、bytes、数组)用
"偏移量指针 + 长度 + 数据本体"三段式——先在固定位置放一个指针(指向数据本体在整个
calldata 里的字节偏移),数据本体本身统一堆在最后。这是为什么下面 C 的 calldata 图里,
动态参数(比如一个地址数组 path)不是按顺序摆在对应位置,而是"头部放指针,
尾部放真身"。
C. calldata 剖面:一次真实调用长什么样
以 Uniswap V2 风格的 swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin,
address[] path, address to, uint256 deadline) 为例:
selector = keccak256("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)")[:4] = 0x38ed1739 0x00 amountIn // 32 字节,定长 0x20 amountOutMin // 32 字节,你能接受的最坏成交量(滑点容忍度,见 F) 0x40 path 的偏移量 // 指向 0xa0 处:数组真身放在哪 0x60 to // 32 字节 0x80 deadline // 32 字节 0xa0 path.length // 数组长度,比如 2 0xc0 path[0] // 第一个地址 0xe0 path[1] // 第二个地址
D. 链上函数匹配(dispatch):EVM 怎么知道该跑哪一段
合约的运行时字节码开头,编译器会自动生成一段"分派表":先用 CALLDATALOAD 读出
calldata 最前面 4 字节,然后是一串 selector 是否等于某个已知值 的比较
(EQ + JUMPI,函数多的话编译器会生成二分查找式的跳转以省 gas),
命中哪一个 selector,就跳(JUMP)到那段函数体真正开始执行的位置。
全部比对不上:如果调用带了 calldata 但没匹配任何已知函数,走 fallback()
(没定义就直接 revert);如果是纯转 ETH、calldata 是空的,走 receive()。
E. 参数怎么真正"传进去"、返回值怎么"传回来"
- 编译期,编译器已经根据 ABI 布局把每个参数在 calldata 里的字节偏移量算死了——函数体内直接用
CALLDATALOAD/CALLDATACOPY从那个固定偏移取值,不需要"查找参数名"这种操作, 变量名早在编译时就被抹掉、变成了纯偏移量。 - 函数执行完,把返回值按 ABI 规则编码好写进 memory,用
RETURN(offset, length)把这段裸字节交还给调用方。 - 调用方——不管是一个外部账户发起的交易、还是另一个合约用
CALL调过来的—— 都得按自己预期的返回类型去解码这段裸字节;如果 ABI 对不上(比如以为返回uint256实际返回(uint256,uint256)),解码出来的值就是错的或直接崩,链上不会替你检查这件事。 - 同一套"匹配→执行→编码返回"机制,走的是两条不同的路:
eth_call(只读模拟,不广播、不上链、不花 gas,用来跑view/pure函数或者 "预检这笔交易会不会成功")和真正发一笔交易(状态真的被改写、要付 gas)——差别只在 "结果要不要被写进全局状态、要不要收钱",匹配和执行的逻辑完全一样。
F. 滑点:写进调用参数里的"我能接受的最坏结果"
精确定义:你发起交易时看到的预期成交价格,和交易真正落地那一刻的实际成交价格之间的差。 要理解它为什么会发生,得先回答一个更根本的问题:链上这个"价格"到底是谁定的、买卖动作为什么 能改动它。
价格不是被写进去的,是被"算"出来的公认值
AMM 池子里没有任何地方存着"当前价格"这个字段——池子只老老实实存两个数:
两种代币各自的数量(reserves)。价格是用这两个数量的比例现算出来的:
价格 = reserve_USDC / reserve_WETH。没有人"公告"这个价格,也没有人有权限去
直接改它——它是全网每一笔历史成交共同留下的残留物:谁愿意用多少 A 换多少 B,
成交完这两个数量就永久地变了一点,下一个人看到的"当前价格",就是所有前人交易行为叠加之后
的这个比例快照。这就是"公认"在链上的准确含义——不是投票表决出来的,是用真金白银的
成交历史"投票"出来的,且这份投票记录(reserves 的当前值)本身就写在链上账本里,
人人可读、人人可验证,不需要任何权威机构来"宣布"价格是多少。
数量怎么决定价格冲击:一个公式 + 一张实测表
正因为价格 = 数量之比,你交易的数量相对池子里已有的数量越大,你自己这一笔就把这个比例
搬动得越多——这是"机制性滑点"真正的数学根源,不是设计者刻意刁难,是"价格由数量比例定义"
这件事必然带来的推论。以 x·y=k 恒定乘积为例,往 x 这一侧投入 Δx,能换出的
另一侧数量 Δy 精确满足:
Δy = y · Δx / (x + Δx) // 换出数量 有效成交价 = Δx / Δy = (x + Δx) / y // 你实际付出的单价 现货价(瞬时价)= x / y // 池子当前的比例 价格溢价 = Δx / x // 你把价格往上搬动的幅度
换句话说:你交易的数量(Δx)占池子里已有数量(x)的比例越大,你要多付的溢价就越大, 而且是接近线性的关系(小额交易时)。拿一个具体池子实测一下(1,000,000 USDC / 500 WETH, 现货价 2,000 USDC/WETH):
| 投入 USDC | 占池子比例 | 按现货价"应得"WETH | 实际到手 WETH | 少拿 |
|---|---|---|---|---|
| 1,000 | 0.1% | 0.5000 | 0.4995 | 0.1% |
| 100,000 | 10% | 50.00 | 45.45 | 9.1% |
| 500,000 | 50% | 250.0 | 166.7 | 33.3% |
池子越浅(两边数量越少)、你交易的数量相对越大,"少拿"的比例越夸张——这就是为什么同样一笔 10,000 USDC 的换币,在深度几千万美元的池子里几乎无感,在一个刚建的小池子里可能直接吃掉 两位数百分比。"币的数量"(池子深度 vs 你的交易量)决定了买卖动作对价格的冲击力度, 这是纯几何/代数关系,跟有没有人恶意操纵无关。
两种完全不同的滑点成因,很多人混为一谈:
- 机制性滑点(跟有没有人抢跑无关):就是上面这张表——你的交易本身相对池子深度越大, 把价格顺着曲线推得越远,成交均价就必然比"下单前一刻的瞬时价格"差,池子深度不变、 没有第三者插手也会发生。
- 时间差滑点(跟第三节的竞价排序直接相关):从你的钱包拿到报价,到这笔交易真正被打包执行, 中间隔着排队等待的时间;如果别的交易先成交、把上面那个"数量之比"先挪走了,你实际吃到的价格就比 报价时更差——这部分滑点的大小,取决于你的 tip 出得够不够高、排得够不够靠前。
竞争机制怎么把这个"公认价格"拉回外部市场
如果这个池子算出来的价格,和交易所/其他池子的价格不一致,就出现了一个免费午餐: 套利者可以在这个池子里把便宜的一边买走,同时在别处把它卖掉赚差价——而套利者这笔交易, 恰恰就是上面那个公式里的"Δx 投入",会把池子的数量比例往"正确"的方向推,直到两边价格重新对齐 为止。池子价格能跟得上外部市场,靠的不是任何人维护,是套利这个财务动机自动驱动的。 而"谁先发现价差、谁先把这笔套利交易打包上链",回到第三节,本身也是一场竞价——多个套利者 同时盯着同一个价差,靠 priority fee 竞速,谁的交易先被打包,谁就吃到这份差价, 池子价格因此常常在一个区块之内就被拉回去。
买卖为什么会反过来影响链上"币的价值"本身
把前面几点串起来看一个更深的结论:在没有外部预言机的情况下,一个代币在链上的"价值", 不是被写进链上的一个既定事实,而是完全由买卖双方的交易数量史构造出来的一个可读数值—— 每一次买卖,不只是让交易发起人自己承受滑点,还永久性地重写了下一个人将要看到的"公认价格"。 这也是为什么"价格发现"这个词用在 AMM 上格外贴切:价格不是被发现了一个客观外部真相, 是被每一笔实际发生的买卖持续构造出来的——你的买入把价格往上推一点,你的卖出把价格往下压 一点,下一个交易者面对的,就是包含了你这笔交易残留影响的新起点。
amountOutMin(或反过来做买单时的 amountInMax)就是"滑点容忍度"在调用参数里
的具体落地:你能接受的最差成交结果,写死在 C 里那段 calldata 的第二个 32 字节里。链上执行到检查
"实际输出是否 ≥ amountOutMin"这一步,如果不满足,整笔交易 revert——回到第四节讲过的规则,
revert 之前已经消耗的 gas 依然要付。
amountOutMin 空间被吃掉大半甚至触发 revert;如果你的
滑点容忍度设得够宽、没有直接 revert,你会以更差的价格成交,攻击者随后立刻反向卖出,赚走你和
普通"机制性滑点"之外的那部分差价。滑点容忍度设置得越宽,留给这种攻击的可榨取空间就越大——
这是为什么"滑点容忍度不是越大越保险,反而越大越危险"这个反直觉结论的根源。G. 串起来看:一次 swap 调用的完整剖面
| 环节 | 对应本节内容 | 这次调用里具体是什么 |
|---|---|---|
| 选择器 | C | 0x38ed1739,锁定要跑 swapExactTokensForTokens |
| 滑点参数 | F | amountOutMin = 报价 ×(1 − 你设的容忍度,比如 0.5%) |
| 路径参数 | B/C | path 数组,动态类型,真身放在 calldata 尾部 |
| 竞价 | 三 · 第三节 | 行情越急,愿意出的 priority fee 越高,换取更靠前的执行顺序 |
| 执行与匹配 | D/E | EVM 用 selector 找到函数体,逐条 opcode 跑,读写 storage 里的 reserves |
| 成功结局 | 四 | 实际输出 ≥ amountOutMin,emit Swap 事件,status=0x1 |
| 失败结局 | 四/F | 被抢跑推高价格,实际输出 < amountOutMin,触发 revert,gas 依然扣 |
这张表把一、三、四、五节抽象讲过的概念,全部落在"这一次具体调用"的某个字节、 某个参数、某条判断上——这就是"底层"这个词的准确含义:不是更难,是更具体。