FinClip小程序+Rust(三):一个加密钱包

前言

在之前的内容我们介绍了整个端到端开发环境的搭建。

其中,我们用 Xcode 开发一个 iOS native App 的“壳”,并集成了 FinClip SDK 让这个“壳应用”具备运行小程序的能力,我们并采用 FinClip.com 的线上企业端、运营端生成 SDK 使用时所需的 App ID 和 Secret;想自己拥有本地小程序中心的朋友,则可以安装 FinClip 服务器端的 docker 镜像,并以本地的企业端、运营端生成 SDK 使用时所需的 App ID 和 Secret - 一切都在你自己的电脑本地发生。

我们又介绍了 Rust 的编译环境针对 iOS 进行编译构建代码所需要安装的相关工具。要端到端开发一个完整的应用,确实涉及到比较多的东西,有学习了解技术工具的学习成本,有时其繁琐性让人却步。但只要搭建起来,就一劳永逸,我们可以开始聚焦应用逻辑本身。

万事俱备,开搞开搞

万事俱备,那就让我们开干吧。作为一个范例,我们需要找一个逻辑比较简单又确实适合用 Rust 来实现的场景。在这里,我们选择实现一个加密钱包。设想是这样的:

  • 用 Rust 来实现密钥对的生成、加密存储、交易签名。加密算法,背后都是数学逻辑,显然在什么硬件、操作系统上实现,都应该是一样通用的对吧?
  • 用小程序来实现各种 dApp 的前端,可以是账户余额、交易历史、支付转账之类的账户管理小程序,也可以是各种 DeFi、GameFi 应用,也可以是 NFT 相关工具... 在 Web3 世界里,限制我们的只是想象力

当然,本文焦点不在于这些内容的具体实现,简单起见我们只象征性的实现一个密钥对的生成,其他剩余的事情,有待感兴趣的朋友去发掘。

特别提醒:加密货币钱包是 Web3 技术的基础,没有了它你什么 Web3 技术都试验不了,了解 Web3,从加密技术常识开始。

项目目录结构

我们姑且把这个项目称之为 finclip-rust,它的目录结构包含了以下内容:

finclip-rust
    |---- ios  (ios相关代码子目录、xcodeproj子目录,生成项目的iOS Wrapper App)
    |---- android (Android版的 wrapper app,本文不覆盖。有兴趣的同学自行尝试)
    |---- desktop (Mac/Linux/Windows版wrapper app,可能是一个Qt或者Electron应用。本文阙如,有时间再继续探讨)
    |---- mini-app (一个FinClip小程序,主要负责设备端的人机交互)
    |---- rust (Rust部分的代码,编译构建出来的应该是目标架构下的静态库,输出".a"文件)

不忙于手工创建所有这些子目录,我们先从 Rust 部分开始:

mkdir finclip-rust
cd flinclip-rust
cargo new --lib rust

Cargo 将创建一个 rust 目录,里面非常简单,仅包含以下内容:

rust
  |-- src
  |    |-- lib.rs
  |-- Cargo.toml

我们就用这个目录去写一个只有密钥对生成功能的“钱包”。

加密货币钱包的 Rust 实现

什么是加密钱包?在开发之前我们总得了解一下它的基本特征。

什么是加密货币钱包

在一般的电子钱包中,我们习惯于看到一些信息例如一某种货币单位为度量的账户余额、交易历史、持有的资产与价值等等,以及支持一些操作如支付、转账等。

加密货币钱包的最核心功能,相比之下就极其简单了,它本质上就是安全存储密钥,以便于持有者通过密钥去访问其链上的加密数字资产 - 资产在某条区块链上,并不在钱包中,除了密钥,钱包里什么都不存储。

在本项目中,简单起见我们仅实现以下两个钱包属性:

  • 一对公私钥:public key 和 private key。私钥是钱包的“命根子”,私钥被盗了(或者自己弄丢了),在链上的资产也就不再是你的。一个公私钥对的例子:公钥是“03fc56c8fa9233a9db9a57b47973058e5cdd7707233619719c604cb11a03dd46d6”,私钥是“721d468dfd4584e88702da69e8e25ebe79bf338ac268413dae3cf73475f5a870”
  • 一个从 public key 产生的 public address。这是便于他人转账、支付给你的账户地址。不同类型的加密数字货币,有不同的地址格式。例如光 Bitcoin 就有 P2PKH、P2SH、Bech32 三种不同格式标准。以太坊的地址格式则是以“0x”起头的 40 位 16 进制字母,例如“0x6400f8fb4953e50ca072e44ddd5fef4c995371a6”

