ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Solidity 智能合约开发:强类型、solc 编译与 ABI 调用

Solidity 智能合约开发:强类型、solc 编译与 ABI 调用 简介这份PDF讲义是「从零开始学基于以太坊的区块链应用开发」系列第13篇面向希望转入以太坊智能合约开发的程序员与高校学生帮助其在动手写合约前建立对Solidity的系统认知。全文围绕Solidity的基础特性展开代码以.sol后缀保存、可在任意支持语法高亮的编辑器中编写作为强类型语言变量需声明类型编译期检查可减少运行时错误语法风格接近JavaScript有JS基础者上手更快但仍需注意智能合约特有的概念与操作。讲义还梳理了合约从编写、经Solidity编译器生成EVM可执行字节码与ABI到由JavaScript前端经ABI间接调用链上合约的完整链路。资源为1个PDF文件压缩包约283KB已有471人学习适合作为入门阶段的轻量速览与概念对照材料。1. Solidity 在以太坊开发栈里到底占哪一环很多人第一次接触以太坊开发会把智能合约想象成部署到服务器上的 JS 脚本——写完传上去发现逻辑不对再改一版。这个直觉在链上会直接翻车合约一旦部署代码就固化在某个地址上逻辑不可改写能动的只有预留的状态变量和升级代理。所以写 Solidity 时每一行类型声明、每一个可见性修饰符成本都比写业务前端高得多。这份资料是系列课程的第 13 讲位置很关键前面刚讲完智能合约的概念后面就要动手写第一个合约中间缺的正是语言关。它要讲清三件事——Solidity 源码存在.sol文件里用什么编辑器都行它是强类型语言变量类型必须显式声明和 JavaScript、Python 那套运行时推断完全不是一回事它的语法风格又确实贴着 JavaScript有 JS 底子的人上手很快。它适合已经会一门脚本语言、准备往链上写逻辑的人也适合被合约改不了这件事逼着回来补类型系统的人。2. 强类型与 .sol 文件结构JS 开发者最容易翻车的地方从 JavaScript 转到 Solidity最别扭的不是语法而是所有东西都要先说清楚。JS 里let x 1之后把x改成字符串完全合法Solidity 里这么写编译器直接拦下来。刚开始会觉得啰嗦写过两个合约就会发现这份啰嗦换来的是部署前就能暴露的错误——链上没有 try-catch 兜底也没有热修复。2.1 变量声明必须先定类型和 JS 的差别在哪状态变量一旦写进合约就占用一个 storage 槽位永久留在链上。类型定错改起来要重新部署整个合约。下面这个最小计数器合约把几个关键点都摆出来了// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // 锁定编译器版本范围0.9.0 之前都能编 contract Counter { uint256 private count; // 状态变量写入 storage改一次花一次 gas function add(uint256 step) external { require(step 0, step must be positive); // 不满足则整笔交易回滚 count step; // 0.8 起默认带溢出检查越界自动回滚 } function get() external view returns (uint256) { return count; // view 只读本地节点调用不消耗 gas } }几个参数值得单独说external表示只能从合约外部调用比public更省 gasrequire的第一个参数是条件第二个是回滚时返回的原因字符串会占用字节码空间生产环境常改成自定义 errorview承诺不修改状态钱包和前端调它不需要签名、不需要付费。类型对照关系大致是这样Solidity 类型含义与范围JS 里最接近的写法容易踩的点uint2560 到 2^256-1BigInt没有负数减法前必须判断下界int256有符号整数BigInt位数写小不省 storage只影响打包address20 字节账户地址string与address payable不等价转账前要转booltrue / falseboolean默认值是 false不是 undefinedbytes32定长 32 字节Buffer常用于哈希和 Merkle 叶子string动态长度 UTF-8string链上读写都贵能存链下就存链下提示uint是uint256的别名但明确写位数能让审计和跨版本迁移少掉很多口水。2.2 pragma 与编译器版本第一行不是装饰老教程里到处是pragma solidity ^0.4.23;。^的含义是允许 0.4.23 到 0.5.0 之间不含 0.5.0的任意版本听着宽松实际用起来很危险0.5 引入了显式类型转换和address payable0.6 改了receive/fallback的写法0.8 默认开启算术溢出检查。同一份源码换个小版本编译器可能从能跑变成编译失败或者更糟——编译通过但语义变了。常见做法是 pragma 写成较窄的范围^0.8.20这类再在项目根目录放一份编译器配置Hardhat 用solidity: 0.8.20Foundry 用solc_version 0.8.20让本地、CI、部署脚本用同一个编译器。原因是编译器版本属于编译产物的一部分版本变了字节码就变之后在区块浏览器上做源码验证会直接对不上。2.3 .sol 文件命名与编辑器配置后缀必须是.sol文件名习惯与合约名一致Counter.sol里放contract Counter。语法上允许一个文件塞多个合约但import ./Token.sol;这类路径解析依赖 source unit 的集合工程变大之后按合约拆文件更省心。编辑器层面不需要特殊 IDE。VS Code 装 Solidity 扩展就有高亮、跳转和基于 solc 的实时检查JetBrains 系和 Vim 也都有对应插件。真正影响体验的是让编辑器知道当前 pragma 对应的编译器版本否则会冒出一堆这个函数不存在的假报错把时间浪费在不存在的问题上。3. solc 编译链路字节码与 ABI 是怎么产出的Solidity 源码不能直接被以太坊节点执行中间必须经过 solc。这一步的产物有两份很多人只记住一个是字节码、一个是 ABI但具体哪个文件什么时候用、参数怎么配往往到部署失败才回头补。3.1 命令行编译–bin、–abi 各自产出什么# npm 分发的是 solcjs纯 JS 实现跨平台省事但比原生二进制慢 npm install -g solc solcjs --version # 编译单个文件同时输出字节码和 ABI 到 build 目录 solcjs --bin --abi --optimize -o ./build Counter.sol # 需要精细控制时用标准 JSON 输入输出 solcjs --standard-json input.json output.json参数含义逐条过一遍--bin输出 EVM 可执行的十六进制字节码--abi输出 JSON 格式的接口描述--optimize打开优化器减小字节码体积、通常也降 gas但会改变最终字节码做源码验证时必须把这个开关状态一起对上报-o指定输出目录不写就散落在当前目录。注意solcjs与官方 C 版solc在极少数优化场景下产出的字节码并不完全一致。生产项目一般用 Hardhat、Foundry 内部封装的编译器或者直接下载官方 release 的solc二进制。3.2 编译产物的四个部分--bin的输出其实包含两段理解这个区别能省掉很多困惑产物来源用途是否长期留在链上creation bytecode--bin前段部署交易的 data 字段否只在部署时出现一次runtime bytecode--bin后段部署后地址上的实际代码是ABI--abiJS / Python 构造 calldata否纯本地文件metadata--metadata源码哈希、编译器与优化器设置视配置可能附在 runtime 尾部metadata 的存在感最低却是源码验证的关键它记录了源码文件的哈希和编译设置验证服务拿它反推当时到底是怎么编的。3.3 用脚本固化编译参数手敲命令容易漏参数把编译逻辑写成脚本本地和 CI 跑的是同一份配置// compile.js —— 固定编译设置避免我这儿能编过的扯皮 const solc require(solc); const fs require(fs); const path require(path); const source fs.readFileSync(path.resolve(__dirname, Counter.sol), utf8); const input { language: Solidity, sources: { Counter.sol: { content: source } }, settings: { optimizer: { enabled: true, runs: 200 }, // runs 越大越偏向省运行 gas越小越偏向省体积 outputSelection: { *: { *: [abi, evm.bytecode.object] } } } }; const output JSON.parse(solc.compile(JSON.stringify(input))); // solc 不抛异常错误全在 errors 数组里必须显式检查 severity const errors (output.errors || []).filter(e e.severity error); if (errors.length) { console.error(errors.map(e e.formattedMessage).join(\n)); process.exit(1); } const contract output.contracts[Counter.sol].Counter; fs.mkdirSync(./build, { recursive: true }); fs.writeFileSync(./build/Counter.abi, JSON.stringify(contract.abi, null, 2)); fs.writeFileSync(./build/Counter.bin, contract.evm.bytecode.object);逻辑上有三处值得留意。第一solc.compile返回的是字符串不JSON.parse拿不到对象第二编译失败时 solc 返回的仍是正常对象只是errors里有severity: error的条目不检查就会带着空产物往下走第三outputSelection决定了生成哪些内容漏掉evm.bytecode.object就拿不到部署字节码只拿到 ABI后面部署会报bytecode 无效。4. 用 JavaScript 通过 ABI 调用合约浏览器里的 JS 没有 EVM也拿不到用户私钥所以前端永远不可能直接访问链上状态。它做的是把方法名和参数编码成一段字节交给 RPC 节点节点再交给 EVM 执行。这段编码规则就是 ABI 定义的。4.1 ABI 到底描述了什么东西编译出来的 ABI 就是一个 JSON 数组每个元素对应一个函数或事件。以 Counter 为例[ { inputs: [{ name: step, type: uint256 }], name: add, outputs: [], stateMutability: nonpayable, type: function }, { inputs: [], name: get, outputs: [{ name: , type: uint256 }], stateMutability: view, type: function } ]字段含义拆开看字段作用漏填的后果name方法名参与计算 4 字节选择器调用时找不到方法inputs/outputs参数与返回值的类型和顺序编码错位链上解码出乱值stateMutability区分view/pure/nonpayable/payable前端误判要不要签名和付 gastypefunction / event / constructor / error事件解析不到日志前 4 字节选择器是keccak256(add(uint256))的前四字节。这也解释了一个常见现象改了参数类型比如uint256换成uint128方法名没变选择器却变了旧前端调用会失败。4.2 在 JS 里部署与调用// interact.js —— 部署合约并调用读写方法 const fs require(fs); const { ethers } require(ethers); const abi JSON.parse(fs.readFileSync(./build/Counter.abi, utf8)); const bytecode 0x fs.readFileSync(./build/Counter.bin, utf8).trim(); async function main() { // 本地节点或测试网 RPC生产环境密钥不要写死在代码里 const provider new ethers.JsonRpcProvider(http://127.0.0.1:8545); const signer await provider.getSigner(0); // 用节点上的第一个账户签名 const factory new ethers.ContractFactory(abi, bytecode, signer); const contract await factory.deploy(); // 发一笔部署交易 await contract.waitForDeployment(); console.log(address:, await contract.getAddress()); const tx await contract.add(3); // 写方法发交易消耗 gas await tx.wait(); // 等交易被打包 console.log(count , await contract.get()); // 读方法本地 call不花 gas } main().catch(console.error);参数与流程说明JsonRpcProvider指向节点地址本地开发常用 8545 端口getSigner(0)取节点管理的账户生产环境应该换成new ethers.Wallet(privateKey, provider)并把私钥从环境变量读进来factory.deploy()的参数对应构造函数入参无构造函数就留空tx.wait()是在等交易进块不等的话后面读到的可能还是旧值。4.3 读方法和写方法的成本差别这个区别决定了前端交互的设计方式维度view读方法写方法调用方式eth_call本地节点执行发一笔真实交易是否需签名否是用户必须确认是否花 gas否是失败也扣已消耗部分返回值立即拿到拿不到只能读事件或再查前端体验页面加载即可刷新需要等待确认、处理 pending 状态明白了这一点前端里为什么我不能直接在 JS 里改计数器这个问题就自然消解了JS 负责构造和签名请求真正的状态变更发生在 EVM 里只有交易被打包才算数。5. 版本锁定、字节码体积与 ABI 漂移前面三步跑通之后剩下的是上线才会撞见的问题集中在三处。第一处是源码验证对不上。区块浏览器验证时它拿你的源码重新编译一遍比对 runtime 字节码。只要 pragma 实际解析到的版本、优化器开关、runs取值、甚至换行符部分工具链会把源码规范化有一项不同哈希就对不上验证直接失败。稳妥做法是把solc版本、optimizer.runs、evmVersion三项全部写进配置并提交到仓库编译脚本只从配置读不再手敲参数。第二处是字节码体积上限。EIP-170 限制单个合约的 runtime 字节码不超过 24576 字节超了能编译但部署一定失败。先用一行命令量一下# 十六进制字符串长度除以 2 就是字节数接近 24576 就该动手了 node -e const brequire(fs).readFileSync(./build/Counter.bin,utf8).trim();console.log(runtime bytes:, b.length/2)超限时常见处理顺序是先开优化器并调低runs偏向省体积而非省运行 gas再把工具函数抽成library或internal外部合约最后才考虑拆分业务模块。注意runs调低会让高频调用的合约运行 gas 上升这是明确要权衡的取舍不是免费的优化。第三处是 ABI 漂移。合约升级或改参数后前端还在用旧的 ABI 文件报错信息通常长这样报错根因排查动作function selector was not recognized选择器变了参数类型被改对比新旧 ABI 的inputs类型call revert exception参数编码长度不对检查uint位数与数组写法cannot estimate gas方法实际会回滚先用staticCall复现一次invalid BigNumberish value传了 JS 的 number改传字符串或 BigInt一个很实用的排查手法是直接从链上反查选择器而不是靠人眼比对 JSON// 读链上代码 本地 ABI 交叉验证定位是 ABI 过期还是合约真的换了 const { ethers } require(ethers); const provider new ethers.JsonRpcProvider(http://127.0.0.1:8545); async function inspect(address, abi) { const code await provider.getCode(address); console.log(on-chain code size:, (code.length - 2) / 2, bytes); const iface new ethers.Interface(abi); for (const frag of iface.fragments) { if (frag.type ! function) continue; console.log(frag.name, -, iface.getFunction(frag.format()).selector); } } inspect(process.env.CONTRACT_ADDRESS, require(./build/Counter.abi));把打印出的选择器和区块浏览器里该地址实际调用的选择器对一遍能立刻分清是前端 ABI 过期还是合约地址填错了。这一步比反复重编译省时间得多。本文还有配套的精品资源点击获取
返回列表