资讯动态

主币与代币的技术区别:余额存储、Gas费、发币与跨链

发布时间:2026/10/29 14:31:26 来源:尧图企业网站定制
区块链世界里主币和代币这两个词常常被混着用我见过不少刚入行的朋友把两者当成同一回事。有一回一个朋友发消息问我他照着教程在测试网络上发了一枚代币钱包里能看到余额可为什么想转出去的时候界面一直提示余额不足他很困惑明明余额数字写得清清楚楚。问题就出在他把主币和代币当成了同一层东西——他转代币的手续费得用主币付而他的主币余额是零。这个误会几乎每个接触链上开发的人都会踩一次它背后牵扯的是账户模型、合约存储、手续费机制乃至跨链映射的一整套设计。这篇内容我想把主币与代币从技术层面彻底拆开讲它们在链上分别以什么形式存在、余额到底存在哪里、一笔转账在底层走了哪些步骤、自己发一枚代币需要定死哪些参数、以及开发过程中最容易翻车的几个具体位置。不管你是刚准备写第一份合约还是已经在做多链资产相关的业务下面这些内容应该都能直接拿去用。1. 原生资产的特殊地位余额不存在任何合约里1.1 主币是协议规则的一部分不是一个变量很多人下意识会觉得一条链上的主币应该也有一个总账户合约里面存着所有人的余额。这个想法很自然但完全不对。主币是链的协议层内置资产它的余额记录方式写在节点客户端的源代码里属于共识规则的一部分而不是部署在某份智能合约的存储里。这意味着你去查一个地址的主币余额链上压根不需要执行任何字节码节点直接在状态数据库里读一个键值对就返回了。这个差别带来的连锁反应很大主币转账是节点层面的一种基础交易类型交易结构里就有专门描述给谁转多少的字段而代币转账本质上是一次合约调用交易里写的是调用哪个合约的哪个函数、带上什么参数至于余额怎么变得等合约代码跑完才知道。理解了这一层你就能明白为什么主币转账的手续费计算方式和代币转账不一样也能明白为什么有些链在升级协议时可以调整主币的通胀规则而代币的规则只受它自己那份合约代码约束。1.2 账户模型与 UTXO 模型下主币的两种呈现方式主币在底层怎么记账取决于这条链选的是账户模型还是 UTXO 模型。这两种模型对开发者的影响远比想象中大尤其是在做余额查询、批量转账和归集的时候。对比维度账户模型UTXO 模型记账单位地址对应一个余额数值地址对应一堆未花费的输出转账逻辑直接加减余额挑出若干输入生成找零输出余额查询读一个数值即可需要遍历并累加所有可用输出并发处理同一账户的交易必须串行不同输出之间可以并行批量归集逐个地址发起成本线性增长可以一次组合多个输入节省空间余额精度可能出现极小额灰尘找零输出天然产生灰尘账户模型理解起来直观一个地址一个数字改多少减多少一目了然代价是同一地址的多笔交易必须排队做高频业务时容易出现拥堵。UTXO 模型更像现金找零你手里有一张 100 的要付 30就得把 100 花掉拿回 70 的找零。它的好处是并行度高、隐私性相对更好麻烦在于余额不是一个数字而是一个集合做资金归集的时候得先把零散的输出合并否则输入太多会导致交易体积暴涨、手续费反超金额本身。我在做归集脚本时就吃过这个亏一个地址里躺了几百笔小额的输出如果不设阈值直接全量合并构造出来的交易大到接近区块上限手续费比归集金额还高。1.3 只有主币能付燃料费这是硬约束手续费这件事值得单独说因为它是最容易让人栽跟头的地方。链上每一笔交易被打包都需要消耗计算和存储资源付费用的资产只能是这条链的原生资产。合约里的代币余额再多也没法直接拿去抵扣手续费原因很简单手续费在交易被执行的那一刻之前就要扣除而代币余额是需要执行合约代码才能读出来的节点不可能先执行一遍再去收钱那会陷入循环依赖。这个约束导致了一系列工程上的麻烦。比如做批量空投的时候你得先给几百个接收地址各打一点主币当手续费否则他们连把代币转走的能力都没有再比如做托管钱包的业务必须维护一个主币资金池随时给热钱包补充燃料。后来出现了一些缓解方案像由第三方代付手续费的元交易模式或者账户抽象里把付费资产的判断逻辑写进入口合约但这些方案本质上都是在原生资产付费的基础上加了一层包装最终还是有人用主币买了单。2. 代币只是合约里的一本账本2.1 从六个函数看同质化代币标准的本质业界的同质化代币标准ERC-20 这一类看着文档挺长核心其实就六个函数加两个事件。totalSupply()返回总量balanceOf(address)查余额transfer(address, uint256)从调用者自己账上转出approve(address, uint256)授权别人能动自己多少钱allowance(address, address)查授权余额transferFrom(address, address, uint256)由被授权方执行转账。两个事件分别是Transfer和Approval作用是让链下的索引服务能够在不重放全部历史的前提下跟踪余额变化。这六个函数构成了一个极简的账本接口所有的钱包、去中心化应用、区块浏览器都是照着这套接口去对接的。你只要实现了这六个函数并且行为符合预期你的代币就能被整个生态识别不需要任何中心化机构批准。这也是为什么发一枚代币在技术上的门槛极低——它真的只是往链上部署一份几百行的合约而已。2.2 一份最小实现的逐行拆解光看接口说明容易飘直接看代码更实在。下面这份实现去掉了所有花哨功能只保留最核心的记账逻辑// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract MinimalToken { string public name Demo Token; string public symbol DMO; uint8 public decimals 18; uint256 public totalSupply; mapping(address uint256) public balanceOf; mapping(address mapping(address uint256)) public allowance; event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); constructor(uint256 initialSupply) { totalSupply initialSupply * 10 ** uint256(decimals); balanceOf[msg.sender] totalSupply; emit Transfer(address(0), msg.sender, totalSupply); } function transfer(address to, uint256 value) external returns (bool) { require(to ! address(0), zero address); uint256 fromBalance balanceOf[msg.sender]; require(fromBalance value, insufficient balance); unchecked { balanceOf[msg.sender] fromBalance - value; } balanceOf[to] value; emit Transfer(msg.sender, to, value); return true; } function approve(address spender, uint256 value) external returns (bool) { allowance[msg.sender][spender] value; emit Approval(msg.sender, spender, value); return true; } function transferFrom(address from, address to, uint256 value) external returns (bool) { uint256 allowed allowance[from][msg.sender]; require(allowed value, allowance exceeded); require(balanceOf[from] value, insufficient balance); if (allowed ! type(uint256).max) { unchecked { allowance[from][msg.sender] allowed - value; } } unchecked { balanceOf[from] - value; } balanceOf[to] value; emit Transfer(from, to, value); return true; } }这份代码里有几个细节值得停下来看。构造函数里做了initialSupply * 10 ** decimals的放大这是全行业最容易出错的地方之一后面会专门讲。transfer里先读余额再减法是为了在减法之前就能给出明确的错误信息同时避免在unchecked块里做减法导致下溢。transferFrom里对type(uint256).max做了特判因为很多前端习惯直接授权无限额度如果每次都做减法减到后面就变成天文数字了这个特判是实践中的常见做法。2.3 余额存在哪个槽位一次存储寻址的完整算账mapping(address uint256) public balanceOf这一行看起来平淡无奇但它决定了代币转账的真实成本。映射类型在存储里并不保存所有键它保存的是每个键对应的值的槽位而这个槽位由keccak256(abi.encode(key, slot))算出来。假设balanceOf在合约里是第 4 个状态变量从 0 开始数那么某个地址的余额实际存放在keccak256(abi.encode(addr, uint256(4)))这个位置。allowance是双层映射要算两次哈希。为什么要算这个因为存储写入是链上最贵的操作。把一个槽位从 0 改成非 0大约要 20000 gas从非 0 改成非 0大约 5000 gas从非 0 改成 0会退回一部分 gas。按这个账算一次从零到有的代币转账至少要写两个槽位发送方余额减少、接收方余额增加如果接收方是首次持有该代币就是一次 20000 加上若干次 5000 的开销。这也是为什么有人做空投聚合、批量分散的时候会盯着接收地址是否首次持有这个条件来做分组因为首次持有和再次接收的成本差了好几倍。2.4 授权模型一个方便但危险的设计approve与transferFrom这套授权机制本意是让合约能代用户操作代币——你去某个去中心化交易协议里换币本质上就是你授权那份合约动你的代币然后它调transferFrom把币划走。方便是真的方便危险也是真的危险。第一个经典问题叫授权竞态用户本来授权了 100想改成 50正常应该先发一笔把授权改成 0 的交易再发一笔改成 50 的。如果直接一笔从 100 改成 50而攻击者在两笔交易之间抢先用掉了那 100 的额度等第二笔生效后攻击者又拿到了 50 的新额度实际花掉了 150。解决方式很简单前端强制走先归零再设置的两步流程或者干脆用一个带版本号的授权函数。第二个问题是无限授权为了省事很多界面默认让你授权一个极大的数值一旦那份合约出问题你的代币可以被一次性搬空。我在做合约审计练习的时候专门统计过绝大多数代币被盗的案例追根溯源都是某次不经意的无限授权。3. 从零发一枚代币参数、代码与验证的完整链路3.1 发币之前必须定死的四个参数写代码之前先想清楚四件事这四件事一旦上链就很难改改起来要么需要预留管理员权限要么得迁移合约。第一是总量是固定 10 亿枚还是预留增发能力固定总量的好处是规则透明、不需要信任管理员坏处是后续要做激励就得另想办法。第二是精度decimals行业惯例是 18但并非必须做积分类应用时用 0 或者 2 反而更贴合用户的直觉因为用户看到的就是整数。第三是可增发性预留mint函数会方便后续运营但也意味着持币者要信任管理员不作恶。第四是权限归属管理员地址放在一个普通钱包里是最常见的错误应该至少放在多签合约里否则一把私钥泄露就是全部归零。这四点没有标准答案取决于你的业务模型但必须在动手前定下来别等合约部署完再回头纠结。3.2 精度这个坑值一次单独的警告decimals只影响显示不影响链上存储。链上永远只存整数18 精度的代币转账 1 枚实际参数是1000000000000000000。新手常犯的错误是在前端把用户输入的1.5直接传给合约结果因为类型不匹配报错或者强行取整变成 1用户少转了三分之一。反过来从链上读回来的余额如果不除以10 ** decimals界面上就会显示一个天文数字。我的习惯是在前端统一写一对工具函数一个负责放大、一个负责截断显示所有涉及金额的地方都强制走这两个函数绝不在业务代码里手写乘除。另外还有一个隐蔽的问题不同代币的精度不同如果你要做一个支持多币种的界面千万别把精度写死成 18一定要从合约里读decimals()。我就见过一个聚合页面把所有代币都按 18 处理结果对接一个 6 精度的代币时显示的金额整整差了一万亿倍。3.3 部署脚本与上链后的自检写完之后别急着往主网推先在本地跑通测试。部署脚本大致是这样的结构const { ethers } require(ethers); async function main() { const provider new ethers.JsonRpcProvider(process.env.RPC_URL); const wallet new ethers.Wallet(process.env.PRIVATE_KEY, provider); const abi [/* 编译产物里的 abi */]; const bytecode 0x...; // 编译产物里的 bytecode const factory new ethers.ContractFactory(abi, bytecode, wallet); const decimals 18n; const supply 1000000n * 10n ** decimals; const token await factory.deploy(supply); await token.waitForDeployment(); console.log(deployed at, await token.getAddress()); const bal await token.balanceOf(wallet.address); console.log(deployer balance, bal.toString()); console.log(total supply, (await token.totalSupply()).toString()); } main().catch((e) { console.error(e); process.exitCode 1; });部署完成之后有一段自检清单我每次都会照着过一遍总供应量是否等于构造时传入的数值、部署者余额是否等于总量、decimals和symbol是否和预期一致、随便转一笔小额给自己再转给另一个地址看事件是否正确触发、approve加transferFrom的组合是否能正常扣减授权额度。这些检查看着琐碎但能挡掉大部分低级错误。尤其是转账测试一定要真转不要只看代码逻辑因为有些错误比如事件参数顺序反了只有实际执行才会暴露。4. 主币与代币混用时的四个翻车现场4.1 三个转账函数的行为差异如果合约需要往外转主币你会遇到transfer、send、call三个选择这三者的差异足够写一篇小文章了。函数返回值失败时行为转发燃料推荐程度transfer无直接回滚整个交易固定 2300 gas不推荐sendbool返回 false不回滚固定 2300 gas谨慎call{value:}(bool, bytes)返回 false不回滚可指定默认全转发推荐看起来transfer最省心失败了整笔交易回滚不会留下半截状态。但问题出在那个固定的 2300 gas 上。这个数值是很久以前定的当时够用后来随着存储读取的定价调整接收方如果是一个合约它的收款回调里只要读一个存储变量就可能超过 2300直接导致转账失败。最典型的场景是往多签钱包或者某些交易合约里转主币用transfer必然失败换成call就正常。所以现在的通行做法是用call拿到返回的布尔值自己判断失败就手动回滚。配套的写法是加一个重入锁因为call会转发全部燃料接收方理论上可以在回调里反过来调你的函数。4.2 合约收代币不会收到任何通知主币转账可以通过回调函数让接收方知道自己收到了钱但标准代币合约在转账时不会通知接收方。这意味着你如果直接往某个合约地址转代币那个合约大概率完全不知道这件事这些代币就永远卡在那里了除非合约里预留了专门的提取函数。这是个非常常见的资金损失场景我见过有人误把代币转到合约地址然后到处找人问能不能找回答案基本上是不能。非同质化代币后来吸取了这个教训在标准里加了一个安全转账版本的函数它会检查接收方如果是合约是否实现了指定的接收接口没实现就直接回滚。但同质化代币的标准早就冻结了没法回头加这个检查。所以工程上的应对办法只有两条一是在自己的合约里主动提供一个sweep函数把误转进来的代币提走二是在前端对接收地址做校验只要目标是合约地址就弹二次确认。4.3 手续费余额为零导致的隐形失败回到开头那个朋友的问题。他的代币转账为什么提示余额不足因为发起一笔代币转账交易本身要消耗燃料燃料只能用主币买。他的地址里有代币但没有主币交易发不出去。这类失败在很多钱包里提示得非常含糊只说什么交易失败或者余额不足让人误以为是代币不够。更麻烦的是批量操作的场景你要给一百个新地址空投代币如果这些地址之前从没接触过链它们的主币余额都是零代币虽然到账了但动不了。所以完整的空投流程必须包含两步先给每个地址打一点主币当启动燃料再发代币而且打燃料那一步要算好别打太少因为代币转到冷地址如果触发首次存储写入链条上消耗的燃料会比普通转账更高。4.4 授权残留与无限额度的长期风险用户在某份合约里授权过一次之后不再使用那份额约授权却一直挂在链上。这类残留授权积累多了风险敞口就变得很大。站在开发者的角度有两件事可以做。第一是在自己的合约里提供revoke或者用完即清的逻辑操作完成后主动把授权额度归零别让它挂在那儿。第二是给用户一个清晰的操作路径别在界面上默认勾选无限授权把授权金额控制在实际需要的范围内。我之前做过一个小实验用一个新地址在几个常用应用里各操作一遍然后去查授权列表结果发现残留授权的数量比想象中多得多其中大部分是无限额度。站在用户角度定期清理一下不再使用的授权是个好习惯成本也就几笔手续费。5. 跨链与二层网络里主币和代币的身份会翻转5.1 桥接之后主币降级成了代币这一点是很多人绕不过来的弯。你在 A 链上持有的是原生资产通过跨链桥转到 B 链之后你在 B 链上拿到的东西不是B 链的原生资产而是一份由桥合约发行的、声称锚定着 A 链资产的代币。它的形态和普通的同质化代币完全一样走的是同一套六函数接口区别只在于发行方是桥合约而且它承诺可以换回去。这个身份变化带来了实际影响桥合约的代币依赖于桥的储备金储备出问题代币的价值锚定就断了而原生资产没有这层依赖它就是链自己。所以判断一个跨链资产的风险等级第一件事就是看它到底是原生资产还是桥发行的映射资产这两者的信任模型差了整整一层。5.2 包装资产为了让同一种资产满足两套规则还有一类特殊的代币叫包装资产最典型的场景是某条链的原生资产需要参与合约逻辑但原生资产本身不是合约没法实现那六个函数于是有人部署一份合约你把原生资产存进去它给你等量的代币凭证要取回时再销毁凭证换回原生资产。这个过程是完全去中心化的合约里锁定的原生资产数量和流通的凭证数量一一对应任何人都可以去验证。包装资产的存在让原生资产获得了合约资产的灵活性——可以授权、可以参与流动性池、可以被其他合约当作标准接口来调用。代价是多了一次兑换步骤而且合约本身如果存在漏洞锁定在里面的原生资产就有风险。5.3 账户抽象带来的付费方式变化前面说过只有原生资产能付手续费这个约束在二层网络和账户抽象的语境下正在松动。账户抽象的核心思路是把账户这个概念从协议层搬到合约层用户的钱包变成一个合约交易的验证逻辑、手续费支付逻辑都可以由这个合约自己定义。于是出现了一种模式用户的合约账户里没有原生资产但有某个代币由第三方中继帮忙把交易打包上链并垫付燃料事后用户用代币偿还。这看起来绕过了只能用原生资产付手续费的规则实际上没有——中继方依然是用原生资产付的只是把这道成本转嫁了一次。理解这一点很重要它意味着任何声称完全免手续费的方案成本一定被藏在某个看不见的地方要么是项目方补贴要么是其他环节加价。6. 我在实际项目里攒下的几条判断经验判断一份代币合约是否值得信任我最先看的是构造函数和权限函数而不是总量和名字。如果构造函数里有一个可以任意改余额的函数或者存在一个没加限制的mint那后面的一切都不用看了。其次是看transfer里有没有做零地址检查和余额检查这两个检查缺失的合约转账时很容易出现余额凭空消失的情况。第三是看有没有在转账里加钩子逻辑加了钩子的合约意味着每笔转账都可能触发额外的合约调用成本和风险都要重新评估。还有一个容易被忽略的点是重入标准代币本身通常不会重入但如果你在合约里先调transfer再改自己的状态接收方如果是恶意合约就有机会在回调里再进来一次。所以我写任何涉及外部调用的函数时都把状态修改放在转账之前这个习惯看起来多余但真的救过我好几次。另外分享一个排查转账失败的实用方法。交易在链上失败时区块浏览器一般会给一个回滚原因但有些情况原因被合约自己吞掉了只显示一个笼统的错误。这时候不要盲目改代码先把交易参数原样拿出来在本地用相同的区块高度去模拟执行逐步打印每一步的余额和授权值通常问题就在那几个数字上——要么是转账金额忘了乘精度要么是授权额度差了一点点要么是接收方是个没实现接收接口的合约。我统计过自己处理过的失败案例八成以上不是合约逻辑写错而是参数传错或者余额状态和预期不一致。把这两类问题分开看排查效率会高很多。最后提一点关于总量的直觉。很多人在设计代币经济模型时喜欢拍一个大数字觉得数字大显得有分量但总量和精度其实是两个独立的维度总量定多大不影响单枚的可分割性精度定多少不影响总量。真正需要想清楚的是最小可分割单位和业务场景之间的关系如果这个代币是用来表示积分的最小单位就应该是 1不该有小数如果是用来做精细计价的18 位精度甚至都不一定够用。把这两个参数混在一起考虑是设计阶段最常见的思维混乱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