怎样生成密钥

生成密钥对通常基于 ECC(Elliptical Curve Cryptography,椭圆曲线)。Bitcoin 和 Ethereum 均采用了一种叫 secp256k1 的算法实现了 ECDSA(Elliptical Curve Digital Signature Algorithm)。

非常幸运的,我们不用自己去啃这个算法、自己去实现,Rust/C 的实现早就有了,这是一个secp256k1的Rust crate,拿来主义,用用就好。

需要用到的一些库

除了最关键的密钥生成算法,还有以下一些库是我们未来可能用得着的,先列在这里:

  • serde: 一个在 Rust 中常用的 serialization/deserialization framework,我们可能把私钥序列化存储在手机设备中,就会用到它
  • serde_json: 一个 JSON serialization 库,我们把私钥序列化存储时,可能以 JSON 格式存储
  • tokio: 一个事件驱动、非阻塞型、异步高性能网络库
  • web3: Ethereum JSON-RPC client. 如果想把自己开发的这个钱包接入以太坊测试链试试的话,这个库跑不掉
  • tiny_keccak:SHA3 等哈希算法的实现

在本篇内容范围内,我们在 Cargo.toml 先作如下配置:

[package]
name = "rust-crypto-wallet"
version = "0.1.0"
edition = "2021"

[lib]
name = "rustywallet"
# this is needed to build for iOS and Android.
crate-type = ["staticlib", "lib"]

# Android专用,本文不涉及,但留着无害
[target.'cfg(target_os = "android")'.dependencies]
jni = { version = "0.19.0", default-features = false }

[dependencies]
anyhow = "1.0"
secp256k1 = { version = "0.20.3", features = ["rand"] }
serde = { version = "1.0", features = ["derive"]}
serde_json = "1.0"
tiny-keccak = { version ="1.4"}
web3 = {version = "0.17.0"}

超级简单的代码实现

代码方面,我们打算这么写:首先写一个 wallet 的实现,内容上就是密钥对的生成、公共地址的生成,其他附加功能先留白了。再写一个转换层,基于 Rust FFI(Foreign Function Interface),把 wallet 的功能输出为 C 接口。

这里的工作比较繁琐,就是把Rust的数据类型转换成 C 的类型,以及对要输出的函数及数据结构用 Rust 提供的 macro 去标识,指导编译器作符合 C 语言规范的编译输出。

我们的代码如下:

finclip-rust
    |---- rust
            |---- Cargo.toml
            |---- src
            |      |---- lib.rs (我们在这里做FFI的系列繁琐工作,输出C接口)
            |      |---- wallet
            |              |---- mod.rs
            |              |---- wallet_impl.rs
            |---- examples
                    |---- test.rs

wallet_impl.rs 目前主要是调用 secp256k1 的函数生成密钥对和钱包地址,看上去有点取巧,好像自己啥都没干,就是封装一下。

但如上所述,我们先“留白”,加密存储密钥、建立网络连接、向测试链查询资产、支付转账等等功能,应该是在此实现的,以后慢慢玩吧,但不影响我们这个项目的根本验证目标(FinClip 小程序调用Rust 功能)。

// wallet_impl.rs

use anyhow::Result;
use secp256k1::{rand::rngs, PublicKey, SecretKey};
use serde::{Deserialize, Serialize};
use tiny_keccak::keccak256;
use web3::{
    types::{Address},
};
use std::io::BufWriter;
use std::{fs::OpenOptions, io::BufReader};
use std::time::{SystemTime, UNIX_EPOCH};

pub fn generate_keypair() -> (SecretKey, PublicKey) {
    let secp = secp256k1::Secp256k1::new();
    let mut rng = rngs::JitterRng::new_with_timer(get_nstime);
    secp.generate_keypair(&mut rng)
}

