DeFi: Deposit Account Tutorial

Writing a Simple Deposit Account Smart Contract in Solidity

Tim Cotten
Cotten.IO

--

This article is a practical introduction to decentralized financial smart contracts on the Ethereum blockchain using the Solidity programming language. It is an entry point for the minimally-viable-while-safe implementation of a Deposit Account. Various financial terms are defined and translated from traditional usage to their blockchain equivalents. Common design “gotchas” and security issues are explained in-depth.

DeFi: Decentralized Finance

Decentralized Finance, or DeFi, is an emerging software and business development field focused on shifting traditional financial services to public blockchain technology.

When we say decentralized we mean “without a central authority” — be it a government, bank, or any other sort of monolithic entity with power to arbitrarily control financial instruments. In the case of a decentralized blockchain we mean one like Ethereum as opposed to Ripple; the former being decentralized by design and hosting over 8,000 distributed nodes, whereas the latter was designed to centralize blockchain functions for the benefit of store-of-value transfer between financial institutions.

In modern finance there are attractive features to decentralization: reduced fees, immutable historical records, automation of contract execution, provably fair conditional logic, equitable access — to name a few.

The backbone of DeFi is the concept of a smart contract. These are small modules of code stored on the blockchain that work without the need for further human decision making: all of the logic is pre-programmed and executes according to conditions baked into the decentralized software.

That same decentralized software, the blockchain, prevents malicious actors from seizing funds inappropriately or manipulating agreed upon terms between parties. The smart contracts are published publicly and anyone can review their logic to ensure they’re doing what they claim to do.

This is why DeFi is the rising star of blockchain platforms like Ethereum. An entire ecosystem of Initial Coin Offerings (ICOs), securities, trading markets, and wealth transfer has exploded onto the financial scene.

DeFi will continue to evolve, replicating real-world financial functions until true, decentralized banks and financial institutions emerge that have no central ownership. These decentralized financial services will offer the same utilities we receive now in the real world, based on provably fair conditions that cannot be altered.

What might some of their offerings look like?

Deposit Accounts

The simplest example I can think of in Ethereum would be a decentralized deposit account.

But what is a deposit account?

A deposit account is any sort of account where the owner can deposit and withdraw funds from. That’s it.

Numerous derivatives of deposit accounts exist: checking accounts, savings accounts, timed deposits, etc. They all share the common features of:

  • Having an owner
  • Being able to deposit funds
  • Being able to withdraw funds

What makes Ethereum different from Bitcoin is the ability to create smart contracts that can act like a standard banking deposit account, without being directly tied to their blockchain wallet.

Ethereum Accounts

Before exploring the implementation of such a smart contract it’s important to review the basics of Ethereum.

Ethereum vs. Bitcoin

First, Ethereum, unlike Bitcoin, uses the concepts of accounts in a user’s blockchain wallet, rather than Bitcoin’s method of creating “one-time use” addresses.

In Bitcoin’s case you have wallet software that generates a private key, and then an associated public address. When you spend funds from that address, the wallet, by default, sends any leftover funds you might have to a “change address” that it automatically generates with a new private/public key combination. Is it possible to reuse an old address? Sure. But that’s not Bitcoin’s default behavior for security reasons.

Ethereum, on the other hand, expects its users to reuse the same address many times, and so Ethereum wallet software, while allowing you to generate new addresses, doesn’t automatically transfer leftover funds from a transaction to a “change address.” The same address is reused, and thus is called an “account” instead.

Those accounts work without the need for any smart contract logic at all; they’re just natural parts of storing the Ethereum unit-of-value called “ether.”

Account Functions in Ethereum

An account in Ethereum can store funds and send funds. It’s not so much a “withdrawal” action for the latter as much as a “transfer” action. You specify a destination which could either be another user’s address (their account), or a smart contract published on the Ethereum blockchain at a certain address.

That’s the important bit to distinguish: the basic behavior of a deposit account already exists as the general functions of any Ethereum user’s address. Every address is already a simple deposit account.

Utility of a Smart Contract Deposit Account

So what’s the point of making a smart contract version of a deposit account?

In the simplest case, such as the one that will be illustrated in this article, perhaps “not much.” On the other hand, a firm foundation — a template — for more interesting and varied behaviors (such as joint ownership, certificates of deposit, etc) requires some sort of standard to work on top of.

Thus, being able to define a simple deposit account and its functions in Ethereum, using the Solidity language, is an important building block to creating any future type of financial instrument.

The goal is to meet or exceed the functionality of a standard Ethereum account (the kind you use in a wallet) as a standalone smart contract, while maintaining safe practices.

Outline: Variables and Functions

The basic smart contract-based deposit account requires only a few variables and functions. This implementation focuses on the minimalist version not just for simplicity, but for security. All examples that follow will be based in the Solidity programming language.

Ownership

The smart contract needs to know what address to send funds to when a withdrawal request is made. In our simplest case we will consider that each deposit account smart contract is an instance belonging to only one user; rather than a bank-like contract holding funds for multiple users.

Thus, only one global variable is needed: let's create an owner of type address payable.

Deposits and Withdrawal

When sending funds to a smart contract some sort of function call, like deposit(), is not explicitly required as long as the fallback function is defined.

If the fallback function is defined simply transferring funds to the contract address will add the funds to the contract’s balance.

function() payable external {} // allows incoming ether

Why not add a deposit() function explicitly, though?

You could make an argument that the fallback is sufficient and, since this is not an aggregate bank-like contract storing multiple user’s funds, there’s no need for an additional function which serves more like decoration than anything else.

More importantly it’s about interoperability: a deposit account contract may be owned by another contract. Should they all be required to conform to an unnecessary function usage when they should just be able to send funds directly to the contract address? We err on the side of minimizing compatibility issues.

Withdrawal, on the other hand, requires an explicit function to call. The owner is the only one withdrawing the funds, be it a user or another contract — that implies pre-planned design.

This could be simplified to a single withdraw() function that sends the contract’s entire balance to the owner. However, while that may indeed be the purest “minimal” version of the contract it has even less utility than a standard Ethereum account in your wallet, since it can’t handle transferring partial amounts of the balance.

Thus, we would want also want withdraw(uint256 amount) function where the owner can specify the amount to receive. This amount will be in wei, the smallest denomination of ether. Since Solidity supports function overloading we can have the two withdrawal functions: a generic one to withdraw all funds, and a specific one to withdraw partial amounts.

Implementation

Here’s an initial implementation to start our analysis.

DON’T USE THIS.

v0.1 (DON’T USE THIS)

What you see above is very barebones: it’s the minimal code needed to deploy the contract but has no guarantees of safety or efficiency (as far as gas usage goes).

Minimal Safety Requirements

A few things are happening here that require comment in regards to how the withdrawal functions are “secured.”

When the contract was created the owner variable was set to be the msg.sender which in this case was the address invoking the contract creation and paying the gas costs needed to publish the contract on the Ethereum blockchain.

Note: In a factory schema where the deposit account contract is being created by another contract the constructor would need to be able to accept an originalOwner parameter.

When the simple withdraw() function is called it first checks that the msg.sender who is calling the function is the same address as the one assigned as the owner. In other words, it’s a public contract on a public blockchain, so anyone could pay the gas and call the function: this makes sure only the actual owner can.

Anyone else will simply receive a transaction failure and a partial refund of their spent gas, like so:

withdraw() failure when not-the-owner

The withdrawal function that supports an arbitrary amount, withdraw(uint256 amount) requires that the amount be less than or equal to the current balance of the contract. Trying to withdraw more than that must also generate an error, like so:

Remix IDE debugging output showing attempt to withdraw 100500 wei from empty contract

Version 0.1 Gas Cost Analysis

Before we begin expanding and optimizing our contract, let’s consider the gas costs for these basic implementations of the functions. All amount below were performed on the Ethereum Ropsten Test Network using this contract:

