I think it is time for BitShares to learn from its younger sibling Steem and adopt some crucial security features: time-locked savings and recovery accounts. These are features that I have quickly come to expect from blockchains, even though I am not aware of any blockchain other than Steem that currently has these important features active. I really feel uncomfortable having large amounts of wealth stored on a blockchain that doesn't provide such features, and I am sure I am not unique in feeling that way. This can hold back investment.
Why time-locked savings balances are a critical feature
Yes, we can use cold storage of private keys and that certainly has its place even with time-locked savings balances. But it also adds a lot of inconvenience when you actually want to spend from those balances, and the tools for securely doing that, i.e. without exposing your keys to a possibly compromised computer, are still (much to my disappointment even after several years) not available in a user-friendly way. This means that users will, for the sake of convenience, likely keep larger amounts of their crypto-wealth in hot wallets than is smart to do just to avoid the massive inconvenience.
Hardware wallets, while still a great and useful tool, also have their own issues.
First, although not the most important justification, it is harder to iterate with hardware wallets to support new useful features added to the blockchain than it is with software running on traditional devices (desktops, smartphones, etc.). In addition a hardware wallet like a Trezor has a very limited human interface which, while it may be suitable for simple transfers, can be incredibly challenging to design for in a way users would find tolerable in order to authenticate more sophisticated transactions (such as ones involving the various capabilities that already exist in BitShares today and even more so with Steem). Some of these operations may not be important enough to protect with a hardware wallet, but there can still be high-value transactions that don't fit well with the authentication workflows allowed by hardware wallets existing today. A more flexible and capable approach to adding security to these types of operations would be to use a security service provider that utilizes multisig (or really the powerful dynamic hierarchical multi-signature threshold permissions of BitShares and Steem) to protect your funds by denying suspicious transactions based on certain user-tunable heuristics unless further verification is provided by the user before authenticating those transactions (there can of course always be a relief valve by using the owner keys in cold storage to take back control of the account should the security service provider disappear or refuse to do their job).
Second, needing to plug in an extra device each time you want to spend from your instantaneously-spendable balances can be inconvenient and annoying. So users would likely keep a reasonable-sized amount for convenient spending in a hot wallet with no hardware wallet protection, and just use the hardware wallet to protect their much larger amount of savings. This means that hardware wallets for the most part are useful for protecting funds that have liquidity demands more comparable to the funds expected to be used with a savings balance feature with time delays. And savings balances have better security properties against certain threats than hardware wallets (as will be discussed shortly by the third justification), so between the two of them I would prefer using savings balances rather than hardware wallets to secure most of my wealth. Of course, you can also just use both at the same time by, for example, keeping the cryptocurrency in a savings balance of a separate more secure account whose active keys are managed by a hardware wallet.

