Sui Move如何重构闪电贷安全机制

本文深入分析Sui Move如何通过"烫手山芋"模式在语言层面保障闪电贷安全,对比Solidity回调模式,探讨Move能力系统、PTBs和字节码验证器如何共同构建更安全的DeFi基础设施。

Sui Move如何重构闪电贷安全机制

闪电贷作为DeFi基础原语,允许无抵押借款,只要在同一交易内完成还款。这一机制历来是把双刃剑:诚实的借款人可进行套利和债务再融资,但攻击者也能利用它放大攻击影响,增加盗取资金量。我们发现Sui的Move语言通过用"烫手山芋"模型取代Solidity对回调和运行时检查的依赖,显著提升了闪电贷安全性。这一转变使闪电贷安全成为语言保证而非开发者责任。

本文分析DeepBookV3(Sui原生订单簿DEX)的闪电贷实现,比较Sui实现与常见Solidity模式,展示Move将安全设为默认而非开发者责任的设计哲学如何提供更强安全保证,同时简化开发者体验。

Solidity方法:回调和运行时检查

Solidity闪电贷协议传统上依赖回调模式,虽提供最大灵活性,但将安全负担完全置于开发者身上。该过程要求借贷协议在验证还款前暂时信任借款人。

典型流程包括以下步骤:

  1. 借款人合约调用借贷协议的flashLoan函数
  2. 协议将代币转移至借款人合约
  3. 协议调用借款人合约的onFlashLoan函数
  4. 借款人合约使用借入代币执行逻辑
  5. 借款人合约偿还贷款
  6. 原始借贷协议检查余额确认还款,若资金未归还将回滚整个交易

图1:Solidity中闪电贷的标准回调流程

这种基于回调的模式将安全责任置于借贷协议开发者身上,他们必须在函数结束时实现余额检查以确保贷款安全(图2)。由于协议对借款人合约进行外部调用,开发者必须仔细管理状态以防止重入风险。Fei Protocol开发者在2022年以8000万美元代价吸取了这一教训,当时黑客利用系统缺陷(特别是未遵循检查-效果-交互模式)借入资金,然后在借款记录前提取抵押品。如果接收者合约的访问控制未正确实施,甚至连借款人也会面临风险。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 Move方法:可组合安全性

Sui的闪电贷实现根本不同。它利用平台的三个核心特性——独特对象模型、可编程交易块(PTBs)和字节码验证器——在语言层面提供闪电贷安全。

Sui的对象模型和Move能力

要理解Move的安全保证,必须首先理解Sui的对象模型。在以太坊基于账户的模型中,代币余额只是账本(ERC20合约)中记录谁拥有什么的数字。用户钱包不直接持有代币,而是持有允许其询问中央合约余额的密钥。

图3:在以太坊中,用户余额是中央合约存储中的条目

相比之下,Sui以对象为中心的模型将每个资产(代币、NFT、管理权或流动性池头寸)视为独特独立对象。在Sui中,一切都是对象,携带属性、所有权以及被转移或修改的能力。用户账户直接拥有这些对象。没有中央合约账本;所有权是账户与对象本身的直接关系。

图4:在Sui中,用户直接拥有独立对象集合

这种以对象为中心的方法(特定于Sui,而非Move语言本身)支持并行交易处理,并允许对象直接作为函数参数传递。这正是Move能力系统发挥作用的地方。能力是编译时属性,定义对象如何使用。

有四个关键能力:

  • key:允许对象用作存储中的键
  • store:允许对象存储在具有key能力的对象中
  • copy:允许对象被复制
  • drop:允许对象在交易结束时被丢弃或忽略

对于闪电贷,关键优势来自省略能力。没有能力的对象无法被存储、复制或丢弃。它成为"烫手山芋":必须在同一交易内被另一个函数消费的临时证明或收据。在Move中,“消费"对象意味着将其传递给取得所有权并销毁它的函数,使其退出流通。如果不这样做,交易无效且不会执行。

虽然Move的能力系统为闪电贷提供安全机制,但Sui的PTBs实现了使它们实用的可组合性。

