MoreRSS

site iconsmallyu修改

区块链行业的开发者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

smallyu的 RSS 预览

Solana 智能合约开发入门教程(二)

2025-06-26 13:56:54

我们已经学会了如何创建智能合约项目、部署合约以及调用连上合约,接下来深入了解一下智能合约编程语言的写法,关注如何写出自己想要的逻辑。我们将会以写一个简单的 USDT 代币合约为例,分析相关的代码,并且理解 Solana 智能合约的写法。

1. 创建项目

用我们已经学会的命令,来创建一个新的项目:

anchor init usdt_clone

2. 配置文件

可以注意到项目路径 programs/usdt_clone/Cargo.toml 下的这个文件,Cargo 是 Rust 语言常用的包管理器,这个 Cargo.toml 则是包管理器的配置文件,指定了要引入哪些依赖库,以及依赖库的版本。我们自动生成的配置文件里有这么两行:

[dependencies]anchor-lang = "0.31.1"

Anchor 提供的宏是 Solana 智能合约的关键,宏的形式如 #[program]#[account] 等,这些宏会告诉 Solana 的 SVM 虚拟机,程序从哪里开始、数据结构在哪里定义等。如果没有 Anchor 这个依赖,合约项目就是普通的 Rust 语言项目了,Solana 的智能合约系统无法识别和解析。这也就解释了,Solana 的智能合约,是如何利用 Rust 语言来实现的。

3. 合约地址

我们近距离看一下合约的代码文件 usdt_clone/programs/usdt_clone/src/lib.rs。文件的第一行内容是这样,use 把 Anchor 常用的类型一下子全部导入进来了,这没什么问题,不需要修改,方便我们后续编写程序。:

use anchor_lang::prelude::*;

第二行内容是一个对 declare_id 函数的调用,declare_id 声明了当前这个智能合约项目的 Program ID,也就是合约地址是什么,之前我们提到过,Solana 的智能合约地址,是可以离线生成的。

declare_id!("CFmGdHuqDymqJYBX44fyNjrFoJx6wRkZPkYgZqfkAQvT");

这个合约地址是一个随机值,但不是随意格式的值,它是一个 Ed25529 的公钥。假如你手动把最后一个字符 T 改为 t,这整个字符串就不是一个合法的公钥了,所以这个值可以随机生成,但是不能随便改。那么既然是公钥,它的私钥在哪里呢?在初始化项目的时候,会自动生成一个私钥,文件位置在 target/deploy/usdt_clone-keypair.json,可以打开看到是一些字节数组,declare_id 使用的公钥,就是根据这个私钥生成的。

4. 储存数据结构

接下来我们需要新增一些自己的逻辑,在 declare_id 语句的下方,写入这个代码:

#[account]pub struct Mint {    pub decimals: u8,    pub mint_authority: Pubkey,}

可以理解为 #[account] 宏是用来定义数据结构的,Anchor 黑魔法会在背后进行一系列操作,让我们可以针对这个数据结构在链上进行读写操作。这里的代码很简单,我们定义了一个叫 Mint 的结构体,这个结构体包含两个属性,decimals 指定 USDT 代币的精度是多少,mint_authority 指定谁可以来挖新的币。

我们继续定义另一个结构体,用来储存每一个用户的代币数量。owner 就是用户地址,balance 则是用户的余额:

#[account]pub struct TokenAccount {    pub owner: Pubkey,    pub balance: u64,}

5. 账户约束结构

你可能注意到当前的代码文件最底部,还有两行自动生成的 #[derive(Accounts)] 开头的代码。这个宏是用来给账户写一些约束规则的。我们可以在 #[derive(Accounts)] 内部定义一些函数,然后再用 #[account] 来定义结构体,那么这个结构体就自动拥有了所有函数。类似于给结构体定义成员函数的意思。

把原本的 Initialize 代码删掉:

#[derive(Accounts)]pub struct Initialize {}    // 删除

然后写入我们自己的逻辑:

#[derive(Accounts)]pub struct InitMint<'info> {    #[account(        init,         payer = authority,        space = 8 + 1 + 32    )]    pub mint: Account<'info, Mint>,    #[account(mut)]    pub authority: Signer<'info>,    pub system_program: Program<'info, System>,}

这段代码有点复杂。我们先看 #[account(...)] 这一段,这里给 account() 函数传递了 3 个参数进去,account() 函数的参数类型是 Anchor 框架定义的,第一个参数 init 是一个固定的关键字,不需要值,表示如果账户不存在,则创建一个新的账户。第二个参数 payer 是需要值的,表示谁来支付创建账户的手续费。第三个参数 space 的值则是我们自己计算的,系统必须预留 8 + Mint 结构体的第一个字段类型 u8 需要空间 1 + Mint 结构体的第二个字段类型 Pubkey 需要空间 32。

这个 #[account(...)] 的宏用来修饰 mint 成员变量。我们接着看 mint 这个成员变量,Account 是 Anchor 框架提供的内置的账户类型,可以对储存数据结构进行读写,例如我们之前定义的 Mint 或者 TokenAccount 结构,这个 mint 成员变量实际操作这些类型的数据。而 Account 接受两个泛型参数,第二个参数 Mint 指明了这个账户是在处理 Mint 类型的结构,而不是 TokenAccount 或者其他。

