Skip to content

feat(gas-keys): Transfer{To/From}GasKey actions #14911

Merged
darioush merged 13 commits into
masterfrom
darioush/gaskeys-transfer
Jan 22, 2026
Merged

feat(gas-keys): Transfer{To/From}GasKey actions #14911
darioush merged 13 commits into
masterfrom
darioush/gaskeys-transfer

Conversation

@darioush

@darioush darioush commented Jan 21, 2026

Copy link
Copy Markdown
Contributor

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.

@darioush darioush marked this pull request as ready for review January 21, 2026 18:18
@darioush darioush requested a review from a team as a code owner January 21, 2026 18:18
Comment thread core/primitives/src/action/mod.rs Outdated
ProtocolSchema,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TransferFromGasKeyAction {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe consider having distinct name so that it is easier to recognize, something like WithdrawFromGasKeyAction

Comment thread runtime/runtime/src/access_keys.rs Outdated
Comment on lines +251 to +270
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(());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(());
}

@darioush darioush Jan 22, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, up to you, no strong opinion from my side

Comment thread runtime/runtime/src/access_keys.rs Outdated
Comment on lines +288 to +307
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(());
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably deserves a dedicated helper function since also used above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread runtime/runtime/src/access_keys.rs Outdated
Comment on lines +309 to +319
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Comment thread runtime/runtime/src/config.rs Outdated
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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it is better to use zero gas as a stub, for simplicity and consistency

@darioush darioush requested a review from frol as a code owner January 22, 2026 19:39
@darioush darioush enabled auto-merge January 22, 2026 19:44
@darioush darioush added this pull request to the merge queue Jan 22, 2026
@codecov

codecov Bot commented Jan 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.45545% with 83 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.78%. Comparing base (3f16757) to head (43c3595).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
runtime/runtime/src/lib.rs 0.00% 17 Missing ⚠️
core/primitives/src/views.rs 0.00% 15 Missing ⚠️
core/primitives/src/errors.rs 0.00% 12 Missing ⚠️
runtime/runtime/src/access_keys.rs 95.72% 6 Missing and 6 partials ⚠️
core/primitives/src/action/mod.rs 9.09% 10 Missing ⚠️
runtime/runtime/src/verifier.rs 81.63% 8 Missing and 1 partial ⚠️
chain/rosetta-rpc/src/adapters/mod.rs 0.00% 3 Missing ⚠️
runtime/runtime/src/config.rs 50.00% 2 Missing ⚠️
tools/state-viewer/src/contract_accounts.rs 0.00% 2 Missing ⚠️
core/primitives-core/src/account.rs 83.33% 1 Missing ⚠️
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     
Flag Coverage Δ
pytests-nightly 1.31% <0.00%> (-0.01%) ⬇️
unittests 68.51% <78.21%> (+0.01%) ⬆️
unittests-nightly 68.30% <79.45%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Merged via the queue into master with commit f4da115 Jan 22, 2026
31 checks passed
@darioush darioush deleted the darioush/gaskeys-transfer branch January 22, 2026 20:23
pull Bot pushed a commit to See887777/nearcore that referenced this pull request Jun 3, 2026
# 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants