EOSCommunity.org Forums

[IDEA] Standard for contracts to signal to history solutions which accounts are relevant to an action

This post discusses a potential standard that could be adopted by the EOSIO community to provide a mechanism for smart contracts to dynamically indicate that their action is relevant to one or more EOSIO accounts so that the action can possibly be presented in the history of those accounts. This mechanism is one that smart contracts could use instead of require_recipient since require_recipient forces dynamics that the smart contract developers may find undesirable; specifically, it allows untrusted third-party code to run which may make the CPU costs of the transaction arbitrarily higher or may even abort the transaction thus preventing the original action from completing in the first place. This post also discusses how the new proposed mechanism, which can be implemented without any EOSIO protocol changes, enables the notified accounts to have some control over what is displayed on their account history feed as a way to protect against spam.

Current approaches

History indexers, which drive the account history feeds of wallets and block explorers, tend to decide whether an action is relevant to a particular account (and therefore is worth showing on the account history feed) based on one of the following limited conditions:

  • the account considered is the receiver account of the action;
  • or, the account considered is in the authorization vector of the action.

The latter condition covers the actions that the user of that account authorized using their credentials. Note that if the permission used in the authorization vector was satisfied via a delegated permission of some other account, that latter delegated account would likely not be considered a relevant account to the action by the history indexers and therefore the action would not show up in that account’s history despite the fact that keys on that account were used to satisfy the authorization of that action.

The former condition based on the receiver makes sense in that it will allow users of block explorers to see all the actions executed on a particular contract account. It also handles the case of smart contract code executing due to handling the notification of an action of another smart contract. For example, if bob had a custom contract to handle receipt of funds via eosio.token::transfer, then if alice transferred tokens to bob, that action would not only show up when exploring the account history of the eosio.token account but also when exploring that of the bob account. This would be true even if the contract on bob did not have any explicit logic to handle the eosio.token::transfer action since the history indexers aren’t trying to be smart enough to determine what side effects the contract handling the notification may have done. In fact, the history indexers take the simpler approach of considering any notified accounts to be relevant whether or not they even have smart contract code on them. This means the transfer would also show up on the account history feed of the alice account even if alice was somehow not an authorizer of the transfer action (this is because the eosio.token::transfer action calls require_recipient on both the from and the to).

Given how history indexers treat the notifications triggered by require_recipient, many smart contract developers have ended up using require_recipient as a mechanism to signal to history indexers that the listed accounts are relevant to this action and therefore this action should show up on their account history pages. The smart contract developers may do this even if they do not intend for code on any smart contracts deployed on the notified accounts to be run. Unfortunately, this approach causes any smart contract code deployed on the notified accounts to run and that means (due to the need to maintain the atomic property of the transaction within which these actions and notifications are executing) that this third-party code could disrupt the intended operations of the action. I discussed in a prior post how this behavior actually caused an attack vector in the original implementation of the eosio::bidname action.

Proposed Standard

It would be helpful to smart contract developers if they had a mechanism to signal to history indexers the list of EOSIO accounts that are relevant to a particular action so that history solutions could then potentially display the action in the account history of the relevant EOSIO accounts. And, in particular, it is important for this mechanism to notify the listed accounts in a manner that does not have the same behavior as require_recipient which as discussed earlier could be problematic for certain applications.

(Keep in mind that the desired mechanism is intended to not trigger the execution of third-party code that may be deployed on the notified account. If you want to still have that behavior, you will either need to use require_recipient or do an inline action to the account directly. In either case, that comes with the consequences that the triggered code can abort the transaction or cause it to consume way more CPU than desired. I have some ideas for how a contract could send an event from an executing action to eventually trigger arbitrary code on the recipient contract without allowing that arbitrary code to block the execution of the current action, but that is not in scope for this post and I will likely write something up for that sometime later.)

