用 Cadence 实现 ERC20 的 approve 机制

·

6 min read

Table of contents

No heading

No headings in the article.

你可以在这里找到本文的所有代码。

在 ERC20 代币标准中,定义了这三个方法:

interface IERC20 {
    // ...

    function approve(address spender, uint256 value) external returns (bool);

    function allowance(address owner, address spender) external view returns (uint256);

    function transferFrom(address from, address to, uint256 value) external returns (bool);
}
  • 通过 approve 某个地址 owner 可以授权其他地址 spender 花费自己一定金额的代币

  • 通过 allowance 我们可以查看 ownerspender 的授信额度余额

  • 通过 transferFromspender 可以从 owner 内取用授信额度内的金额

这几个方法在 ERC20 的世界中是必须的,尤其在使用各种 DeFi 产品的时候。以 Uniswap 为例,Alice 如果希望把自己的 USDC 换成 AAVE,则流程大概是这样:

Alice 对 Uniswap:小 U 啊,我允许你动我的 USDC,给你 1000 USDC 的额度吧。 Alice 对 Uniswap:小 U 啊,我想要把 1000 USDC 换成 AAVE,给我搞定!

Uniswap 对 USDC 合约:我代表 Alice 从她账上转 1000 USDC 到 AAVE-USDC 流动性池的账上! (假设算出来 Alice 能换到 5 个 AAVE) Uniswap 对 AAVE 合约:把这 5 个 AAVE 从我池子账上转到 Alice 账上!

从上面可以看出:

  • ERC20 标准下,所有代币都是由其合约做统一记账的。比如 Uniswap 从 Alice 账户向流动性池转账 USDC 的操作,实际上就是由 USDC 合约做一下账目变更。代币合约可以类比为现实世界中的银行,每个储户的资金只是他们账上的数字。

  • EVM 生态中和合约的交互有点像对自己的助理说话,给他授权,具体的执行由他负责。每笔交易的内容基本上就是对合约中特定方法的调用。执行操作的主要逻辑都在合约里。

在这样的机制下,approve 机制就显得格外重要。没有授权,则接下来的执行无从谈起。

在以对资源的处理为特色的 Cadence 世界中,情况就不一样了。以 BloctoSwap 为例,Alice 如果希望把自己的 1000 FUSD 换成 BLT,则:

Alice 从自己的储藏室里面拿出 FUSD 保险箱,从中取出 1000 个 FUSD Alice 把 1000 个 FUSD 送进 BloctoSwap,BloctoSwap 收到后给她送回 2000 个 BLT

Alice 把 2000 个 BLT 放进自己的 BLT 保险箱,再把保险箱放进储藏室里

从上面可以看出:

  • 在 Flow 生态中,代币是由账户直接持有的,而非由代币合约记账表示。相较于 EVM 生态,这种模式是更去中心化的。

  • Flow 生态中和合约的交互更像是从工具箱中取工具,具体事务的执行还需要账户在交易中亲力亲为。Flow 的交易不是单纯的方法调用,还有许多执行逻辑。

两相对比,我们可以知道在 Flow 生态对 approve 机制并没有像 EVM 生态那样的刚需,但如果我们就是要用 Cadence 来实现 approve 功能,让别人可以动用自己的部分资金,应该怎么做呢?

首先我们回顾👆提到的 Flow 和 EVM 系的区别:

  • EVM 的代币只是合约中记录的数字,由一个中心账本管理。实现 approve 只需要在账本中记录下 spenderowner 和授信金额,转账的时候据此验证、调账即可。

  • Flow 的代币是分散存储在不同账户中的。要实现 approve 功能,我们需要让 spender 能够确实地访问到 owner 账户中的“保险箱”,而非在某个账本上调账。

这点差异使得在 Flow 上实现 ERC20 标准中的 approve 机制会更复杂。

Flow 是 Capability-based Access Control,可以理解成“认证不认人”,持有特定“令牌”就能做特定的事。因此我们需要设计一个能让 spender 访问并有限制地使用 owner ”保险箱“的”令牌“。再具体整理下,就有以下需求:

  1. owner 可以授权给多个 spender 访问自己的资金。

  2. spender 对资金的访问是受限于授信额度的,每个 spender 可以有不同的授信额度。

  3. 授信额度可以超过 owner 现有资金总量。比如 Alice 拥有 40 FUSD,他可以授权 Bob 和 Carl 最多动用 30 FUSD,Dave 最多动用 50 FUSD。

  4. owner 可以随时调整 spender 的授信额度,或者取消、恢复授信。