接着看 #[account(mut)] 这个宏,mut 的意思是账户金额可以变化。authority 也是一个成员变量,它的类型同样是一个 Anchor 内置的账户类型 Signer,与 Account 不同的是,Signer 意味着需要传入账户持有者本人签名,才符合类型定义。后面的 ‘info 则是一个泛型参数,其中 info 是结构体的泛型传递进来的。至于 info 前面的单引号 ',是 Rust 语言里的一个特性,可以简单理解为对参数的引用传递。整体来看,这两行代码的宏和语句,共同定义了一个可以对其扣费的账户地址作为成员变量。

最后的 system_program 成员变量,可以把这一行理解为固定写法,只要合约需要转账 SOL,就得写上这一行。总的来说,这几行代码定义了一个新的结构体 InitMint,这个结构体是基于 Mint 进行包装的,包装后的 InitMint 拥有了一些账户相关的属性。

6. 代币合约初始化

接下来开始关注 #[program] 宏定义的函数。这个宏用来标注智能合约的程序入口,也就是真正执行合约逻辑的部分。我们当前文件里有几行默认的代码:

#[program]pub mod usdt_clone {    use super::*;    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {   // 删除        msg!("Greetings from: {:?}", ctx.program_id);             // 删除        Ok(())                                                    // 删除    }                                                             // 删除}

删掉这个项目自动生成的 initialize 函数,我们自己写一个函数:

pub fn init_mint(ctx: Context<InitMint>, decimals: u8) -> Result<()> {    let mint = &mut ctx.accounts.mint;    mint.decimals = decimals;    mint.mint_authority = ctx.accounts.authority.key();    Ok(())}

把这个 init_mint 函数放在原先 initialize 函数的位置。如果抛开 Anchor 的宏,这个函数则是一个普通的 Rust 语法定义的函数。Context 类型是 Anchor 提供的包装类型 所以你也许好奇我们明明没有定义 Context,但是这里却直接使用了。InitMint 类型是则我们上一个步骤定义好的。

这个函数接受两个参数,第一个参数的类型是 InitMint,表示哪个账户拥有铸币权限。第二个参数类型是 u8,表示 USDT 的精度是多少位。这个函数返回一个空的元组 (),说明如果成功什么都不返回,如果失败则会报错。

函数内部的逻辑相对好理解,函数把参数接收进来的数据,赋值给了一个叫 mint 的变量,要注意这不是普通的新定义的变量,而是从 ctx.accounts 反序列化过来的、mut 声明的可变类型的变量,相当于直接修改一个引用类型的结构体内的属性值,所以只要给 mint 赋值,结构体内的数据都会保存下来,也就是保存到链上。

7. 单元测试

可以先到目录下,运行一下编译,看程序是否写对了,如果编译报错,可能是哪里复制漏了。由于 Rust 语言的编译器非常严格,所以即使没有错误,也会有很多 warning,暂时不用管那些警告信息:

anchor build  

接下来到 usdt_clone/tests/usdt_clone.ts 文件,复制这些代码进去:

import anchor from "@coral-xyz/anchor";import { Program } from "@coral-xyz/anchor";import { SystemProgram, Keypair } from "@solana/web3.js";import { assert } from "chai";const { AnchorProvider, BN } = anchor;describe("usdt_clone / init_mint", () => {  const provider = AnchorProvider.env();  anchor.setProvider(provider);  const program = anchor.workspace.UsdtClone as Program;  const mintKey = Keypair.generate();  it("creates a Mint with correct metadata", async () => {    const txSig = await program.methods      .initMint(new BN(6))      .accounts({        mint: mintKey.publicKey,        authority: provider.wallet.publicKey,        systemProgram: SystemProgram.programId,      })      .signers([mintKey])      .rpc();    console.log("tx:", txSig);    const mintAccount = await program.account.mint.fetch(mintKey.publicKey);    assert.equal(mintAccount.decimals, 6);    assert.equal(      mintAccount.mintAuthority.toBase58(),      provider.wallet.publicKey.toBase58()    );  });});

这段代码使用本地的单元测试框架,构造了一些参数去调用我们在合约里写的 initMint 方法,比如指定精度为 6 位,传递了 InitMint 结构体需要的 3 个参数等。模拟交易的执行结果赋值给了 txSig 变量,可以在输出日志中看到交易哈希。并且在交易结束后,用语句 program.account.mint.fetch 查询了合约的 mint 属性的值,它的精度应该等于我们的参数,authority 也应该是我们本地发起模拟交易的账户地址。

运行这个命令来查看单元测试的效果:

anchor test

如果一切顺利,会看到 1 passing (460ms) 的字样。

8. 开户和转账

基于上面我们已经看懂的语法规则,可以继续在合约代码中新增这样两个账户结构的定义,分别用来开户和转账。这里的 #[error_code] 是新出现的宏,比较容易理解,它是一个枚举类型,用于程序报错的时候调用:

#[derive(Accounts)]pub struct InitTokenAccount<'info> {    #[account(init, payer = owner, space = 8 + 32 + 8)]    pub token: Account<'info, TokenAccount>,    #[account(mut, signer)]    pub owner: Signer<'info>,    pub system_program: Program<'info, System>,}#[derive(Accounts)]pub struct Transfer<'info> {    #[account(mut, has_one = owner)]    pub from: Account<'info, TokenAccount>,    #[account(mut)]    pub to: Account<'info, TokenAccount>,    #[account(signer)]    pub owner: Signer<'info>,}#[error_code]pub enum ErrorCode {    InsufficientFunds,    ArithmeticOverflow,}

