ERC-20 & ERC-721 may be the most successful and widely adopted smart contract standards to date and have played a key role in the success of Ethereum. Despite this, the Cadence smart contract language on Flow chose to do things quite differently.
In this first of two articles we compare ERC-20/ERC-721 to their equivalents on Flow to illustrate the fundamental differences between Solidity and Cadence’s approach to token transfer. We look at how each smart contract language effects token transfer, and how the design of Cadence provides new language and security features to benefit both builders and users. We assume readers have a basic understanding of decentralized application development using Solidity or another smart contract language.
The reference contracts
As a Solidity reference, we use the excellent and commonly-used implementations of ERC-20 and ERC-721 as provided by the OpenZeppelin project.
High level comparison basis
The design of Cadence, and the Fungible Token (FT) and Non-Fungible Token (NFT) standards were informed by real-world experience with ERC-20 and ERC-721. (Indeed, one of the primary designers of both the language and the standards was the original author of ERC-721.) The respective language standards compare logically in terms of core functionality, in that they facilitate the exchange of tokens between parties. However, beyond the shared goals, that comparison breaks down at the interface and code level. However, the detailed nature of the functional and design differences between the two languages has important implications for builders which affect application design, security and composability.
Comparing token transfer mechanics
A defining feature of Solidity contracts is that each token contract stores and manages owners and access for balances or NFTs. In addition, Solidity relies on an address-based access and authorization model, where access to privileged functionality is via public methods which verify who you are by checking your address before executing any important functionality. By contrast, in Cadence, all tokens using the FT/NFT standards are first-class language types called Resources which exist programatically, and for which uniqueness is managed inherently by the language. Access and security is managed through Capability-based access control, programmatic objects similar to encryption keys which, when possessed, grant the bearer some scoped right of access to another object. As we will explain, Resources and Capabilities together with the Flow account model provide new primitives and distinct functionalities that absorb complexity instead of passing it onto developers making development of decentralized apps easier than ever.
ERC-20 delegated transferFrom()
The delegated token transfer pattern has become ubiquitous in EVM-based dapps and is an ideal example to illustrate some of the challenges that the address-based access model creates. This figure shows the typical program flow for an example GIBBON token ERC-20 contract when someone performs a delegated token transferFrom() from a dapp/service contract.
The approve() step, a familiar process for Metamask users is required by most dapps as a pre-requisite to pretty much all functionality on EVM-based chains. The user approves the dapp contract, for a set amount, on the GIBBON contract before the user’s following interaction with dapp contract. The dapp contract then calls transferFrom() referencing the sender, thereby completing the 10 GIB transfer from the sender to itself.
When complete, the _balances mapping held in the GIBBON token contract will have decreased the token balance for the user account by the given amount (although no more than the amount specified in approve), and will also have increased the owner _balances mapping for the dapp contract address by that same amount. Fundamentally this is because the contract serves as a centralized ledger, recording owners mapped to their balances similar to a bank account.
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!