This desired mechanism is only about signaling information to off-chain systems and not having other side-effects on-chain. Therefore, there is no need for some new host function in the EOSIO protocol to achieve the stated objective. There are already plenty of mechanisms available to signal information to off-chain processes (with an inline action being the most obvious mechanism). The issue is that for this signaling to be useful, there would need to be some standard that smart contracts, history indexers/solutions, wallets, and block explorers would all adopt. So this post is about proposing an initial iteration of such a standard and getting feedback from the community about it.

The proposed eosio.notify standard

The standard requires a new action named notify to be available to call on the account eosio.notify. The account eosio.notify must have an appropriate ABI deployed on it but it may or may not have a contract deployed on it.

If a contract is not deployed on the account, the signature of the action must be the following:

void notify(eosio::name sender_account, eosio::name action_name, const std::vector<eosio::name>& accounts_to_notify);

If a contract is deployed on the account, the blockchain it is deployed on must have activated the ACTION_RETURN_VALUE protocol feature introduced in EOSIO 2.1 and the signature of the action must be the following:

std::vector<eosio::name> notify(eosio::name sender_account, eosio::name action_name, const std::vector<eosio::name>& accounts_to_notify);

History indexers can check the return value within the action trace of eosio.notify::notify action. If there is no return value (not to be confused with a return value consisting of an empty vector), then they should behave as if there is no contract deployed on the eosio.notify account (even if there technically is one).

History indexers should ignore the eosio.notify::notify action if any of the following conditions are true:

  • the eosio.notify::notify action is not an inline action;
  • the eosio.notify::notify action is a context-free action;
  • the eosio.notify::notify action trace is for a receiver other than eosio.notify;
  • the eosio.notify::notify action arguments do not successfully unpack according to the signatures above;
  • the eosio.notify::notify action return value (assuming it has one) does not successfully unpack according to the return type of the second signature above;
  • the action that sent the eosio.notify::notify action has a receiver that does not match the value of the sender_account argument;
  • the action that sent the eosio.notify::notify action has an action name that does not match the value of the action_name argument (however ignore this condition if the action trace that is the creator of the eosio.notify::notify action trace was running in a notify context, i.e. receiver != act.account).

In addition, the history indexer is allowed to ignore a eosio.notify::notify action even if none of the above conditions are true if the account that sent the eosio.notify::notify action is on the history indexer’s subjective blacklist.

If the history indexer is behaving as if there is no contract deployed on the eosio.notify account, then it should construct the set of accounts pulled from the accounts_to_notify argument of the eosio.notify::notify action, add to the set the account included in sender_account, remove any accounts from the set that are included in its subjective blacklist or that are not valid EOSIO accounts, and finally consider the accounts in the remaining set as relevant to the action that called that eosio.notify::notify action. If an account is considered relevant to an action, it means that a reference to that action should exist in the history feed of the account.

If the history indexer is behaving as if there is a contract deployed on the eosio.notify account, then its behavior should be very similar to the above except that it constructs the initial set of accounts from the return value of the eosio.notify::notify action rather than from the accounts_to_notify argument.

Smart contracts are expected to use the eosio.notify::notify action to signal to history indexers the set of accounts they consider relevant to the calling action. They do this by sending an inline eosio.notify::notify action with the following arguments set according to the following rules:

  1. sender_account must be set to the account name of the calling contract (this is typically determined via the get_self() function);
  2. action_name cannot be the empty name;
  3. action_name must be set to the name of the action currently being processed by the calling contract assuming this is not in a notification context (if this is in a notification context, then the calling contract is free to choose any non-empty name for the action_name);
  4. accounts_to_notify must be in strictly increasing order.

A couple quick clarifications about rule 4: the names do not necessarily need to refer to valid EOSIO accounts; strictly increasing order also means that names cannot be repeated in the vector.