在 Flow 中,Capability 就是所谓的“令牌“。假设已经有了一个遵循官方 FT 接口的代币 FUSD,在我们的 /storage/fusdVault 路径中存放着 @FUSD.Vault ,如果我们是这个 Vault 的持有人 Alice,现在想让 Bob 也能够使用它,一个方法就是将其 link 到 /private 中,生成私有的 Capability<&FUSD.Vault>,再将这个 Capability 交给 Bob。这样一来,Bob 就可以通过 Capability 访问到我们的 @FUSD.Vault

这样做的问题在于:Bob 对于 Vault 的访问是无限制的。如果他想,他可以转走整个 Vault 中的资金。这样不符合需求 2。

如果我们把 Vault 对每个 spender 拆分出等同于授信额度的一份,则不满足需求 3。

我们重点解决对 Vault 访问无限制的问题。现在我们在 Vault 外再包一层,用于添加额度限制:

pub resource Allowance: AllowanceInfo, AllowanceProvider, AllowanceManager {
    pub var value: UFix64
    priv let vaultCap: Capability<&{FungibleToken.Provider, FungibleToken.Balance}>

    pub fun getVaultOwner(): Address {
        return self.vaultCap.address
    }

    pub fun setAllowance(value: UFix64) {
        self.value = value
    }

    pub fun withdraw(amount: UFix64): @FungibleToken.Vault {
        pre {
            amount <= self.value: "Withdraw amount exceed allowance value"
            amount <= self.vaultCap.borrow()!.balance: "Withdraw amount exceed vault's balance"
        }

        self.value = self.value - amount
        return <- self.vaultCap.borrow()!.withdraw(amount: amount)
    }

    init(value: UFix64, vaultCap: Capability<&{FungibleToken.Provider, FungibleToken.Balance}>) {
        self.value = value
        self.vaultCap = vaultCap

        emit AllowanceCreated(by: self.owner?.address, value: value)
    }
}

Allowance 资源的 vaultCap 字段存储的是访问目标 Vault 的 Capability,我们将其限定为实现 FungibleToken.ProviderFungibleToken.Balance 的任意资源。

另外,Allowance 还实现了 3 个接口:

pub resource interface AllowanceInfo {
    pub var value: UFix64
    pub fun getVaultOwner(): Address
}

pub resource interface AllowanceProvider {
    pub fun withdraw(amount: UFix64): @FungibleToken.Vault
}

pub resource interface AllowanceManager {
    pub fun setAllowance(value: UFix64)
}
  • AllowanceInfo 提供了这个授权的金额和创建人信息。

  • AllowanceProvider 提供了和 Vault 一致的 withdraw 方法,在具体实现中,我们需要比较取款金额和授信额度,保证取款金额在授信额度内。

  • AllowanceManager 提供了修改授信额度的方法。

现在 Alice 可以向 Bob 提供 Allowance 的 Capability 来让他可以动用自己的资金了。

对于 Bob 而言,他需要能够通过 AllowanceInfo 提供的字段来获取剩余额度和授权人,需要通过 AllowanceProviderwithdraw 方法来转账。但是他们不应该被允许调用 setAllowance 方法修改限额,所以提供给 Bob 的应该是 Capability<&{AllowanceProvider, AllowanceInfo}>

Bob 可以创建一个 AllowanceCapReceiver 来存放自己接收到的各种授权:

pub resource AllowanceCapReceiver: AllowanceCapReceiverPublic {
    priv var allowanceCaps: {Address: [Capability<&{AllowanceProvider, AllowanceInfo}>]}

    pub fun addAllowanceCap(_ cap: Capability<&{AllowanceProvider, AllowanceInfo}>) {
        let caps: [Capability<&{AllowanceProvider, AllowanceInfo}>] = self.allowanceCaps[cap.address] ?? []
        caps.append(cap)
        self.allowanceCaps[cap.address] = caps
    }

    pub fun getAllowanceCapsInfoByApprover(_ approver: Address): [&{AllowanceInfo}] {
        let infos: [&{AllowanceInfo}] = []
        if let caps = self.allowanceCaps[approver] {
            for cap in caps {
                if let info = cap.borrow() {
                    infos.append(info)
                }
            }
        }

        return infos
    }

    pub fun getAllowanceCapsByApprover(_ approver: Address): [Capability<&{AllowanceProvider, AllowanceInfo}>] {
        return self.allowanceCaps[approver] ?? []
    }

    init() {
        self.allowanceCaps = {}

        emit AllowanceCapReceiverCreated(by: self.owner?.address)
    }
}

AllowanceReceiver 实现了下面的接口:

pub resource interface AllowanceCapReceiverPublic {
    pub fun addAllowanceCap(_ allowance: Capability<&{AllowanceProvider, AllowanceInfo}>)
    pub fun getAllowanceCapsInfoByApprover(_ approver: Address): [&{AllowanceInfo}]
}

Bob 只会暴露 AllowanceCapReceiverPublic 中的方法给外部,使得 Alice 可以通过 addAllowanceCap 方法来对 Bob 进行授权。getAllowanceCapsByApprover 则只能由被授权方 Bob 自己调用。

Bob 可能收到来自多个其他账户的授权,所以我们需要一个数组来存放 allowances。

现在 Alice 和 Bob 要使用 Approver 合约里面的工具进行资金的授权,Alice 是授权人,Bob 是被授权人。那么:

  • Bob 需要创建 AllowanceCapReceiver,并暴露 AllowanceCapReceiverPublic 接口
import FUSD from "../contracts/FUSD.cdc"
import FungibleToken from "../contracts/FungibleToken.cdc"
import Approver from "../contracts/Approver.cdc"

transaction {
    prepare(signer: AuthAccount) {
        if signer.borrow<&Approver.AllowanceCapReceiver>(from: Approver.AllowanceCapReceiverStoragePath) != nil {
            return
        }

        signer.save(
            <- Approver.createAllowanceCapReceiver(), 
            to: Approver.AllowanceCapReceiverStoragePath
        )

        signer.link<&{Approver.AllowanceCapReceiverPublic}>(
            Approver.AllowanceCapReceiverPubPath, 
            target: Approver.AllowanceCapReceiverStoragePath
        )
    }
}
  • Alice 生成 Vault 的私有 Capability,并将之装入 allowance 中,再将 allowance 也放入 /private 内,生成私有的 Capability<&{Approver.AllowanceInfo, Approver.AllowanceProvider}> 。之后 Alice 从 Bob 账户中借出 AllowanceCapReceiver,将上面生成的 Allowance 的 Capability 放进 Receiver 中供 Bob 使用(这个 Capability 可以也可以交给其他人使用)
import FUSD from "../contracts/FUSD.cdc"
import FungibleToken from "../contracts/FungibleToken.cdc"
import Approver from "../contracts/Approver.cdc"

transaction(spender: Address, value: UFix64) {
    let allowanceCap: Capability<&{Approver.AllowanceProvider, Approver.AllowanceInfo}>

    prepare(signer: AuthAccount) {
        signer.link<&{FungibleToken.Provider, FungibleToken.Balance}>(/private/fusdVault, target: /storage/fusdVault)
        let vaultCap = signer.getCapability<&{FungibleToken.Provider, FungibleToken.Balance}>(/private/fusdVault)!

        let allowance <- Approver.createAllowance(
            value: value, 
            vaultCap: vaultCap
        )

        let pathID = "fusdAllowanceFor".concat(spender.toString())
        let storagePath = StoragePath(identifier: pathID)!
        let publicPath = PublicPath(identifier: pathID)!
        let privatePath = PrivatePath(identifier: pathID)!

        signer.save(<- allowance, to: storagePath)

        signer.link<&{Approver.AllowanceInfo}>(publicPath, target: storagePath)

        signer.link<&{Approver.AllowanceProvider, Approver.AllowanceInfo}>(
            privatePath, 
            target: storagePath
        )

        self.allowanceCap = signer.getCapability<&{Approver.AllowanceProvider, Approver.AllowanceInfo}>(privatePath)
    }

    execute {
        let receiver = getAccount(spender).getCapability<&{Approver.AllowanceCapReceiverPublic}>(
            Approver.AllowanceCapReceiverPubPath).borrow()
            ?? panic("Could not borrow AllowanceCapReceiver capability")

        receiver.addAllowanceCap(self.allowanceCap)
    }
}
  • Bob 现在可以从 AllowanceCapReceiver 中取出 Alice 给的 Capability,并通过它转走 Alice 账户中的资金了。这并不影响 Alice 自己对资金的自由使用。
import FUSD from "../contracts/FUSD.cdc"
import FungibleToken from "../contracts/FungibleToken.cdc"
import Approver from "../contracts/Approver.cdc"

transaction(from: Address, to: Address, value: UFix64) {
    let capReceiver: &Approver.AllowanceCapReceiver

    prepare(signer: AuthAccount) {
        self.capReceiver = signer.borrow<&Approver.AllowanceCapReceiver>(from: Approver.AllowanceCapReceiverStoragePath)
            ?? panic("Could not borrow AllowanceCapReceiver reference")
    }

    execute {
        let vaultReceiver = getAccount(to).getCapability<&{FungibleToken.Receiver}>(/public/fusdReceiver).borrow()
            ?? panic("Could not get Receiver capability")

        let cap = self.capReceiver.getAllowanceCapsByApprover(from)[0]
        vaultReceiver.deposit(from: <- cap.borrow()!.withdraw(amount: value))
    }
}
  • Alice 可以随时修改给 Bob 的额度。