PTBs如何工作

在以太坊中,直到EIP-7702(账户抽象)成为规范,与DeFi协议的交互需要多个独立交易(例如,一个用于代币授权,另一个用于交换)。这造成摩擦和潜在故障点。

Sui的PTBs通过允许多个操作链接到单个原子交易中来解决这个问题。虽然这听起来像Solidity的multicall()模式,但PTBs是原生集成的且更强大。关键区别在于PTBs允许一个操作的输出用作下一个操作的输入,所有这些都在同一区块内完成。

以下是通过Sui CLI进行的闪电贷套利示例,在后续交易命令中使用先前交易命令的结果。(注意实际函数签名和参数会更复杂。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 此PTB从一个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的receipt还款
--move-call $DEEPBOOK::vault::return_flashloan_base @$POOL result(3,0) result(0,1) \

# 5 - 将剩余利润发送给用户
--transfer-objects [result(2,0)] @$SENDER

图5:使用PTBs执行多步套利的Sui CLI简化示例

这种原子执行模型是Sui闪电贷的基础,但安全机制在于Move语言如何处理资产。

Move字节码验证器

Move字节码验证器是在发布时在模块上运行的静态验证步骤。它强制执行类型、资源、引用和能力约束。管道分两个阶段工作:编译器对源代码进行类型检查并将其转换为字节码,在模块在链上发布之前,字节码验证器在字节码级别再次执行类型和资源检查,拒绝任何类型错误或不安全的字节码。这防止手工制作的字节码绕过"烫手山芋"限制,确保此类值必须在同一交易内消费才有效。

实践中的烫手山芋模式:DeepBookV3

DeepBookV3的闪电贷实现使用这种"烫手山芋"模式创建无需回调或运行时余额检查的安全系统。

流程简单:

  1. 用户调用borrow_flashloan_base
  2. 函数返回两个对象(Coin, FlashLoan):借入资金的Coin对象和FlashLoan收据对象
  3. 用户使用Coin执行操作
  4. 用户调用return_flashloan_base,传回借入资金和FlashLoan收据
  5. 最终函数消费收据,交易成功完成

图6:Sui Move中闪电贷的烫手山芋流程

让我们看看返回借入资产和FlashLoan结构体的borrow_flashloan_base函数代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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烫手山芋收据

技巧在于FlashLoan结构体的定义。注意缺少什么?……没有能力!

1
2
3
4
5
public struct FlashLoan {
    pool_id: ID,
    borrow_quantity: u64,
    type_name: TypeName,
}

图8:FlashLoan结构体故意缺少能力,使其成为烫手山芋

由于此结构体是"烫手山芋”,交易有效的唯一方式是通过将其传递给相应的return_flashloan_base函数来消费它,该函数会销毁它。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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对象作为参数,从而消费它

烫手山芋模式如何确保还款

这种模式与PTBs的原子性结合,创建了内置安全保证。不依赖运行时检查,Move字节码验证器防止无效字节码被执行。

例如,如果交易调用borrow_flashloan_base但随后未消费返回的FlashLoan对象,交易无效且失败。由于结构体缺少drop能力,它不能被丢弃。由于它也不能被存储或转移,交易逻辑不完整,整个操作在处理前就会失败。

类似地,如果开发者构建借入资金但省略最终return_flashloan_base调用的PTB,交易同样无效。MoveVM识别未处理的"烫手山芋"并中止整个交易,回滚所有前置操作。

未能还款不是开发者需要防止的风险,而是系统通过设计防止的逻辑不可能。有效、可执行的交易必须包含还款逻辑。

通过设计实现安全

使用Sui Move,语言本身成为主要安全卫士。在Solidity要求开发者实现运行时检查和仔细状态管理以防止漏洞利用的地方,Move的类型系统首先使为此用例编写不安全代码变得困难。可以与Rust的安全模型进行类比:正如Rust编译器保证内存安全,Sui Move的类型系统(由字节码验证器强制执行)保证资产安全。这种模型将安全执行从开发者实现的运行时检查转移到语言自身的字节码验证规则。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计