Rust 智能合约养成日记(10-2)Sputnik DAO::Factory合约解读

往期回顾:
前文 Rust智能合约养成日记(10-1)Sputnik DAO 概述 已为大家介绍了在区块链智能合约中引入DAO社区治理模式的重要性,并简要描述了SputnikDAO平台的主要功能。
本期摘要:
从本文开始,本系列合约代码解读将自顶向下地为大家介绍NEAR生态基础设施—Sputnik-DAO 平台。首先为大家带来的是 Sputnik_DAOv2::Factory Contract 的合约解读
1. Sputnik-DAO 工厂合约
Sputnik-DAO 采用创建型工厂设计模式(Factory Pattern)实现了该平台下去中心化自治组织(DAO)的统一创建与管理。
本文将详细介绍 Sputnik-DAO 平台工厂模式(sputnikdao-factory)的设计实现。
对应合约的源代码仓库位于:https://github.com/near-daos/sputnik-dao-contract/tree/518ad1d97614fff4b945aba75b6c8bd2483187a2

2. DAPP 模块功能介绍
打开Sputnik DAO 平台的 DAPP页面,可见已经有不少去中心化自治组织在该平台中创建并定制了属于自己的DAO实例对象(Sputnikdaov2合约)。
截止2022年03月,该平台下所创建最活跃的DAO为news.sputnik-dao.near,其中已有3051个提案(proposals)正在公开投票中或状态已结。

通过在NEAR Explorer中探索,我们不难发现,该平台各DAO实例合约由NEAR账户sputnik-dao.near(sputnikdao-factory合约)统一部署。

即所有基于 Sputnik DAO 平台创建的DAO实例合约分别被部署在该NEAR账户的子账户下,例如:
pcp.sputnik-dao.near
test-dao-bro.sputnik-dao.near
blaqkstereo.sputnik-dao.near
octopode-dao.sputnik-dao.near
有关 NEAR Protocol 中的子账户定义,可以在 https://docs.near.org/docs/concepts/account#subaccounts
🔗 获得参考。
如下图所示,去中心化组织可在NEAR主网中公开发起交易,通过调用sputnikdao-factory合约所提供的create()方法,创建新的DAO实例。

3. sputnikdao-factory 合约代码解读
为帮助大家更好地了解Rust工厂模式合约的编写方法,本文将深入解读sputnikdao-factory的合约代码。
3.1 创建 DAO
sputnikdao-factory合约状态主要由如下两个部分组成:
pub struct SputnikDAOFactory {factory_manager: FactoryManager,daos: UnorderedSet<AccountId>,}
factory_manager:合约主要的内部功能逻辑实现,提供了一系列创建/删除/更新DAO实例的方法。
daos:采用集合数据结构,记录了该平台历史上所有已创建DAO实例的NEAR账户地址。
创建DAO实例所使用的sputnikdao-factory合约方法create()定义如下:
1 #[payable]2 pub fn create(&mut self, name: AccountId, args: Base64VecU8) {3 let account_id: AccountId = format!("{}.{}", name, env::current_account_id())4 .parse()5 .unwrap();6 let callback_args = serde_json::to_vec(&json!({7 "account_id": account_id,8 "attached_deposit": U128(env::attached_deposit()),9 "predecessor_account_id": env::predecessor_account_id()10 }))11 .expect("Failed to serialize");12 self.factory_manager.create_contract(13 self.get_default_code_hash(),14 account_id,15 "new",16 &args.0,17 "on_create",18 &callback_args,19 );20 }
代码3-5行的作用是将调用
create()方法时函数参数所指定的用户名name补全,以获得未来部署DAO合约的NEAR子账户地址。此处env::current_account_id()指代了sputnikdao-factory合约的地址,即sputnik-dao.near。代码6-11行构造了
create()方法在调用factory_manager.create_contract后回调函数on_create的函数参数。代码12-19行调用了工厂合约中
factory_manager所提供的create_contract接口为create()方法调用者新建并部署新的DAO实例合约。同时,对于新部署的DAO实例合约,合约的基本配置信息可通过create_contract参数args以Base64字符串的形式进行传递。
如下是NEAR主网中某一去中心化组织在Sputnik-DAO平台中创建DAO实例合约所用的一笔交易:
FyECaggFxATGaUMrRKkbotRWAPkhjw5SBnZfRHpzSiQ8
该笔交易调用了sputnikdao-factory合约代码中的create()方法,实现了multicall.sputnik-dao.near子账户的创建,并成功部署了相应DAO实例的合约代码(具体实现细节将在后文详细展开说明)。