https://ropsten.etherscan.io/address/0x8CC93989A590083143674B583c61d6CdA136ec63

Depositing Ether

Based on the fallback function having no actual functionality (it blindly allows anyone to deposit ether into the contract), the gas cost was consistently 21,040.

Withdrawing All Funds: withdraw()

The simplest withdrawal function succeeds if the owner is the same as msg.sender. Otherwise it reverts and refunds unused gas.

  • Success: 29,818 gas
  • Failure: 22,486 gas (75.4% consumption)

Withdrawing Partial Funds: withdraw(uint 256 amount)

The more complicated withdrawal requires both the owner to be the msg.sender and for the amount to be less than or equal to the funds in the contract’s balance.

  • Success: 30,232 gas
  • Failure owner: 22,049 (72.9% consumption)
  • Failure amount: 22,486 (74.3% consumption)

In the above failures the second one costs more because gas has already been spent proving the owner is the msg.sender and thus authorized to withdraw the funds, but then the amount check fails.

Conventions

Despite this being a simple contract there are a slew of conventions employed that bear further explanation, particularly because many examples I see floating around the web about smart contract development use older Solidity code that doesn’t meet modern guidelines.

address vs. address payable

The address variable and the address payable variable both store as a 160-bit Ethereum address in contract code; however, the address payable variable comes with the built-in transfer() and send() functions that get compiled into the code as well.

Our first contract version goes against the “Withdrawal Pattern” in the Solidity development guide which prefers that variables like owner be pure addresses because the msg.sender is going to be the same as owner and is already address payable.

In other words, the first version, while “correct”, allocates extra information for the transfer() and send() functions in the contract bytecode that aren’t necessary if we just swap the function calls to msg.sender instead of owner (and leave the latter as an address).

Here’s version 0.1.1 with the adoption of the Withdrawal Pattern implementation (at least the relevant bits).

v0.1.1 (DON’T USE THIS)

You can compare the bytecode implementation of the v0.1 contract with the v0.1.1 contract and notice that the newer version is 66 bytes smaller.

require() vs assert()

In order to guarantee that access controls are implemented (preventing anyone but the owner from withdrawing from the contract) we’ve used the require() function in our initial implementations.

Solidity also features an assert() function — what’s the difference between these two “guard” functions?

The Solidity guide provides insight:

The assert function should only be used to test for internal errors, and to check invariants. The require function should be used to ensure valid conditions, such as inputs, or contract state variables are met, or to validate return values from calls to external contracts.

First, it’s important to understand that these guard functions replace the older Solidity pattern of if (bad_condition) { throw; }. They look identical, but actually output two different ways in the bytecode: 0xfe for assert() and 0xfd for require().

Why? Because EIP-140 added the REVERT instruction which allows for reverting on errors without consuming all the gas.

transfer() vs. send() vs. call

Our “simple” contract fails if its owner happens to be another smart contract with a non-default fallback function. Why?

The transfer() and send() functions are considered reentrancy safe because they only send 2300 gas (allowing for a single Event emission) along with the transaction. So if the recipient is in fact a smart contract that has any logic or safe-guarding in their fallback function, then the withdrawal functions will fail.

Obviously this doesn’t affect an owner address that links to an Ethereum user account — just other smart contracts. Yet if our goal is to create a decentralized deposit account in Ethereum then we must allow for contract-to-contract messaging that supports transfer of funds without introducing security risks.

Quick overview:

address.send(amount): Sends the amount to the address and returns false on failure. It’s up to us to handle the failure condition explicitly and revert gas if needed. Hard-coded 2300 gas limit to prevent reentrancy.

address.transfer(amount): Is syntactic sugar for require(address.send(amount)) and thus sends the amount to the address and reverts execution and refunds remaining gas on failure. Hard-coded 2300 gas limit to prevent reentrancy.

address.call: Allows the user to specify gas and the amount (value) sent to the address. Not safe. Susceptible to reentrancy attacks as made famous by the hacking of the DAO.

