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。
这涉及两个非常重要的问题:
- 如何在 solana 中 sstore
solana 中的 storage 叫做 account,也就是刚才入口点传入的 accounts: &[AccountInfo] ,这是个复杂的概念,阅读单独的章节👉: Account
- 如何 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 这两个重要的概念:
- 如何让 DEX program 调用 SPL Token Program 进行转账 👉 Cross Program Invocations
- 如何让 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会发生什么。
Cargo.toml
添加 sdk
[dependencies]
solana-sdk = "1.17.6"
排除依赖(相当于依赖也是智能合约)的入口点:
[features]
no-entrypoint = []