然后新增两个方法,分别执行开户的逻辑以及转账的逻辑。注意这里开户的时候,token.balance = 1000 意味着每一个开户的地址,默认都会有 1000 的余额。这里主要是为了简化流程和代码、方便单元测试,这个数字可以随意改动:

pub fn init_token_account(ctx: Context<InitTokenAccount>) -> Result<()> {  let token = &mut ctx.accounts.token;  token.owner = ctx.accounts.owner.key();  token.balance = 1000;  Ok(())}pub fn transfer(ctx: Context<Transfer>, amount: u64) -> Result<()> {  let from = &mut ctx.accounts.from;  let to   = &mut ctx.accounts.to;  require!(from.balance >= amount, ErrorCode::InsufficientFunds);  from.balance -= amount;  to.balance = to      .balance      .checked_add(amount)      .ok_or(ErrorCode::ArithmeticOverflow)?;  Ok(())}

这是针对开户和转账功能的单元测试代码:

const tokenA = Keypair.generate();const tokenB = Keypair.generate();it("initializes tokenA & tokenB, each with balance 1000", async () => {  for (const tok of [tokenA, tokenB]) {    await program.methods      .initTokenAccount()      .accounts({        token: tok.publicKey,        owner: provider.wallet.publicKey,        systemProgram: SystemProgram.programId,      })      .signers([tok])      .rpc();    const acc = await program.account.tokenAccount.fetch(tok.publicKey);    assert.equal(      acc.owner.toBase58(),      provider.wallet.publicKey.toBase58()    );    assert.equal(acc.balance.toNumber(), 1000);  }});it("transfers 250 from A to B (balances 750 / 1250)", async () => {  await program.methods    .transfer(new BN(250))    .accounts({      from:  tokenA.publicKey,      to:    tokenB.publicKey,      owner: provider.wallet.publicKey,    })    .rpc();  const a = await program.account.tokenAccount.fetch(tokenA.publicKey);  const b = await program.account.tokenAccount.fetch(tokenB.publicKey);  assert.equal(a.balance.toNumber(), 750);  assert.equal(b.balance.toNumber(), 1250);});

如果有兴趣,可以试着把这个合约也部署到 devnet 上,然后通过 SDK 来发起对链上合约的调用。

Solana 智能合约开发入门教程(一)

2025-06-24 21:51:06

这个一个零基础系列教程,可以从最基础的操作开始学会 Solana 智能合约的开发。

1. 安装环境

访问 Solana 官方提供的安装教程:https://solana.com/docs/intro/installation

文档中提供了一键安装全部依赖的单个命令行,也有分阶段安装的详细教程。要注意其中 Solana Cli 是需要修改环境变量文件的。安装好一切后,solana 命令应该是可用的:

solana --help

2. 初始化项目

使用 anchor 命令来初始化一个智能合约的项目,这个命令行工具在上个步骤已经安装好了,可以先不用管生成的目录结构是什么样子:

anchor init hello_solcd hello_sol

3. 写入合约代码

programs/hello_sol/src 目录下有一个 lib.rs 文件,.rs 结尾意味着这是一个 Rust 语言的代码文件。把这些代码复制进去,注意 declare_id 中的内容是你的项目在初始化的时候,就会自动为你生成,不需要原封不动复制下面的内容:

use anchor_lang::prelude::*;declare_id!("3Zbdw1oWu1CiMiQr3moQeT4XzMgeqmCvjH5R5wroDWQH");#[program]pub mod hello_sol {    use super::*;    pub fn say_hello(ctx: Context<Hello>) -> Result<()> {        msg!("Hello, world!");        Ok(())    }}#[derive(Accounts)]pub struct Hello {}

4. 编译智能合约

使用 anchor 命令编译你刚才复制进去的智能合约代码,确保编译是成功的,代码没有写错。编译过程中可能会有一些警告,那些警告不要紧,因为 Rust 语言对于代码非常严格,很小的问题都会抛出大段的警告。如果一切顺利,命令行的输出不会有错误日志:

anchor build

5. 设置本地默认网络

运行这个命令,让你本地的 solana 命令默认使用 devnet,因为 devnet 是给开发者使用的,可以用来测试自己的程序,而不需要真的花钱去买 SOL 代币:

solana config set --url https://api.devnet.solana.com

6. 创建本地账户文件

这个命令用于在你本地的默认路径下,创建一个用来部署智能合约的 Solana 账户。因为部署智能合约需要消耗手续费,这些手续费需要一个账户来支付:

solana-keygen new -o ~/.config/solana/id.json  

这个命令的运行结果中,有一行 pubkey: 开头的输出,pubkey 后面的就是你本地的账户地址。因为上一个步骤已经设置了 devnet 为默认网络,所以可以直接使用这个命令来查看你本地账户的余额:

solana balance

也可以打开 devnet 的 浏览器,搜索你刚才生成的地址。搜索之后的 URL 形如:https://explorer.solana.com/address/75sFifxBt7zw1YrDfCdPjDCGDyKEqLWrBarPCLg6PHwb?cluster=devnet

