跳转到内容

随机性 API

以前如何获取随机数(不安全/笨拙的方法)

Section titled “以前如何获取随机数(不安全/笨拙的方法)”

在中心化世界中,构建一个抽奖系统并从 n 个参与者中随机选出一个获胜者非常简单:后端只需调用一个随机整数采样函数(在 python 中是 random.randint(0, n-1),在 JS 中是 Math.floor(Math.random() * n)).

不幸的是,在 Aptos Move 中没有等价的 random.randint(),因此在 dApp 中实现这一功能曾经非常困难.

有些人可能会写一个合约,从区块链时间戳等不安全的来源采样随机数:

module module_owner::lottery {
// ...
struct LotteryState {
players: vector<address>,
winner_idx: std::option::Option<u64>,
}
fun load_lottery_state_mut(): &mut LotteryState {
// ...
}
entry fun decide_winner() {
let lottery_state = load_lottery_state_mut();
let n = std::vector::length(&lottery_state.players);
let winner_idx = aptos_framework::timestamp::now_microseconds() % n;
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

上述实现存在多种不安全之处:

  • 恶意用户可以通过选择交易提交时间来操控结果;
  • 恶意验证者可以轻松操控结果,通过选择 decide_winner 交易进入的区块.

还有一些 dApp 选择使用外部安全随机源(如 drand),但这通常流程复杂:

  1. 参与者约定使用随机源承诺的未来随机种子来决定获胜者.
  2. 随机种子揭晓后,客户端获取并在本地推导出获胜者.
  3. 其中一位参与者将种子和获胜者提交到链上.
module module_owner::lottery {
// ...
struct LotteryState {
players: vector<address>,
/// 关于"未来随机性"的公开信息,通常是 VRF 公钥和输入。
seed_verifier: vector<u8>,
winner_idx: std::option::Option<u64>,
}
fun load_lottery_state_mut(): &mut LotteryState {
// ...
}
fun is_valid_seed(seed_verifier: vector<u8>, seed: vector<u8>): bool {
// ...
}
fun derive_winner(n: u64, seed: vector<u8>): u64 {
// ...
}
entry fun update_winner(winner_idx: u64, seed: vector<u8>) {
let lottery_state = load_lottery_state_mut();
assert!(is_valid_seed(lottery_state.seed_verifier, seed), ERR_INVALID_SEED);
let n = std::vector::length(players);
let expected_winner_idx = derive_winner(n, seed);
assert!(expected_winner_idx == winner_idx, ERR_INCORRECT_DERIVATION);
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

用 Aptos 随机性 API 实现简单与安全

Section titled “用 Aptos 随机性 API 实现简单与安全”

使用 Aptos 随机性 API,代码实现如下:

module module_owner::lottery {
// ...
struct LotteryState {
players: vector<address>,
winner_idx: std::option::Option<u64>,
}
fun load_lottery_state_mut(): &mut Lottery {
// ...
}
#[randomness]
entry fun decide_winner() {
let lottery_state = load_lottery_state_mut();
let n = vector::length(&lottery_state.players);
let winner_idx = aptos_framework::randomness::u64_range(0, n);
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

其中:

  • let winner_idx = aptos_framework::randomness::u64_range(0, n); 是随机性 API 调用,返回 [0, n) 区间内均匀分布的 u64 整数.
  • #[randomness] 是启用该 API 调用所需的属性.

确保你已安装最新版 aptos-cli.

识别依赖随机性的入口函数并使其合规

Section titled “识别依赖随机性的入口函数并使其合规”

为安全起见(后文有详细讨论),随机性 API 只能在满足以下条件的入口函数中调用:

  • 私有,且
  • 带有 #[randomness] 注解.

现在是思考哪些用户操作需要用到随机性 API 的好时机,将其列出,并确保它们是私有且带有正确属性,如下例所示:

module module_owner::lottery {
// ...
#[randomness]
entry fun decide_winner() {
// ...
}
}

运行时,调用随机性 API 时,虚拟机会检查调用栈最外层是否为带 #[randomness] 注解的私有入口函数. 否则,整个交易会被中止.

这些 API 是 0x1::randomness 下的公共函数,可直接引用,如上方抽奖示例所示.

module module_owner::lottery {
// ...
#[randomness]
entry fun decide_winner() {
// ...
let winner_idx = aptos_framework::randomness::u64_range(0, n);
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

上述示例使用了 u64_range(),但还支持许多其他基础类型. 以下是所有 API 的简要概览,其中 T 可以是 u8, u16, u32, u64, u128, u256 之一.

module aptos_framework::randomness {
/// 均匀生成一个随机数。
fun u8_integer(): u8 {}
/// 均匀生成一个随机数。
fun u16_integer(): u16 {}
// fun u32_integer(), fun u64_integer() ...
/// 均匀生成 `[min_incl, max_excl)` 区间的随机数。
fun u8_range(min_incl: u8, max_excl: u8): u8 {}
/// 均匀生成 `[min_incl, max_excl)` 区间的随机数。
fun u16_range(min_incl: u16, max_excl: u16): u16 {}
// fun u32_range(), fun u64_range() ...
/// 均匀生成指定字节数的随机字节序列
/// n 为字节数
/// 若 n 为 0,返回空向量。
fun bytes(n: u64): vector<u8> {}
/// 均匀生成 `[0, 1, ..., n-1]` 的一个排列。
/// n 为字节数
/// 若 n 为 0,返回空向量。
fun permutation(n: u64): vector<u64> {}
}

完整 API 函数列表及文档见 这里.

随机性 API 功能强大,能解锁新的 dApp 设计; 但若使用不当,可能让你的 dApp 暴露于攻击之下! 以下是一些常见错误,务必避免.

随着 dApp 复杂度提升,可能有多个入口函数需要共享依赖随机性的逻辑,并希望将其提取为辅助函数.

如下面所示,这是支持的,但需格外小心.

module module_owner::lottery {
// ...
#[randomness]
entry fun decide_winner_v0() {
// ...
decide_winner_internal(lottery_state);
}
#[randomness]
entry fun decide_winner_v1() {
// ...
decide_winner_internal(lottery_state);
}
// 私有辅助函数
fun decide_winner_internal(lottery_state: &mut lottery_state) {
let n = std::vector::length(&lottery_state.players);
let winner_idx = aptos_framework::randomness::u64_range(0, n);
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

如果 decide_winner_internal() 被错误地标记为 public, 恶意玩家可以部署自己的合约:

  1. 调用 decide_winner_internal();
  2. 读取抽奖结果(假设 lottery 模块有结果 getter);
  3. 若结果不利于自己则中止. 通过反复调用自己的合约直到交易成功, 恶意用户可以操控获胜者分布(违背 dApp 开发者初衷). 这被称为 test-and-abort 攻击.

Aptos Move 编译器已更新以防止此类攻击,保障你的合约安全: 依赖随机性的 public 函数会被视为编译错误. 如果你已完成”构建 Aptos CLI”部分, 那么你的 Aptos CLI 已配备了新版编译器.

module module_owner::lottery {
// 编译错误!
public fun decide_winner_internal(lottery_state: &mut lottery_state) {
let n = std::vector::length(&lottery_state.players);
let winner_idx = aptos_framework::randomness::u64_range(0, n);
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

不推荐,但如你确实想将此类依赖随机性的函数暴露为 public,可通过注解 #[lint::allow_unsafe_randomness] 绕过编译器检查.

module module_owner::lottery {
// 可以编译,但风险自负!
#[lint::allow_unsafe_randomness]
public fun decide_winner_internal(lottery_state: &mut lottery_state) {
let n = std::vector::length(&lottery_state.players);
let winner_idx = aptos_framework::randomness::u64_range(0, n);
lottery_state.winner_idx = std::option::some(winner_idx);
}
}

假设有这样一个 dApp.它定义了一个私有入口函数,用户可以:

  1. 抛硬币(gas 消耗:9),然后
  2. 若为正面获得奖励(gas 消耗:10),否则做清理(gas 消耗:100).

恶意用户可以控制其账户余额,使其最多覆盖 108 gas 单位(或设置交易参数 max_gas=108), 这样清理分支(总 gas 消耗:110)总会因 gas 不足而中止. 用户便可反复调用入口函数直到获得奖励.

严格来说,这被称为 undergasing 攻击, 攻击者可控制入口函数剩余 gas,从而随意中止高 gas 路径, 进而操控随机数分布(即改变原本的概率分布).

虽然随机性 API 模仿了你在中心化服务器上实现的标准库, 但请记住:种子是公开的,交易执行过程也是公开的, 并非所有依赖随机性的私有逻辑都能安全迁移到链上, 尤其涉及只有服务器可见的秘密时.

例如,在合约中请勿尝试如下操作:

  • 用随机性 API 生成非对称密钥对,丢弃私钥,认为公钥是安全的.
  • 用随机性 API 洗牌后遮盖部分牌面,认为没人知道排列顺序.

Aptogotchi Random Mint 是官方演示随机性 API 用法的 dApp.

完整 API 函数列表及文档见 这里.

部分 API 实现及单元测试见 这里.

API 设计详见 AIP-41, 系统级/密码学细节见 AIP-79.