一文了解 Aleo 智能合约特点与安全

封面:Aleo

9 月 15 日,可编程隐私网络 Aleo 主网上线。作为首个将零知识证明 (ZKP) 技术深度融入 Layer 1 的明星项目,其核心特点是采用零知识证明(ZKP)技术,使用户能够在不暴露具体数据的前提下进行交易和互动。Aleo 还引入了一种创新的混合架构,将链上存储与链下计算结合起来,提升了网络的扩展性和处理效率。所有交易的计算过程在链下进行,链上只存储必要的证明数据,这大幅减少了区块链的负担,优化了性能。

本文旨在为 Aleo 的合约开发人员和爱好者提供一份关于 Aleo 智能合约(Aleo 中也称为程序)安全实践与审计要点,帮助开发人员构建强大且安全的链上项目。

Leo 语言特性

Leo 是 Aleo 区块链设计的编程语言,专注于隐私保护应用的开发。它内置零知识证明(ZKP)功能,允许开发者构建能够保护数据隐私的智能合约,适用于需要隐私保障的去中心化应用(dApps)。通过使用零知识证明,Leo 支持验证计算的正确性,而无需公开底层数据。

Leo 是一种静态类型语言,它的语法结构类似于 Rust,主要特性如下:

●  隐私性:Leo 设计的一个核心目标是支持隐私保护,通过零知识证明(ZKP)技术,开发者可以创建能够验证交易而不泄露敏感信息的智能合约。这使得用户在进行交易时,能够保护其身份和数据隐私。

●  安全性:Leo 内置了多种安全特性,帮助开发者避免常见的智能合约漏洞,如重入攻击和溢出错误等。它的编译器会在合约编译时捕捉潜在的错误,从而提高合约的安全性。

●  简洁性:Leo 合约的代码通常比较简洁,这是因为它可利用零知识证明将计算负载转移到链下,只有最终结果(例如,计算的哈希或证明)会被上传到链上。

Record

Record 是 Aleo 中的一种核心数据结构,用于记录用户资产和程序状态,其可见性可以是 constant、public 或 private,默认情况是 private,表示该 record 的内容是私密的,只有拥有者或授权方才能查看和访问Record 的创建和使用与比特币的 UTXO 类似,但需要调用 Transition 函数来创建和使用 record,当程序创建 Record 时,该 Record 随后可由同一程序作为函数的输入使用。一旦将记录用作输入,即视为已花费,不能再次使用。

在下面的代码中,定义了一个包含代币所有者和数量的 token record。mint_private 函数创建一个新的 token record,而 transfer_private 函数将以一个 token record 作为数输入被消耗掉,同时创建两个新的 token record,分别记录代币发送和接收者的代币记录。