当然,你会发现自己的账户余额是 0 SOL

7. 领取 devnet 上的空投

运行这个命令,你的账户就可以收到 2 个 SOL。其中参数里的 2 就是请求发放 2 个 SOL 的意思。因为领水的额度限制,你只能一次性最多领 2 个。不用担心太少,足够我们接下来的步骤使用了。

solana airdrop 2

8. 部署合约到 devnet

现在我们已经有了智能合约代码,有了本地账户,并且本地账户里有 SOL 余额。现在可以部署合约到 devnet 上了。运行这个命令:

anchor deploy --provider.cluster devnet 

如果部署成功,会看到 Deploy success 的字样。命令行输出中还有一行需要留意,Program Id: 后面的,就是部署之后的合约地址,你可以直接在 devnet 的浏览器上搜索这个地址,然后看到类似这个 URL 的页面,URL 中的 3Zbdw1oWu1CiMiQr3moQeT4XzMgeqmCvjH5R5wroDWQH 就是我部署的合约地址:https://explorer.solana.com/address/3Zbdw1oWu1CiMiQr3moQeT4XzMgeqmCvjH5R5wroDWQH?cluster=devnet

9. 调用链上合约

hello_sol/app 目录下,新建一个叫 app.js 的文件,把这些代码复制进去。简单来说,这段代码读取了你本地默认的账户文件,然后用你的 Solana 账户发起一笔对智能合约调用的交易,这个脚本每执行一次,就会在链上创建一笔交易。:

const anchor = require('@coral-xyz/anchor');const fs     = require('fs');const os     = require('os');const path   = require('path');const { Keypair, Connection } = anchor.web3;const RPC_URL    = process.env.RPC_URL;const connection = new Connection(RPC_URL, { commitment: 'confirmed' });const secretKey = Uint8Array.from(  JSON.parse(    fs.readFileSync(      path.join(os.homedir(), '.config/solana/id.json'),      'utf8',    ),  ),);const wallet   = new anchor.Wallet(Keypair.fromSecretKey(secretKey));const provider = new anchor.AnchorProvider(connection, wallet, {  preflightCommitment: 'confirmed',});anchor.setProvider(provider);const idlPath = path.resolve(__dirname, '../target/idl/hello_sol.json');const idl     = JSON.parse(fs.readFileSync(idlPath, 'utf8'));const program = new anchor.Program(idl, provider);(async () => {  try {    const sig = await program.methods.sayHello().rpc();    console.log('✅ tx', sig);    console.log(`🌐 https://explorer.solana.com/tx/${sig}?cluster=devnet`);  } catch (err) {    console.error('❌', err);  }})();

返回 hello_sol 项目的顶层目录,执行这些命令来安装 nodejs 的依赖:

npm init -y npm install @coral-xyz/anchor

然后记得现在仍然是在顶层目录,运行这个命令,来执行刚才写的 app.js 脚本,脚本会到 devnet 上调用我们部署的智能合约:

export RPC_URL=https://api.devnet.solana.comnode app/app.js

这里有一个环境变量 RPC_URL 是脚本请求的 API 地址,因为 nodejs 脚本默认不走系统代理,所以对于网络受阻的同学,需要用一个比公开 RPC 更好用的 API 地址。可以使用例如 Helius 的服务,注册一个免费的账号就可以了。假如执行脚本的过程中遇到下面的错误,那就说明是网络问题,换一个好用的 RPC 地址就好了:

❌ Error: failed to get recent blockhash: TypeError: fetch failed    at Connection.getLatestBlockhash (/Users/smallyu/work/github/hello_sol/node_modules/@solana/web3.js/lib/index.cjs.js:7236:13)    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)    at async AnchorProvider.sendAndConfirm (/Users/smallyu/work/github/hello_sol/node_modules/@coral-xyz/anchor/dist/cjs/provider.js:89:35)    at async MethodsBuilder.rpc [as _rpcFn] (/Users/smallyu/work/github/hello_sol/node_modules/@coral-xyz/anchor/dist/cjs/program/namespace/rpc.js:15:24)    at async /Users/smallyu/work/github/hello_sol/app/app.js:40:17

你也许好奇为什么不需要指定调用的合约地址,这个脚本怎么知道你刚才,部署到链上的合约在哪里?注意看脚本中有一个 idlPath 的变量,你可以直接打开这个路径的文件 target/idl/hello_sol.json 查看,里面是一些合约编译后的元信息,包括合约的地址也在里面,没错合约地址是离线生成的,不需要上链,合约就有属于自己的唯一地址了。

如果执行脚本没有输出错误,就会看到终端打印出了这一次调用合约的交易哈希,以及可以直接复制访问的浏览器 URL,例如这就是一笔调用合约的交易:https://explorer.solana.com/tx/2fnPgKkv3tGKKq72hhRxmW6WFSXuofMzXfY2UYoFZXTdJi37btdESy9NzS2gjpWzXX4CL5F7QfxugpctBVaMcBFY?cluster=devnet

这笔交易页面的最下方,可以看到我们写的智能合约在被交易调用后,打印出了 Program logged: "Hello, world!" 的日志,这正是我们写在合约代码中的 msg。

10. Troubleshooting

如果在执行上述命令或者代码的过程中,遇到了错误,可以优先考虑是命令行工具版本的问题。由于区块链行业和技术迭代比较快,很容易出现版本不兼容的情况。我本地的环境和版本是:

rustup: rustup 1.28.2 (e4f3ad6f8 2025-04-28)rustc: rustc 1.90.0-nightly (706f244db 2025-06-23)solana: solana-cli 2.2.18 (src:8392f753; feat:3073396398, client:Agave)archor: anchor-cli 0.31.1node: v24.2.0@coral-xyz/anchor(nodejs): ^0.31.1

学习王垠老师的计算机科学视频课接近尾声

2025-06-08 08:01:32

最近报名了王垠老师的 计算机科学视频班(基础班),现在学习进度已经接近尾声。因为最后一节课是选修课,我选了 Rust,而 Rust 语言本身的学习成本高,感觉不会像前几节课那样能够快速掌握并完成。不过 Rust 对我来说属于可选的进阶内容,所以并不着急结束。

前几期基础班的课程没有第八节课甚至没有第七节课,所以后两节课的内容并不影响课程本身的价值,能学到属于附加福利。我现在已经学完了前七节课,感觉收获很多。对于基础班课程内容的整体感受,我的结论是,物超所值。

课程内容后续的学习计划

对于基础班课程内容后续的学习计划,我大概列了几条:

  1. 给课程中实现的解释器写一个简单的 parser
  2. 重新做一遍所有的练习题
  3. 复刻王垠老师讲课的思路,自己写出全部课程的代码
  4. 理解练习题出题的动机,然后自己发掘新的练习题
  5. 用 Go 语言实现课程中的解释器

这些目标的难度是递进的,要真实现起来需要耗费很多很多时间。所以你看,即使基础班的课程结束,但是对于其中知识的学习,还远远没有结束。基础班是一个非常好的起点,在里面学到的内容可以延展出很多有价值的东西,这个可扩展能力的价值甚至超过课程本身的价值。

基础班的知识好比非常高级的原材料,从基础班毕业就意味着拿到了这些原材料。但原材料需要经过反复打磨、锤炼、加工,才能变成更加实际可用的装备。所以我猜测有的同学学完之后感觉什么都没学到,而有的同学觉得如获至宝,能够反复加以利用并产生许多价值,大概就是这个原因吧。

关于职业反思的反思

我开始反思自己的职业路径是从 4 月份开始的,当时我入职了一家新的公司,做普通的钱包后端开发。

实际的工作过程中,我发现同事以比较低的效率写着比较差的代码,整体工程能力差,但是每天工作显得忙的不可开交,领导也对他委以重任,因为他们之前就认识。

这样的现象让我很难受,我很懂区块链,但是这种懂不但没有让我的职业道路变轻松,没有给我的生活带来改善,反而在新的工作中,沦为新人一样的角色被轻视。

同样的,我有很强的工程实践能力,工作效率一向很高,但是这样能将技术设计短时间落地为工程代码的能力,不但没有给我在工作中带来应有的重视,反而在前公司受到了同事极其不尊重的对待。

所以我开始反思,是不是我的技术能力不够,是不是因为我没有上过好的学校,是不是行业环境整体的问题,是不是我过往的职业道路选择错误。

而反思的产物,就是 4 月份高频率的博客更新,尝试做一些技术产品的设计、学习新技术、改变社交态度。事实证明,只要我想,就真的可以学会使用 ZK,或者做点 EVM 相关的事情。但是这些事情还不够,我应该把这种学习能力和工程能力用到更有价值的地方。

关于博客内容的反思

以前习惯于把学习到的技术、职业经历、观察到的行业现象都毫无保留分享出来,因为很多内容是我自己摸索出来的,观点也是自己经过学习和思考形成,所以分享出来没什么问题。

但是观点和知识是两种东西,从基础班学习到的知识包含很高价值,自然不可能分享。而对于博客后续的内容,也有必要进行反思和调整。

区块链技术后续的学习计划

我在区块链方面需要补齐的知识:

  1. 学习研究比特币脚本
  2. 学习编写和部署 Solana、Aptos、Sui 等热门链的智能合约
  3. 学习研究 Uniswap、AAVE 之类的 DeFi 协议
  4. 调研分析一些 web3 领域知名技术人员的履历和工作成果,将其作为技术标杆提升自己

想开发一个最小 EVM 虚拟机

2025-05-11 01:15:07

我给这个项目命名为 echoevm.com,主要目标是从最简单的堆栈操作开始,逐步实现一个完整的以太坊字节码执行环境。

为什么选择这个方向?解析下以太坊客户端的技术模块:

  1. RPC:GRPC 套壳?重点在于协议设计而不是技术实现
  2. P2P:有现成的 libp2p 可用,无非是节点发现、路由表之类,比如深入下 Kademlia DHT?
  3. 账户体系:ECDSA?密码学?
  4. 交易池:交易分析、密封交易、MEV保护方向
  5. 共识机制:共识机制的设计属于研究级别,至少得是个博士发论文、实验室里做研究、出各种测试数据,然后证明在哪方面做出了业界前沿的优化、最后融资雇人做工程化的实现
  6. 储存:搞数据库底层的专家应该干什么都一样,哪里都有用武之地,跟区块链没关系
  7. 数据结构:去研究 Merkle Patricia Tree 的实现吗?
  8. 状态同步:轻节点方向,比如用 Celestia 的核心技术把执行和储存分开,或者 Archive 节点数据的 offload?