import Approver from "../contracts/Approver.cdc"

transaction(spender: Address, value: UFix64) {
    prepare(signer: AuthAccount) {
        let pathID = "fusdAllowanceFor".concat(spender.toString())
        let storagePath = StoragePath(identifier: pathID)!

        let allowance = signer.borrow<&Approver.Allowance>(from: storagePath)
            ?? panic("Could not borrow Allowance reference")

        allowance.setAllowance(value: value)
    }
}
  • Alice 也可以终止给 Bob 授信。
transaction(spender: Address) {
    prepare(signer: AuthAccount) {
        let pathID = "fusdAllowanceFor".concat(spender.toString())
        let privatePath = PrivatePath(identifier: pathID)!
        let publicPath = PublicPath(identifier: pathID)!

        signer.unlink(privatePath)
        signer.unlink(publicPath)
    }
}
  • Alice 也可以恢复给 Bob 的授信。
import Approver from "../contracts/Approver.cdc"

transaction(spender: Address) {
    prepare(signer: AuthAccount) {
        let pathID = "fusdAllowanceFor".concat(spender.toString())
        let storagePath = StoragePath(identifier: pathID)!
        let privatePath = PrivatePath(identifier: pathID)!
        let publicPath = PublicPath(identifier: pathID)!

        signer.link<&{Approver.AllowanceProvider, Approver.AllowanceInfo}>(privatePath, target: storagePath)
        signer.link<&{Approver.AllowanceInfo}>(publicPath, target: storagePath)

        signer.getCapability<&{Approver.AllowanceProvider, Approver.AllowanceInfo}>(privatePath).borrow()
            ?? panic("Could not get private {AllowanceProvider, AllowanceInfo} capability")
        signer.getCapability<&{Approver.AllowanceInfo}>(publicPath).borrow()
            ?? panic("Could not get public {AllowanceInfo} capability")
    }
}
  • Alice 可以查询自己给某人的授信情况。
import Approver from "../contracts/Approver.cdc"

pub struct AllowanceInfo {
    pub let value: UFix64
    pub let vaultOwner: Address

    init(value: UFix64, vaultOwner: Address) {
        self.value = value
        self.vaultOwner = vaultOwner
    }
}

pub fun main(approver: Address, spender: Address): AllowanceInfo {
    let path = PublicPath(identifier: "fusdAllowanceFor".concat(spender.toString()))!
    let info = getAccount(approver)
        .getCapability<&{Approver.AllowanceInfo}>(path)
        .borrow() ?? panic("Could not borrow AllowanceInfo capability")

    return AllowanceInfo(value: info.value, vaultOwner: info.getVaultOwner())
}
  • Bob 也可以查询目前自己所有的授信情况。
import Approver from "../contracts/Approver.cdc"

pub struct AllowanceInfo {
    pub let value: UFix64
    pub let vaultOwner: Address

    init(value: UFix64, vaultOwner: Address) {
        self.value = value
        self.vaultOwner = vaultOwner
    }
}

pub fun main(approver: Address, spender: Address): [AllowanceInfo] {
    let receiver = getAccount(spender)
        .getCapability<&{Approver.AllowanceCapReceiverPublic}>(Approver.AllowanceCapReceiverPubPath)
        .borrow() 
        ?? panic("Could not borrow AllowanceCapReceiverPublic capability")

    let infos: [AllowanceInfo] = []
    for i in receiver.getAllowanceCapsInfoByApprover(approver) {
        infos.append(
            AllowanceInfo(value: i.value, vaultOwner: i.getVaultOwner())
        )
    }

    return infos
}

另外,在 Solidity 中,如果 Alice 只给 Bob 授信,则只有 Bob 可以动用 Alice 的资产。在 Cadence 中,Bob 完全可以把 Capability 转移给 Carl,让 Carl 也可以动用 Alice 的资产(当然,额度是 Bob 和 Carl 共享的)。这是由二者访问机制的差异造成的。事实上,如果 Bob 和 Carl 共谋了,则 Bob 将资产转移给 Carl 和 Bob 给 Carl Capability 让他自行取用也没什么区别。

Alice 和 Bob 的存储大概是这样的情况:

至此我们实现了和 ERC20 代币标准中的 approve 机制类似的功能,而且在总限额之外还可以方便地添加授权过期时间、单笔最大限额等机制。虽然目前看起来没什么应用场景,不过……这不重要hhh。