Skip to content

9. 智能合约设计①

手把手编写智能合约,经典代码学到什么程度:看懂合约,不能当个大傻子,只会被淘汰

Truffle

  • 提供一套本地模拟的区块链,非常直观,只用于学习使用
  • 实际开发中是不用的
  • 文档地址truffleganachedrizzle
    • truffle:创建合约和部署合约,写合约 + 编译 + 部署(后端工具)npm install -g truffle
    • Ganache:本地区块链模拟器(测试网络)下载桌面端工具
    • Drizzle:前端框架,帮你读取链上数据(前端工具)
    • 目前用前两个就够用了
    • 它们三个加起来叫 Truffle Suite,以前是一整套开发以太坊 Dapp 的工具链。
  • ganache/ɡəˈnæʃ/
    • ACCOUNTSBLOCKSTRANSACTIONSCONTRACTSEVENTSLOGS

Ganache 工具

栏目作用
Accounts钱包、余额、私钥
Blocks区块浏览器、挖矿
Transactions链上所有交易记录
Contracts已部署的合约
EventsSolidity emit 的事件
LogsEVM 底层日志

学习关系

工具作用使用场景
Remix IDE在线写、编译、部署、调试智能合约的网页 IDE入门学习、快速实验合约
Ganache本地假链(本地区块链环境)本地测试部署合约,不花真币
Hardhat专业的智能合约开发框架正式开发、自动化测试、真实网络部署

学习流程

  • 三阶段工具:
js
1. Remix —— 入门写合约(最简单)

2. Ganache —— 本地跑假链(测试)

3. Hardhat —— 专业框架(正式开发 + 部署到测试网/主网)
  • 真正 DApp 流程:
js
用 Hardhat 写合约 & 自动化测试

部署到 Hardhat 内置链(或 Ganache)

测试没问题

部署到测试网(Goerli / Sepolia)

部署到主网

编写智能合约

  • Solidity:写智能合约的编程语言
  • 合约编辑器:类似babel
  • 位置:00:52:00开始一步步写合约、部署
  • 钱包:
    • 不要:创建账户就无法删除了,不同的账户只是私钥不用,助记词是一样的
    • 优选:添加钱包-导入账户(通过私钥),这种本地测试用的才能删除
    • 添加网络:添加测试账户是没有钱的,需要添加测试网络才能有钱

Solidity

  • 需要进行版本的强控制,不同版本的兼容性几乎没有,类似曾经的Angular 1.0 和 2.0之间的差距一样

  • 行业本身就这样,愿用不用,不用滚;一堆投机搞钱的,主流层能跑通就行了

  • 学习一个基本的hello world并部署线上

  • 区块链 id 集合:每个链都有自己独立的 id

  • 创建项目:truffle init

  • 创建合约:truffle create contract xxName,一键在contracts文件夹创建.sol文件

  • 部署脚本:migrations文件夹下面编写.js部署脚本,命令行执行truffle deploy

  • 调用events

调用钱包的方法

  1. 使用ethereum进行原生js调用
  2. 使用ethers.js v6调用
  3. 使用wagmi封装好的工具,进行调用

Solidity源码·Hello World

  • constructor:从前端角度叫变量,从web3角度叫世界状态,存储在所有人的电脑里面
Hello World
js
// SPDX-License-Identifier: GPL-3.0

pragma solidity >=0.7.0 <0.9.0;

import "hardhat/console.sol";

contract Hello {
    string public greeting;

    constructor() {
        greeting = "Hello World!";
        console.log(888, greeting);
    }

    function setGreeting(string memory _greeting) public {
        greeting = _greeting;
    }

    function say() public view returns (string memory) {
        return greeting;
    }
}

使用服务启动页面

  • 由于开发区块链浏览器,所以单纯的是用html无法满足需求为此添加server.js
server.js
js
const http = require('http');
const fs = require('fs');
const path = require('path');

const PORT = 8080;

// MIME 类型映射
const mimeTypes = {
    '.html': 'text/html',
    '.js': 'text/javascript',
    '.json': 'application/json',
    '.css': 'text/css',
    '.png': 'image/png',
    '.jpg': 'image/jpg',
    '.gif': 'image/gif',
    '.svg': 'image/svg+xml'
};