Note: I really enjoy xkcd comics. Check it out if you haven't already. Don't forget the hover text. Although in the case of this particular comic, I would say his claims of "actual actual reality" are soon becoming obsolete for more and more people due to adoption of cryptocurrencies and how attractive they are to criminals trying to steal them.
But there is an even more important consideration that shows why hardware wallets aren't enough and why time-locked savings really are needed. Imagine the scenario of a home invader seeing your Trezor, putting a gun to your head, and demanding that you unlock it to make a transfer of all your cryptocurrency to him. Are you really going to say no?
Time-locked savings and recovery accounts provides huge peace of mind since you can know that a sudden compromise of your private keys, whether by hacking or rubber-hose cryptanalysis (see relevant xkcd comic to the right) does not need to mean that all of your crypto-wealth will be instantly and permanently stolen.
Proposal for BitShares
The recovery account feature that I think BitShares should adopt is pretty straightforward. Just copy Steem's mechanism. You set a recovery_account
(which would be any other BitShares account). Changing the recovery_account
would be an operation that takes 30 days to complete and it could be canceled by the owner authority at any time in those 30 days. With a recovery account set, the user (in conjunction with the recovery partner) could use an owner authority that was active in the last 30 days to replace their account's owner authority.
Time-locked savings are needed to protect the user's wealth from being instantaneously stolen as soon as their private keys are compromised. Only a small portion of their wealth should be kept in a form that can be instantaneously spent; this includes (but is not limited to) assets in what is currently the only available place to simply hold balance in BitShares (which I will going forward call their checking balances), assets held in escrow contracts, assets in the order books of the DEX, and collateral assets held in short positions (although unlocking all of this collateral would still require paying off the debt of the short position of course).
Time-locked savings even without the recovery account feature are incredibly useful, because normally, if recommended security practices are followed, the owner keys of the account will be offline, and so a hacker compromising the account would likely only have active keys. They would then most likely immediately change their active keys, steal the checking balances (and other liquid balances, e.g. in order books), and initiate the savings withdrawals of all the savings balances to their account. However, the legitimate user could then bring out their owner keys (hopefully on an offline computer, or at least a freshly re-installed clean computer rather than their regular one that was likely hacked) to change the active keys and to cancel all the savings withdrawals. However, time-locked savings coupled with recovery accounts provide even greater security to the user, because even if their owner keys are also compromised, they can still work with their recovery partner to get back access to their account and hopefully they can do this in time to cancel the savings withdrawals.
Steem currently has one savings balance (however both STEEM and SBD assets can utilize that single savings balance) that enforces a fixed 3 day delay on savings withdrawals. However, if I was building a time-locked savings feature today, I would want to provide users with more flexibility than that. There are some balances that I would like to be protected for longer than 3 days for extra peace of mind, while for other smaller amounts 1 day is sufficient protection against hacks and it allows for quicker access to funds if I need it for a larger than normal transaction (or likely selling in the market to catch a good pump). Users should be able to make their own decisions on the trade-offs between security and liquidity, and they should be able to do it in easy-to-use ways that don't require them to run bots on servers that have access to their active keys (which itself causes security risk). Below I will describe the design for the time-locked savings feature that I would want BitShares (and frankly all other blockchains) to adopt.
Details of the proposed design for time-locked savings
I am going to use some of the implementation specific details of Graphene objects and operations to describe the design.
First, a brief background to those unfamiliar with the Graphene code base: the objects (ending in _object
) are data structures that are kept in the database state and are created, modified, and destroyed by the evaluation of operations (ending with _operation
), which are included in the actual blockchain, as well as by the evaluation of code (which I will refer to as triggers) that may potentially run every block to act on pending actions that were scheduled by prior operations to run at future times. In order to schedule actions for future times, an operation would need to create an appropriate object in the database containing the timestamp of when the system should trigger the action. This timestamp field in the object that determines when the action should be triggered may typically be given a name such as effective_on
.
With all that said, I will still be using pseudocode and leaving off some of the details that actual make them legitimate code in the BitShares (or for that matter Steem) code bases. For example, in the pseudocode the structs for operations will not include the fields and methods relevant for tracking the fees that need to be paid for the operation, or other unimportant details such as the validate()
declaration, the extensions
field, or inheriting from base_operation
for the sake of clarity. Furthermore, rather than provide code or pseudocode for the implementation of validate()
or the operation's evaluator, I will simply describe the restrictions on the operation's values as well as how it works through prose and comments in the pseudocode for the struct definition. Similarly, in the pseudocode defining the classes for the objects I will not bother with unimportant details like inheriting the appropriate class, defining the space_id
and type_id
, or declaring/defining all helper methods relevant to the class. And rather than defining the the necessary indices, these can be inferred from the comments and by what would be necessary to carry out the triggers and operation evaluators described in this section in prose.
One last thing to mention before getting into the technical details is that this proposal only covers the savings balance feature for public balances. All funds held in these savings balances will be publicly visible to all (as typical usage of checking balances in BitShares today does). However, the BitShares blockchain does currently support blinded balances using the technology of Confidential Transactions (CT), even though most people do not use it because there is no user-friendly (and safe in terms of backing up the necessary keys to not lose funds) GUI interface to manage it. Those blinded balances would not be able to use the savings feature described in this proposal unless they were first unblinded into the checking balance. It should be possible to later augment the savings balance feature by providing support for blinded savings balances as well, but there are a lot of complications to work through with that design to preserve the safety guarantees of savings balances in the event of a hack and to reduce the likelihood that users lose access to funds due to improper private key backup particular when changing keys. At that later point in time when such an enhancement is being considered, I would also desire to use Confidential Assets (CA) on BitShares rather than CT, since CA would also blind the asset type in addition to the amount.
New objects
This proposal would introduce 3 new objects to BitShares: account_savings_balance_object
, savings_withdraw_object
, and savings_reduce_delay_object
. The pseudocode for the objects is given below without much explanation. This is to introduce the objects and their fields which is necessary to better explain how the new operations work in the next sub-section. In addition, the account_object
would be modified to include two new uint16_t
fields called savings_balances_counter
and pending_savings_adjustments_counter
(both initialized to 0). These two counters will be needed to limit the number of Graphene objects that can be created per account for the purposes of this savings feature in order to prevent abuse of the memory usage of the Graphene object database. The limits would be hardcoded (although I suppose they could be committee parameters) and would require introducing two new constants: GRAPHENE_MAX_SAVINGS_BALANCES
and GRAPHENE_MAX_PENDING_SAVINGS_ADJUSTMENTS
. It is important to make sure GRAPHENE_MAX_SAVINGS_BALANCES
is generously large because even if a single withdrawal delay was used, each unique asset type in the savings balance would count as its own savings balance object.
Pseudocode for account_savings_balance_object
:
class account_savings_balance_object
{
public:
account_id_type owner;
asset_id_type asset_type;
share_type amount;
uint32_t delay = 0; // Withdrawal delay, in seconds.
uint16_t num_pending_withdrawals_originating_from_here = 0;
asset get_balance()const { return asset(amount, asset_type); }
void adjust_balance(const asset& delta); // Adds delta.amount to amount if delta.asset_id == asset_type
};
An account_saving_balance_object
is uniquely determined by the tuple (owner
, asset_type
, delay
).
To aid with the explanations of the new operations, it will be very helpful to first define the helper functions adjust_savings_balance
and clear_unneeded_savings_balance
defined below:
void database::adjust_savings_balance( account_id_type account, asset delta, uint32_t delay )
{ try {
if( delta.amount == 0)
return;
auto& index = get_index_type().indices().get();
auto itr = index.find(boost::make_tuple(account, delta.asset_id, delay));
if( delta.amount < 0 )
{
FC_ASSERT( itr, "No such savings balance exists" );
FC_ASSERT( itr->get_balance() >= -delta, "Insufficient savings balance" );
if( itr->get_balance() != -delta )
{
modify(*itr, [&delta](account_savings_balance_object& b) {
b.adjust_balance(delta);
});
}
else if( itr->num_pending_withdrawals_originating_from_here == 0 )
{
remove(*itr);
modify(account(*this), [](account_object& a) {
a.savings_balances_counter--;
});
}
}
else // delta.amount > 0
{
if(itr == index.end())
{
auto& acnt = account(*this);
FC_ASSERT( acnt.savings_balances_counter < GRAPHENE_MAX_SAVINGS_BALANCES, "Too many savings balances already exist.");
create([account,delay,&delta](account_savings_balance_object& b) {
b.owner = account;
b.asset_type = delta.asset_id;
b.amount = delta.amount;
b.delay = delay;
});
modify(acnt, [](account_object& a) {
a.savings_balances_counter++;
});
}
else
{
modify(*itr, [&delta](account_savings_balance_object& b) {
b.adjust_balance(delta);
});
}
}
} FC_CAPTURE_AND_RETHROW( (account)(delta)(delay) ) }
bool database::clear_unneeded_savings_balance( account_id_type account, asset_id_type asset_type, uint32_t delay )
{ try {
auto& index = get_index_type().indices().get();
auto itr = index.find(boost::make_tuple(account, asset_type, delay));
if( itr != index.end() && itr->amount == 0 && itr->num_pending_withdrawals_originating_from_here == 0 )
{
remove(*itr);
modify(account(*this), [](account_object& a) {
a.savings_balances_counter--;
});
return true;
}
return false;
} FC_CAPTURE_AND_RETHROW( (account)(asset_type)(delay) ) }
Pseudocode for savings_withdraw_object
:
class savings_withdraw_object
{
public:
account_id_type from;
account_id_type to;
uint32_t request_id = 0;
uint32_t delay = 0; // Must be set to the delay of the savings balance this withdrawal was initiated from.
asset balance;
optional memo;
time_point_sec effective_on;
};
A savings_withdraw_object
is uniquely determined by the tuple (from
, request_id
). Furthermore, when creating a new savings_withdraw_object
it is important to ensure that there is not already a savings_reduce_delay_object
that both has an account
field equal to the from
field of the savings_withdraw_object
and a request_id
field equal to the request_id
field of the savings_withdraw_object
(see definition of savings_reduce_delay_object
below).
As soon as the head_block_time
is equal to or exceeds the effective_on
time, the system will trigger an action which will first add balance
to the checking account of to
, then decrement the num_pending_withdrawals_originating_from_here
field of the account_saving_balance_object
identified by the tuple (from
, balance.asset_id
, delay
), then call clear_unneeded_savings_balance(from, balance.asset_id, delay)
, then decrement the pending_savings_adjustments_counter
of the from
account (which counts the total savings_withdraw_object
s and savings_reduce_delay_object
s associated with the account), and finally destroy the savings_withdraw_object
.
Pseudocode for savings_reduce_delay_object
:
class savings_reduce_delay_object
{
public:
account_id_type account;
uint32_t request_id = 0;
uint32_t original_delay = 0; // Must be set to the delay of the savings balance this withdrawal was initiated from.
uint32_t new_delay = 0; // Should always be less than original_delay since increasing the delay is an instantaneous operation.
asset balance;
time_point_sec effective_on;
};
A savings_reduce_delay_object
is uniquely determined by the tuple (account
, request_id
). Furthermore, when creating a new savings_reduce_delay_object
it is important to ensure that there is not already a savings_withdraw_object
that both has a from
field equal to the account
field of the savings_reduce_delay_object
and a request_id
field equal to the request_id
field of the savings_reduce_delay_object
.
As soon as the head_block_time
is equal to or exceeds the effective_on
time, the system will trigger an action which will first decrement the num_pending_withdrawals_originating_from_here
field of the account_saving_balance_object
identified by the tuple (from
, balance.asset_id
, original_delay
). The system will then check to see if the savings_balances_counter
field of the account is less than GRAPHENE_MAX_SAVINGS_BALANCES
. If so it will call clear_unneeded_savings_balance(account, balance.asset_id, original_delay)
and then call adjust_savings_balance(account, balance, new_delay)
; but if not, it will instead simply call adjust_savings_balance(account, balance, original_delay)
. Finally, in either cas