If the calling contract follows rules 1, 2, and 4, they have a guarantee that the eosio.notify::notify action will never explicitly abort (not counting deadline timeout or CPU/NET exhaustion) assuming that any contract deployed on the eosio.notify account is compliant with the standard. In addition, while not a guarantee as part of the standard, the calling contract can expect that the computational cost of the eosio.notify::notify action will scale linearly with the size of accounts_to_notify and will take a reasonable amount of time. This gives confidence to smart contract developers that it is safe to call the eosio.notify::notify action regardless of the current state as long as they correctly follow rules 1, 2, and 4.

Smart contract developers are also highly encouraged to follow rule 3. If they do not, then the history indexers are likely to not actually respect their notification request. However, rule 3 is considered separately from rules 1, 2, and 4 because the eosio.notify contract (assuming it exists) has no way in general to validate the name of the action from which the eosio.notify::notify inline action was sent. In addition, the standard can make no guarantees about whether a particular history indexer will respect the request to notify the accounts listed in accounts_to_notify (or for that matter the ones in the return value). This is because each history indexer is allowed to enforce its own local subjective blacklist of accounts.

A smart contract can be deployed to the eosio.notify account as long as it respects the eosio.notify action according to the signature defined earlier that has the non-void return value and it does not fail (as discussed above) if rules 1, 2, and 4 are satisfied.

Optional eosio.notify contract

If the operators of a particular EOSIO blockchain decided to actually deploy a smart contract to the eosio.notify account, then they would need to ensure it complied with the standard (see last sentence of the previous section). But there are other recommendations I would like to make (that are not actually required by the eosio.notify standard proposed above) for how this eosio.notify contract should behave.

First, I would recommend that the eosio.notify contract enforce rules 1, 2, and 4. It can enforce rule 1 by using the get_sender() host function, ensuring it is not the empty value, and then ensuring it is identical to the value of sender_account.

In addition, the smart contract is expected to return a reasonable return value so that intended off-chain behavior of the notify action can function properly. If the contract always returns the empty vector, then the notify action will have no use. One option is to just return the same vector provided via accounts_to_notify. This would result in the same behavior for history indexers as they would have if the smart contract was not deployed at all; however, now if the calling contract fails to satisfy rules 1, 2, and 4, it could be detected at transaction execution time (via aborting the transaction) rather than later when it was noticed that the history solutions were not displaying the actions in the account history feed of the notified accounts.

A more interesting thing that the notify action could do, however, is to make the return value a subset of the set defined by accounts_to_notify. It could remove from the specified set any account that was configured (according to configuration state tracked by the eosio.notify contract) to not receive notifications from the contract on the sender_account account (possibly modulated by the further context of action_name). This could allow accounts to opt-in or opt-out of receiving notifications from specific contracts which can help address the spam problem that already exists due to require_recipient.

My recommendation would be to allow both system-level configuration and user-level configuration within the eosio.notify account. These configurations would allow specifying the pair of the sender_account and action_name as a filter (with the action_name optionally set to empty name to act as a wildcard) which would map to behavior to take: permit notification, deny notification, or inherit decision from ancestor. The user-level configuration would be lower in the inheritance hierarchy than the system-level configuration which means it would have precedence over the system-level decision. At the top of the inheritance hierarchy (and therefore the one with the lowest precedence) would be the default decision to deny notification.

Then the blockchain operators could add system-level filters to permit notification for the few system contracts. That way users would not have to take any action (and use up any RAM) to opt-in to being notified of these important system functions; though they could still opt-out if they desired. However, for other smart contracts, the user would need to use an action on the eosio.notify contract to opt-in to being notified by that contract. Otherwise, by default, those actions would not show up on their account history feed which helps address the spam issue that would otherwise exist if the default was to permit notification by all contracts.

However, it is important to note that this particular policy decision in the eosio.notify contract is just one choice and is not required to be compliant with the proposed standard above. Again, as a reminder, it is possible to be compliant with the standard without deploying any contract to the eosio.notify account (which would be choosing the most permissive policy). There are also CPU benefits in not deploying a contract to the eosio.notify account because then the inline eosio.notify::notify action would not require running any WebAssembly code and would therefore take very little time to execute.