其中args参数Base64解码后具体的内容为:
{"config": {"name": "multicall","purpose": "governance for near-multicall","metadata": ""},"policy": ["multicall.near"]}
该内容正是部署multicall.sputnik-dao.near合约时,执行合约初始化方法new()时所需的合约配置信息。
下面本文将详细剖析factory_manager.create_contract的具体实现:
1 pub fn create_contract(2 &self,3 code_hash: Base58CryptoHash,4 account_id: AccountId,5 new_method: &str,6 args: &[u8],7 callback_method: &str,8 callback_args: &[u8],9 ) {10 let code_hash: CryptoHash = code_hash.into();11 let attached_deposit = env::attached_deposit();12 let factory_account_id = env::current_account_id().as_bytes().to_vec();13 let account_id = account_id.as_bytes().to_vec();14 unsafe {15 // Check that such contract exists.16 assert_eq!(17 sys::storage_has_key(code_hash.len() as _, code_hash.as_ptr() as _),18 1,19 "Contract doesn't exist"20 );21 // Load input (wasm code) into register 0.22 sys::storage_read(code_hash.len() as _, code_hash.as_ptr() as _, 0);23 // schedule a Promise tx to account_id24 let promise_id =25 sys::promise_batch_create(account_id.len() as _, account_id.as_ptr() as _);26 // create account first.27 sys::promise_batch_action_create_account(promise_id);28 // transfer attached deposit.29 sys::promise_batch_action_transfer(promise_id, &attached_deposit as *const u128 as _);30 // deploy contract (code is taken from register 0).31 sys::promise_batch_action_deploy_contract(promise_id, u64::MAX as _, 0);32 // call `new` with given arguments.33 sys::promise_batch_action_function_call(34 promise_id,35 new_method.len() as _,36 new_method.as_ptr() as _,37 args.len() as _,38 args.as_ptr() as _,39 &NO_DEPOSIT as *const u128 as _,40 CREATE_CALL_GAS.0,41 );42 // attach callback to the factory.43 let _ = sys::promise_then(44 promise_id,45 factory_account_id.len() as _,46 factory_account_id.as_ptr() as _,47 callback_method.len() as _,48 callback_method.as_ptr() as _,49 callback_args.len() as _,50 callback_args.as_ptr() as _,51 &NO_DEPOSIT as *const u128 as _,52 ON_CREATE_CALL_GAS.0,53 );54 sys::promise_return(promise_id);55 }56 }
该函数的参数具体说明如下:
code_hash:由 Sputnik-DAO 平台所提供标准DAO实例合约模板代码的哈希值。
account_id:未来新创建DAO实例合约的部署账户,例如
multicall.sputnik-dao.near,该参数的内容已在create_contract()的上层函数create()中构造。new_method:指定了新创建DAO实例合约中的合约初始化函数,一般为
new()。args:执行DAO实例合约初始化函数
new()时所需的配置信息,同时包括如下两个方面:
由去中心化自治组织所提供的DAO基本信息:Config
pub struct Config {/// Name of the DAO.pub name: String,/// Purpose of this DAO.pub purpose: String,/// Generic metadata. Can be used by specific UI to store additional data./// This is not used by anything in the contract.pub metadata: Base64VecU8,}
以及未来该DAO内部治理策略的基本配置:
Policy
pub struct Policy {/// List of roles and permissions for them in the current policy.pub roles: Vec<RolePermission>,/// Default vote policy. Used when given proposal kind doesn't have special policy.pub default_vote_policy: VotePolicy,/// Proposal bond.pub proposal_bond: U128,/// Expiration period for proposals.pub proposal_period: U64,/// Bond for claiming a bounty.pub bounty_bond: U128,/// Period in which giving up on bounty is not punished.pub bounty_forgiveness_period: U64,}
5. callback_method:指定了create_contract()方法执行完毕后的回调函数,用于维护处理新建DAO实例合约在本工厂合约中的信息。
6. callback_args:回调函数的函数参数。
该函数的执行主要分为如下几个步骤:
代码15-22行根据
code_hash找到并载入工厂合约所提供的DAO实例合约模板代码(wasm格式)到编号为0的寄存器中。代码23-25行构造一个
Promise用于跟踪如下所有步骤(3-6)的处理结果。代码26-27行创建部署DAO实例合约的账户。
代码28-29行为新创建的账户转送NEAR代币,这笔代币源于最初工厂合约
create()方法调用者所attached_deposit的数额。代码30-31行从0号寄存器读取wasm代码,并部署合约。
代码32-41行调用DAO实例合约代码的初始化函数
new()。
最终DAO实例合约部署完毕后,将在factory_manager.create_contract()执行的末尾代码32-53行回调 on_create()函数。
如下是回调函数on_create的内部代码实现:
#[private]pub fn on_create(&mut self,account_id: AccountId,attached_deposit: U128,predecessor_account_id: AccountId,) -> bool {if near_sdk::is_promise_success() {self.daos.insert(&account_id);true} else {Promise::new(predecessor_account_id).transfer(attached_deposit.0);false}}
该函数具体的处理逻辑为:
若上述步骤(3-6)中存在错误无法正常执行,此时在回调函数
on_create()中通过调用near_sdk::is_promise_success()查询获得的Promise的返回结果将是false。此时将退还最初工厂合约create()方法调用者所attached_deposit的NEAR代币数额。若上述步骤(3-6)执行准确无误,说明用户请求的新DAO实例合约(Sputnikdaov2)被正常创建。同时本工厂合约将记录追踪该DAO实例合约所部属的子账户地址。
如下是一个在工厂合约中实际成功部署新DAO实例的交易执行结果:

3.2 更新 DAO
在Sputnik-DAO平台中,DAO可通过该工厂合约进行升级(其他方式将在后续文章中进行介绍)。
如下为工厂合约所提供的合约接口update(),它将在底层调用factory_manager所提供的update_contract()接口。
代码位于:sputnikdao-factory2/src/lib.rs # Line136-149
/// Tries to update given account created by this factory to the specified code.pub fn update(&self, account_id: AccountId, code_hash: Base58CryptoHash) {let caller_id = env::predecessor_account_id();assert!(caller_id == self.get_owner() || caller_id == account_id,"Must be updated by the factory owner or the DAO itself");assert!(self.daos.contains(&account_id),"Must be contract created by factory");self.factory_manager.update_contract(account_id, code_hash, "update");}
factory_manager.update_contract()处理细节如下:该接口可实现对相应DAO实例合约中update()函数的调用。
/// Forces update on the given contract./// Contract must support update by factory for this via permission check.pub fn update_contract(&self,account_id: AccountId,code_hash: Base58CryptoHash,method_name: &str,) {let code_hash: CryptoHash = code_hash.into();let account_id = account_id.as_bytes().to_vec();unsafe {// Check that such contract exists.assert!(env::storage_has_key(&code_hash), "Contract doesn't exist");// Load the hash from storage.sys::storage_read(code_hash.len() as _, code_hash.as_ptr() as _, 0);// Create a promise toward given account.let promise_id =sys::promise_batch_create(account_id.len() as _, account_id.as_ptr() as _);// Call `update` method, which should also handle migrations.sys::promise_batch_action_function_call(promise_id,method_name.len() as _,method_name.as_ptr() as _,u64::MAX as _,0,&NO_DEPOSIT as *const u128 as _,(env::prepaid_gas() - env::used_gas() - GAS_UPDATE_LEFTOVER).0,);sys::promise_return(promise_id);}}
BlockSec在对 Sputnik-DAO 代码进行解析的过程中发现其 Factory 合约中存在着一个严重的安全问题,会影响所有使用了Sputnik-DAO的合约。经与项目方联系后,最终该Issue被确认并及时修复。
在先前版本的代码中,sputinikdao工厂合约所提供的public update()方法缺少了如下一个关键的断言检查。这导致了该方法可以被任何人调用。
assert!(caller_id == self.get_owner() || caller_id == account_id,"Must be updated by the factory owner or the DAO itself");
而巧合的是,DAO实例合约(Sputnikdaov2合约)默认允许了可由Sputnik-DAO Factory通过跨合约调用实现本合约的升级。
DAO实例合约中实现的update()方法如下,代码位于 sputnikdao2/src/upgrade.rs # Line 62
1. #[no_mangle]2. pub fn update() {3. env::setup_panic_hook();4. let factory_info = internal_get_factory_info();5. let current_id = env::current_account_id();6. assert!(7. env::predecessor_account_id() == current_id8. || (env::predecessor_account_id() == factory_info.factory_id9. && factory_info.auto_update ),10. "{}",11. ERR_MUST_BE_SELF_OR_FACTORY12. );13.......
上述代码的第9行中,factory_info.auto_update该值在DAO实例合约部署调用new()方法进行初始化时被默认设置为 True。
DAO实例合约new()方法实现如下:代码位于sputnikdao2/src/lib.rs # Line 83-104
#[init]pub fn new(config: Config, policy: VersionedPolicy) -> Self {let this = Self {config: LazyOption::new(StorageKeys::Config, Some(&config)),policy: LazyOption::new(StorageKeys::Policy, Some(&policy.upgrade())),staking_id: None,total_delegation_amount: 0,delegations: LookupMap::new(StorageKeys::Delegations),last_proposal_id: 0,proposals: LookupMap::new(StorageKeys::Proposals),last_bounty_id: 0,bounties: LookupMap::new(StorageKeys::Bounties),bounty_claimers: LookupMap::new(StorageKeys::BountyClaimers),bounty_claims_count: LookupMap::new(StorageKeys::BountyClaimCounts),blobs: LookupMap::new(StorageKeys::Blobs),locked_amount: 0,};internal_set_factory_info(&FactoryInfo {factory_id: env::predecessor_account_id(),auto_update: true, // 这里 factory_info.auto_update该值被默认设置为 true.});this}
综上,一位普通用户(非Factory合约以及DAO合约本身)即可通过Factory合约所提供的 pub fn update()方法实现对任意DAO合约的代码升级(篡改),这会给 Sputnik-DAO 平台以及所有依赖于 Sputnik-DAO 平台的合约项目带来极大的安全隐患。
🪴 好在,发现此问题时该版本代码暂未上线NEAR主网,因此没有造成损失。

由于项目方响应迅速,目前该漏洞通过增加合理的白名单校验机制已被正确修复
详见此 Fixing Commit: 518ad1d97614fff4b945aba75b6c8bd2483187a2
4. Sputnik-DAO Factory合约安全性分析
上述发现并已修复的漏洞之外,Sputnik-DAO Factory合约的安全性主要还从如下几个方面进行保证:
【权限控制】合约开放的view类方法,不应修改合约的状态变量,即方法定义中的第一个参数需设置为
&self,而非&mut self。以下函数均未修改状态变量:
get_owner(&self)
get_number_daos(&self)
get_default_version(&self)
get_default_code_hash(&self)
get_daos(&self, from_index: u64, limit: u64)
get_dao_list(&self)
get_contracts_metadata(&self)
get_code(&self, code_hash: Base58CryptoHash)
【权限控制】合约开放的特权函数,这些函数只能由合约owner(或DAO合约账户)执行,并在方法中存在相应的assertion,例如:
fn assert_owner(&self) {assert_eq!(self.get_owner(),env::predecessor_account_id(),"Must be owner");}
以下函数均实现添加了assertion:
set_owner
set_default_code_hash
delete_contract
store_contract_metadata
delete_contract_metadata
store
【错误处理】Sputnik-DAO Factory合约对可能发生的异常情况都实现了相应合理的错误处理机制。例如用户使用Factory合约创建新DAO实例合约最后会检查创建所有的步骤时候都已正常完整地执行,否则不应对用户造成损失。详见章节 3.1 创建 DAO
更多的合约安全性 Check Points 将在后续的文章中列举详细说明。敬请期待。

【免责声明】市场有风险,投资需谨慎。本文不构成投资建议,用户应考虑本文中的任何意见、观点或结论是否符合其特定状况。据此投资,责任自负。
来源链接

