Skip to main content

Run a Uniswap V2 on Solana

我粗浅地认为 Uniswap V2是最为美妙的智能合约,所以我们试图在solana上运行一个 uniswap v2 pool 来学习 Solana program 到底如何运行。

环境配置

看文档。

最最重要的一点,我猜是一些管道或者文件描述符之类的原因,如果你需要debug,或者跑集成测试(这会开启一个local test validator),千万千万不要在 wsl 的 windows 文件分区下运行,你会遇到无数莫名其妙的错误。。。在 wsl 的 linux 分区下运行是没啥问题的。

新建一个 shared lib

cargo new suniswap --lib

智能合约

入口点

程序导出一个已知的入口点符号,Solana 运行时在调用程序时会查找并调用该符号。

目前有两个受支持的加载器:BPF Loader and BPF loader deprecated

加载器会去指定的地址(这个涉及到程序部署和字节码索引,后面会提到)去找入口点,入口点定义:

#[no_mangle]
pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64;

所以除了 Solana BPF 本身的一些限制外,智能合约做的事情其实很简单,定义一个入口点函数,然后接收 BPF Loader 发给他的数据,解码,处理后,返回一个结果标志,0就是entrypoint::SUCCESS 其他数字就是 error code,包括程序自定义的 error code。

这个裸的入口点很简洁,但是相当难用,约等于是传进来一坨 bytes 让合约处理。这时候可以使用 entrypoint! 这个宏包装一下入口点,宏里已经实现了输入数据和返回的解码/编码:

entrypoint!(process_instruction);

docs: entrypoint in solana_program - Rust

fn process_instruction(
program_id: &Pubkey, // Public key of the account the program was loaded into
accounts: &[AccountInfo], // All accounts required to process the instruction
instruction_data: &[u8], // Serialized instruction-specific data
) -> ProgramResult;

其中 program_id 关系到bpf load,尽管也是用户的input,但是也可以理解为不可控的,就像 address(this)

accounts 和 instruction_data 都是完全接受外部输入。

instruction_data 就像是命令行参数里的 args 序列化之后一股脑传进来,这个序列化/反序列化过程完全由program自己控制。

对于 accounts ,他类似 evm 中的 storage,是solana中非常重要的概念,我们将在接下来开发时遇到的时候展开说明。

在入口写一个对 instruction_data 的 parse :

        let (&tag, rest) = instruction_data.split_first().ok_or(SwapError::InvalidInstruction)?;
match tag {
0xff => init(accounts, rest),
0 => deposit(),
1 => withdraw(),
2 => swap(),
_ => Err(SwapError::InvalidInstruction.into())
}

由输入的第一个字节决定我们要让智能合约干什么。

instruction

这是一个solana中的术语,可以缩写为 IX,类似与 evm 中的 call opcode所代表的行为(他不是一个opcode)。

init

想一下 uniswap v2 pair的 initialize 做了什么,sstore token0 和 token1。

这涉及两个非常重要的问题:

  1. 如何在 solana 中 sstore

solana 中的 storage 叫做 account,也就是刚才入口点传入的 accounts: &[AccountInfo] ,这是个复杂的概念,阅读单独的章节👉: Account

  1. 如何 solana 中的 erc20 token 是什么

Solana中需要使用官方构建的 SPL-Token program 来创建 token,由于需要额外的知识来理解 SPL-Token, 暂时只简单解释一下,下一个小节会展开说明:

Solana 中的 ERC20 token 是一个 SPL(Solana Program Library) 中合约,实际上它并不是一个 Native Program, 但是由于它是由官方发布的高并发程序的标准,因此solana上的所有代币统一以该程序发行。并且由于 solana 代码和数据分开的特性,协议的开发者不太可能去兼容其他的 token program,因为这要求 CPI 调用不可信的程序,这无法保证安全。

在这里我们只需要知道用到了两个由SPL-Token program拥有的 pubkey 作为 token0 和 token1 写入到了一个 account 中。

我们将这个存储主要数据的 account 命名为 Pair 。

deposit & withdraw

deposit:将用户的token转入 pair,并mint lp 给用户;

withdraw:将用户的 lp burn,将 token 从 pair 转给用户。

在展开说明 SPL Token 之前先思考这样一个问题,用户把 token 转入了 pair,这要求除 program 的调用外,其他用户不能随便把 pair 中的 token 转走。也就是说 Token Program 需要 program pubkey的签名才可以把 pair 里token转出来。program pubkey 实际上是有 私钥的,我们会在 [TODO]program deploy 这个章节提到,但问题是,你不能把这个 private key公开出来,让所有用户用的时候都用这个 private key签下名。这还会涉及到账户映射等相关问题 Account Maps | Solana Cookbook 。这时候我们需要首先了解 CPI 和 PDA 这两个重要的概念:

  1. 如何让 DEX program 调用 SPL Token Program 进行转账 👉 Cross Program Invocations
  2. 如何让 DEX program 能够不使用私钥就能签名 / 对 Token 账户 authority : Program Derived Addresses

对PDA有一定了解后,关于SPL-Token 阅读单独的章节 👉:SPL Token

swap

主要在此小节讨论 math / data type / decimals / precision 的问题。

先回去看一下 SPL-Token章节中的 token account struct:

    /// The amount of tokens this account holds.
pub amount: u64,

会发现 solana 中的 token 是用u64 存储的,u64::MAX = (1<<64) - 1 = 18446744073709551615

类比 EVM 中的 1e18: 18446744073709551615 / 1e18 = 18.44

一般 spl-token 会使用 6/8/9 这样的 decimals。

因此 solana 中的精度问题会比 evm 中严重很多,而且由于solana tx 费用很低,甚至可以使用 1 unit 的 WBTC token进行套利, exploit 👉 Becoming a Millionaire, 0.000150 BTC at a Time

在处理固定点数计算时要非常小心,通常对于像 LP liquidity 、shares 、reward rate 等跟比率相关的变量,一般都需要扩展为 u128 / u256 的 wad 或者固定点数来保存。

在固定点数类型转化时,经常会出现向上取整和向下取整用错的情况,这会导致比 evm 中更为严重的后果。举个例子 compound 有一个 withdraw 接口在计算 token amount → shares 时使用了向下取整,导致 burn 的shares变少,在evm中 18 位 decimals 可能只造成很小的损失,但在 solana中损失会扩大 1e10 倍。

overflow

如果cargo.toml不指定

[profile.release]
overflow-checks = true

则编译出来的 bpf 是没有溢出检查的,就可能发生溢出。

不过 rust 自带了一些溢出检查的方法:例如 checked_sub 。还有 saturating_sub 如果发生溢出,则只会返回范围的最大/最小值,而不会直接截断。

[explore] flashloan

思考一下如何实现 flashloan,如果直接CPI到其他program会发生什么。

参考实现:https://github.com/solana-labs/solana-program-library/blob/da94833aa16d756aed49ee1a7aa295295b41d19a/token-lending/program/src/processor.rs#L112

Cargo.toml

添加 sdk

[dependencies]
solana-sdk = "1.17.6"

排除依赖(相当于依赖也是智能合约)的入口点:

[features]
no-entrypoint = []