Here’s our conundrum then: transfer() is the safest use, but won’t work when communicating with more complex contracts that have non-default fallback functions.

We have to ask ourselves the trade-off question of which is more valuable? Increasing the implementation cost of this simple deposit contract and introducing security risks for maximum compatibility (by blindly forwarding the remaining gas), or recognize that such complexity is better handled in a derivative, special-case contract instead of this one?

We’re not trying to design the end-all-be-all smart contract, and thus we will stick with the predictable, safe behavior of transfer().

Improvement Analysis

Let’s focus next on analyzing possible improvements of the implementation as it currently stands. First, let’s address the repetitive use of the require statements.

Function Modifiers

Solidity has a function “modifier” pattern that follows this form:

modifier myMod()
{
// logic here
_;
}

This creates a wrapper for a given function that executes the logic described in the modifier before the rest of the function. This is particularly useful for wrapping functions in restrictions based on input data, since the modifier will have access to the message variables like msg.sender.

The _; at the end of the modifier is the stand-in for the rest of the function that is being called.

For example, based on the examples in the Solidity guide, we could rewrite the withdrawal functions to use a modifier:

modifier onlyOwner()
{
require(msg.sender == owner);
_;
}

This would use the global owner variable and the implicit msg.sender variable to run the safety check before the rest of the withdrawal function executes, like so:

function withdraw() onlyOwner public
{
msg.sender.transfer(address(this).balance);
}

We can do the same for the balance check in the partial withdrawal function:

modifier hasBalance(amount)
{
require(address(this).balance >= amount);
_;
}
function withdraw(uint256 amount) onlyOwner hasBalance(amount) public
{
msg.sender.transfer(amount);
}

Note that the parameter amount is specified in the function definition with its type, and that the modifier needs to only reference the same parameter name.

Having updated the code it now looks like this:

v0.1.2 (DON’T USE THIS)

Does it improve maintainability? Yes.

Does it incur additional bytecode storage? Yes, a bit.

Does it incur additional gas usage? Yes, a bit.

So here’s another trade-off: we’re exchanging gas per transaction for a reduced risk of introducing errors when maintaining/updating the code. Rather than have multiple instances of the same require functions, we have decorative modifiers that make the function purposes clear when being read by other developers.

The older v0.1.1 version using the repetitive require statements used 658 bytes, while this newer v0.1.2 uses 660 bytes with the modifiers. Most likely there were some savings involved in de-duping the logic and making calls to the modifier instead, which explains why the creation cost only increased by such a tiny amount.

On the other hand, a call to the partial funds withdrawal function in v0.1.1 costs 30,064 gas versus the previous version’s 30,059 (and 32,315 vs 32,312 gas limits). Those are negligible increases.

And yes, while it’s still a too early to be trying to deeply optimize the contract, we at least know that such a switchover to modifiers won’t deeply impact our gas considerations.

But if we’re talking about self-documenting code then let’s address also the naming of our modifiers:

  • onlyOwner: This makes sense because the modifier only allows the owner to call the function. Another name like isOwner is asking a true/false question, not demonstrating a requirement.
  • hasBalance: Using the preceding argument, this modifier name is asking a question, not enforcing a requirement. It would be better to rename it as withBalance to imply that the function will only work with a balance of at least the input amount. We could call it withMinBalance to be completely clear.

Access Modifiers

As in many other languages Solidity and Ethereum support the idea of access modifiers: the visibility and accessibility of a given variable or function.

The owner variable doesn’t have a specific access modifier like public or private set, so by default it will be public.

Unlike other languages however, this only means the “getter” is public. Another contract cannot modify the public owner variable without explicit “setter” code being included in the original contract (imagine the chaos otherwise).

Truthfully, setting the owner to private doesn’t hide the ability to get the owner address from the blockchain. How could it? The code is on a public blockchain, even in its compiled bytecode format. External tools can analyze and discover the private variable value without issue.

Within the Ethereum network, however, setting the owner variable to private would prevent other contracts from seeing the address value of owner unless we create our own “getter” function for the deposit account contract.