record token {        // The token owner.        owner: address,        // The token amount.        amount: u64,}
// The function `mint_private` initializes a new record with the specified amount of tokens for the receiver.transition mint_private(receiver: address, amount: u64) -> token {        return token {            owner: receiver,            amount: amount,        };}
// The function `transfer_private` sends the specified token amount to the token receiver from the specified token record.transition transfer_private(sender: token, receiver: address, amount: u64) -> (token, token) {        let difference: u64 = sender.amount - amount;        // Produce a token record with the change amount for the sender.        let remaining: token = token {            owner: sender.owner,            amount: difference,        };        // Produce a token record for the specified receiver.        let transferred: token = token {            owner: receiver,            amount: amount,        };        // Output the sender's change record and the receiver's record.        return (remaining, transferred);}

在 Aleo 上,一笔交易可以包含多个 Transition(最多 32 个),每个 Transition 都可以创建和花费 record。

来源:https://developer.aleo.org/concepts/beginner/records

Gas

在 Aleo 中,部署和调用合约同样会消耗 Gas,但与以太坊等传统区块链的 Gas 计算方式不同,由于 Aleo 依赖于零知识证明(ZKP)技术,其 Gas 费用的计算更多地考虑了计算复杂性、证明生成和验证的开销,而不仅仅是合约执行时的指令消耗。所以对于 Leo 合约的 Gas 消耗计算,需要转化为零知识电路,电路规模越大,Gas 费用越高。每个操作(如加法、乘法、哈希函数等)都会增加电路的门数,导致更多的 Gas 消耗。特别是涉及到加密运算(如哈希、签名验证)时,电路门数显著增加。

因此,Leo 合约的 Gas 费用更多关注计算复杂性和证明效率,开发者需要优化合约逻辑以降低电路复杂度,从而减少 Gas 费用。

Aleo 智能合约安全注意事项

Aleo 提供了强大的原语,使开发者能够轻松构建私有应用程序。尽管如此,不谨慎的编码可能导致不直观的行为和安全漏洞。因此,了解 Aleo 程序的编码模式及其潜在风险至关重要。在本节中,我们将探讨 Aleo 智能合约的编码模式和常见的安全隐患,以帮助开发者提升应用程序的安全性。

1. Record 模型的调用者检查

Aleo 的 record 模型类似于 UTXO 模型,但是 record 的所有权通过 owner 字段标识。因此,在与 record 相关的函数逻辑中,一般无需验证调用者身份,因为链层会自动校验发起交易的人是否为 record 的实际所有者。以下为 join 函数的代码示例,该函数用于将 owner 持有的两个 record 合并为一个,此处无需验证它们的所有者是否一致。

下面是链上 credits.aleo 程序中 join 函数的 Aleo 电路示例,同样也不需要校验两个 record 的 owner 是否一致。

record credits:    owner as address.private;    microcredits as u64.private;function join:    input r0 as credits.record;    input r1 as credits.record;    add r0.microcredits r1.microcredits into r2;    cast r0.owner r2 into r3 as credits.record;    output r3 as credits.record;

2. Aleo 中的递归调用

对于 Ethereum 上的智能合约,限定了调用栈深度最大为 1024,但一般合约很少达到这一限制。这主要是因为 EVM 的执行主要受 Gas 费用的限制,每次交易执行都需支付一定的 Gas,用以控制计算资源的消耗。当 Gas 消耗完毕,交易将被中止。因此,递归调用在 Solidity 中会显著增加 Gas 的消耗,一旦超出交易设定的 Gas 上限,递归调用将失败。

而 Aleo 使用零知识证明(ZK-SNARKs)来验证交易和程序执行的正确性,因此其程序执行会被编译为零知识电路。每个程序执行都需在电路中定义明确的计算路径,这意味着所有可能的计算步骤必须预先设定。这带来了一个关键限制:递归调用在零知识电路中难以处理,因为电路的深度和复杂性必须是固定的,否则编译时无法生成有效的证明。递归调用会导致电路结构不确定,特别是在递归深度未知时,电路编译器无法推导出合适的电路大小和约束。因此,如果 Aleo 代码中出现递归调用,编译器将抛出错误,提示电路无法生成

下面仅给出一个 leo 语言的代码示例,该代码无法通过编译生成 aleo 电路。因为代码中 transition test3 会调用 inline 函数 test4,而 test4 会调用另一个 inline 函数 test5,test5 又会回调 test4,形成一个完整的递归调用,但是测试结果是无法通过编译,原因是编译的电路由于存在环路而无法构建。

3. Aleo 中的溢出

溢出是所有智能合约都需要重点关注的话题,而在 Aleo 中对于整数类型(例如 i32 和 u32),Leo 在运行时将始终会捕获下溢和溢出。在 transition 中,如果发生下溢或溢出,证明者将无法创建证明。在 function 中,如果发生这种情况,整个交易将被还原。

对于 field 类型,由于它是模运算,因此不会出现下溢和溢出,这是因为与标准整数类型(如 i32 或 u32)不同,field 类型中的所有运算都使用模数运算来确保结果始终在有限域的范围内。这就意味着无论执行多少次加减乘除运算,数值都不会超出有限域的界限,也不会出现下溢或溢出。

4. Aleo 中的异步调用安全性

在 Aleo 中,async 命令取代了之前用于在函数体中 output 语句后执行操作的 finalize 命令,async 的引入允许在 Aleo 智能合约中执行非阻塞操作。这意味着当函数遇到 async 操作时,它可以启动该进程,并继续执行其他代码,而不需要等待异步任务立即完成。

在之前的 finalize 模式中,函数的输出完成后才可以执行一些收尾操作,而这种执行方式是线性的,无法并发处理,async 则提供了更高的灵活性和效率。但是这也在大大增大了部分审计业务场景中的难度

例如下面的质押合约中,转账和 finalize_stake 涉及到的账本更新分别在两个不同的异步函数中,在异步环境中转账是可能失败的,因此需要使用 await 操作等到转账成功执行之后,再进行质押账本的更新

// Claim unbonding microcreditsasync transition stake(public amount: u64) -> Future {        let f1: Future = credits.aleo/transfer_public(Staking.aleo, amount);        return finalize_stake(self.caller, amount, f1, f2);    }    async function finalize_stake(public caller: address, public amount: u64, public f1: Future) {        f1.await();        // Update states        let pre_state: StakerState = state.get(true);        state.set(true, StakerState {            total_pushed: pre_state.total_staked + amount,            total_reward: pre_state.total_reward,        });    }

而下面代码示例中,由于 transition 不存在关键状态变量修改,所以 finalize_set_admin 异步函数中无需 await。

// Add or remove an adminasync transition set_admin(public admin: address, public flag: bool) -> Future {        // Prevent all admins from getting lost        assert_neq(self.caller, admin);        return finalize_set_admin(self.caller, admin, flag);    }
async function finalize_set_admin(public caller: address, public admin: address, public flag: bool) { // Only admins can perform this action assert_eq(admins.get(caller), true); admins.set(admin, flag); }

5. Aleo 中的初始化函数

合约的构造函数通常用于在智能合约部署时初始化一些状态变量或执行一次性设置,因为 Aleo 中的智能合约通过 transition 函数来更新状态,而非在部署时初始化,所以没有构造函数。因此对于部分项目来说,需要额外的安全校验限制初始化函数的调用顺序以及次数,否则可能存在合约反复初始化等安全问题

// Initialize the programasync transition init() -> Future {        assert_eq(self.caller, DEFAULT_ADMIN);        return finalize_init();    }
async function finalize_init() { // Can only be initialized once. assert_eq(admins.contains(DEFAULT_ADMIN), false); admins.set(DEFAULT_ADMIN, true); operators.set(DEFAULT_ADMIN, true); state.set(true, StakerState { total_pulled: 0u64, total_pushed: 0u64, total_reward: 0u64, });    }

6. Aleo 中的三元运算符

在 Leo 语言中,三元运算符会同时计算条件两边的表达式。这意味着无论条件为 true 还是 false,Leo 都会执行 value_if_true 和 value_if_false 两侧的代码,而非仅根据条件选择一边执行。这可能是因为在零知识证明中,验证方需要对整个电路进行验证(无论实际的条件如何)。因此,使用三元运算符时,Leo 在生成证明的过程中需要同时评估 value_if_true 和 value_if_false,以确保电路涵盖所有可能分支,便于正确生成和验证证明。以下是一个具体的示例:

7. Aleo 中程序的唯一性

Aleo 对于身份和资源的标识方式与传统区块链网络有所不同,在以太坊中,主要通过地址来标识用户、合约和账户的唯一性。而 Aleo 使用合约名称来标识唯一性,这样虽然降低了开发者在引用其他合约时的技术难度,但是却带来了很多新的安全问题。

开发者需要特别注意自己合约中引用的 Aleo 程序是否已经部署,否则可能导致逻辑被劫持或者是项目直接无法运行的情况。

// Claim unbonding microcreditsasync transition stake(public amount: u64) -> Future {        let f1: Future = credits.aleo/transfer_public(Staking.aleo, amount);        return finalize_stake(self.caller, amount, f1, f2);    }    async function finalize_stake(public caller: address, public amount: u64, public f1: Future) {        f1.await();        // Update states        let state: State = state.get(true);        state.set(true, StakerState {            total_pushed: state.total_staked + amount,            total_reward: state.total_reward,        });    }

综上所述,Aleo 通过 Leo 抽象低级密码学并使零知识逻辑表达变得容易,从而简化了零知识编程。开发人员可以编写简洁的代码,这些代码可以编译为零知识电路并进行验证,而无需任何密码学专业知识或经验。此外,Leo 通过安全模型增强零知识应用程序的安全性,而无需任何显式加密或隐藏。与此同时,开发者在进行开发 Aleo 智能合约开发时需要了解并注意 Leo 的一些独特功能和特性,提升其智能合约的安全性,减少潜在的安全风险。

免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。文章内的信息仅供参考,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。