本文分析了Sui Move语言如何通过“热土豆”模型在编译层面强制执行还款,从而显著提高闪电贷的安全性。与Solidity依赖回调和运行时检查不同,Sui Move利用其独特的对象模型和可编程交易块(PTB),使得闪电贷的安全性成为一种语言级别的保证,而非开发者责任。
闪电贷是一种基本的 DeFi 原语,它允许无抵押借款,只要在同一笔交易内偿还即可。从历史上看,它是一把双刃剑。虽然它允许诚实的借款人进行套利和债务再融资,但它也使攻击者能够扩大其攻击的影响并增加被盗资金的数额。我们发现,Sui 的 Move 语言通过用“烫手山芋”模型取代 Solidity 对回调函数和运行时检查的依赖,显著提高了闪电贷的安全性,该模型在编译器级别强制执行还款。这种转变使闪电贷的安全性成为一种语言保障,而不是开发者的责任。
这篇文章分析了 Sui 原生订单簿 DEX DeepBookV3 中闪电贷的实现。我们将 Sui 的实现与常见的 Solidity 模式进行比较,并展示 Move 的设计理念(将安全性作为默认设置,而不是开发者的责任)如何在简化开发者体验的同时提供更强的安全保障。
Solidity 闪电贷协议传统上依赖于回调模式,这种模式提供了最大的灵活性,但也把整个安全负担都压在了开发者身上。这个过程要求借贷协议在验证还款之前暂时信任借款人。
典型的流程包括以下步骤:
flashLoan
。onFlashLoan
函数。图 1:Solidity 中闪电贷的标准回调流程
这种基于回调的模式将安全责任放在了借贷协议的开发者身上,他们必须在函数末尾实现余额检查,以确保贷款的安全(图 2)。由于该协议对借款人的合约进行外部调用,因此开发者必须小心管理状态以防止重入风险。Fei 协议的开发者在 2022 年付出了 8000 万美元的代价才 吸取了这个教训 ,当时一个黑客利用系统中的一个漏洞(特别是,它没有遵循 check-effect-interaction (CEI) 模式)来借入资金,然后在借款被记录之前提取他们的抵押品。即使是借款人,如果他们的接收合约上的访问控制没有正确实现,也可能面临风险。
function flashLoan(uint256 amount, address borrowerContract) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.transfer(borrowerContract, amount);
borrowerContract.onFlashloan();
if (token.balanceOf(address(this)) < balanceBefore) {
revert RepayFailed();
}
}
图 2:闪电贷的伪 Solidity 实现
此外,缺乏标准接口最初导致了碎片化。虽然 EIP-3156 后来提出了单资产闪电贷的标准,其中贷款人 从借款人那里拉回资金,而不是期望资金由借款人发送,但它尚未被所有主要的 DeFi 协议采用,并且有其自身的一系列 安全挑战。
Sui 的闪电贷实施方式从根本上不同。它利用了平台的两个核心功能——独特的对象模型和可编程交易块(PTB)——以在语言层面提供闪电贷的安全性。
要理解 Move 的安全保证,必须首先理解 Sui 的对象模型。在以太坊的基于账户的模型中,代币余额只是账本中的一个数字(ERC20 合约),用于跟踪谁拥有什么。用户的钱包并不直接持有代币,而是持有一个密钥,允许它向中央合约询问其余额是多少。
图 3:在以太坊中,用户的余额是中央合约的存储中的条目。
相比之下,Sui 的以对象为中心的模型将每种资产(代币、NFT、管理权限或流动性池位置)视为一个独特的、独立的对象。在 Sui 中,一切都是对象,具有属性、所有权,以及被转移或修改的能力。用户的账户直接拥有这些对象。没有中央合约账本;所有权是账户和对象本身之间的直接关系。
图 4:在 Sui 中,用户直接拥有一系列独立的对象的集合。
这种以对象为中心的方法(特定于 Sui,而不是 Move 语言本身)使得 并行交易处理 成为可能,并允许对象直接作为参数传递给函数。这就是 Move 的 能力系统 发挥作用的地方。能力是在编译时定义的属性,用于定义对象的使用方式。
有四个关键的能力:
key
:允许对象用作存储中的键。store
:允许对象存储在具有 key 能力的对象中。copy
:允许复制对象。drop
:允许在交易结束时丢弃或忽略对象。在我们的闪电贷的例子中,关键优势来自于 省略 能力。没有能力的对象不能被存储、复制或丢弃。它变成了一个“烫手山芋”:一个临时的证明或收据,必须 在同一笔交易中被另一个函数消耗掉。在 Move 中,“消耗”一个对象意味着将其传递给一个获取所有权并销毁它的函数,将其从流通中移除。如果不是这样,交易是无效的,将不会执行。虽然 Move 的能力系统为闪电贷提供了安全机制,但 Sui 的 PTB 实现了使它们具有实用性的可组合性。
在以太坊中,直到 EIP-7702(账户抽象)成为常态,与 DeFi 协议的交互需要多个独立的交易(例如,一个用于代币批准,另一个用于交换)。这会产生摩擦和潜在的故障点。
Sui 的 PTB 通过允许多个操作链接到单个原子交易中来解决这个问题。虽然这可能听起来像 Solidity 的 multicall()
模式,但 PTB 是原生集成的,并且功能更强大。关键的区别在于,PTB 允许一个操作的输出用作下一个操作的输入,所有这些都在同一个区块内。
这是一个例子,通过 Sui CLI,展示了一个闪电贷套利,它使用了前一个交易命令的结果作为后续命令的输入。(请注意,实际的函数签名和参数会更复杂。)
## 这个 PTB 从一个 DEX 借款,在其他两个 DEX 上进行交换,并在一次原子交易中偿还贷款
$ sui client ptb \
## 0 - 借入 1,000 USDC (返回: borrowed_coin, receipt)
--move-call $DEEPBOOK::vault::borrow_flashloan_base @$POOL 1000000000 \
## 1 - 使用步骤 0 中的 borrowed_coin 交换 USDC→SUI
--move-call $CETUS::swap result(0,0) @$CETUS_POOL \
## 2 - 使用步骤 1 中的 SUI 交换 SUI→USDC
--move-call $TURBOS::swap result(1,0) @$TURBOS_POOL \
## 3 - 从总 USDC 中拆分偿还金额
--move-call 0x2::coin::split result(2,0) 1000000000 \
## 4 - 使用步骤 0 中的拆分代币和收据进行偿还
--move-call $DEEPBOOK::vault::return_flashloan_base @$POOL result(3,0) result(0,1) \
## 5 - 将剩余利润发送给用户
--transfer-objects [result(2,0)] @$SENDER
图 5:一个 Sui CLI 的简化例子,展示了使用 PTB 执行多步骤套利
这种原子执行模型是 Sui 闪电贷的基础,但安全机制在于 Move 语言如何处理资产。
DeepBookV3 的闪电贷实现使用了这种“烫手山芋”模式来创建一个安全的系统,该系统不需要回调或运行时余额检查。
流程很简单:
borrow_flashloan_base
。(Coin<BaseAsset>, FlashLoan)
:借入资金的 Coin
对象和一个 FlashLoan
回执对象。Coin
执行操作。return_flashloan_base
,将借入的资金和 FlashLoan
回执传递回去。图 6:Sui Move 中闪电贷的“烫手山芋”流程
让我们看一下返回借入资产的 borrow_flashloan_base
函数的代码和 FlashLoan
结构体:
public(package) fun borrow_flashloan_base<BaseAsset, QuoteAsset>(
self: &mut Vault<BaseAsset, QuoteAsset>,
pool_id: ID,
borrow_quantity: u64,
ctx: &mut TxContext,
): (Coin<BaseAsset>, FlashLoan) {
assert!(borrow_quantity > 0, EInvalidLoanQuantity);
assert!(self.base_balance.value() >= borrow_quantity, ENotEnoughBaseForLoan);
let borrow_type_name = type_name::get<BaseAsset>();
let borrow: Coin<BaseAsset> = self.base_balance.split(borrow_quantity).into_coin(ctx);
let flash_loan = FlashLoan {
pool_id,
borrow_quantity,
type_name: borrow_type_name,
};
event::emit(FlashLoanBorrowed {
pool_id,
borrow_quantity,
type_name: borrow_type_name,
});
(borrow, flash_loan)
}
图 7:borrow 函数同时返回 Coin 和 FlashLoan “烫手山芋”回执。(deepbookv3/packages/deepbook/sources/vault/vault.move#109–133)
诀窍在于 FlashLoan
结构体的定义。注意到缺少了什么吗?… 没有能力!
public struct FlashLoan {
pool_id: ID,
borrow_quantity: u64,
type_name: TypeName,
}
图 8:FlashLoan 结构体有意缺少能力,使其成为一个“烫手山芋”。(deepbookv3/packages/deepbook/sources/vault/vault.move#28–32)
由于这个结构体是一个“烫手山芋”,因此交易有效的唯一方法是通过将其传递给相应的 return_flashloan_base
函数来消耗它,该函数会销毁它。
public(package) fun return_flashloan_base<BaseAsset, QuoteAsset>(
self: &mut Vault<BaseAsset, QuoteAsset>,
pool_id: ID,
coin: Coin<BaseAsset>,
flash_loan: FlashLoan,
) {
assert!(pool_id == flash_loan.pool_id, EIncorrectLoanPool);
assert!(type_name::get<BaseAsset>() == flash_loan.type_name, EIncorrectTypeReturned);
assert!(coin.value() == flash_loan.borrow_quantity, EIncorrectQuantityReturned);
self.base_balance.join(coin.into_balance<BaseAsset>());
let FlashLoan {
pool_id: _,
borrow_quantity: _,
type_name: _,
} = flash_loan;
}
图 9:return 函数要求 FlashLoan 对象作为参数,从而消耗掉它。(deepbookv3/packages/deepbook/sources/vault/vault.move#161–178)
这种模式,结合 PTB 的原子性,创建了内置的安全保证。Move 编译器不是依赖于运行时检查,而是阻止无效的交易被执行。
例如,如果一个交易调用 borrow_flashloan_base
,但随后没有消耗返回的 FlashLoan
对象,则该交易无效并失败。由于该结构体缺少 drop
能力,因此无法丢弃它。由于它也不能被存储或转移,因此交易逻辑不完整,整个操作在被处理之前就会失败。
类似地,如果开发者构建了一个 PTB,该 PTB 借入资金但省略了最后的 return_flashloan_base
调用,则该交易也无效。MoveVM 识别出未处理的“烫手山芋”并中止整个交易,恢复所有先前的操作。
未能偿还不是开发者需要预防的风险,而是系统通过设计来防止的逻辑上的不可能。一个有效的、可执行的交易 必须 包含还款逻辑。
使用 Sui Move,编译器本身就成了主要的保安。Solidity 要求开发者实现运行时检查和仔细的状态管理,以防止漏洞利用,而 Move 的类型系统使得首先编写不安全的代码变得很困难。可以与 Rust 的安全模型进行类比:正如 Rust 的编译器保证内存安全一样,Sui Move 的类型系统保证资产安全。该模型将安全执行从开发者实现的运行时检查转移到语言自身的编译时规则。
- 原文链接: blog.trailofbits.com/202...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!