const server = http.createServer((req, res) => {
    console.log(`请求: ${req.url}`);

    // 路由处理:如果 URL 包含 /tx/、/block/、/address/,则返回 explorer.html
    if (req.url.includes('/tx/') || req.url.includes('/block/') || req.url.includes('/address/')) {
        const filePath = path.join(__dirname, 'explorer.html');
        fs.readFile(filePath, (err, content) => {
            if (err) {
                res.writeHead(500);
                res.end('服务器错误: ' + err.code);
            } else {
                res.writeHead(200, { 'Content-Type': 'text/html' });
                res.end(content, 'utf-8');
            }
        });
        return;
    }

    // 默认处理静态文件
    let filePath = path.join(__dirname, req.url === '/' ? 'index-json.html' : req.url);
    const extname = path.extname(filePath);
    const contentType = mimeTypes[extname] || 'application/octet-stream';

    fs.readFile(filePath, (err, content) => {
        if (err) {
            if (err.code === 'ENOENT') {
                res.writeHead(404);
                res.end('文件未找到: ' + req.url);
            } else {
                res.writeHead(500);
                res.end('服务器错误: ' + err.code);
            }
        } else {
            res.writeHead(200, { 'Content-Type': contentType });
            res.end(content, 'utf-8');
        }
    });
});

server.listen(PORT, '0.0.0.0', () => {
    console.log(`服务器运行在 http://0.0.0.0:${PORT}/`);
    console.log(`支持路由: /tx/..., /block/..., /address/...`);
});
  • 查看服务器进程:ps aux | grep "node server.js" | grep -v grep
  • 停止服务器:killall node,指定进程ID kill 69285
  • 启动服务器:node server.js

memory 是什么?

  • Solidity 存储变量有三种地方:
关键字存在哪生命周期成本
storage区块链永久存储永久
memory内存(临时)函数执行期间便宜
calldata外部输入,不可修改函数期间最便宜

为什么要 view?

  • 因为 EVM 需要知道函数是否“修改状态”。
  • 三种类型: | 修饰符 | 是否修改链状态 | 是否需要 Gas | 说明 | | --- | --- | --- | --- | | view | ❌不修改 | ❌不需要(读操作) | 只能读取,不允许写入 | | pure | ❌只计算 | ❌不需要 | 不读也不写 | | 无修饰符 | ✔️修改 | ✔️需要 Gas | 写入链上状态 |

view 是告诉区块链:“我只看,不动。”