The design philosophy for any financial product on a blockchain should be security and encapsulation first, and exposure of data as an option. Thus, we will make the variable private in our next iteration.

Here’s v0.1.3:

v0.1.3 (Probably OK? But Don’t Use It)

What About getBalance()?

The balance of an Ethereum address is always public and always available to a smart contract. Simply calling address(target).balance will achieve this in Solidity.

Why create a dedicated getBalance() function for this sort of contract that doesn’t contain multiple virtual accounts and sub-balances?

Optional Features (Beyond Our Scope)

transferOwnership(address newOwner)

In the real world a given deposit account can change hands to a new owner, perhaps through inheritance or some other life event.

The same need exists in the Ethereum blockchain, where a given contract might “own” another deposit account contract in escrow, and then sign it and its associated functions over to a new owner once some condition is met.

Implementing a transfer of ownership function is simple, given the existing framework:

function transferOwnership(address newOwner) onlyOwner public
{
require(newOwner != address(0));
owner = newOwner;
}

Nothing very surprising is happening here, except perhaps the additional sanity check to make sure that there is actually a new address in newOwner and not 0.

However, there is NOT a mechanism to verify that the newOwner is a valid, real address. Such a mechanism would require a verification schema such as the new owner submitting their address to the contract first for consideration before the turnover.

Using this function with a bad or incorrect address means that all funds in it are permanently lost.

Such a deceptively simple function can be very easily misused.

However, if we did implement such a thing it would make more sense to utilize the Ownable contract as the base for this sort of thing.

Feature: close()

What if we want to terminate this contract and return all of the money to ourselves?

Doing so is easy:

function close() onlyOwner public
{
selfdestruct(owner);
}

This will destroy the contract and send the remaining ether balance to the owner.

One advantage to this is that is costs less gas than using the withdrawal functions.

On the other hand the danger here is that other users or smart contracts might still send ether to the address where this contract no longer resides — those funds will be lost forever.

So now, much like transferOwnership we have a case of a benign seeming function that has terribly important consequences. Do we want to support such behaviors in a basic implementation of a deposit account?

No.

The Solidity Styleguide

Solidity has a helpful styleguide that wags its fingers at some of the code we’ve written above. Let’s scan through the guide and adjust it to meet community standards.

  1. “The visibility modifier for a function should come before any custom modifiers.”

Curses. We’ve done the opposite:

// bad
function withdraw() onlyOwner public
// good
function withdraw() public onlyOwner

2. Function ordering

We should rearrange the order of functions according to make logical flow and entry points easier to discern:

  • constructor
  • fallback function (if exists)
  • external
  • public
  • internal
  • private

I find that adding the modifiers and events (in that order) before the constructor keeps things neat and easy to understand.

3. Adding comments

There’s a great guide for doctag style guidelines in Solidity using NatSpec, and the code has been updated to include those tags.

Conclusion

Our final, simplified, deposit account smart contract looks like this:

v1.0.0 (Usable)

The code above represents the simplest deposit account I could conceive in Ethereum that maintains the usability of a native account while allowing contracts to interact with it programmatically.

It avoids unnecessary complications while providing useful functions for withdrawal, secures usage by owner, and allows other contracts or users to deposit funds into it. Several “obvious” functions, like deposit() and getBalance() are purposefully omitted.

The singular instantiation nature of this contract precludes the need for mutexes/locks to prevent reetrancy attacks, as the owner isn’t stealing the balance from themselves.

Safety is chosen when transferring funds over handling compatibility with contracts using non-default fallback functions. Conversely, an empty fallback is specified for this contract so that other contracts utilizing transfer() and send() with their baked-in 2300 gas payment can successfully transfer funds to it.

While the code ultimately generated for the contract may seem short (far shorter than this article!), I hope this tutorial demonstrates the type of thinking that goes into Ethereum smart contract development and Solidity in general.

--

--

Founder of Scrypted Inc: Building interactive digital assets for the Metaverse. <tim@cotten.io> @cottenio