ERC-20和ERC-721可能是迄今为止最成功和最广泛采用的智能合约标准,对以太坊的成功起到了关键作用。尽管如此,Flow上的Cadence智能合约语言选择了相当不同的方式。
在这两篇文章的第一篇中,我们将ERC-20/ERC-721与它们在Flow上的对应物进行比较,以说明Solidity和Cadence的代币转移方法之间的根本区别。我们看看每种智能合约语言是如何影响代币转移的,以及Cadence的设计是如何提供新的语言和安全功能,使建设者和用户都受益。我们假设读者对使用Solidity或其他智能合约语言的去中心化应用开发有基本了解。
参考合同
作为Solidity的参考,我们使用OpenZeppelin项目所提供的ERC-20和ERC-721的优秀和常用的实现。
高水平的比较基础
Cadence的设计,以及Fungible Token(FT)和Non-Fungible Token(NFT)标准都是根据ERC-20和ERC-721的实际经验设计的。(事实上,语言和标准的主要设计者之一是ERC-721的原作者)。各自的语言标准在核心功能方面进行了逻辑上的比较,即它们促进了各方之间的代币交换。然而,除了共同的目标之外,这种比较在接口和代码层面上是崩溃的。然而,这两种语言之间的功能和设计差异的详细性质,对影响应用设计、安全性和可组合性的建设者有重要影响。
比较代币转移机制
Solidity合约的一个决定性特征是,每个代币合约存储和管理余额或NFT的所有者和访问。此外,Solidity依赖于基于地址的访问和授权模型,对特权功能的访问是通过公共方法进行的,这些方法在执行任何重要功能之前通过检查你的地址来验证你是谁。相比之下,在Cadence中,所有使用FT/NFT标准的令牌都是一流的语言类型,称为资源,以程序方式存在,其唯一性由语言固有地管理。访问和安全是通过基于能力的访问控制来管理的,这些程序化的对象类似于加密钥匙,当拥有这些钥匙时,赋予持有者访问另一个对象的某些范围内的权利。正如我们将解释的那样,资源和能力与Flow账户模型一起提供了新的基元和独特的功能,吸收了复杂性,而不是将其传递给开发者,使去中心化应用程序的开发比以往更容易。
ERC-20 委托的 transferFrom()
在基于EVM的dapp中,委托代币转移模式已经变得无处不在,这是一个理想的例子,可以说明基于地址的访问模式所带来的一些挑战。该图显示了一个GIBBON代币ERC-20合约的典型程序流程,当有人从dapp/服务合约中执行委托代币转移From()时。
approve()步骤,对于Metamask用户来说是一个熟悉的过程,被大多数dapp要求作为几乎所有基于EVM的链上的功能的前提条件。在用户与dapp合约进行后续互动之前,用户在GIBBON合约上批准了dapp合约,以获得一定的金额。然后,dapp合约调用引用发送方的transferFrom(),从而完成从发送方到自身的10个GIB转账。
当完成后,GIBBON代币合约中持有的_balances映射将使用户账户的代币余额减少给定的金额(尽管不超过批准中指定的金额),同时也将使dapp合约地址的所有者_balances映射增加相同的金额。从根本上说,这是因为合同作为一个集中的分类账,记录所有者与他们的余额的映射,类似于银行账户。
Why delegation?
Taking a step back, the reason why the approve() step is required on the GIBBON contract is because the contract IS the token and also stores the owner-to-balances mapping data. The approval given to the dapp contract has similarities to someone being given a signed bankers draft that permits them to withdraw the sender’s value for themselves. Consequently, approvals have become a critical part of the security fabric for Solidity, and for good reason. However, taking one step further back, the reason why the approval pattern must exist comes down to the address-centric nature of Solidity. Interactions with any Solidity contract assumes that the account interacting with the contract is the one executing the function. The runtime context provides the invoking contract’s address via the msg.sender property and it’s that address only which is being authorized during the function call.
Therefore, because dapp contract’s interaction with the GIBBON contract will reflect its address in msg.sender, the only way to facilitate acting on the sender’s behalf is to have first been authorized. Knowing this, the transferFrom() method internally performs this check before any changes are applied, which in the case of the OpenZeppelin base contract is handled in spendAllowance() and allowance() functions.
Cadence is different, but how?
As was touched upon earlier, Cadence introduces new concepts into the picture which makes it possible to solve token transfer very differently. To understand the mechanics of token transfer in Cadence it’s essential to know the following:
The account model
The account model in Cadence combines storage for the keys and code (”smart contracts”) associated with an account with storage for the assets owned by that account. That’s right: In Cadence, your tokens are stored in your account, and not in a smart contract. Of course, smart contracts still define these assets and how they behave, but those assets can be securely stored in a user’s account through the magic of Resources.
Resources
Resources are unique, linear-types which can never be copied or implicitly discarded, only moved between accounts. If, during development, a function fails to store a Resource obtained from an account in the function scope, semantic checks will flag an error. The run-time enforces the same strict rules in terms of allowed operations. Therefore contract functions which do not properly handle Resources in scope before exiting will abort, reverting them to the original storage. These features of Resources make them perfect for representing tokens, both fungible and non-fungible. Ownership is tracked by where they are stored, and the assets can’t be duplicated or accidentally lost since the language itself enforces correctness.
Capabilities
Remote access to stored objects can be delegated via Capabilities. This means that if an account wants to be able to access another account's stored objects, it must have been provided with a valid Capability to that object. Capabilities can be either public or private. An account can share a public Capability if it wants to give all other accounts access. (For example, it’s common for an account to accept fungible token deposits from all sources via a public Capability.) Alternatively, an account can grant private Capabilities to specific accounts in order to provide access to restricted functionality. For example, an NFT project often controls minting through an “administrator Capability” that grants specific accounts with the power to mint new tokens.
Contract standards
The Cadence FT contract standard defines a contract interface to be implemented similarly to ERC-20. However, the standard also extends into sub-types such as Resources, Resource interfaces, or other types. The standard is thus able to define and limit behaviour and/or set conditions which FT implementations cannot violate. Specifically, the FT standard defines a Vault Resource subtype meaning that all implementations of the standard must implement the same Vault Resource type, with the same interfaces and functions., with the same interfaces and functions. Provider, Receiver, and Balance Resource interfaces separate vault interactions and are used for fine grained security control as we’ll expand on later. Function pre and post conditions specified in FT standard vault functions are enforced at runtime for FT implementations. The standard leaves out minting, creation and other concerns as these may vary depending on the project. The class diagram below shows the FT contract standard and how the FLOW token has implemented it.
Freedom from msg.sender!
To understand why these new concepts matter we’ll quickly dip into some history. The development of Cadence as a language started in 2018 by the Dapper Labs team who had long struggled with Solidity’s limitations. The most frustrating aspects of engineering decentralized applications trace back to address-based access which makes contracts very difficult to compose. Composability in Web3 is the idea that a contract serves as a lego-block around which other contracts can be built, combining their functionalities additively. For example, if a game contract stores win/loss results on-chain for games played, another contract can be created that uses the win/loss data to surface top players in a leaderboard. Another contract might go even further, using the win/loss history to calculate odds for betting on players’ future games. However, due to address-based access, contract calls are firmly gated on who someone is. This makes it impossible for two contracts to interact if the first contract doesn’t have access to the second contract, even if the user has access to both.
The directionality of access controls in Solidity is from the protected function to the subjects that are authorized. Consequently contracts internalize authorizations and inspect all addresses who access the protected function.
Cadence reverses the access-based paradigm with Capabilities. Once obtained, Capabilities grant access from the subject to the protected function (or Resource) so the contract no longer needs to authorize addresses. This is because access to a protected object is only possible through the Capability using borrow(). Goodbye msg.sender!
The implications for composability are significant. If contracts aren’t required to know upfront for whom interactions are on behalf of, it’s possible for users to interact with any arbitrary number of contracts and their functions during a transaction providing they have the Capabilities. It also means that contracts can interact directly with one another with no approvals or other setup, the only requirement is that the calling contract possesses the needed Capabilities. For a more detailed, deep dive into the expansive and exciting possibilities for composability on Flow stay tuned for a future article currently in the works!
From the authority delegation mindset to domain-driven coding
Rather than code directly interacting with a FT token contract, the first step is to interact with an account to obtain a Capability for the specific FT token type. In the figure we see the Capability is for the FLOW vault, as identified by its public path argument (and yes, you can list public capabilities that accounts have). The Capability instance returned enables the bearer to borrow() the referenced vault Resource, in this case the FLOW vault object. In our example the return type is restricted to the FT receiver Resource interface. This strictly limits what can be called on the vault reference to only the deposit() function.
As we’ll explore below, the code for this is intuitive, object-oriented and types clearly pertain to the token transfer domain in question.
Essential transaction elements
Let’s use your new-found understanding of Capability-based access and the FT standard to walk through the basic elements needed for the transfer transaction.
1. Obtain the provider vault to withdraw from
As the previous section outlined, obtaining access to the user’s vault containing the funds is a required first step. In this step the user is the party whose funds are being sent and for whom the transaction is being executed, so they can directly access their own provider vault using borrow() without first obtaining a Capability.
The interimVault constant is defined at the top of the transaction code to temporarily hold tokens withdrawn from the provider before depositing to the recipient. Perhaps subtly, the movement of provider vault tokens to interimVault using the move <- operator validates the amount to be withdrawn is available. These steps both occur in the prepare block of the transaction which is for loading objects from providers’ storage. If the provider has insufficient tokens, or if a vault is somehow not available, the transaction will abort.
2. Obtain a receiver vault reference to deposit to
Cadence transactions occur in two phases, the execute phase of which is for working with previously obtained provider Resources and interacting with other accounts. When prepare has completed successfully the execute phase will run. Note that in this phase the receiver vault Resource reference must be obtained via the receiver account’s public Capability, since otherwise there would be no access.
From the account we obtain the public Capability matching FlowToken.ReceiverPublicPath then borrow the FLOW vault receiver reference via that Capability. We conclude the transaction by depositing the amount held in interimVault into the recipients token vault using the move <- operator. On completion, the transaction is committed. If at any time a panic occurs in either phase, the entire transaction is aborted.
Putting it all together
As the above code snippets may have hinted, transactions in Cadence differ from Solidity in that they can contain any arbitrary amount of code rather than just a single function call. In the complete example below, contract imports for the FT standard and FLOW token establish the needed types resolve into the transaction scope, after which interacting with those types follows standard object-oriented programming conventions.
How is this secure, can’t anyone access the full funds from the vaults in scope?
At first glance it may seem worryingly insecure to grant access to account vaults. However, initial account setup ensures that publicly accessible Capabilities also scope down access privileges to prevent clients from accessing the full vault interface. This example shows how the FLOW token contract scopes down access when linking the Capability for the token vault into the account during initial setup:
The linked Capability returns an attenuated reference to the account vault, only exposing the FlowToken.Vault{FungibleToken.Receiver} interface. This enforces that no other functions can be called on the vault despite the source object having a broader API surface area. The use of attenuation to scope down access via the exposed Capability is one of Cadence’s unique and commonly observed security control mechanisms.
The truth about delegation in Cadence
The keen eyed among you may have observed the transfer example above isn’t delegating control. The fact is, an apples-to-apples comparison of delegated transfer isn’t possible between Solidity & Cadence due to the differences in address-vs-Capability-based security models. That being said, delegation is possible - just not in the way that Solidity does it.
Delegation via Capability
The simplest way of delegating access or control is via Capabilities. If Alice decides to delegate a token transfer to Bob, allowing him to spend some of her funds on something, she would issue a private Capability to Bob specifically granting access to the provider resource interface on her token vault. Access to withdraw unlimited funds from Alice’s main FLOW vault is obviously not desirable. With Capabilities it’s possible to design expressive and creative solutions best suited to ones use cases, and the examples here are just some of the ways one might use to implement delegation.
- Create a new, temporary Flow vault in Alice’s account funded with the limited funds that Alice will allow Bob to withdraw. Then link a private Capability to that vault and provide it to Bob
- Create a new Resource - ScopedProvider in our example (full example Gist) - implementing the Provider interface. In that resource, we save a provider Capability on Alice’s Vault and enforce a withdrawal limit with the wrapping Resource’s withdraw() method. The ScopedProvider can then be saved in Alice's account, its provider Capability linked and given to Bob
3. Hybrid custody! A ground breaking feature, unique to Flow, that is soon to be released. Stay tuned, it might just blow your mind!
What’s the story with Cadence transactions?
Another major difference between Cadence and Solidity is that deployed contracts are not the only code being executed in the VM. Cadence offers scripts, of which a subset are transactions, and both permit arbitrary code. Scripts or transactions are not deployed on-chain and always exist off-chain, however, they are the top-level code payload being executed by the execution runtime. Clients send scripts and transactions through the Flow Access API gRPC or REST endpoints, returning results to clients when applicable. Scripts and transactions enable more efficient and powerful ways to integrate dapps with the underlying blockchain, where contracts can more purely be thought of as services or components, with scripts or transactions becoming the dapp-specific API interface for chain interactions.
Scripts are read-only in nature, requiring only a main function declaration and which perform queries against chain state, eg:
Transactions are an ACID (Atomic, Consistent, Isolated and Durable) version of scripts having only prepare and execute functions that either succeed in full and mutate chain state as described, or otherwise fail and mutate nothing. They also support setting of pre and post conditions. In the example transaction below ExampleTokens are deposited into multiple receiver vaults for each address in the input map.
Transactions can encompass an arbitrary number withdrawals/deposits, across multiple FTs, sending to multiple addresses, or other more complex variations, all of which will succeed or fail in their entirety given their ACID properties.
Post transaction state
We’ve covered the runtime mechanics of token transfer for Solidity and Cadence’s respective token standards and you should have a clear understanding of implementation and design differences that set them apart. However, the story is not complete until we unpack what we’re left with on-chain after these transactions complete.
As mentioned _balances is where Solidity contracts maintain ledger mapping entries. Wallets bring these balances together into a coherent singular view for the user. Assuming that I just completed a transferFrom() using my GIBBON token, my wallet would update the balance for GIBBON token by calling its balanceOf() method.
Since account 0x001 possesses multiple tokens, the wallet queries balanceOf() against each respective ERC-20 compliant token contract.
Unsurprisingly, Cadence leverages the account model, holding a vault for each different token type as needed. Because of this there’s no need for a wallet to create a unified view - it’s equally possible to see an account’s balances across all tokens possessed by inspecting the account using the flow-cli or other online tools.
Framing ownership
The distinction in how ownership of tokens is framed between Solidity and Cadence is perhaps the most significant contrasting aspect of their philosophies. Resources, the account model and Capability-based access are potently combined through the FT standard to realize token ownership as domain centric primitives that are easy to reason about and expand the solution-space for builders. Whereas Solidity maintains owner balance records - similar to a bank account - vaults in Cadence ensure tokens are owned in your account like having cash in your wallet. Access to contract interfaces and functions is managed by Capabilities owned by those interacting with a contract. Indeed, the philosophy to which builders need to adjust to is perhaps best summed up as “it’s not who you are, but what you have” that defines how contract interactions happen in Cadence. As we’ll explore in the next article, ownership of NFTs also follows the same principles, additionally providing rich semantics for managing NFT collections, network-wide discoverability and reach for listings and offers, and beneath it all how wonderful it is that all metadata lives on-chain with your NFT.
Coming soon.. NFT transfers and composability
In this article we explored how fungible token transfers work each language and how the contrasts run through design and mindset all the way to how data and compute are handled on-chain. We highlighted how limitations arising from Solidity’s simple, access-based model can prove problematic, how that affects composability, and how Cadence offers a powerful and compelling alternative. With the industry at a critical inflection point, where real-life utility and composability use-cases still demonstrably fall short of the promised future, Cadence empowers builders with a realistic and practical way to push the frontiers of decentralized application engineering. The global, open source engineering community now evolving Cadence remain super focused on creating the most fit-for-purpose, effective and secure smart contract language for current and future generations of Web3 builders. Many of the novel language features shared above, and all of the ground-breaking new features coming soon include numerous critical contributions from the community.
If you want to learn more about Cadence, or to participate in the community, we invite you to join our discord! Check out our detailed guide for Solidity developers and the many other resources for developers. We look forward to seeing what exciting solutions you end up building in the coming years and would love for you to tag us on Twitter, hashtag #onFlow!
Stay tuned for Part 2 where we’ll deep dive into the comparison between ERC-721 and the corresponding Cadence standard for NFT transfers!