为什么要 returns(string memory)?

  • Solidity 要非常明确地告诉 EVM
    1. 返回值的类型(string
    2. 返回值放在哪(memory

因为链上需要严格定义内存位置,不然没法运行。

JS 不需要声明类型,但 Solidity 必须声明:

部分作用
状态变量(链上永久)string public greeting
构造函数(部署时执行)设置默认 "Hello World"
写入函数(会消耗 GassetGreeting
读取函数(不消耗 Gassay()

部署上线

  • Remix:Ethereum IDE
    • Ethereum IDE = 以太坊智能合约编辑器 + 编译器 + 调试器 + 部署工具 的集合
    • 换句话说:它不只是一个代码编辑器,而是一整套专门为 Solidity/EVM 开发准备的开发环境。
  1. contracts文件夹新建一个Hello.sol文件,复制粘贴即可
  2. 选中Hello.sol文件,切换Solidity compiler模块
  3. 点击Compile 5_Hello.sol按钮,进行编译
    • 编译完成会拿到ABI二进制应用程序接口,和API一样,一个JSON数据:
      • 编译合约之后,编译器会生成一个 ABIApplication Binary Interface,应用二进制接口) 文件。
      • 它通常是一个 JSON 格式的数据,里面描述了合约对外暴露的函数、事件和参数类型,相当于“合约的使用说明书”,方便前端或其他程序按这个接口和链上的二进制合约交互。
  4. 切换到Deploy & run transactions(部署并运行交易)
    • 虚拟机:最有名的是Remix VM (Cancun),但目前默认Remix VM (Prague),这块就不要改了
    • 账户:默认第一个用户,谁部署合约,谁就是这个合约的owner
      • 有很多貔貅盘,只允许owner提取,其他人都只能充值
    • Gas:有四种单位,最小为Wei
  5. 操作按钮:点击黄色按钮Deploy
    • 黄色按钮花费Gas,蓝色按钮免费,红色按钮花费本金
    • contract address(合约地址):xxxxx
      • 形式上和默认的钱包地址没有任何区别
      • 最大的区别:这里面的钱,只有owner通过代码操作,没有私钥

从零创建项目

创建-编写-编译-部署合约

前端调用

  1. mkdir test && cd test
  2. truffle init
  3. Ganache工具-NEW WORKSPACE-ADD PROJECT(可以重命名)-引入项目的truffle-config.js文件-START
  4. Ganache-ACCOUNTS-随便找个账户的私钥复制-Metamask-添加钱包-通过私钥导入账户
    • 注意:这里不要创建账户,创建的账户是无法删除的
  5. 开启Metamask测试网络:
    • 位置:设置-高级
    • 操作:勾选三个项(显示十六进制数据、在测试网络上显示转换、显示测试网络)
    • 查看:所有热门网络-Select network-Custom
    • 开启:所有热门网络-Select network-Custom-添加自定义网络(URL/ID)都可以在Ganache顶部栏找到
    • Sepolia(以太坊官方测试链):这是唯一一个以太坊基金会维护的官方链
  6. 接下来整体就打通了
    • 存在问题:由于没有测试没有汇率,导致账户额度为零
  7. 打开部署项目配置
  • truffle-config.js里面的networks下面代码打开注释就可以了
    • 注意:port7545
  • 说明:用于部署时连接区块链网络(Ganache、本地节点、测试链)
js
development: {
	host: "127.0.0.1", // Localhost (default: none)
	port: 7545, // Standard Ethereum port (default: none)
	network_id: "*", // Any network (default: none)
},
  • truffle-config.js里面的compilers-solc下面代码打开注释就可以了
  • 说明:控制 Solidity 编译行为
js
settings: { // See the solidity docs for advice about optimization and evmVersion
	optimizer: {
		enabled: false,
		runs: 200
	},
	evmVersion: "byzantium"
}
  1. 创建合约:truffle create contract TestContract
    • truffle create all xxx:创建整套合约,合约文件、测试文件、部署脚本
编写合约
js
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;

contract InfoContract {
    string name;
    uint256 age;

    event Instructor(string name, uint256 age);

    function setInfo(string memory _name, uint256 _age) public {
        name = _name;
        age = _age;
        // 触发事件
        emit Instructor(name, age);
    }

    function sayHi() public view returns(string memory) {
        return "Hello World!";
    }

    function getInfo() public view returns(string memory, uint256) {
        return (name, age);
    }
}
  1. 编写部署脚本
    • migrations文件夹1_deploy_contracts.js
js
// 部署脚本
const InfoContract = artifacts.require("InfoContract");

module.exports = function (deployer) {
  deployer.deploy(InfoContract);
};
  1. 部署脚本:truffle deploybuild文件夹生成对应的文件
  2. 前端调用:独立的index.html页面(面试有的公司让手写)

前端 js 原生调用合约代码

前端js原生调用合约代码
js
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>调用钱包</title>
</head>

<body>
    使用原生的JavaScript API连接钱包 获取钱包地址和余额
    <button id="connect">连接钱包</button>
    <p>钱包地址:<span id="address">未连接</span></p>
    <p>余额:<span id="balance">-</span> ETH</p>

    <script>
        document.getElementById('connect').addEventListener('click', async () => {
            // 检查是否安装了 MetaMask 插件
            if (typeof window.ethereum === 'undefined') {
                // if (provider?.isMetaMask) {
                //   console.log('MetaMask');
                // }
                console.log('请安装 MetaMask');
                return;
            }

            try {
                const accounts = await window.ethereum.request({
                    // 强行弹出钱包 请求用户授权连接钱包
                    method: 'eth_requestAccounts',
                });

                // 很多账户,默认获取第一个账户
                const address = accounts[0];
                console.log('Connected account:', address);
                document.getElementById('address').textContent = address;

                const balance = await window.ethereum.request({
                    // 查看&获取账户余额
                    method: 'eth_getBalance',
                    params: [address, 'latest'],
                });

                // balance 是十六进制字符串,需要先转换为十进制
                // 这里的 balance 是字符串,如果使用 parseFloat 返回 0
                const balanceInWei = BigInt(balance);       // hex → BigInt
                const balanceInEth = Number(balanceInWei) / 1e18; // 转浮点显示
                document.getElementById('balance').textContent = balanceInEth; // 保留4位小数
            } catch (error) {
                console.error('连接钱包失败:', error);
            }
        });
    </script>
</body>

</html>

使用 ethers.js 调用

ethers.js调用钱包
js
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>调用钱包 - Ethers.js</title>
</head>

<body>
    使用 Ethers.js 连接钱包 获取钱包地址和余额
    <button id="connect">连接钱包</button>
    <p>钱包地址:<span id="address">未连接</span></p>
    <p>余额:<span id="balance">-</span> ETH</p>

    <!-- 引入 ethers.js 库 -->
    <script src="./ethers.umd.js"></script>
    <script>
        document.getElementById('connect').addEventListener('click', async () => {
            // 检查是否安装了 MetaMask 插件
            if (typeof window.ethereum === 'undefined') {
                console.log('请安装 MetaMask');
                alert('请安装 MetaMask 钱包插件');
                return;
            }

            try {
                // 使用 ethers.js v6 创建 BrowserProvider
                const provider = new ethers.BrowserProvider(window.ethereum);

                // 请求用户授权连接钱包
                await provider.send("eth_requestAccounts", []);

                // 获取签名者(当前连接的账户)
                const signer = await provider.getSigner();

                // 获取账户地址
                const address = await signer.getAddress();
                console.log('Connected account:', address);
                document.getElementById('address').textContent = address;

                // 获取账户余额
                const balance = await provider.getBalance(address);

                // 使用 ethers.js v6 的 formatEther 方法转换为以太币单位
                const balanceInEth = ethers.formatEther(balance);
                document.getElementById('balance').textContent = balanceInEth;
            } catch (error) {
                console.error('连接钱包失败:', error);
                alert('连接钱包失败,请查看控制台');
            }
        });
    </script>
</body>

</html>

部署的 json 是什么

前端 = 读取 JSON → 解析 ABI → 使用 Web3/ethers 调用链上的合约

  • build/contracts/*.json文件,包含一个合约所有需要的元信息
    • 名字:合约构建产物(contract artifact),简称 Artifact 文件。
    • 包含了 ABI,但不是纯 ABI 文件,是合约完整构建信息
    • truffle deploy 生成的 json 是合约 Artifact(构建产物),包含 ABI,但远不止 ABI
    • Artifact(构建产物)是软件编译后生成的最终可用文件/成果物。
文件/概念作用是否包含 ABI
build/contracts/*.jsonTruffle合约构建产物(全面信息)✔包含
ABI (.abi)只用于合约交互的接口描述✔只有 ABI
bytecodeEVM执行字节码❌不包含 ABI

那我是不是可以理解,前端调用.json文件就是调用整个合约呢?

  • 前端调用 .json ≠ 调用整个合约
  • 更准确是:前端读取 .json 中的 ABI + 合约地址,然后通过区块链节点调用链上的合约。
  • .json 本身不是合约,它只是描述合约长什么样的说明书。
  • 你可以把 .json Artifact 想象成:
比喻用途
合约 .sol = 电器实际功能(部署后才能用)
ABI + 地址 = 遥控器按钮对应函数,帮你远程调用
.json = 包含说明书+遥控器配置的使用手册前端读取后才能正确控制电器

调用余额精度异常问题

  • 前端调用钱包余额,出现了多种情况,原生的有三种:

第一种:parseFloat

  • 问题:由于balance是字符串类型,所以导致最终解析为0
js
const balanceInEth = parseFloat(balance) / 1e18;

第二种:parseInt

  • 解决第一种:字符串解析异常问题
  • 问题:存在溢出与精度丢失问题,超过 2^53–1 会精度丢失
js
const balanceInWei = parseInt(balance, 16); // 从十六进制转换为十进制
const balanceInEth = balanceInWei / 1e18; // 转换为以太币单位,18个0

第三种:BigInt+Number

  • 解决:精度丢失问题
js
const balanceInWei = BigInt(balance);       // hex → BigInt
const balanceInEth = Number(balanceInWei) / 1e18; // 转浮点显示
阶段第一种 parseInt第二种 BigInt
读取 0x52654f43a73934068直接变 Number → 可能溢出转为 BigInt → 无损储存
计算 balanceInWei可能已变成错误数仍然保持超高精度
最后转 Number无意义(已错)此时才缩小值 → 减少溢出概率

parseInt 是先溢出再计算,BigInt 是先保证精度再缩小范围。

部署项目不属于打包项目

  • 打包前端 = 把代码做成可运行的软件安装包
  • 部署合约 = 打包 + 安装 + 上线给所有人使用
概念前端打包(build)区块链部署(deploy)
打包后是否自动发布?build只是生成文件,不会上线✔ 部署 = 编译+上线一步到位
是否可修改?✔ 可覆盖重新发布❌ 上链后不可随便替换(除非升级合约)
  • 打包是“做出东西”
  • 部署是“做出来 + 放到环境里跑”

项目架构说明

结论先说:一个项目 ≠ 一个合约

  • 项目(Project):包含很多内容:多个合约、脚本、测试、迁移、配置等
  • contracts 文件夹里每个 .sol 文件,通常就是一个合约(也可能一个文件里写多个合约)
  • 一个系统(DApp)通常由多个合约组成,而不是一个合约撑到底。

truffle 命令

常用命令

  • truffle init:初始化项目
  • truffle create:创建合约、迁移脚本或测试文件的模板
  • truffle build:打包项目,不会编译合约,编译合约是truffle compile
  • truffle deploy:部署项目

Truffle 开发全流程图(从 initcompilemigratetestdebugconsole

全部命令

  • truffle build:执行构建流程(打包项目)
  • truffle call:调用只读合约函数(不改链上状态)
  • truffle compile:编译合约源码
  • truffle config:设置 Truffle 的配置项
  • truffle console:打开带合约对象的交互控制台
  • truffle create:创建合约 / 迁移 / 测试文件模板
  • truffle dashboard:用浏览器钱包(如 MetaMask)签名开发交易
  • truffle db:使用 Truffle 的区块链数据库功能
  • truffle debug:调试链上的某笔交易
  • truffle deploy:部署合约(等同于 migrate
  • truffle develop:启动本地开发链并进入控制台
  • truffle exec:在 Truffle 环境中执行 JS 脚本
  • truffle help:查看所有命令或某个命令的帮助
  • truffle init:初始化一个 Truffle 项目
  • truffle migrate:执行迁移脚本,部署合约
  • truffle networks:查看各网络上部署的合约地址信息
  • truffle obtain:下载并缓存指定版本的编译器
  • truffle opcode:打印合约编译后的 EVM 操作码
  • truffle preserve:把数据保存到 IPFS / Filecoin 等去中心化存储
  • truffle run:运行第三方扩展命令
  • truffle test:运行测试(JSSolidity
  • truffle unbox:下载并解压 Truffle 模板项目(Box
  • truffle version:显示 Truffle 版本信息
  • truffle watch:监听文件变化并自动重新构建项目

Gas 和本金

一句话总结

  • Gas = 手续费(给矿工 / 验证者的费用)
  • 本金 = 你在合约里实际转出去的钱(业务逻辑的金额)

👉 两者都用 ETH 支付,但是它们不是同一种钱,也不是同一个用途。

Case 1:普通转账 1 ETH

  • 你支付的有:
类型数值去向
本金1 ETH对方账户
Gas0.0005 ETH(例子)矿工

你总共扣了 1.0005 ETH,但 1 ETH 是业务款,gas 是手续费。

Case 2:执行 ENS 设置头像(没有转钱)

  • 你支付的有:
类型数值去向
本金0 ETH
Gas0.003 ETH(假设)矿工
  • 你只花 gas,没有本金。

Case 3:Mint NFT,价格 0.08 ETH

  • 你支付的有:
类型数值去向
本金0.08 ETHNFT 项目方
Gas0.002 ETH矿工
  • 总扣除 0.082 ETH,但性质完全不同。

对比一下

  • 都是 ETH
  • 但它们是“两个口袋”的钱,平台处理方式完全不同:
    • 这里的两个口袋指的是进入的两个地方
    • 一个是接收方,一个是手续费旷工
项目本金Gas
必须转出?不一定(看业务逻辑)必须(所有交易)
谁收到?合约或对方用户矿工/验证者
由什么决定?你调用的合约逻辑网络繁忙程度、你设置的 gas price
消耗方式作为支付内容只作为手续费销毁或奖励

佳哥笔记

Details
js
1.web2 
架构组 性能优化  基建(monorepo yd-hooks yd-utils yd-libs wasm) AI工程组 

cli -> 创建项13目
AI -> 前端代码 合适的文件夹里
基建 -> 引用
性能 -> sdk

更熟的人 
设计稿 ??? 牛马 业务

2.web3
钱包封装、合约SDK

同样的链不同的钱包
不同链的不同的钱包 


编译
上线版本

1.部署需要花钱 gas 
2.执行构造函数需要还钱 gas 
3.写变量需要花钱 gas 
4.某个函数 执行时间+写变量 花很多钱 gas