Contract Pays
A system contract change, which is proposed here, can enable contract-pays on eosio chains. This document explains how the PR enables contract-pays and how contract developers may implement it.
The Approach
Early in eosio history, some on the Eosio Developers chat discussed doing the following:
- Create an account, let's call it
provider, with a new authority, let's call itpayforit. - Create a contract on that account with an action, let's call it
acceptcharge. This action scans the transaction and aborts ifprovideris unwilling to pay for it. - Use
linkauthto enableprovider@payforitto authorizeprovider::acceptcharge. - Delegate plenty of CPU and NET to
provider. - Publish the private key of
provider@payforit.
Usage:
- Anyone could use
provider@payforit's private key to sign a transaction. - The only thing
provider@payforitshould authorize (see below) isprovider::acceptcharge. - If
provider::acceptchargeis the first action, and that action doesn't abort the transaction, thenproviderwill cover NET and CPU costs.
Attack Vectors:
provider@payforitcan authorizeupdateauth, allowing anyone to replace the published private key with their own, denying access to others.updateauthalso would allow anyone to create a new subauthority underpayforit, with keys of their choosing.provider@payforitcan authorizelinkauth, allowing anyone to relinkprovider::acceptchargeto the new subauthority, or toactiveorowner.provider@payforitcan authorizeunlinkauth, allowing anyone to disablepayforit's access toprovider::acceptcharge.provider@payforitcan also authorizedeleteauth.- Since
provider@payforit's authorization appears within the transaction, an attacker can setdelayto non-0, consumingprovider's RAM to store deferred transactions.
Attack Mitigation
A system contract update could block updateauth, linkauth, unlinkauth, and deleteauth. This covers most of the attack vectors.
To prevent RAM consumption attacks, provider must not pay for deferred transactions. The easiest way to prevent this is for provider to have no free RAM. Since provider::acceptcharge may need free RAM for tracking purposes, the contract should be on a different account than the resource provider account.
Example Code
// This version of eosio::get_action doesn't abort when index is out of range.
std::optional<eosio::action> better_get_action(uint32_t type, uint32_t index)
{
auto size = eosio::internal_use_do_not_use::get_action(type, index, nullptr, 0);
if (size < 0)
return std::nullopt;
std::vector<char> raw(size);
auto size2 = eosio::internal_use_do_not_use::get_action(
type, index, raw.data(), size);
eosio::check(size2 == size, "get_action failed");
return eosio::unpack<eosio::action>(raw.data(), size);
}
// Examine the transaction to see if we're ok accepting the CPU and NET charges
void the_contract::acceptcharge()
{
// type 0: context-free action
// type 1: normal action
for (uint32_t type = 0; type < 2; ++type)
{
for (uint32_t index = 0;; ++index)
{
auto action = better_get_action(type, index);
if (!action)
break;
// Simple rule: only allow actions on this contract
eosio::check(action->account == get_self(),
"This transaction has something I won't pay for");
}
}
}