综合来看,我倾向于做一件侧重工程而不是学术、同时又有技术含量的事情,无论是从个人技术能力的提升,还是后续有可能带来的成果上,都要有意义。假如这个最小EVM开发出来了,是可以带来一系列成果的,后续也可以基于此延伸出很多更有价值的产品。

从 Solidity 语言到 bytecode 的转换过程,那是编译器专家干的事情,我要做的,是针对 bytecode 做执行,先从最简单的加法运算和 jump 开始,然后是 Gas 的计算、上下文环境的切换,直到能够执行全部以太坊历史交易。

v0.0.1(2025.05.27)

实现了一个非常简单的版本,现在可以用 solc 编译一个 Add.sol 合约,然后让 echoevm 读取生成的 Add.bin 部署代码,就会输出合约部署之后的运行时代码。

在实现这个版本的过程中,学习到的东西是部署代码和运行时代码的区别。我们一般会先部署一个合约到链上,然后再对这个合约产生调用,这实际上是两个不同的操作,但又都在使用相同的 EVM 执行,EVM 并不关心输入的 bytecode 是部署还是调用,只是对不同的操作码处理方式不同。一般部署代码会同时包含 CODECOPYRETURN 两个操作码,可以利用这一点来区分输入的类型。

v0.0.2(2025.06.09)

这个版本增加了运行 runtime bytecode 的能力,也就是先部署合约,然后再针对部署之后的合约内容,进行调用,调用的时候可以带上一些参数,比如:

go run ./cmd/echoevm -bin ./build/Add.bin -function 'add(uint256,uint256)' -args "3,5"

这个命令的含义是,会执行 ./build/Add.bin 文件内的 bytecode,并且调用 add 函数,传入参数 3 和 5,最终程序运行结束后,会返回出计算结果 8。

v0.0.3(2025.06.24)

好消息,现在 echoevm 已经可以执行以太坊主网前 10000 个区块的合约交易!因为前 10000 个区块根本没有合约交易 :P

这个版本新增了执行以太坊区块的模式,可以执行单个区块执行,也可以执行区块范围执行。当然,还需要一个获取区块数据的 url,注意对于以太坊早期的区块数据,得找 archive 模式的节点。整个命令行看起来是这样:

echoevm -start-block 0 -end-block 10000 -rpc <url>

现在 echoevm 支持的字节码有限,如果执行最新的一些区块交易,会发现报错说不支持某些字节码,这个是正常现象。

基于 zk 和智能合约的链上身份认证系统设计

2025-04-30 22:18:54

我给这个系统取名 zkgate.fun,主要想发挥零知识证明的特性,结合区块链做个小工具。关键功能是实现,用户证明自己属于某一个群组,但是不需要暴露自己真实的链上身份。

目前的设想是这样,管理员首先有一个名单列表,可以是以太坊地址的数组,然后根据这个地址列表,计算出一个 Merkle Root Hash。接着把这个 root hash 提交到智能合约上。处于这个名单中的人,可以使用 Circom 电路的 proving key,来给自己生成一个 zk proof,随后将 zk proof 提交到智能合约上。

在智能合约上,会使用 Circom 电路生成的 verifier.sol,对收到的 zk proof 进行验证,判断用于生成 zk proof 的地址,是否在 Merkle Root Hash 中,最后将判断结果返回。

这样的话,管理员不需要公开自己的群组中有哪些地址,属于群组中的地址也不需要声明自己的身份,只需要提交零知识证明生成的 zk proof,就可以证明自己真的归属于这个群组。我接下来会具体在技术上实现这个设计。

更新 v0.1.0 版本 (2025.05.09)

首先要纠正之前设计中的一个错误的地方,管理员必须要公开自己群组的地址列表,否则无法根据地址列表来生成 Merkle Tree,用户也无法根据树结构,来找到自己地址所在的节点位置、生成路径证明。

其次是很高兴地说,现在跑通了一个非常初级的 Demo(smallyunet/zkgate-demo),这个 Demo 功能并不完善,甚至没有办法在电路中验证地址的所有权,但至少是一个工具链路层面的跑通。

具体实现是这样:

  1. 有一个 链下程序 来根据地址列表,以及自己的地址,生成 zk 电路的 inputs.json,这个输入文件包含了 Merkle Root Hash 和验证节点位置所需要的路径
  2. 根据 电路代码 来编译出一些 二进制文件,这些编译后的产物是用来生成 witness 文件的
  3. 基于公开的 ptau 文件 生成 .zkey 文件
  4. 从 .zkey 文件中导出 proof.json, public.json, verification_key.json,这 3 个 json 文件可以做链下离线验证,证明 prove 的有效性
  5. 从 .zkey 文件中导出 .sol 文件,也就是智能合约代码,部署到链上
  6. 拿着 prove.json 文件和 public.json 文件的内容,作为 参数 调用合约的 verifyProof函数,如果 prove 有效则返回 true,否则返回 false

假如一个地址不在群组列表中,有两种情况:

  1. 试图用一个不在群组列表中的 地址 生成 inputs.json,然后拿着 inputs.json 去根据电路生成 prove,会直接被电路拒绝报错
  2. 试图用一些假的 prove 参数 提交到链上做验证,最终无法通过链上验证