pub fn public_key_address(public_key: &PublicKey) -> Address {
    let public_key = public_key.serialize_uncompressed();
    debug_assert_eq!(public_key[0], 0x04);
    let hash = keccak256(&public_key[1..]);

    Address::from_slice(&hash[12..])
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Wallet {
    pub secret_key: String,
    pub public_key: String,
    pub public_address: String,
}

impl Wallet {
    pub fn new(secret_key: &SecretKey, public_key: &PublicKey) -> Self {
        let addr: Address = public_key_address(&public_key);
        Wallet {
            secret_key: format!("{}", secret_key.to_string()),
            public_key: public_key.to_string(),
            public_address: format!("{:?}", addr),
        }
    }

    pub fn save_keys(&self, file_path: &str) -> Result<()> {
        let file = OpenOptions::new()
            .write(true)
            .create(true)
            .open(file_path)?;
        let buf_writer = BufWriter::new(file);

        serde_json::to_writer_pretty(buf_writer, self)?;

        Ok(())
    }

    pub fn retrieve_keys(file_path: &str) -> Result<Wallet> {
        let file = OpenOptions::new().read(true).open(file_path)?;
        let buf_reader = BufReader::new(file);

        let wallet: Wallet = serde_json::from_reader(buf_reader)?;
        Ok(wallet)
    }
}

pub fn get_nstime() -> u64 {
    let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
    // The correct way to calculate the current time is
    // `dur.as_secs() * 1_000_000_000 + dur.subsec_nanos() as u64`
    // But this is faster, and the difference in terms of entropy is
    // negligible (log2(10^9) == 29.9).
    dur.as_secs() << 30 | dur.subsec_nanos() as u64
}

在和 wallet_impl.rs 的同一个目录下,还有一个 mod.rs,它用来定义 Rust 的module,详情见 Rust 相关文档,不在此赘述,代码只有一行:

// mod.rs: wallet module
pub mod wallet_impl;

现在轮到这个项目的焦点部分,就是把上述功能以 C 接口方式输出,以便于我们集成到 FinClip SDK 中,供小程序调用。我们把这部分工作在 lib.rs 中实现:

// lib.rs
//
// 此部分代码主要负责Rust-C两侧的数据内存结构转换,提供了C侧的函数接口。注意命名规范:
// 在C侧使用时,凡是函数名带有 '_cwallet'的,调用过之后都必须用'free_cwallet'释放内存,
// 否则导致内存泄漏
// 

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

mod wallet;

use wallet_impl::Wallet;

use crate::wallet::*;

#[cfg(target_os = "android")]
mod android;

#[repr(C)]
pub struct CWallet {
    pub public_key: *mut c_char,
    pub private_key: *mut c_char,
    pub public_addr: *mut c_char,
}

#[no_mangle]
pub unsafe extern "C" fn generate_cwallet() -> CWallet {
    println!("generating wallet");
    let (secret_key, pub_key) = wallet_impl::generate_keypair();

    // println!("secret key: {}", &secret_key.to_string());
    // println!("public key: {}", &pub_key.to_string());

    //let pub_address = eth_wallet::public_key_address(&pub_key);
    //println!("public address: {:?}", pub_address);

    let rust_wallet = wallet_impl::Wallet::new(&secret_key, &pub_key);
    println!("rust_wallet: {:?}", &rust_wallet);

    convert_to_cwallet(rust_wallet)
}

#[no_mangle]
pub unsafe extern "C" fn free_cwallet(cw: CWallet) {
    drop(CString::from_raw(cw.public_key));
    drop(CString::from_raw(cw.private_key));
    drop(CString::from_raw(cw.public_addr));
}

#[no_mangle]
pub unsafe extern "C" fn save_wallet(cw: &CWallet) {
    let rwallet = convert_to_rwallet(cw);
    wallet_impl::Wallet::save_keys(&rwallet, "wallet.json").unwrap();
}

#[no_mangle]
pub unsafe extern "C" fn fetch_cwallet() -> CWallet {
    match wallet_impl::Wallet::retrieve_keys("wallet.json") {
        Err(_) => {
            let wallet = wallet_impl::Wallet {
                secret_key: "".to_string(),
                public_key: "".to_string(),
                public_address: "".to_string(),
            };
            return convert_to_cwallet(wallet)
        }
        Ok(w) => return convert_to_cwallet(w)
    };
}

unsafe fn convert_to_cwallet(rwallet: Wallet) -> CWallet {
   // 转换Rust字符串数据为C的字符串并移交ownership
   let pubkey = CString::new(rwallet.public_key).unwrap();
   let c_pubkey: *mut c_char = pubkey.into_raw();
   let seckey = CString::new(rwallet.secret_key).unwrap();
   let c_seckey: *mut c_char = seckey.into_raw();
   let pubaddr = CString::new(rwallet.public_address).unwrap();
   let c_pubaddr: *mut c_char = pubaddr.into_raw();

   //println!("crypto wallet address: {}", CStr::from_ptr(c_pubaddr).to_str().unwrap());

   let cw = CWallet {
       public_key: c_pubkey,
       private_key: c_seckey,
       public_addr: c_pubaddr,
   };
   
      //println!("crypto_wallet addr: {}", CStr::from_ptr(cw.public_addr).to_str().unwrap());

   cw
}

unsafe fn convert_to_rwallet(cwallet: &CWallet) -> Wallet {

    let a = CStr::from_ptr(cwallet.public_addr);
    let pa = a.to_str().unwrap();

    let pk = CStr::from_ptr(cwallet.public_key);
    let ppk = pk.to_str().unwrap();

    let sk = CStr::from_ptr(cwallet.private_key);
    let psk = sk.to_str().unwrap();

    Wallet {
        secret_key: psk.to_string(),
        public_key: ppk.to_string(),
        public_address: pa.to_string(),
    }
}

这部分代码的 tricks 是什么呢?挑重点讲:

  • 用"#[repr(C)]"来标识我们要输出到 C 侧的数据结构,如 CWallet。它在 wallet_impl 里面有一个对应的 Wallet,用的都是 Rust 的数据类型,例如String。但是到了 C 侧,需要变成以"\0"结束的字符串表示方式。其他任何 Rust 的数据类型或者 struct 结构,如果要输出被外部以“C style”去调用,首先得把函数的输入参数、返回值都“翻译”成 C 侧能“理解”的结构;
  • 用"#[no_managle]"来标识要输出到 C 侧的函数,防止 Rust 编译器在编译过程中把函数名进行改变;
  • Rust 侧的一个字符串如果要作为返回值输送至 C 侧供其使用,需要把所谓的ownership 也转交过去(否则 Rust 函数在结束执行退出前会把内存清除释放,交到 C 侧变成空指针),这里一个重要手段是用 Rust FFI 提供的CString::into_raw()函数,把一个 Rust 字符串转化成 C 的指针;
  • 对应于上述每一个 CString::into_raw()的调用,必须有一个CString::from_raw()的调用,以便于把 C 侧的字符串内存的 ownership 返还给 Rust 侧,由 Rust 侧释放掉;
  • C 侧的使用者,在调用完 generate_wallet 的接口获得一个 CWallet 后,不得改变 CWallet 中几个字符串的长度,且事后必须使用 free_wallet 去告知 Rust 侧对内存释放,否则会导致内存泄漏;

上述几点确实有点“别扭”,这和 C 侧以及 Rust 侧的内存管理模型的差异有关,其中Rust 内存模型的“所有权”(ownership)设计让一些事情变得复杂。确实,用 Rust语言开发的过程往往是和编译器博弈的过程,这背后的逻辑是,我们宁愿把痛苦控制在开发编译阶段,换取生产运行阶段的安全稳定、精神安宁。

最后,我们来写一个测试程序,命令行验证一下能正常运行,在 examples 目录下,编辑一个 test.rs 文件:

//test.rs
// 
// 测试在Rust侧生成钱包密钥对,转换成C侧的数据结构。
// 测试钱包在C侧调用接口存储和重新读出钱包密钥
//

use std::ffi::{CStr};
//use rustylib::gen::{CWallet};
use rustywallet::{CWallet, generate_cwallet, free_cwallet, save_wallet, fetch_cwallet};

fn main() {

    unsafe {
        let wallet: CWallet = generate_cwallet();
        println!("---- generated a wallet to be used on C-side ----");
        print_wallet(&wallet);

        println!("---- saving the wallet to wallet.json ----");
        save_wallet(&wallet);
        println!("---- saved! ----");

        println!("---- fetching the saved wallet to be exposed to C-side ----");
        let fetched = fetch_cwallet();
        print_wallet(&fetched);

        free_cwallet(wallet);  // 对应 generate_cwallet()
        free_cwallet(fetched); // 对应 fetch_wallet()
    }
}

unsafe fn print_wallet(wallet: &CWallet) {
    let a = CStr::from_ptr(wallet.public_addr);
    let pa = a.to_str().unwrap();
    println!("public address=> {}", pa);

    let pk = CStr::from_ptr(wallet.public_key);
    let ppk = pk.to_str().unwrap();
    println!("public key=> {}", ppk);

    let sk = CStr::from_ptr(wallet.private_key);
    let psk = sk.to_str().unwrap();
    println!("private key=> {}", psk);
}

这里涉及到一些本来很简单偏偏比较繁琐的事情,就是我们尝试从一个准备输出给 C 侧的数据结构 CWallet 中打印出它的 public_addr、public_key、private_key 三个 c_char 指针(记得我们现在是验证一下数据是否正确传递到C侧),我们没办法直接打印 c_char 指针下的内容,因为它们的内存 layout 已经不是 Rust 的 String 类型。

这时我们不得不用 Rust FFI 里提供的 CStr::from_ptr() 函数来帮助我们把一个c_char 指针构建出一个 CStr 的 wrapper,然后再通过 to_str() 函数指向一个有效的Rust &str(或称之为 Rust 字符串切片),值才能被打印出来。

验证上述代码运行,我们在 rust 项目目录下:

cargo run --example test

应该产生类似下面的结果:

Compiling rust-crypto-wallet v0.1.0 (/Users/cliang/rusty/finclip-rust/rust-wallet)
Finished dev [unoptimized + debuginfo] target(s) in 14.82s
Running `target/debug/examples/test`
generating wallet
rust_wallet: Wallet { secret_key: "0cb5fe0b77a75a43c5d5aac449df17c1849ef39da6134419c7416ef79304e5bb", public_key: "03b03e2bde039d222cb8b9bc36d9919323485ae495916d8c234c6910c75ad4e1c5", public_address: "0x1a2c83981ab4679d10b7889f9f97028265991c8f" }
---- generated a wallet to be used on C-side ----
public address=> 0x1a2c83981ab4679d10b7889f9f97028265991c8f
public key=> 03b03e2bde039d222cb8b9bc36d9919323485ae495916d8c234c6910c75ad4e1c5
private key=> 0cb5fe0b77a75a43c5d5aac449df17c1849ef39da6134419c7416ef79304e5bb
---- saving the wallet to wallet.json ----
---- saved! ----
---- fetching the saved wallet to be exposed to C-side ----
public address=> 0x1a2c83981ab4679d10b7889f9f97028265991c8f
public key=> 03b03e2bde039d222cb8b9bc36d9919323485ae495916d8c234c6910c75ad4e1c5
private key=> 0cb5fe0b77a75a43c5d5aac449df17c1849ef39da6134419c7416ef79304e5bb

考虑到加密货币钱包还有 paper wallet 一说,即你可以把自己钱包的密钥和钱包地址对应的二维码打印保存在一张纸上并锁在保险箱,使用的时候再取出来,并且因为 paper wallet 是离线的,还被认为是比“热钱包”(即在线的、软件实现的)更安全的机制,所以我们这个小项目虽然简陋也勉强算的上是一个钱包吧?

构建 iOS 二进制库准备供小程序取用

走到这一步,剩下的事情就比较简单了。正如我们在《FinClip小程序+Rust(二)》所介绍,需要构建一个 Fat library,以便于我们在 iPhone Simulator 或者在真机都可以调试:

cargo lipo --release

将在 finclip-rust/rust/target/universal/release 下生成一个 librustywallet.a 文件,这是我们准备添加至xcode项目中的库。

此外,我们尚需要生成一个 C 的头文件,在 Xcode 的项目中,当我们引入 C library时,项目需要用到:

cbindgen src/lib.rs -l c > rustywallet.h

头文件应该生成在哪里,不是很重要,我们届时在xcode中可以配置指向头文件所在位置。

至此,我们的Rust部分工作告一段落,钱包还有很多有趣的高级功能可以添加,但那已经不是本文要关注的,欢迎读者们继续试验和丰富。


关联文章

敬请移步至更多内容:

Source code on Github: https://github.com/kornhill/finclip-rust-demo.git