-
Notifications
You must be signed in to change notification settings - Fork 161
Draft NEP for pending transaction queue. #611
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
31d207a
4abd9a3
2d8c2db
1139911
99f068c
206bb6f
b0f7e99
518901f
637de53
083ae3b
5934f57
34faa06
bdf0b92
b8273f1
e38a0a9
0ffa666
a78fe6e
f74756b
0c0e66b
2323607
66995c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| --- | ||
| NEP: 611 | ||
| Title: Pending Transaction Queue | ||
| Authors: Robin Cheng <[email protected]> | ||
| Status: Draft | ||
| DiscussionsTo: https://github.com/near/NEPs/pull/611 | ||
| Type: Protocol | ||
| Version: 1.0.0 | ||
| Created: 2025-05-28 | ||
| LastUpdated: 2025-05-28 | ||
| --- | ||
|
|
||
| ## Summary | ||
|
Check failure on line 13 in neps/nep-0611.md
|
||
| In the near future of the Near blockchain, we foresee that via the SPICE project, transaction and receipt | ||
| execution will become become decoupled from the blockchain itself; they will no longer run in lockstep. | ||
| Instead, transactions will be included in the blocks first, and then execution will follow later. | ||
|
|
||
| This inherently introduces a problem that we must accept transactions before we know whether they are | ||
| valid. Today, when a chunk producer produces a chunk containing a transaction, it can verify using the | ||
| current shard state that the transaction has a valid signature, has enough balance, and a valid nonce. | ||
| But as execution becomes asynchronous, we no longer have the current shard state to verify the | ||
| transactions against. | ||
|
|
||
| This NEP proposes a mechanism called the Pending Transaction Queue to solve this problem. | ||
|
|
||
| ## Motivation | ||
|
|
||
| ### Why is this worth solving? | ||
|
|
||
| A potential for DoS attacks exists whenever the blockchain allows anyone to submit work without paying. | ||
| Invalid transactions present such a vulnerability: if a transaction is included in a block (or more | ||
| precisely, a chunk of the block) but ends up being invalid because the sender does not have enough | ||
| balance, this transaction takes block space but cannot be charged against anyone. | ||
|
|
||
| A very easy-to-perform attack exists if we do nothing to mitigate the problem: | ||
|
|
||
| * The attacker creates two accounts, $A$ and $B$, with sufficiently many access keys each, and deploying | ||
| a specific contract for both accounts that provides a `send_near` function. | ||
| * The attacker deposits 10 NEAR in $A$. | ||
| * The attacker then performs the following, repeatedly: | ||
| * Submit a transaction to call A's `send_near` function, instructing it to send the account's remaining | ||
| balance to $B$. | ||
| * Right after, it floods the blockchain by signing and submitting many (arbitrary) transactions as $A$. | ||
| Because execution is asynchronous, the chunk producers think that there is still enough balance in | ||
| $A$'s account, so these transactions are accepted into chunks. | ||
| * Some blocks later, the execution catches up, and the `send_near` function drains $A$'s account. | ||
| * Subsequent executions of the following transactions all fail because $A$ has insufficient balance. | ||
| * After this is done, the attacker repeats but with $A$ and $B$ swapped. | ||
|
|
||
| This attack can be carried out with a very simple script and requires no cost other than a single contract | ||
| call every few blocks, but this ends up filling up the blockchain, denying legitimate transactions from | ||
| being included. This is also very hard to defend against, because the attacker can simply create more | ||
| accounts. Note that this attack pattern is not the only problematic one; instead of sending away the | ||
| balance, the attacker can also delete access keys or delete the whole account. | ||
|
|
||
| ## Specification | ||
|
Check failure on line 56 in neps/nep-0611.md
|
||
| To solve this problem, we present two critical components which work together to ensure that all accepted | ||
| transactions are valid. At a high level, they are: | ||
| * **Access Key vs Gas Key**: In addition to Access Keys, we introduce a second kind of signing key called the Gas Key. A gas key can be funded with NEAR, and when issuing a transaction using a gas key, the transaction only consumes gas from the gas key, not from the account. | ||
|
Check failure on line 59 in neps/nep-0611.md
|
||
| * **Pending Transaction Queue**: Chunk producers keep track of pending (accepted into a block, but not | ||
| executed) transactions, and ensures that transactions sent with access keys are limited in parallelism, | ||
| whereas transactions sent with gas keys are limited to the available gas in the gas key. | ||
|
|
||
| We will now specify how exactly they work. | ||
|
|
||
| ### Definition of "Pending Transactions" | ||
|
Check failure on line 66 in neps/nep-0611.md
|
||
| In a model where execution follows but lags behind consensus, there are transactions which are accepted | ||
| into consensus and thus committed to be executed in the near future, but are not yet executed. This set | ||
| of transactions is called the *pending transactions*. We always discuss this in the context of one shard. | ||
|
|
||
| Note that there are two slightly different ways to treat this definition, depending on how exactly the | ||
| "execution head" (how far the execution has caught up) is defined: | ||
| * It can be defined locally as the progress of execution at a node, but this will be different between | ||
|
Check failure on line 73 in neps/nep-0611.md
|
||
| nodes. | ||
| * It can be defined deterministically as the last block whose execution result is certified by consensus. | ||
|
|
||
| For the purpose of this NEP, we use the latter definition, so that the notion of pending transactions is | ||
| consistent across all nodes and the determinination of what transactions are eligible to be included by | ||
| a chunk producer can be verified -- even though we do not plan to implement this verification right now. | ||
|
|
||
| Another note is that the notion of pending transactions is anchored at a specific chunk that is being produced. In case of forks, we use the block that the chunk is being produced on top of to compute the | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "The notion of pending transactions is anchored at a specific chunk that is being produced." Agree. The tricky part is that the chunk producer is required to know about all the transactions included in the previous chunks all the way up to the chunk that it is producing. I.e., producing a chunk for height h requires the knowledge of all transactions associated with heights until h-1. (We're considering a single shard.) In SPICE we count on decoupling (from consensus) not just the execution, but data availability (even though we are not implementing it in SPICE v1). That is, making transaction data available will also be asynchronous. A chunk producer with the right of producing a chunk associated with height h might actually have a window of a few blocks after h to make sure the data is available, during which the data owners will certify (on chain) having received their respective data parts. This means that the availability certificate for a chunk associated with height h might only occur in a block at height h+a (a being the number of blocks it takes to ensure the availability). The next chunk producer (producing a chunk for h+1), however, might not yet know the contents of the chunk associated with h, and thus not have a precise view of the pending transaction set. This is a more general problem that also has other implications (such the problem with transaction duplication), but an incomplete view of the pending transaction set is also an issue. I could imagine a solution that requires the chunk producer for h to post at least a commitment to the chunk on chain as soon as possible, and the pending transaction set at h+1 would be defined through these commitments. The chunk producer for h+1 would then need to obtain the transaction data associated with h before proposing its own chunk. Ideally the chunk producer at h would directly (with high priority) send its chunk to the producer at h+1. If the former does not send the chunk, the latter would need to retrieve the chunk from the data owners, slowing down the chunk production. |
||
| set of pending transactions. | ||
|
|
||
| Finally, this NEP does *not* depend on the implementation of SPICE. In the context where SPICE is not | ||
| yet in effect, we consider the pending transactions queue to always be empty (despite technically being | ||
| one chunk worth of transactions due to execution lagging one block behind in the current implementation), | ||
| because we can always verify the validity of all transactions at the moment of inclusion. | ||
|
|
||
| ### Access Key vs Gas Key | ||
|
Check failure on line 89 in neps/nep-0611.md
|
||
| We introduce a new transaction version, `TransactionV2`, which is able to either specify an access key | ||
| or a gas key. Any older transaction version is equivalent to a `TransactionV2` that specifies an access | ||
| key. | ||
|
|
||
| #### Access Key Parallelism Restriction | ||
|
Check failure on line 94 in neps/nep-0611.md
|
||
| We now restrict the ability to send multiple parallel pending transactions with Access Keys. | ||
|
|
||
| Specifically, for any given account $A$ with any number of access keys, the total number of access key transactions in the pending transaction queue whose sender is $A$ cannot exceed $P_{\mathrm {max}}$, a | ||
|
||
| constant determined by the epoch; we propose $P_{\mathrm {max}} = 4$. | ||
|
|
||
| In other words, with traditional access keys, one cannot send more than 4 transactions with the same | ||
| account before they are executed. If one wishes the send more transactions in parallel, they would need | ||
| to create a gas key. | ||
|
|
||
| From a UX perspective, we pick the $P_{\mathrm {max}}$ constant so that it is very unlikely that anyone | ||
| exceeding this parallelism is an end user with a wallet app. Those who need more parallelism than this | ||
| would be using a script to send transactions, and for those use cases we require them to use gas keys. | ||
|
|
||
| #### Gas Keys | ||
|
Check failure on line 108 in neps/nep-0611.md
|
||
| This NEP adds Gas Keys, **conceptually** defined as the following: | ||
| ```rust | ||
|
Check failure on line 110 in neps/nep-0611.md
|
||
| struct ConceptualGasKey { | ||
| public_key: PublicKey, | ||
| nonces: Vec<Nonce>, | ||
| balance: Balance, | ||
| permission: AccessKeyPermission, | ||
| } | ||
|
Comment on lines
+132
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As I wrote in another comment to make refunds easier, can we consider another representation? Specifically, if we can store gas keys the same way we store the two existing access key types, then this would let them share the same namespace. Which makes it possible to detect refunds that should go to a gas key by using the public key. So I would suggest something more like this: pub enum AccessKeyPermission {
FunctionCall(FunctionCallPermission),
/// Grants full access to the account.
/// NOTE: It's used to replace account-level public keys.
FullAccess,
+ // Same access as FullAccess but with separate gas accounting
+ ConceptualGasKey,
}
struct ConceptualGasKey {
- public_key: PublicKey,
nonces: Vec<Nonce>,
balance: Balance,
- permission: AccessKeyPermission,
}This would be slightly less flexible, as it doesn't allow to limit methods callable by the gas key. We could add a Of course, this has pretty large ramifications for technical code details. We might not even need Maybe this is going too far off the intended design. But from my point of view, this seems to be an alternative that simplifies a bunch of things by staying closer to the existing concepts of access key types. |
||
| ``` | ||
|
|
||
| We add these operations to manipulate gas keys: | ||
| * `AddGasKey(PublicKey, AccessKeyPermission, usize)`: Creates a gas key with the given public key, | ||
|
Check failure on line 120 in neps/nep-0611.md
|
||
| permission, and number of nonces. | ||
| * `DeleteGasKey(PublicKey)`: Deletes a gas key. | ||
| * `FundGasKey(PublicKey, Balance)`: deducts balance from the account and gives it to the gas key. | ||
|
|
||
| #### Semantics of Gas Key Actions | ||
| `AddGasKey` is verified and executed as follows: | ||
| * Check that the key does not already exist. | ||
| * Check that if the permission is a `FunctionCallPermission`, the allowance is `None` (unlimited | ||
| allowance). | ||
| * A new `GasKey` entry is added to the trie, keyed by (gas key prefix, account ID, public key). The | ||
| gas key prefix is a new trie prefix. | ||
| * For each nonce ID (from 0 to the number of nonces minus 1), store the default nonce at the trie | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is mildly problematic for scaling/perf reasons. We're spending a humongous amount of time accessing the account + accesskey during transaction validation in tx runtime in some workloads already. This is adding another walk through a trie. OTOH if we make nonces array be inline the I see that this multiple-nonce mechanism is meant to enable something outside of this NEP's primary motivation. This is okay, but we should introduce proper motivation for adding this feature in the NEP text. I also wouldn't lock in a specific implementation here for now. I suspect that with Another reason to keep MAX_NONCES low initially is that it is trivial to increase the limit in the future, but decreasing it would be effectively impossible. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using gas keys would add one additional trie access (to load the nonce from a separate key). However I am not sure it is a major performance concern as gas keys are only needed by accounts with contracts that wish to have more than 4 in-flight transactions (or by users that issue parallel transactions using multiple nonces). There is a proof size tradeoff as well: if vector approach is used, value of all nonces will be included in the proof vs. just the accessed nonce. I agree about keeping MAX_NONCES low initially. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @robin-near can you please weigh in on the choice about making each nonce its own trie key? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually it seems with some of the recent caching efforts we can cache fetching the "main part" of the GasKey (with balance, permissions etc). so an additional trie access only happens only for the first access of each GasKey (then accessing other nonce indexes of the same gas key become cheaper for the same chunk).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I'm not sure that holds true with memtries at least. Accesses to memtrie don't really retain any memory of the prior accesses and so any new access will start walking from the trie root until it gets to the value. Conceptually, though, this could be possible to make optimal by changing the memtrie. It would just need a much more involved API that allowed you to pass in a node from previous lookup to start walking from and only use the tail of the key, rather than the entire key.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that #522 is also open and might have a different solution to parallelization. But I'm not sure if it is compatible with the exact limit we want to enforce here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an interesting proposal, and indeed a valid approach to dealing with duplicate transactions. This means the nodes would have to re-compute the new "recently accepted transactions" data structure. In spice we are trying to avoid this type of dependency on past chunks / blocks. |
||
| prefix (gas key prefix, account ID, public key, nonce ID). | ||
| * The default nonce is block height * 1e6, the same as the default nonce calculation for access keys. | ||
| * The operation is charged according to the amount of trie modifications. | ||
|
|
||
| `FundGasKey` is verified and executed as follows: | ||
| * The gas key must exist under the account. | ||
| * The cost of this action is the amount to fund; it is then verified the same way as a `Transfer` action. | ||
| * See below for "gas key transactions": it is also possible to fund a gas key using a gas key; so this | ||
| amount is not necessarily deducted straight from the account. | ||
| * To apply this action, the balance on the gas key is increased by the same amount. | ||
|
|
||
| `DeleteGasKey` is verified as executed as follows: | ||
| * The gas key must exist under the account. | ||
| * To apply this action, all relevant trie nodes are deleted, and the cost accounted for from trie | ||
| modifications. | ||
| * The remaining balance left in the key is **burned**. (This prevent the key deletion attack.) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there is a possible middle ground that does not make gas keys be this risky. Indeed, if this were to transfer the balance out of the gas key, we'd have to prevent the issue described in the motivation section (with
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a compounding effect here with deleting the account (which can be done programmatically). If we do a refund on delete for GasKey then we need to do something like not allowing accounts with GasKey to be deleted.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, fair enough. It could be quite nasty if an account deletion with a boatload of gas keys required a bunch of book-keeping on the chunk producer side as well. I think it would be a fair requirement to impose that if you want to delete an account you have to get rid of all gas keys first and use the access key to sign the action. We already have a limit on the amount of storage for the contract already, this wouldn't be an unusual requirement. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I also agree with the alternative of forbidding a deletion of accounts with gas keys. This is because if we try to add some protection from deleting high balances, then we have to check an unspecified number of gas keys to do 1 account delete, which makes charging for it difficult. This makes it such that the user has to first delete each of the gas keys and pay for the nonce deletion accordingly, in a separate action. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As I understand it, the requirement for burning the remaining balance stems from the fact that a gas key (or the whole account) can be deleted through an access key, i.e. not involving a transaction signed by the gas key. Is this the case? Even if we disallow deleting accounts with gas keys? If yes, would it make sense (or even be possible) to enforce the same restrictions for gas key deletions as we do for transfers? Putting it differently, can we treat a gas key deletion the same way as we treat a withdrawal, with the exception that the gas key would be deleted afterwards? The NEP later says that " contract execution cannot create receipts that withdraw from gas keys; only gas key transactions can". Is there a fundamental reason to restrict this to withdrawals? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is correct. Notably, account deletion can also occur programmatically (as a result of contract execution).
We could force the same restriction, i.e., disallow programmatic deletion of gas keys. However, contracts may benefit from the flexibility of programmatically adding/deleting gas keys. |
||
| * For user's benefit we may consider failing this action if the balance exceeds a certain threshold. | ||
|
||
| * Note that this does not mean that all balance in a gas key is eventually burned; one can withdraw from | ||
| a gas key with a gas key transaction with a simple `Transfer` action. | ||
|
|
||
| #### Gas Key Transactions | ||
|
|
||
| `TransactionV2` reuses the fields of `TransactionV1` except replacing the `public_key` and `nonce` fields | ||
| with an enum: | ||
| ```rust | ||
| enum TransactionKeyKind { | ||
| AccessKey { | ||
| key: PublicKey, | ||
| nonce: Nonce | ||
| }, | ||
| GasKey { | ||
| key: PublicKey, | ||
| nonce_id: usize, | ||
| nonce: Nonce, | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| The semantics for gas key transactions, at the moment of execution (conversion to receipts), are: | ||
| * A gas key transaction is valid iff all of the following are true: | ||
| * The public key corresponds to a valid gas key; | ||
| * The gas key has enough balance to cover the total transaction cost (all costs included in config.rs | ||
| `tx_cost`; this includes amounts included in `Transfer` actions too); | ||
| * The nonce ID < total number of nonces for the gas key, and the nonce is a valid nonce for that nonce | ||
| ID (per the same nonce checks as access key); | ||
| * When converting the gas key transaction to a receipt, the same logic applies as for access key | ||
| transactions, except | ||
| * The transaction cost is deducted from the gas key instead of the account. | ||
| * The new nonce is written for the specific nonce ID under the gas key. | ||
|
|
||
| #### Gas Key Pending Transaction Constraints | ||
| Unlike access key transactions, gas key transactions are not limited in parallelism; they are only limited | ||
| by the amount of gas these transactions consume. Specifically, for a gas key $G$, the total cost of | ||
| the transactions signed with $G$ in the pending transactions must not exceed the balance of $G$ | ||
| (according to the state that the pending transactions are calculated based on). | ||
darioush marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| This constraint should be good enough to cover cases of adding, funding, removing, or withdrawing from gas | ||
| keys as well. For adding and funding, we do need the execution to catch up before the newly available | ||
| balance can be used for pending transactions, which is suboptimal but correct. For withdrawing, the | ||
| action of withdrawing itself goes into the cost of the transaction and therefore is accounted for (and | ||
| note that contract execution cannot create receipts that withdraw from gas keys; only gas key | ||
| transactions can). For deletion, balance in gas keys are not refunded, so although subsequent pending | ||
| transactions may end up failing, the gas committed to those transactions are already burnt, eliminating | ||
| the opportunity for the aforementioned attack. | ||
|
|
||
| ### Pending Transaction Queue | ||
|
|
||
| We would now maintain a new data structure that stores the pending transactions, called the Pending | ||
| Transaction Queue. Although it's conceptually a queue, it is stored as a collection indexed by | ||
| (account ID, transaction type), and further indexed by the block hash. Furthermore, the pending | ||
| transaction queue is stored per block per shard, not as a single data structure. The contents of the queue | ||
| is exactly the pending transactions according to the definition above. | ||
|
|
||
| The constraints are enforced as described above; we reiterate it here: | ||
| * There cannot be more than 4 access key transactions under the same account in the queue; | ||
| * There cannot be more gas key transactions under the same gas key in the queue whose total transaction | ||
| cost exceed the gas key's balance. | ||
|
|
||
| The constraints are maintained at the time of chunk production: when producing a chunk, we only accept | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To check the constraints hold, it sounds like this means chunk producers need to include a state witness to validators along with the proposed chunk, just like they do today. Does this work against the idea of SPICE separating the consensus and execution? I suppose maybe if this state witness is very small relative to today then SPICE could still be effective.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, good point. We are only addressing the attack surface from (unauthenticated) users, not attacks from chunk producers. Chunk producers today (and in SPICE) are high-stake entities because they are also block producers, and mounting this attack as a chunk producer is not even very effective because you can only affect the chunks you produce. It is also already possible today to simply produce a chunk that is empty. |
||
| transactions that would maintain these constraints. For limiting gas key transactions, we always query the | ||
| balance of the gas key from the executed state (as opposed to storing it in the queue). | ||
|
|
||
| To compute the pending transaction queues (one for each tracked shard) for a new block, | ||
| * Start from the queue from the previous block; | ||
| * Subtract the transactions that are included in each newly certified block; | ||
| * Add new transactions included in this block. | ||
|
|
||
| ### Impact to Existing Protocol without SPICE | ||
| As mentioned above, this NEP applies with or without SPICE. The impact to the existing protocol is purely | ||
| positive: | ||
| * Access keys are not restricted, as the set of pending transactions is always empty. | ||
| * Gas keys allow programmatic transaction senders to more easily manage multiple nonces. Rather than | ||
| requiring multiple access keys to be created, they just need to create a single gas key. | ||
|
|
||
| The implementation of gas keys before SPICE also allows programmatic users to migrate to using gas keys, | ||
| preparing them for when SPICE is launched. | ||
|
|
||
| ## Alternatives | ||
| TODO | ||
|
|
||
| ## TODO: Other things | ||
Uh oh!
There was an error while loading. Please reload this page.