feat(gas-keys): Transfer{To/From}GasKey actions #14911
Conversation
| ProtocolSchema, | ||
| )] | ||
| #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] | ||
| pub struct TransferFromGasKeyAction { |
There was a problem hiding this comment.
maybe consider having distinct name so that it is easier to recognize, something like WithdrawFromGasKeyAction
| let mut access_key = match get_access_key(state_update, account_id, &action.public_key)? { | ||
| Some(key) => key, | ||
| None => { | ||
| result.result = Err(ActionErrorKind::GasKeyDoesNotExist { | ||
| account_id: account_id.clone(), | ||
| public_key: Box::new(action.public_key.clone()), | ||
| } | ||
| .into()); | ||
| return Ok(()); | ||
| } | ||
| }; | ||
| let Some(gas_key_info) = access_key.gas_key_info_mut() else { | ||
| // Key exists but is not a gas key | ||
| result.result = Err(ActionErrorKind::GasKeyDoesNotExist { | ||
| account_id: account_id.clone(), | ||
| public_key: Box::new(action.public_key.clone()), | ||
| } | ||
| .into()); | ||
| return Ok(()); | ||
| }; |
There was a problem hiding this comment.
let Some(gas_key_info) = get_access_key(state_update, account_id, &action.public_key)?
.and_then(|key| key.gas_key_info_mut()) else {
result.result = Err(ActionErrorKind::GasKeyDoesNotExist {
account_id: account_id.clone(),
public_key: Box::new(action.public_key.clone()),
}
.into());
return Ok(());
}
There was a problem hiding this comment.
This doesn't work since we still need the access_key and gas_key_info_mut is a ref into it.
The best I can come up with here is:
/// Returns the access key if it exists and is a gas key.
/// Sets GasKeyDoesNotExist error in result and returns None otherwise.
fn get_gas_key_or_set_error(
state_update: &TrieUpdate,
account_id: &AccountId,
public_key: &PublicKey,
result: &mut ActionResult,
) -> Result<Option<AccessKey>, RuntimeError> {
let access_key = get_access_key(state_update, account_id, public_key)?;
match access_key {
Some(key) if key.gas_key_info().is_some() => Ok(Some(key)),
_ => {
result.result = Err(ActionErrorKind::GasKeyDoesNotExist {
account_id: account_id.clone(),
public_key: Box::new(public_key.clone()),
}
.into());
Ok(None)
}
}
}
Then both action_transfer_to_gas_key and action_withdraw_from_gas_key simplify to:
let Some(mut access_key) = get_gas_key_or_set_error(state_update, account_id, &action.public_key, result)? else {
return Ok(());
};
let gas_key_info = access_key.gas_key_info_mut().unwrap(); // Safe: helper verified it's a gas key
Overall I prefer to leave it as is without any unwrap and the duplication seems fine to me, but let me know if you want me to change it to the above structure or another suggestion.
There was a problem hiding this comment.
makes sense, up to you, no strong opinion from my side
| let mut access_key = match get_access_key(state_update, account_id, &action.public_key)? { | ||
| Some(key) => key, | ||
| None => { | ||
| result.result = Err(ActionErrorKind::GasKeyDoesNotExist { | ||
| account_id: account_id.clone(), | ||
| public_key: Box::new(action.public_key.clone()), | ||
| } | ||
| .into()); | ||
| return Ok(()); | ||
| } | ||
| }; | ||
| let Some(gas_key_info) = access_key.gas_key_info_mut() else { | ||
| // Key exists but is not a gas key | ||
| result.result = Err(ActionErrorKind::GasKeyDoesNotExist { | ||
| account_id: account_id.clone(), | ||
| public_key: Box::new(action.public_key.clone()), | ||
| } | ||
| .into()); | ||
| return Ok(()); | ||
| }; |
There was a problem hiding this comment.
probably deserves a dedicated helper function since also used above
| if gas_key_info.balance < action.amount { | ||
| result.result = Err(ActionErrorKind::InsufficientGasKeyBalance { | ||
| account_id: account_id.clone(), | ||
| public_key: Box::new(action.public_key.clone()), | ||
| balance: gas_key_info.balance, | ||
| required: action.amount, | ||
| } | ||
| .into()); | ||
| return Ok(()); | ||
| } | ||
| gas_key_info.balance = gas_key_info.balance.checked_sub(action.amount).unwrap(); |
There was a problem hiding this comment.
avoid unnecessary unwrap:
let Some(updated_balance) = gas_key_info.balance.checked_sub(action.amount) else {
result.result = Err(ActionErrorKind::InsufficientGasKeyBalance {
account_id: account_id.clone(),
public_key: Box::new(action.public_key.clone()),
balance: gas_key_info.balance,
required: action.amount,
}
.into());
return Ok(());
}
gas_key_info.balance = updated_balance;
| base_fee.checked_add(all_bytes_fee).unwrap().checked_add(all_entries_fee).unwrap() | ||
| } | ||
| // TODO(gas-keys): properly handle GasKey fees | ||
| TransferToGasKey(_) => fees.fee(ActionCosts::transfer).send_fee(sender_is_receiver), |
There was a problem hiding this comment.
nit: it is better to use zero gas as a stub, for simplicity and consistency
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #14911 +/- ##
==========================================
+ Coverage 68.76% 68.78% +0.02%
==========================================
Files 916 916
Lines 199819 200221 +402
Branches 199819 200221 +402
==========================================
+ Hits 137396 137731 +335
- Misses 56507 56576 +69
+ Partials 5916 5914 -2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
# Gas Keys ([NEP-611](near/NEPs#611)) Gas keys are a new type of access key that carries a pre-funded NEAR balance and multiple independent nonce sequences. When a transaction is signed with a gas key, gas costs are deducted from the gas key's balance rather than the signer's account balance (deposits like NEAR transfers still come from the account). ## Why do we need gas keys? - Enabling SPICE without a DoS vector. The SPICE project (Separation of Execution from Consensus) decouples transaction execution from block production: transactions are included in blocks first, and executed asynchronously later. This means chunk producers can no longer verify account balances at inclusion time. An attacker could drain an account's balance in one transaction, then flood the chain with many transactions signed by that account. All would be accepted (since execution hasn't caught up), consuming block space for free when they inevitably fail. Gas keys close this gap by requiring gas to be pre-committed in the key itself, so chunk producers can verify gas availability without access to the latest state. - Parallel transaction submission. Today, programmatic users who submit many transactions in parallel must manage multiple access keys (each with its own nonce sequence). A gas key supports up to 1,024 independent nonce slots via a single key, removing that complexity. # Context - NEP (if exists): near/NEPs#611 - Implementation: - near#14868 - near#14869 - near#14883 - near#14891 - near#14892 - near#14911 - near#14924 - near#14925 - near#14930 - near#14933 - near#14969 - near#14985 - near#15033 - near#15034 - near#15067 - near#15068 - near#15100 - near#15101 - near#15186 # Testing and QA - unit tests - test-loop: https://github.com/near/nearcore/blob/master/test-loop-tests/src/tests/gas_keys.rs - forknet (automated): near#15207 - forknet (manual testing, planned) # Checklist - [x] Link to nightly nayduck run: https://nayduck.nearone.org/#/run/4756 - [x] Update CHANGELOG.md to include this protocol feature in the `Unreleased` section.
This pull request introduces two new actions to support transferring NEAR tokens to and from gas key balances and adds unit tests. The actions are disabled in verify by requiring ProtocolFeature::GasKeys.
The fees for these actions are TODO for a separate PR and for now set to be the same as transfer (we need to charge for the additional trie operations).
Will update protocol schema & openapi prior to merge.