那么目前这个最初级版本的 Demo,问题在于,构建 prove 使用的是明文地址,比如:

const members = [  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",  "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",  "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",];const proofKey = toField(members[0]);const { siblings } = await tree.find(proofKey);

这个语句的含义是在让 zk 电路判断,members[0] 是否属于 members 数组构建出来的树结构,这显然是属于的。如果想要用不属于群组的地址构建 prove,只需要替换一下 proofKey 指向的地址:

const nonMemberAddress = "0x1234567890123456789012345678901234567890";const proofKey = toField(nonMemberAddress);const { siblings } = await tree.find(proofKey);

也就是说,members 列表必须是公开的,而现在的程序只能判断一个地址在不在 members 里面,但即使 members[0] 不是我的地址,我也能用来构建一个合法的 prove。那还要 zk 干嘛?

所以下一步要解决的问题,是让用户用私钥对某个消息进行签名,然后在 zk 电路中根据签名 recover 出地址,接着判断 recover 出来的地址是否属于 members 数组。

这个过程是不是听起来简单?可实际上用 zk 电路来 recover 出一个 ECDSA 签名算法的地址,别说复杂度非常高,难度就像用乐高搭核电站一样。难怪人们都说,搞 zk 真的很掉头发。

更新 v0.2.0 版本(2025.05.13)

这个版本解决了验证地址所有权的问题,基本思路是让 zk 证明和地址所有权的证明分开,链下用 zk 证明地址的路径在 Merkle Root 上,链上需要用户提交用私钥对 root 的签名,并且将签名提交到链上。然后合约 recover 出签名的地址,跟 zk 电路的 prove 中包含的地址信息对比。

1. zk prove 包含地址信息 -> 链上验证 zk prove -> 得知 zk prove 中的地址信息2. 用私钥对 root 签名 -> 链上得到签名 -> recover 出签名对应的地址信息3. 判断 zk prove 中的地址 == 签名 recover 出的地址

演示代码具体改动的地方有:

  1. offchain 部分的代码不需要变动,生成 inputs.json 的脚本中 inputs 里已经有 key 的信息了
  2. 电路代码中,需要把 inputs 中的 key 变为 public
  3. 合约代码需要接受用户的 签名 作为参数,并且得到 recover 出的地址,将这个地址与 proof key 进行对比
  4. 调用合约的脚本,需要用私钥对 root 进行签名,并且把签名数据作为参数调用合约

到此为止,zkgate.fun 实现的功能是,群组管理员不必在链上公开自己的群组成员信息,只需要提交 Merkle Root Hash 到链上。对于群组内的成员,需要完整的成员列表,以及自己地址对应私钥签名后的信息,就可以生成 zk prove 去链上,证明自己确实是群组内的成员。

在这个过程中,使用 zk 唯一隐藏掉的信息,是群组成员的完整信息不必上链公开,只需要一个 Merkle Root Hash。而用户的地址目前无法隐藏,必须提交到链上用于验证。

更新(2025.05.14)

有一个现有的、以太坊基金会支持的、工具链和生态都已经比较成熟的 zk 协议,同样是用来做身份验证的项目,叫 Semaphore,官网是这个,可以直接在上面体验一下包含前端界面的 Demo:

在 zkgate.fun 前面两个版本的迭代中,没有选择 Semaphore 使用 EdDSA 账户体系的方案,主要是不想脱离以太坊的账户体系,也不想放弃 ECDSA,而实际上只有 EdDSA 是 zk 友好的,可以使用 Poseidon Hash 签名,zk 电路中也能对签名进行验证,不需要 “链下签名、链上 recover” 这种丑陋的实现方式。

不得不说,从个人学习的角度,虽然没几天的时间,但是我已经大概理解了 zk(工具链)的操作过程。从行业前沿的角度,我仅凭个人力量不可能做的比 Semaphore 更好。即使 zkgate.fun 进一步开发出前端界面、可视化地演示出具体的交互过程,也顶多就是 Semaphore 的这个 Demo 的样子,而且技术上没有 Semaphore 硬核。

所以 zkgate.fun 这个项目不再继续开发,域名一年后会自动到期,不再续费。

关于 web3 打赏系统的设计

2025-04-29 19:26:45

产品形态

giveme.wtf 是我刚注册的一个域名,计划做一个 web3 打赏的小工具,类似的 web2 平台有:

与之不同的是,giveme.wtf 的个人页面上,将显示 web3 钱包的收款地址、二维码,就像 Paypal 的个人收款链接一样,并且同时支持多种链的地址格式,包括比特币、以太坊、狗狗币等,可以自由选择。

giveme.wtf 不做任何资金的中转,仅仅只是展示打赏地址这一信息,比如,访问 giveme.wtf/{username},这个页面将显示出 username 设置好的收款地址信息,包括以太坊地址文本是什么,二维码是什么。就这么简单。

当然 giveme.wtf/{username} 下,也可以设置简单的 bio,头像、域名、社交媒体等,像是一个小型的个人主页,让人知道你是谁,稍微更值得分享出去一点。

技术实现

  • 注册

user 使用 MetaMask 钱包注册,连接钱包后可以设置 username,username 是全局唯一的,在智能合约上管理,user 需要发一笔与合约交互的交易,来将自己心仪的 username 提交到合约上。

  • profile 信息

绑定好 EVM 地址与 username 的关系后,就可以设置 profile 信息,包括头像、bio、钱包地址等。

填写信息后,前端页面将数据提交到后端,后端用 IPFS 节点保存这些数据(长期开启 Pin),同时生成 CID 信息,将 CID 返回给前端。

前端收到 CID 后,再发起一次合约交互,将 username->CID 的映射关系,写入到智能合约里。这个步骤可以和注册步骤合并,也可以拆开,因为有时候 user 只想注册,不想设置 profile。

  • 展示

合约上的 username->CID 是最权威的数据,前端页面将根据 giveme.wtf/{username} 中的 username,从合约中获取到 CID,再拿着 CID 去 IPFS 的网关查询出具体数据,根据数据渲染出页面。

profile 会是一些非常精简的 json 数据,数据量很小,同时为了加快网关的查询速度,可以用 Cloudflare 提供的 web3 gateway CDN。

  • 网络选择

智能合约部署在 base 上。

扩展优化

后期可以根据链上数据,统计出使用打赏系统的收款地址,以及收到打赏的金额总量,做个排行榜,按照 username 或者链分类,分析出一堆数据。

如果上了排行榜,username 下的 bio 可以增大曝光率。给你心目中的偶像上分吧,让他保持在榜首。

还可以增加一些 24小时榜单、PK 性质之类的排名。

同时也可以扩展到社交系统,如有打赏记录的地址可以形成关系图谱,甚至可以直接以某种 IM 工具的方式通讯、自动拉群等。

username 找回

MetaMask 钱包注册的问题在于,钱包丢了怎么办,是不是就失去了对 username 的控制。这里可以设计一个恢复机制,比如允许 username 设置一个恢复地址列表,只要是这个恢复列表中的地址,都可以找回 username 的控制权,进而改变 username 对应的 CID。这个机制主要是针对钱包遗失的情况。

至于钱包被黑了怎么办,黑客岂不是能直接修改恢复地址的列表。他都已经有 username 控制权了,再改也是改成他的地址,加固他对 username 的控制权。那么有没有钱包被黑还能夺回控制权的办法?web3 里没有。

网络的选择

目前必须要选择一条链来部署智能合约,智能合约是数据正确性的来源。那么选择哪条链其实是个问题,因为作为 user,不一定有链上的代币作手续费。

比如选择了 base,那么 user 首先得有 ETH,其次得在 base 上有 ETH,然后才能后续的操作。光是这两步,就能劝退大多数人。

那么为了解决这个问题,后面可以考虑的方向是手续费代付,用 ERC-4337 (现在差不多凉了)的 paymaster,或者比较原始的 Meta Transaction 方式。但是又得考虑到薅羊毛的问题,代付也得付得起才行。

数据可用性

MVP 里的方案是,数据用 IPFS 存,但仅仅只有一个服务器。IPFS 是比较底层的文件路由协议,可以考虑在上面包一层,像 Filecoin 一样,但是不会有 Filecoin 那么复杂,因为 giveme.wtf 的数据量比较小。PoST 难用的地方就在于需要对文件做加密解密,因为文件太大又不能全量校验,但 giveme.wtf 不一样,往简单了做就行,比如验证一下 Merkle Root Hash,也就是说,后面需要在 IPFS 的基础上,加上适当的文件校验和激励机制,让更多的节点愿意存下 giveme.wtf 完整的数据,然后用一种方式来定期检查每个节点是否真的储存了完整数据,如果存了,就给一点奖励。具体奖励给什么再说。

链下数据缓存

每次前端页面都从合约上查 username->CID,交互太慢了,而且消耗节点的 rpc 资源。需要考虑链下来缓存这部分数据,比如有一个中心化的后台程序,监听合约的事件,实时拿到 username->CID 的内容,然后写入到 Cloudflare Workers KV 服务里。前端页面首先请求 Cloudflare Workers KV,如果没有内容再 fallback 到合约上查。

那么这里又涉及到一个问题,如果中心化的服务作恶,或者被黑了怎么办,username->CID 的映射关系一改,钱直接打到黑客的地址上了。

这个链下数据完整性校验的问题,其实是 Optimistic Rollup 在解决的问题,也有相对成熟的方案。然后结合 Zetachain 的跨链逻辑,可以这样设想。

首先用来缓存的链下程序,将每一个 username->CID 的数据作为子节点,构建一个 Merkle Tree,最终会得到一个 Merkle Root Hash,这个 root hash 将是校验数据完整性的凭证,把这个 root hash 定时提交到合约上,前端页面去合约上查一下这个 root hash,就可以知道从缓存里拿到的 CID 有没有被篡改。

其次链下的索引程序可以有多个,通过 TSS 协商出一个私钥,只有这个私钥,才可以向合约提交 Metkle Hash Root,并且这多个索引程序,只有 root hash 相同,才会协商成功。相当于做了多签。

最后是冷静期+挑战期,Merkle Root 提交之后,在冷静期内不生效,同时任何人都可以发起挑战,如果挑战成功,则新提交的 Root 作废,继续用旧的 Root。当然这个步骤中的挑战是很麻烦的,得考虑到怎么发起挑战,尤其是怎么挑战才算是成功这个机制。但是好在不用着急做那么复杂,这个属于后期可以优化的方向。