对比三种语言的闪电贷流程,均为借款-\x26gt; 使用-\x26gt; 还款三步,只是由于语言的特性,在实现方式上有所不同。
作者:Sivan,Beosin 安全研究专家
封面:Photo by Abdullah Ahmad on Unsplash
闪电贷是一种无抵押借款的服务,由于其拥有无需抵押便能借出资金的特性,使得资金利用率大大提高。在常见的以太坊闪电贷中,是通过以太坊交易机制来保证可以进行无抵押借出资金,以太坊中一个交易可以包含很多步骤,如:借款、兑换、使用、还款等,所有的步骤相辅相成,若其中某一个或多个步骤出现错误,都将导致本次的整个交易被回滚。
随着区块链生态发展,出现了大量公链以及合约编程语言,例如:除了 Solidity 之外最常见的 Move 和 Rust,这些合约编程语言有本质上的区别,框架与编程理念也有所不同,本篇文章我们来对比一下 Solidity 闪电贷实现方式与 Move 以及 Rust 闪电贷实现方式有何不同,同时可以初步了解一下各种语言的编程理念。
Solidity 相关闪电贷:
Solidity 的闪电贷是基于 Solidity 支持动态调用这一特性来设计的,何为动态调用,也就是 solidity 支持在调用一个函数的过程中,动态传入需要调用的地址,如下例代码。每次调用都可以传入不同的地址,根据这个特点,便出现了 solidity 闪电贷的实现逻辑。
function callfun(address addr) public {
addr.call();
}
如下代码,将闪电贷抽象成了 3 个核心功能,
1、首先直接将资金发送给调用者;
2、再调用调用者合约,从而让调用者使用这些资金;
3、调用者使用结束,检查是否归还资金以及手续费,如果检查失败则回滚交易。(此处也可以直接使用 transferfrom 函数将调用则资金转移回来)
function flashloan(uint amount, address to) {
transfer( to, amount); // 发送资金给调用者
to.call();//调用调用者的合约函数
check();//检查是否归还资金
}
如下图,为 Solidity 语言中闪电贷的实现流程:
下列代码为真实项目 Uniswap 闪电贷逻辑。代码示例:
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
uint balance0;
uint balance1;
{
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
/**将资金转给用户**/
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
/**调用用户指定的目标函数**/
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
{
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
/**检查用户是否归还资金以及手续费**/
require(balance0Adjusted.mul(balance1Adjusted)>=uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
Move 相关闪电贷:
Move 闪电贷和 solidity 设计思想不同,move 中没有动态调用这一个特性,在所有函数调用过程之前,都必须确定调用流程,明确调用合约地址是什么,所以无法像 solidity 里面那样动态传入地址再进行调用。
那么 move 能实现闪电贷功能吗?当然可以,move 的特性使得人们设计出与 solidity 实现方式不同的闪电贷。
在 Move 中,将数据和执行代码分离,造就了 Move VM 独特的资源-模块模型。在这种模型中,不允许资源在交易结束时未被销毁或者保存在全局存储中,因此 Move 中的资源存在一种特殊的结构体——烫手山芋(Hot Potato),它是一个没有任何能力修饰符的结构体,因此它只能在其模块中被打包和解包。
*Move 能力详情:
https://move-book.com/advanced-topics/types-with-abilities.html
因此在 move 语言中的闪电贷实现,巧妙地利用了这种模式,将闪贷和还款操作抽象为两个函数进行处理,中间产生借贷资源记录借贷情况,该资源并没任何能力,只能够在还款函数中通过解包的方式将借贷资源给消耗掉,因此借贷操作必须和还款操作绑定在同一个操作中,否则闪电贷交易就会失败。
如下图,为 move 语言中闪电贷的实现流程。
如下代码,loan 与 repay 两个函数相结合便可以实现闪电贷。需要使用闪电贷服务的用户,先调用 loan 函数申请借款。函数会首先判断是否有足够的资金提供借款,随后将资金发送给调用者,计算好费用后,创建一个没有任何能力的资源”receipt ” 并返回给调用者。调用者在自己的合约中使用借贷的资金,最后需要将”receipt” 返还到 repay 函数,并且附带归还的资金。在 repay 函数中,首先将”receipt” 资源解构,以确保交易成功执行,随后判断用户归还资金是否与之前计算好的资金数量相同,最后完成整个交易。
代码示例:
struct Receipt<phantom T> {
flash_lender_id: ID,
repay_amount: u64
}
public fun loan<T>(self: &mut FlashLender<T>, amount: u64, ctx: &mut TxContext):
(Coin<T>, Receipt<T>) {
let to_lend = &mut self.to_lend;
assert!(balance::value(to_lend) >= amount, ELoanTooLarge);
let loan = coin::take(to_lend, amount, ctx);
let repay_amount = amount + self.fee;
let receipt = Receipt { flash_lender_id: object::id(self), repay_amount };
(loan, receipt)
}
public fun repay<T>(self: &mut FlashLender<T>, payment: Coin<T>, receipt: Receipt<T>) {
let Receipt { flash_lender_id, repay_amount } = receipt;
assert!(object::id(self) == flash_lender_id, ERepayToWrongLender);
assert!(coin::value(&payment) == repay_amount, EInvalidRepaymentAmount);
coin::put(&mut self.to_lend, payment)
}
Rust 相关闪电贷:
Rust 由于其提供内存安全、并发安全和零成本抽象等特性。也被用在了区块链智能合约语言开发中,接下来我们以 Solana 智能合约(Program)为例讲解使用 Rust 开发实现的闪电贷。
Solana VM 亦将数据和执行代码进行了分离,使得一份执行代码可以处理多份数据副本,但与 Move 不同的是,数组账户是通过程序派生的方式完成的,并且没有类似于 Move 特性的限制。因此 Solana Rust 不能够使用 Move 的方式实现闪电贷,并且 Solana Rust 动态调用指令(等同于理解为合约的函数)递归深度限制为 4,使用 Solidity 动态调用的方式同样不可取。但在 Solana 中每个指令(instruction)调用在交易中是原子类型的,因此在一笔交易中可以在一个指令中检查是否存在另一个指令。而 Solana 中的闪电贷依赖此了特性,Solana 闪电贷在闪贷的指令中将检查闪电贷交易中是否存在还款的指令,并检查还款的数量是否正确。
如下图,为 Rust 语言中闪电贷的实现流程:
代码示例:
pub fn borrow(ctx: Context<Borrow>, amount: u64) -> ProgramResult {
msg!("adobe borrow");
if ctx.accounts.pool.borrowing {
return Err(AdobeError::Borrowing.into());
}
let ixns = ctx.accounts.instructions.to_account_info();
// make sure this isnt a cpi call
let current_index = solana::sysvar::instructions::load_current_index_checked(&ixns)? as usize;
let current_ixn = solana::sysvar::instructions::load_instruction_at_checked(current_index, &ixns)?;
if current_ixn.program_id != *ctx.program_id {
return Err(AdobeError::CpiBorrow.into());
}
let mut i = current_index + 1;
loop {
// 遍历交易序列中的指令,
if let Ok(ixn) = solana::sysvar::instructions::load_instruction_at_checked(i, &ixns) {
// 查找是否同时调用了该程序的中还款指令(repay)
if ixn.program_id == *ctx.program_id
// 检查 invoke data 中 函数签名
&& u64::from_be_bytes(ixn.data[..8].try_into().unwrap()) == REPAY_OPCODE
&& ixn.accounts[2].pubkey == ctx.accounts.pool.key() {
// 检查 函数 invoke data 中 amount 数量是否正确
if u64::from_le_bytes(ixn.data[8..16].try_into().unwrap()) == amount {
break;
} else {
return Err(AdobeError::IncorrectRepay.into());
}
} else {
i += 1;
}
}else {
return Err(AdobeError::NoRepay.into());
}
}
let state_seed: &[&[&[u8]]] = &[&[
&State::discriminator()[..],
&[ctx.accounts.state.bump],
]];
let transfer_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.pool_token.to_account_info(),
to: ctx.accounts.user_token.to_account_info(),
authority: ctx.accounts.state.to_account_info(),
},
state_seed,
);
// cpi 转账
token::transfer(transfer_ctx, amount)?;
ctx.accounts.pool.borrowing = true;
Ok(())
}
// REPAY
// receives tokens
pub fn repay(ctx: Context<Repay>, amount: u64) -> ProgramResult {
msg!("adobe repay");
let ixns = ctx.accounts.instructions.to_account_info();
// make sure this isnt a cpi call
let current_index = solana::sysvar::instructions::load_current_index_checked(&ixns)? as usize;
let current_ixn = solana::sysvar::instructions::load_instruction_at_checked(current_index, &ixns)?;
if current_ixn.program_id != *ctx.program_id {
return Err(AdobeError::CpiRepay.into());
}
let state_seed: &[&[&[u8]]] = &[&[
&State::discriminator()[..],
&[ctx.accounts.state.bump],
]];
let transfer_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.user_token.to_account_info(),
to: ctx.accounts.pool_token.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
state_seed,
);
// 还款
token::transfer(transfer_ctx, amount)?;
// 更新账本状态
ctx.accounts.pool.borrowing = false;
Ok(())
}
对比三种语言的闪电贷流程,均为借款-> 使用-> 还款三步,只是由于语言的特性,在实现方式上有所不同。
Solidity 支持动态调用,所以可以在单个函数中完成整个交易;
Move 不支持动态调用,由于资源的特性,需要使用两个函数进行借款和还款逻辑;
Rust(Solana)能支持动态调用,但是仅支持 4 层 CPI 调用,使用 CPI 实现闪电贷将产生局限性,但是 Solana 每个指令都是原子类型,并且支持指令自省,因此使用指令自省的方式实现闪电贷是较好的方式
免责声明:作为区块链信息平台,本站所发布文章仅代表作者及嘉宾个人观点,与 Web3Caff 立场无关。本文内容仅用于信息分享,均不构成任何投资建议及要约,并请您遵守所在国家或地区的相关法律法规。