From a90681ce784ce563387c47996ef69f20d3d3f57c Mon Sep 17 00:00:00 2001 From: Sean Young Date: Wed, 22 Jul 2026 16:09:01 +0100 Subject: [PATCH 1/8] Add gas_fa_coin to transaction format --- api/types/src/convert.rs | 6 +- .../aptos-vm/src/transaction_metadata.rs | 4 + .../aptos-framework/doc/aptos_governance.md | 101 ++++++++++++++++++ .../aptos-framework/doc/delegation_pool.md | 61 ++++++++++- .../aptos-framework/doc/staking_contract.md | 16 +++ .../src/aptos_framework_sdk_builder.rs | 46 ++++++++ .../framework/move-stdlib/doc/features.md | 1 + types/src/transaction/mod.rs | 26 +++++ 8 files changed, 258 insertions(+), 3 deletions(-) diff --git a/api/types/src/convert.rs b/api/types/src/convert.rs index 015473d224d..50c79d91421 100644 --- a/api/types/src/convert.rs +++ b/api/types/src/convert.rs @@ -360,8 +360,10 @@ impl<'a, S: StateView> MoveConverter<'a, S> { extra_config, }) => match extra_config { aptos_types::transaction::TransactionExtraConfig::V1 { - multisig_address, - replay_protection_nonce: _, + multisig_address, .. + } + | aptos_types::transaction::TransactionExtraConfig::V2 { + multisig_address, .. } => { if let Some(multisig_address) = multisig_address { match executable { diff --git a/aptos-move/aptos-vm/src/transaction_metadata.rs b/aptos-move/aptos-vm/src/transaction_metadata.rs index 4a47557112d..d5f64211723 100644 --- a/aptos-move/aptos-vm/src/transaction_metadata.rs +++ b/aptos-move/aptos-vm/src/transaction_metadata.rs @@ -102,6 +102,10 @@ impl TransactionMetadata { TransactionExtraConfig::V1 { multisig_address: Some(multisig_address), .. + } + | TransactionExtraConfig::V2 { + multisig_address: Some(multisig_address), + .. }, }) => Some(Multisig { multisig_address: *multisig_address, diff --git a/aptos-move/framework/aptos-framework/doc/aptos_governance.md b/aptos-move/framework/aptos-framework/doc/aptos_governance.md index a1af79916d8..72d8499f0ab 100644 --- a/aptos-move/framework/aptos-framework/doc/aptos_governance.md +++ b/aptos-move/framework/aptos-framework/doc/aptos_governance.md @@ -37,6 +37,8 @@ on a proposal multiple times as long as the total voting power of these votes do - [Function `initialize`](#0x1_aptos_governance_initialize) - [Function `update_governance_config`](#0x1_aptos_governance_update_governance_config) - [Function `initialize_partial_voting`](#0x1_aptos_governance_initialize_partial_voting) +- [Function `partial_voting_initialized`](#0x1_aptos_governance_partial_voting_initialized) +- [Function `initialize_partial_voting_if_needed`](#0x1_aptos_governance_initialize_partial_voting_if_needed) - [Function `get_voting_duration_secs`](#0x1_aptos_governance_get_voting_duration_secs) - [Function `get_min_voting_threshold`](#0x1_aptos_governance_get_min_voting_threshold) - [Function `get_required_proposer_stake`](#0x1_aptos_governance_get_required_proposer_stake) @@ -72,6 +74,8 @@ on a proposal multiple times as long as the total voting power of these votes do - [Function `initialize`](#@Specification_1_initialize) - [Function `update_governance_config`](#@Specification_1_update_governance_config) - [Function `initialize_partial_voting`](#@Specification_1_initialize_partial_voting) + - [Function `partial_voting_initialized`](#@Specification_1_partial_voting_initialized) + - [Function `initialize_partial_voting_if_needed`](#@Specification_1_initialize_partial_voting_if_needed) - [Function `get_voting_duration_secs`](#@Specification_1_get_voting_duration_secs) - [Function `get_min_voting_threshold`](#@Specification_1_get_min_voting_threshold) - [Function `get_required_proposer_stake`](#@Specification_1_get_required_proposer_stake) @@ -1099,6 +1103,65 @@ proposals with a signer for the aptos_framework (0x1) account. + + + + +## Function `partial_voting_initialized` + + + +
#[view]
+public fun partial_voting_initialized(): bool
+
+ + + +
+Implementation + + +
public fun partial_voting_initialized(): bool {
+    exists<VotingRecordsV2>(@aptos_framework)
+}
+
+ + + +
+ + + +## Function `initialize_partial_voting_if_needed` + +Initializes the state for Aptos Governance partial voting if it has not already been initialized. +This can only be called with a signer for the aptos_framework (0x1) account. + + +
public fun initialize_partial_voting_if_needed(aptos_framework: &signer)
+
+ + + +
+Implementation + + +
public fun initialize_partial_voting_if_needed(
+    aptos_framework: &signer,
+) {
+    system_addresses::assert_aptos_framework(aptos_framework);
+
+    if (!partial_voting_initialized()) {
+        move_to(aptos_framework, VotingRecordsV2 {
+            votes: smart_table::new(),
+        });
+    }
+}
+
+ + +
@@ -2319,6 +2382,44 @@ Abort if structs have already been created. + + +### Function `partial_voting_initialized` + + +
#[view]
+public fun partial_voting_initialized(): bool
+
+ + + + +
pragma opaque;
+aborts_if false;
+ensures result == exists<VotingRecordsV2>(@aptos_framework);
+
+ + + + + +### Function `initialize_partial_voting_if_needed` + + +
public fun initialize_partial_voting_if_needed(aptos_framework: &signer)
+
+ + +Signer address must be @aptos_framework. + + +
let addr = signer::address_of(aptos_framework);
+aborts_if addr != @aptos_framework;
+ensures exists<VotingRecordsV2>(@aptos_framework);
+
+ + + diff --git a/aptos-move/framework/aptos-framework/doc/delegation_pool.md b/aptos-move/framework/aptos-framework/doc/delegation_pool.md index 1b91662621b..065149909a1 100644 --- a/aptos-move/framework/aptos-framework/doc/delegation_pool.md +++ b/aptos-move/framework/aptos-framework/doc/delegation_pool.md @@ -152,6 +152,7 @@ transferred to A - [Function `owner_cap_exists`](#0x1_delegation_pool_owner_cap_exists) - [Function `get_owned_pool_address`](#0x1_delegation_pool_get_owned_pool_address) - [Function `delegation_pool_exists`](#0x1_delegation_pool_delegation_pool_exists) +- [Function `governance_records_initialized`](#0x1_delegation_pool_governance_records_initialized) - [Function `partial_governance_voting_enabled`](#0x1_delegation_pool_partial_governance_voting_enabled) - [Function `observed_lockup_cycle`](#0x1_delegation_pool_observed_lockup_cycle) - [Function `is_next_commission_percentage_effective`](#0x1_delegation_pool_is_next_commission_percentage_effective) @@ -179,6 +180,7 @@ transferred to A - [Function `initialize_delegation_pool`](#0x1_delegation_pool_initialize_delegation_pool) - [Function `beneficiary_for_operator`](#0x1_delegation_pool_beneficiary_for_operator) - [Function `enable_partial_governance_voting`](#0x1_delegation_pool_enable_partial_governance_voting) +- [Function `enable_partial_governance_voting_if_needed`](#0x1_delegation_pool_enable_partial_governance_voting_if_needed) - [Function `vote`](#0x1_delegation_pool_vote) - [Function `create_proposal`](#0x1_delegation_pool_create_proposal) - [Function `assert_owner_cap_exists`](#0x1_delegation_pool_assert_owner_cap_exists) @@ -2120,6 +2122,32 @@ Return whether a delegation pool exists at supplied address addr. + + + + +## Function `governance_records_initialized` + +Return whether a delegation pool has governance records initialized. + + +
#[view]
+public fun governance_records_initialized(pool_address: address): bool
+
+ + + +
+Implementation + + +
public fun governance_records_initialized(pool_address: address): bool {
+    exists<GovernanceRecords>(pool_address)
+}
+
+ + +
@@ -2140,7 +2168,7 @@ Return whether a delegation pool has already enabled partial governance voting.
public fun partial_governance_voting_enabled(pool_address: address): bool {
-    exists<GovernanceRecords>(pool_address) && stake::get_delegated_voter(pool_address) == pool_address
+    governance_records_initialized(pool_address) && stake::get_delegated_voter(pool_address) == pool_address
 }
 
@@ -3083,6 +3111,37 @@ The existing voter will be replaced. The function is permissionless. + + + + +## Function `enable_partial_governance_voting_if_needed` + +Enable partial governance voting on a delegation pool if it has not already been initialized. +This is intended for idempotent migration scripts over existing delegation pools. + + +
public entry fun enable_partial_governance_voting_if_needed(pool_address: address)
+
+ + + +
+Implementation + + +
public entry fun enable_partial_governance_voting_if_needed(
+    pool_address: address,
+) acquires DelegationPool, GovernanceRecords, BeneficiaryForOperator, NextCommissionPercentage {
+    assert_delegation_pool_exists(pool_address);
+    if (!governance_records_initialized(pool_address)) {
+        enable_partial_governance_voting(pool_address);
+    }
+}
+
+ + +
diff --git a/aptos-move/framework/aptos-framework/doc/staking_contract.md b/aptos-move/framework/aptos-framework/doc/staking_contract.md index 85a4ffa6b75..4df53db1a5f 100644 --- a/aptos-move/framework/aptos-framework/doc/staking_contract.md +++ b/aptos-move/framework/aptos-framework/doc/staking_contract.md @@ -1319,6 +1319,16 @@ Store amount must be at least the min stake required for a stake pool to join th + + +Beneficiary cannot be a reserved address that cannot receive coin distributions. + + +
const EINVALID_BENEFICIARY_ADDRESS: u64 = 10;
+
+ + + Caller must be either the staker, operator, or beneficiary. @@ -2283,6 +2293,12 @@ the beneficiary. An operator can set one beneficiary for staking contract pools, assert!(features::operator_beneficiary_change_enabled(), std::error::invalid_state( EOPERATOR_BENEFICIARY_CHANGE_NOT_SUPPORTED )); + // @vm_reserved can never have an account created for it, so it can't receive coin distributions. + // Allowing it as a beneficiary would permanently brick distribution for the staking contract. + assert!( + new_beneficiary != @vm_reserved, + error::invalid_argument(EINVALID_BENEFICIARY_ADDRESS), + ); // The beneficiay address of an operator is stored under the operator's address. // So, the operator does not need to be validated with respect to a staking pool. let operator_addr = signer::address_of(operator); diff --git a/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs b/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs index 2cff97836bb..7cf74286cc3 100644 --- a/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs +++ b/aptos-move/framework/cached-packages/src/aptos_framework_sdk_builder.rs @@ -525,6 +525,12 @@ pub enum EntryFunctionCall { pool_address: AccountAddress, }, + /// Enable partial governance voting on a delegation pool if it has not already been initialized. + /// This is intended for idempotent migration scripts over existing delegation pools. + DelegationPoolEnablePartialGovernanceVotingIfNeeded { + pool_address: AccountAddress, + }, + /// Evict a delegator that is not allowlisted by unlocking their entire stake. DelegationPoolEvictDelegator { delegator_address: AccountAddress, @@ -1657,6 +1663,9 @@ impl EntryFunctionCall { DelegationPoolEnablePartialGovernanceVoting { pool_address } => { delegation_pool_enable_partial_governance_voting(pool_address) }, + DelegationPoolEnablePartialGovernanceVotingIfNeeded { pool_address } => { + delegation_pool_enable_partial_governance_voting_if_needed(pool_address) + }, DelegationPoolEvictDelegator { delegator_address } => { delegation_pool_evict_delegator(delegator_address) }, @@ -3498,6 +3507,25 @@ pub fn delegation_pool_enable_partial_governance_voting( )) } +/// Enable partial governance voting on a delegation pool if it has not already been initialized. +/// This is intended for idempotent migration scripts over existing delegation pools. +pub fn delegation_pool_enable_partial_governance_voting_if_needed( + pool_address: AccountAddress, +) -> TransactionPayload { + TransactionPayload::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::new([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, + ]), + ident_str!("delegation_pool").to_owned(), + ), + ident_str!("enable_partial_governance_voting_if_needed").to_owned(), + vec![], + vec![bcs::to_bytes(&pool_address).unwrap()], + )) +} + /// Evict a delegator that is not allowlisted by unlocking their entire stake. pub fn delegation_pool_evict_delegator(delegator_address: AccountAddress) -> TransactionPayload { TransactionPayload::EntryFunction(EntryFunction::new( @@ -6722,6 +6750,20 @@ mod decoder { } } + pub fn delegation_pool_enable_partial_governance_voting_if_needed( + payload: &TransactionPayload, + ) -> Option { + if let TransactionPayload::EntryFunction(script) = payload { + Some( + EntryFunctionCall::DelegationPoolEnablePartialGovernanceVotingIfNeeded { + pool_address: bcs::from_bytes(script.args().get(0)?).ok()?, + }, + ) + } else { + None + } + } + pub fn delegation_pool_evict_delegator( payload: &TransactionPayload, ) -> Option { @@ -8390,6 +8432,10 @@ static SCRIPT_FUNCTION_DECODER_MAP: once_cell::sync::LazyALLOW_SERIALIZED_SCRIPT_ARGS as feature flag 72
const NATIVE_BRIDGE: u64 = 72;
diff --git a/types/src/transaction/mod.rs b/types/src/transaction/mod.rs
index 78ca9206773..dd27df5884a 100644
--- a/types/src/transaction/mod.rs
+++ b/types/src/transaction/mod.rs
@@ -741,6 +741,13 @@ pub enum TransactionExtraConfig {
         // Some(nonce) for orderless transactions
         replay_protection_nonce: Option,
     },
+    V2 {
+        multisig_address: Option,
+        // None for regular transactions
+        // Some(nonce) for orderless transactions
+        replay_protection_nonce: Option,
+        gas_fa_coin: Option,
+    },
 }
 
 impl TransactionPayload {
@@ -863,6 +870,18 @@ impl TransactionPayload {
                             Some(rng.gen())
                         }),
                     },
+                    TransactionExtraConfig::V2 {
+                        multisig_address,
+                        replay_protection_nonce,
+                        gas_fa_coin,
+                    } => TransactionExtraConfig::V2 {
+                        multisig_address,
+                        replay_protection_nonce: replay_protection_nonce.or_else(|| {
+                            let mut rng = rand::thread_rng();
+                            Some(rng.gen())
+                        }),
+                        gas_fa_coin,
+                    },
                 }
             }
             TransactionPayload::Payload(TransactionPayloadInner::V1 {
@@ -885,6 +904,10 @@ impl TransactionExtraConfig {
             Self::V1 {
                 replay_protection_nonce,
                 ..
+            }
+            | Self::V2 {
+                replay_protection_nonce,
+                ..
             } => *replay_protection_nonce,
         }
     }
@@ -898,6 +921,9 @@ impl TransactionExtraConfig {
             Self::V1 {
                 multisig_address,
                 replay_protection_nonce: _,
+            }
+            | Self::V2 {
+                multisig_address, ..
             } => *multisig_address,
         }
     }

From 87da65a78c9d3fab79313b30d8c651027a9e032e Mon Sep 17 00:00:00 2001
From: Sean Young 
Date: Thu, 23 Jul 2026 15:35:26 +0100
Subject: [PATCH 2/8] Add feature

---
 .../src/components/feature_flags.rs           |  3 +
 aptos-move/aptos-vm/src/aptos_vm.rs           |  9 ++
 .../src/tests/gas_fa_coin_feature_gating.rs   | 88 +++++++++++++++++++
 aptos-move/e2e-move-tests/src/tests/mod.rs    |  1 +
 .../framework/move-stdlib/doc/features.md     | 60 +++++++++++++
 .../move-stdlib/sources/configs/features.move | 11 +++
 types/src/on_chain_config/aptos_features.rs   |  7 ++
 types/src/transaction/mod.rs                  | 18 +++-
 8 files changed, 195 insertions(+), 2 deletions(-)
 create mode 100644 aptos-move/e2e-move-tests/src/tests/gas_fa_coin_feature_gating.rs

diff --git a/aptos-move/aptos-release-builder/src/components/feature_flags.rs b/aptos-move/aptos-release-builder/src/components/feature_flags.rs
index 90060fb3fb4..3f7608f9b73 100644
--- a/aptos-move/aptos-release-builder/src/components/feature_flags.rs
+++ b/aptos-move/aptos-release-builder/src/components/feature_flags.rs
@@ -149,6 +149,7 @@ pub enum FeatureFlag {
     EnableLazyLoading,
     CalculateTransactionFeeForDistribution,
     DistributeTransactionFee,
+    GasPayableFa,
     GovernedGasPool,
     SteakRewardUsingTreasury,
     ExtractAbortInfoExactMatch,
@@ -398,6 +399,7 @@ impl From for AptosFeatureFlag {
                 AptosFeatureFlag::CALCULATE_TRANSACTION_FEE_FOR_DISTRIBUTION
             },
             FeatureFlag::DistributeTransactionFee => AptosFeatureFlag::DISTRIBUTE_TRANSACTION_FEE,
+            FeatureFlag::GasPayableFa => AptosFeatureFlag::GAS_PAYABLE_FA,
             FeatureFlag::GovernedGasPool => AptosFeatureFlag::GOVERNED_GAS_POOL,
             FeatureFlag::SteakRewardUsingTreasury => AptosFeatureFlag::STAKE_REWARD_USING_TREASURY,
             FeatureFlag::ExtractAbortInfoExactMatch => {
@@ -576,6 +578,7 @@ impl From for FeatureFlag {
                 FeatureFlag::CalculateTransactionFeeForDistribution
             },
             AptosFeatureFlag::DISTRIBUTE_TRANSACTION_FEE => FeatureFlag::DistributeTransactionFee,
+            AptosFeatureFlag::GAS_PAYABLE_FA => FeatureFlag::GasPayableFa,
             AptosFeatureFlag::GOVERNED_GAS_POOL => FeatureFlag::GovernedGasPool,
             AptosFeatureFlag::STAKE_REWARD_USING_TREASURY => FeatureFlag::SteakRewardUsingTreasury,
             AptosFeatureFlag::EXTRACT_ABORT_INFO_EXACT_MATCH => {
diff --git a/aptos-move/aptos-vm/src/aptos_vm.rs b/aptos-move/aptos-vm/src/aptos_vm.rs
index bbfa49b4703..41c909523f1 100644
--- a/aptos-move/aptos-vm/src/aptos_vm.rs
+++ b/aptos-move/aptos-vm/src/aptos_vm.rs
@@ -1734,6 +1734,15 @@ impl AptosVM {
             }
         }
 
+        if !self.features().is_gas_payable_fa_enabled()
+            && transaction.extra_config().has_gas_fa_coin()
+        {
+            return Err(VMStatus::error(
+                StatusCode::FEATURE_UNDER_GATING,
+                Some("Paying gas in a fungible asset is not yet supported".to_string()),
+            ));
+        }
+
         // The prologue MUST be run AFTER any validation. Otherwise you may run prologue and hit
         // SEQUENCE_NUMBER_TOO_NEW if there is more than one transaction from the same sender and
         // end up skipping validation.
diff --git a/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_feature_gating.rs b/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_feature_gating.rs
new file mode 100644
index 00000000000..e93dc46722a
--- /dev/null
+++ b/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_feature_gating.rs
@@ -0,0 +1,88 @@
+// Copyright © Aptos Foundation
+// SPDX-License-Identifier: Apache-2.0
+
+use crate::MoveHarness;
+use aptos_cached_packages::aptos_stdlib;
+use aptos_types::{
+    on_chain_config::FeatureFlag,
+    transaction::{
+        TransactionExecutable, TransactionExtraConfig, TransactionPayload,
+        TransactionPayloadInner, TransactionStatus,
+    },
+};
+use move_core_types::{account_address::AccountAddress, vm_status::StatusCode};
+
+/// Builds a transaction payload in the new (versioned) format carrying an optional `gas_fa_coin`,
+/// wrapping a simple APT transfer entry function as the executable.
+fn transfer_payload_with_gas_fa_coin(
+    recipient: AccountAddress,
+    gas_fa_coin: Option,
+) -> TransactionPayload {
+    let executable = match aptos_stdlib::aptos_account_transfer(recipient, 1) {
+        TransactionPayload::EntryFunction(entry_function) => {
+            TransactionExecutable::EntryFunction(entry_function)
+        },
+        _ => unreachable!("aptos_account_transfer builds an entry function payload"),
+    };
+    TransactionPayload::Payload(TransactionPayloadInner::V1 {
+        executable,
+        extra_config: TransactionExtraConfig::V2 {
+            multisig_address: None,
+            replay_protection_nonce: None,
+            gas_fa_coin,
+        },
+    })
+}
+
+/// A transaction that specifies a `gas_fa_coin` must be rejected while the `GAS_PAYABLE_FA` feature
+/// is disabled, even though the underlying (versioned) payload format itself is enabled.
+#[test]
+fn gas_fa_coin_is_rejected_when_feature_disabled() {
+    // TRANSACTION_PAYLOAD_V2 is on so the versioned payload format is not what triggers the gate;
+    // GAS_PAYABLE_FA is off so the `gas_fa_coin` field is the sole reason for rejection.
+    let mut h = MoveHarness::new_with_features(
+        vec![FeatureFlag::TRANSACTION_PAYLOAD_V2],
+        vec![FeatureFlag::GAS_PAYABLE_FA],
+    );
+    let alice = h.new_account_with_key_pair();
+    let bob = h.new_account_with_key_pair();
+
+    let payload = transfer_payload_with_gas_fa_coin(*bob.address(), Some(*bob.address()));
+    let txn = h.create_transaction_payload(&alice, payload);
+    let output = h.run_raw(txn);
+
+    match output.status() {
+        TransactionStatus::Discard(status) => assert_eq!(
+            *status,
+            StatusCode::FEATURE_UNDER_GATING,
+            "expected a FEATURE_UNDER_GATING discard, but got: {:?}",
+            status
+        ),
+        other => panic!(
+            "expected a transaction carrying gas_fa_coin to be discarded, but got: {:?}",
+            other
+        ),
+    }
+}
+
+/// Control: an identical transaction in the same feature configuration but *without* a
+/// `gas_fa_coin` is accepted. This proves the rejection above is caused by `gas_fa_coin`
+/// specifically, not by the versioned payload format.
+#[test]
+fn transaction_without_gas_fa_coin_is_not_gated() {
+    let mut h = MoveHarness::new_with_features(
+        vec![FeatureFlag::TRANSACTION_PAYLOAD_V2],
+        vec![FeatureFlag::GAS_PAYABLE_FA],
+    );
+    let alice = h.new_account_with_key_pair();
+    let bob = h.new_account_with_key_pair();
+
+    let payload = transfer_payload_with_gas_fa_coin(*bob.address(), None);
+    let status = h.run_transaction_payload(&alice, payload);
+
+    assert!(
+        matches!(status, TransactionStatus::Keep(_)),
+        "an identical transaction without gas_fa_coin must not be gated, but got: {:?}",
+        status
+    );
+}
diff --git a/aptos-move/e2e-move-tests/src/tests/mod.rs b/aptos-move/e2e-move-tests/src/tests/mod.rs
index c432ce73def..dbaf112b6b5 100644
--- a/aptos-move/e2e-move-tests/src/tests/mod.rs
+++ b/aptos-move/e2e-move-tests/src/tests/mod.rs
@@ -26,6 +26,7 @@ mod function_value_depth;
 mod function_values;
 mod fungible_asset;
 mod gas;
+mod gas_fa_coin_feature_gating;
 mod generate_upgrade_script;
 mod governance_updates;
 mod infinite_loop;
diff --git a/aptos-move/framework/move-stdlib/doc/features.md b/aptos-move/framework/move-stdlib/doc/features.md
index 817df6403da..d08fb849155 100644
--- a/aptos-move/framework/move-stdlib/doc/features.md
+++ b/aptos-move/framework/move-stdlib/doc/features.md
@@ -166,6 +166,8 @@ return true.
 -  [Function `is_calculate_transaction_fee_for_distribution_enabled`](#0x1_features_is_calculate_transaction_fee_for_distribution_enabled)
 -  [Function `get_distribute_transaction_fee_feature`](#0x1_features_get_distribute_transaction_fee_feature)
 -  [Function `is_distribute_transaction_fee_enabled`](#0x1_features_is_distribute_transaction_fee_enabled)
+-  [Function `get_gas_payable_fa_feature`](#0x1_features_get_gas_payable_fa_feature)
+-  [Function `is_gas_payable_fa_enabled`](#0x1_features_is_gas_payable_fa_enabled)
 -  [Function `get_stake_reward_using_treasury_feature`](#0x1_features_get_stake_reward_using_treasury_feature)
 -  [Function `stake_reward_using_treasury_enabled`](#0x1_features_stake_reward_using_treasury_enabled)
 -  [Function `change_feature_flags`](#0x1_features_change_feature_flags)
@@ -693,6 +695,18 @@ Lifetime: transient
 
 
 
+
+
+Whether a transaction may pay for gas in a fungible asset other than APT,
+specified via the gas_fa_coin field of the transaction's extra config.
+Lifetime: transient
+
+
+
const GAS_PAYABLE_FA: u64 = 98;
+
+ + + Whether the Governed Gas Pool is used to capture gas fees @@ -4301,6 +4315,52 @@ Whether the Governed Gas Pool is enabled. + + + + +## Function `get_gas_payable_fa_feature` + + + +
public fun get_gas_payable_fa_feature(): u64
+
+ + + +
+Implementation + + +
public fun get_gas_payable_fa_feature(): u64 { GAS_PAYABLE_FA }
+
+ + + +
+ + + +## Function `is_gas_payable_fa_enabled` + + + +
public fun is_gas_payable_fa_enabled(): bool
+
+ + + +
+Implementation + + +
public fun is_gas_payable_fa_enabled(): bool acquires Features {
+    is_enabled(GAS_PAYABLE_FA)
+}
+
+ + +
diff --git a/aptos-move/framework/move-stdlib/sources/configs/features.move b/aptos-move/framework/move-stdlib/sources/configs/features.move index c5d7cc6b856..e5a3b7b78f6 100644 --- a/aptos-move/framework/move-stdlib/sources/configs/features.move +++ b/aptos-move/framework/move-stdlib/sources/configs/features.move @@ -793,6 +793,17 @@ module std::features { is_enabled(DISTRIBUTE_TRANSACTION_FEE) } + /// Whether a transaction may pay for gas in a fungible asset other than APT, + /// specified via the `gas_fa_coin` field of the transaction's extra config. + /// Lifetime: transient + const GAS_PAYABLE_FA: u64 = 98; + + public fun get_gas_payable_fa_feature(): u64 { GAS_PAYABLE_FA } + + public fun is_gas_payable_fa_enabled(): bool acquires Features { + is_enabled(GAS_PAYABLE_FA) + } + /// Whether the staking rewards are mint (diseable) or withdraw from the gouverned gas pool treasury (enable). /// /// Lifetime: permanent diff --git a/types/src/on_chain_config/aptos_features.rs b/types/src/on_chain_config/aptos_features.rs index baca5752346..2232f581946 100644 --- a/types/src/on_chain_config/aptos_features.rs +++ b/types/src/on_chain_config/aptos_features.rs @@ -143,6 +143,9 @@ pub enum FeatureFlag { CALCULATE_TRANSACTION_FEE_FOR_DISTRIBUTION = 96, DISTRIBUTE_TRANSACTION_FEE = 97, + /// Whether a transaction may pay for gas in a fungible asset other than APT, + /// specified via the `gas_fa_coin` field of the transaction's extra config. + GAS_PAYABLE_FA = 98, GOVERNED_GAS_POOL = 223, STAKE_REWARD_USING_TREASURY = 224, /// Use the fixed `extract_abort_info` lookup that does not spuriously match @@ -433,6 +436,10 @@ impl Features { self.is_enabled(FeatureFlag::DISTRIBUTE_TRANSACTION_FEE) } + pub fn is_gas_payable_fa_enabled(&self) -> bool { + self.is_enabled(FeatureFlag::GAS_PAYABLE_FA) + } + pub fn get_max_identifier_size(&self) -> u64 { if self.is_enabled(FeatureFlag::LIMIT_MAX_IDENTIFIER_LENGTH) { IDENTIFIER_SIZE_MAX diff --git a/types/src/transaction/mod.rs b/types/src/transaction/mod.rs index dd27df5884a..a248850dc43 100644 --- a/types/src/transaction/mod.rs +++ b/types/src/transaction/mod.rs @@ -815,13 +815,15 @@ impl TransactionPayload { match self { TransactionPayload::Script(_) | TransactionPayload::EntryFunction(_) - | TransactionPayload::ModuleBundle(_) => TransactionExtraConfig::V1 { + | TransactionPayload::ModuleBundle(_) => TransactionExtraConfig::V2 { multisig_address: None, replay_protection_nonce: None, + gas_fa_coin: None, }, - TransactionPayload::Multisig(multisig) => TransactionExtraConfig::V1 { + TransactionPayload::Multisig(multisig) => TransactionExtraConfig::V2 { multisig_address: Some(multisig.multisig_address), replay_protection_nonce: None, + gas_fa_coin: None, }, TransactionPayload::Payload(TransactionPayloadInner::V1 { extra_config, .. }) => { extra_config.clone() @@ -927,6 +929,18 @@ impl TransactionExtraConfig { } => *multisig_address, } } + + pub fn gas_fa_coin(&self) -> Option { + match self { + Self::V1 { .. } => None, + Self::V2 { gas_fa_coin, .. } => *gas_fa_coin + } + } + + pub fn has_gas_fa_coin(&self) -> bool { + self.gas_fa_coin().is_some() + + } } /// Two different kinds of WriteSet transactions. From 548ce9587f0684e35a1e67000dbafccbefebeeb5 Mon Sep 17 00:00:00 2001 From: Sean Young Date: Fri, 31 Jul 2026 16:53:52 +0100 Subject: [PATCH 3/8] add native function for fa payment --- .../src/gas_schedule/aptos_framework.rs | 3 +- .../aptos-vm/src/transaction_metadata.rs | 3 + .../tests/gas_fa_coin_transaction_context.rs | 109 ++++++++++++++++++ aptos-move/e2e-move-tests/src/tests/mod.rs | 1 + .../sources/transaction_context_test.move | 11 ++ .../doc/transaction_context.md | 62 ++++++++++ .../sources/transaction_context.move | 25 ++++ .../src/natives/transaction_context.rs | 28 +++++ .../transaction/user_transaction_context.rs | 9 ++ 9 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs diff --git a/aptos-move/aptos-gas-schedule/src/gas_schedule/aptos_framework.rs b/aptos-move/aptos-gas-schedule/src/gas_schedule/aptos_framework.rs index bd35621c0d3..dbb1d498685 100644 --- a/aptos-move/aptos-gas-schedule/src/gas_schedule/aptos_framework.rs +++ b/aptos-move/aptos-gas-schedule/src/gas_schedule/aptos_framework.rs @@ -7,7 +7,7 @@ use crate::{ gas_feature_versions::{RELEASE_V1_14, RELEASE_V1_8, RELEASE_V1_9_SKIPPED}, gas_schedule::NativeGasParameters, ver::gas_feature_versions::{ - RELEASE_V1_12, RELEASE_V1_13, RELEASE_V1_23, RELEASE_V1_26, RELEASE_V1_28, + RELEASE_V1_12, RELEASE_V1_13, RELEASE_V1_23, RELEASE_V1_26, RELEASE_V1_28, RELEASE_V1_32, }, }; use aptos_gas_algebra::{ @@ -314,6 +314,7 @@ crate::gas_schedule::macros::define_gas_parameters!( [transaction_context_entry_function_payload_per_byte_in_str: InternalGasPerByte, {RELEASE_V1_12.. => "transaction_context.entry_function_payload.per_abstract_memory_unit"}, 18], [transaction_context_multisig_payload_base: InternalGas, {RELEASE_V1_12.. => "transaction_context.multisig_payload.base"}, 735], [transaction_context_multisig_payload_per_byte_in_str: InternalGasPerByte, {RELEASE_V1_12.. => "transaction_context.multisig_payload.per_abstract_memory_unit"}, 18], + [transaction_context_gas_payment_fa_metadata_base: InternalGas, {RELEASE_V1_32.. => "transaction_context.gas_payment_fa_metadata.base"}, 735], [code_request_publish_base: InternalGas, "code.request_publish.base", 1838], [code_request_publish_per_byte: InternalGasPerByte, "code.request_publish.per_byte", 7], diff --git a/aptos-move/aptos-vm/src/transaction_metadata.rs b/aptos-move/aptos-vm/src/transaction_metadata.rs index d5f64211723..343fb8d647b 100644 --- a/aptos-move/aptos-vm/src/transaction_metadata.rs +++ b/aptos-move/aptos-vm/src/transaction_metadata.rs @@ -36,6 +36,7 @@ pub struct TransactionMetadata { pub is_keyless: bool, pub entry_function_payload: Option, pub multisig_payload: Option, + pub gas_fa_coin: Option, } impl TransactionMetadata { @@ -119,6 +120,7 @@ impl TransactionMetadata { }), _ => None, }, + gas_fa_coin: txn.payload().extra_config().gas_fa_coin(), } } @@ -213,6 +215,7 @@ impl TransactionMetadata { .map(|entry_func| entry_func.as_entry_function_payload()), self.multisig_payload() .map(|multisig| multisig.as_multisig_payload()), + self.gas_fa_coin, ) } } diff --git a/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs b/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs new file mode 100644 index 00000000000..ac9ce4552a8 --- /dev/null +++ b/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs @@ -0,0 +1,109 @@ +// Copyright © Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end coverage that a transaction's `gas_fa_coin` (from the versioned payload's +//! `TransactionExtraConfig::V2`) is surfaced to Move via +//! `transaction_context::gas_payment_fungible_asset()`. + +use crate::{assert_abort, assert_success, tests::common, MoveHarness}; +use aptos_types::{ + account_address::AccountAddress, + on_chain_config::FeatureFlag, + transaction::{ + EntryFunction, TransactionExecutable, TransactionExtraConfig, TransactionPayload, + TransactionPayloadInner, + }, +}; +use move_core_types::{ident_str, language_storage::ModuleId}; + +/// The transaction_context test pack publishes to `@admin` = 0x1. +fn setup() -> (MoveHarness, AccountAddress) { + // `TRANSACTION_PAYLOAD_V2` so the versioned payload is accepted, and `GAS_PAYABLE_FA` so a + // transaction carrying `gas_fa_coin` is not rejected by the VM gate. + let mut h = MoveHarness::new_with_features( + vec![ + FeatureFlag::TRANSACTION_PAYLOAD_V2, + FeatureFlag::GAS_PAYABLE_FA, + ], + vec![], + ); + let admin = h.new_account_at(AccountAddress::ONE); + let path = common::test_dir_path("transaction_context.data/pack"); + assert_success!(h.publish_package_cache_building(&admin, &path)); + (h, *admin.address()) +} + +fn entry(name: &'static str, args: Vec>) -> TransactionPayload { + let executable = TransactionExecutable::EntryFunction(EntryFunction::new( + ModuleId::new( + AccountAddress::ONE, + ident_str!("transaction_context_test").to_owned(), + ), + ident_str!(name).to_owned(), + vec![], + args, + )); + TransactionPayload::Payload(TransactionPayloadInner::V1 { + executable, + extra_config: TransactionExtraConfig::V2 { + multisig_address: None, + replay_protection_nonce: None, + gas_fa_coin: None, + }, + }) +} + +/// A versioned transaction that sets `gas_fa_coin = Some(fa)` makes +/// `transaction_context::gas_payment_fungible_asset()` return `Some(fa)` during execution. +#[test] +fn gas_fa_coin_is_visible_in_transaction_context() { + let (mut h, _admin) = setup(); + let sender = h.new_account_with_key_pair(); + let fa = AccountAddress::from_hex_literal("0xfa").unwrap(); + + // Entry function aborts unless the accessor returns Some(fa). + let mut payload = entry("assert_gas_payment_fungible_asset", vec![bcs::to_bytes(&fa) + .unwrap()]); + if let TransactionPayload::Payload(TransactionPayloadInner::V1 { extra_config, .. }) = + &mut payload + { + if let TransactionExtraConfig::V2 { gas_fa_coin, .. } = extra_config { + *gas_fa_coin = Some(fa); + } + } + + let txn = h.create_transaction_payload(&sender, payload); + assert_success!(h.run_raw(txn).status().clone()); +} + +/// A versioned transaction with no `gas_fa_coin` makes the accessor return `None`. +#[test] +fn absent_gas_fa_coin_reads_as_none_in_transaction_context() { + let (mut h, _admin) = setup(); + let sender = h.new_account_with_key_pair(); + + // `gas_fa_coin` left as None by `entry`; entry function aborts unless the accessor is None. + let payload = entry("assert_no_gas_payment_fungible_asset", vec![]); + let txn = h.create_transaction_payload(&sender, payload); + assert_success!(h.run_raw(txn).status().clone()); +} + +/// With `GAS_PAYABLE_FA` disabled, `gas_payment_fungible_asset()` aborts at its feature gate with +/// `invalid_state(EGAS_PAYABLE_FA_NOT_ENABLED)` (= 196611) rather than returning a value. A plain +/// (legacy) entry-function call suffices, since the abort happens before the accessor returns. +#[test] +fn accessor_aborts_when_feature_disabled() { + let mut h = MoveHarness::new_with_features(vec![], vec![FeatureFlag::GAS_PAYABLE_FA]); + let admin = h.new_account_at(AccountAddress::ONE); + let path = common::test_dir_path("transaction_context.data/pack"); + assert_success!(h.publish_package_cache_building(&admin, &path)); + + let sender = h.new_account_with_key_pair(); + let status = h.run_entry_function( + &sender, + str::parse("0x1::transaction_context_test::assert_no_gas_payment_fungible_asset").unwrap(), + vec![], + vec![], + ); + assert_abort!(status, 196611); +} diff --git a/aptos-move/e2e-move-tests/src/tests/mod.rs b/aptos-move/e2e-move-tests/src/tests/mod.rs index dbaf112b6b5..bdd6a468d17 100644 --- a/aptos-move/e2e-move-tests/src/tests/mod.rs +++ b/aptos-move/e2e-move-tests/src/tests/mod.rs @@ -27,6 +27,7 @@ mod function_values; mod fungible_asset; mod gas; mod gas_fa_coin_feature_gating; +mod gas_fa_coin_transaction_context; mod generate_upgrade_script; mod governance_updates; mod infinite_loop; diff --git a/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move b/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move index de1ef952205..18ec4402c95 100644 --- a/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move +++ b/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move @@ -86,6 +86,17 @@ module admin::transaction_context_test { store.chain_id = transaction_context::chain_id(); } + /// Aborts unless the current transaction elected to pay gas in the fungible asset whose metadata + /// object lives at `expected`. + public entry fun assert_gas_payment_fungible_asset(_s: &signer, expected: address) { + assert!(transaction_context::gas_payment_fungible_asset() == option::some(expected), 1000); + } + + /// Aborts unless the current transaction did not elect to pay gas in a fungible asset. + public entry fun assert_no_gas_payment_fungible_asset(_s: &signer) { + assert!(option::is_none(&transaction_context::gas_payment_fungible_asset()), 1001); + } + entry fun store_entry_function_payload_from_native_txn_context( _s: &signer, arg0: u64, diff --git a/aptos-move/framework/aptos-framework/doc/transaction_context.md b/aptos-move/framework/aptos-framework/doc/transaction_context.md index d4dbf2d6358..13a3b13b6d4 100644 --- a/aptos-move/framework/aptos-framework/doc/transaction_context.md +++ b/aptos-move/framework/aptos-framework/doc/transaction_context.md @@ -28,6 +28,8 @@ - [Function `gas_unit_price_internal`](#0x1_transaction_context_gas_unit_price_internal) - [Function `chain_id`](#0x1_transaction_context_chain_id) - [Function `chain_id_internal`](#0x1_transaction_context_chain_id_internal) +- [Function `gas_payment_fungible_asset`](#0x1_transaction_context_gas_payment_fungible_asset) +- [Function `gas_payment_fa_metadata_internal`](#0x1_transaction_context_gas_payment_fa_metadata_internal) - [Function `entry_function_payload`](#0x1_transaction_context_entry_function_payload) - [Function `entry_function_payload_internal`](#0x1_transaction_context_entry_function_payload_internal) - [Function `account_address`](#0x1_transaction_context_account_address) @@ -186,6 +188,16 @@ Represents the multisig payload. ## Constants + + +Paying gas in a fungible asset (the GAS_PAYABLE_FA feature) is not enabled. + + +
const EGAS_PAYABLE_FA_NOT_ENABLED: u64 = 3;
+
+ + + The transaction context extension feature is not enabled. @@ -683,6 +695,56 @@ This function aborts if called outside of the transaction prologue, execution, o + + + + +## Function `gas_payment_fungible_asset` + +Returns the fungible asset metadata address that the current transaction elected to pay gas in, +or None if gas is paid in the default currency (APT). +This function aborts if called outside of the transaction prologue, execution, or epilogue phases. + + +
public fun gas_payment_fungible_asset(): option::Option<address>
+
+ + + +
+Implementation + + +
public fun gas_payment_fungible_asset(): Option<address> {
+    assert!(features::is_gas_payable_fa_enabled(), error::invalid_state(EGAS_PAYABLE_FA_NOT_ENABLED));
+    gas_payment_fa_metadata_internal()
+}
+
+ + + +
+ + + +## Function `gas_payment_fa_metadata_internal` + + + +
fun gas_payment_fa_metadata_internal(): option::Option<address>
+
+ + + +
+Implementation + + +
native fun gas_payment_fa_metadata_internal(): Option<address>;
+
+ + +
diff --git a/aptos-move/framework/aptos-framework/sources/transaction_context.move b/aptos-move/framework/aptos-framework/sources/transaction_context.move index 74ce7c1140d..c39e4464ffe 100644 --- a/aptos-move/framework/aptos-framework/sources/transaction_context.move +++ b/aptos-move/framework/aptos-framework/sources/transaction_context.move @@ -10,6 +10,9 @@ module aptos_framework::transaction_context { /// The transaction context extension feature is not enabled. const ETRANSACTION_CONTEXT_EXTENSION_NOT_ENABLED: u64 = 2; + /// Paying gas in a fungible asset (the `GAS_PAYABLE_FA` feature) is not enabled. + const EGAS_PAYABLE_FA_NOT_ENABLED: u64 = 3; + /// A wrapper denoting aptos unique identifer (AUID) /// for storing an address struct AUID has drop, store { @@ -124,6 +127,15 @@ module aptos_framework::transaction_context { } native fun chain_id_internal(): u8; + /// Returns the fungible asset metadata address that the current transaction elected to pay gas in, + /// or `None` if gas is paid in the default currency (APT). + /// This function aborts if called outside of the transaction prologue, execution, or epilogue phases. + public fun gas_payment_fungible_asset(): Option
{ + assert!(features::is_gas_payable_fa_enabled(), error::invalid_state(EGAS_PAYABLE_FA_NOT_ENABLED)); + gas_payment_fa_metadata_internal() + } + native fun gas_payment_fa_metadata_internal(): Option
; + /// Returns the entry function payload if the current transaction has such a payload. Otherwise, return `None`. /// This function aborts if called outside of the transaction prologue, execution, or epilogue phases. public fun entry_function_payload(): Option { @@ -276,4 +288,17 @@ module aptos_framework::transaction_context { // expected to fail with the error code of `invalid_state(E_TRANSACTION_CONTEXT_NOT_AVAILABLE)` let _multisig = multisig_payload(); } + + #[test(framework = @std)] + #[expected_failure(abort_code=196611, location = Self)] + fun test_gas_payment_fungible_asset_aborts_when_feature_disabled(framework: signer) { + // With `GAS_PAYABLE_FA` disabled, the accessor must abort at the feature gate with + // `invalid_state(EGAS_PAYABLE_FA_NOT_ENABLED)` before ever reaching the native. + features::change_feature_flags_for_testing( + &framework, + vector[], + vector[features::get_gas_payable_fa_feature()], + ); + let _fa = gas_payment_fungible_asset(); + } } diff --git a/aptos-move/framework/src/natives/transaction_context.rs b/aptos-move/framework/src/natives/transaction_context.rs index 044e948a0ec..7deb24f348b 100644 --- a/aptos-move/framework/src/natives/transaction_context.rs +++ b/aptos-move/framework/src/natives/transaction_context.rs @@ -368,6 +368,30 @@ fn native_multisig_payload_internal( } } +fn native_gas_payment_fa_metadata_internal( + context: &mut SafeNativeContext, + _ty_args: Vec, + _args: VecDeque, +) -> SafeNativeResult> { + context.charge(TRANSACTION_CONTEXT_GAS_PAYMENT_FA_METADATA_BASE)?; + + let user_transaction_context_opt = get_user_transaction_context_opt_from_context(context); + if let Some(transaction_context) = user_transaction_context_opt { + // `Option
` is `struct { vec: vector
}`: a singleton vector for `Some`, + // an empty one for `None`. Build the inner vector with `vector_address` so it carries the + // proper element type (the testing-only vector helpers do not, which fails type checks). + let inner = match transaction_context.gas_fa_coin() { + Some(metadata_address) => Value::vector_address(vec![metadata_address]), + None => Value::vector_address(vec![]), + }; + Ok(smallvec![Value::struct_(Struct::pack(vec![inner]))]) + } else { + Err(SafeNativeError::Abort { + abort_code: error::invalid_state(abort_codes::ETRANSACTION_CONTEXT_NOT_AVAILABLE), + }) + } +} + fn get_user_transaction_context_opt_from_context<'a>( context: &'a SafeNativeContext, ) -> &'a Option { @@ -405,6 +429,10 @@ pub fn make_all( "multisig_payload_internal", native_multisig_payload_internal, ), + ( + "gas_payment_fa_metadata_internal", + native_gas_payment_fa_metadata_internal, + ), ]; builder.make_named_natives(natives) diff --git a/types/src/transaction/user_transaction_context.rs b/types/src/transaction/user_transaction_context.rs index 4c9d3f71d3d..91bf3a6cdc9 100644 --- a/types/src/transaction/user_transaction_context.rs +++ b/types/src/transaction/user_transaction_context.rs @@ -13,6 +13,7 @@ pub struct UserTransactionContext { chain_id: u8, entry_function_payload: Option, multisig_payload: Option, + gas_fa_coin: Option, } impl UserTransactionContext { @@ -25,6 +26,7 @@ impl UserTransactionContext { chain_id: u8, entry_function_payload: Option, multisig_payload: Option, + gas_fa_coin: Option, ) -> Self { Self { sender, @@ -35,6 +37,7 @@ impl UserTransactionContext { chain_id, entry_function_payload, multisig_payload, + gas_fa_coin, } } @@ -69,6 +72,12 @@ impl UserTransactionContext { pub fn multisig_payload(&self) -> Option { self.multisig_payload.clone() } + + /// The fungible asset metadata address the transaction elected to pay gas in, if any. + /// `None` means gas is paid in the default currency (APT). + pub fn gas_fa_coin(&self) -> Option { + self.gas_fa_coin + } } #[derive(Debug, Clone)] From c5c60aa3fa1bf041fc3af8defeee76be6a4bd853 Mon Sep 17 00:00:00 2001 From: Sean Young Date: Tue, 11 Aug 2026 14:50:31 +0100 Subject: [PATCH 4/8] add gas price per coin --- .../e2e-move-tests/src/tests/fee_payer.rs | 53 ++ .../tests/gas_fa_coin_transaction_context.rs | 412 +++++++++- .../sources/transaction_context_test.move | 39 + .../aptos-framework/doc/governed_gas_pool.md | 729 +++++++++++++++++- .../doc/transaction_validation.md | 199 +++-- .../sources/governed_gas_pool.move | 445 ++++++++++- .../sources/transaction_validation.move | 178 +++-- 7 files changed, 1891 insertions(+), 164 deletions(-) diff --git a/aptos-move/e2e-move-tests/src/tests/fee_payer.rs b/aptos-move/e2e-move-tests/src/tests/fee_payer.rs index a774904fdd1..4d135e02d5b 100644 --- a/aptos-move/e2e-move-tests/src/tests/fee_payer.rs +++ b/aptos-move/e2e-move-tests/src/tests/fee_payer.rs @@ -66,6 +66,59 @@ fn test_existing_account_with_fee_payer() { assert!(bob_start > bob_after); } +/// A fee-payer (sponsored) transaction with account abstraction disabled routes gas collection +/// through the legacy `transaction_validation::epilogue_gas_payer_extended` Move function. +/// +/// Per the VM dispatch in `run_epilogue`, that function is selected exactly when neither +/// account-abstraction feature is enabled AND the transaction has a fee payer; with account +/// abstraction on (the default), the unified epilogue is used instead. The gas is charged to the +/// fee payer, not the sender. +#[test] +fn test_fee_payer_runs_epilogue_gas_payer_extended() { + let mut h = MoveHarness::new_with_features( + vec![ + FeatureFlag::GAS_PAYER_ENABLED, + FeatureFlag::SPONSORED_AUTOMATIC_ACCOUNT_V1_CREATION, + ], + // Disabling account abstraction forces the non-unified (legacy) epilogue path. + vec![ + FeatureFlag::DEFAULT_ACCOUNT_RESOURCE, + FeatureFlag::ACCOUNT_ABSTRACTION, + FeatureFlag::DERIVABLE_ACCOUNT_ABSTRACTION, + ], + ); + + let alice = h.new_account_at(AccountAddress::from_hex_literal("0xa11ce").unwrap()); + let bob = h.new_account_at(AccountAddress::from_hex_literal("0xb0b").unwrap()); + + let alice_start = h.read_aptos_balance(alice.address()); + let bob_start = h.read_aptos_balance(bob.address()); + + // A no-op self-transfer; bob sponsors the gas. + let payload = aptos_stdlib::aptos_coin_transfer(*alice.address(), 0); + let transaction = TransactionBuilder::new(alice.clone()) + .fee_payer(bob.clone()) + .payload(payload) + .sequence_number(h.sequence_number(alice.address())) + .max_gas_amount(1_000_000) + .gas_unit_price(1) + .sign_fee_payer(); + + let output = h.run_raw(transaction); + assert_success!(*output.status()); + + // The fee payer (bob) paid the gas via epilogue_gas_payer_extended; the sender (alice) did not. + let alice_after = h.read_aptos_balance(alice.address()); + let bob_after = h.read_aptos_balance(bob.address()); + assert_eq!(alice_start, alice_after); + assert!( + bob_start > bob_after, + "fee payer should have paid the gas: {} -> {}", + bob_start, + bob_after + ); +} + #[test] fn test_existing_account_with_fee_payer_aborts() { let mut h = MoveHarness::new_with_features( diff --git a/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs b/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs index ac9ce4552a8..de67611695e 100644 --- a/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs +++ b/aptos-move/e2e-move-tests/src/tests/gas_fa_coin_transaction_context.rs @@ -1,11 +1,13 @@ // Copyright © Aptos Foundation // SPDX-License-Identifier: Apache-2.0 -//! End-to-end coverage that a transaction's `gas_fa_coin` (from the versioned payload's -//! `TransactionExtraConfig::V2`) is surfaced to Move via -//! `transaction_context::gas_payment_fungible_asset()`. +//! End-to-end coverage for paying transaction gas in a selected fungible asset: +//! the `gas_fa_coin` from the versioned payload's `TransactionExtraConfig::V2` is surfaced to Move +//! via `transaction_context::gas_payment_fungible_asset()`, validated in the prologue, and routed +//! into that FA's governed gas pool by the epilogue. use crate::{assert_abort, assert_success, tests::common, MoveHarness}; +use aptos_language_e2e_tests::account::{Account, TransactionBuilder}; use aptos_types::{ account_address::AccountAddress, on_chain_config::FeatureFlag, @@ -16,8 +18,9 @@ use aptos_types::{ }; use move_core_types::{ident_str, language_storage::ModuleId}; -/// The transaction_context test pack publishes to `@admin` = 0x1. -fn setup() -> (MoveHarness, AccountAddress) { +/// The transaction_context test pack publishes to `@admin` = 0x1, which is also @aptos_framework, so +/// the returned account doubles as the governance signer. +fn setup() -> (MoveHarness, Account) { // `TRANSACTION_PAYLOAD_V2` so the versioned payload is accepted, and `GAS_PAYABLE_FA` so a // transaction carrying `gas_fa_coin` is not rejected by the VM gate. let mut h = MoveHarness::new_with_features( @@ -30,7 +33,7 @@ fn setup() -> (MoveHarness, AccountAddress) { let admin = h.new_account_at(AccountAddress::ONE); let path = common::test_dir_path("transaction_context.data/pack"); assert_success!(h.publish_package_cache_building(&admin, &path)); - (h, *admin.address()) + (h, admin) } fn entry(name: &'static str, args: Vec>) -> TransactionPayload { @@ -53,17 +56,13 @@ fn entry(name: &'static str, args: Vec>) -> TransactionPayload { }) } -/// A versioned transaction that sets `gas_fa_coin = Some(fa)` makes -/// `transaction_context::gas_payment_fungible_asset()` return `Some(fa)` during execution. -#[test] -fn gas_fa_coin_is_visible_in_transaction_context() { - let (mut h, _admin) = setup(); - let sender = h.new_account_with_key_pair(); - let fa = AccountAddress::from_hex_literal("0xfa").unwrap(); - - // Entry function aborts unless the accessor returns Some(fa). - let mut payload = entry("assert_gas_payment_fungible_asset", vec![bcs::to_bytes(&fa) - .unwrap()]); +/// Same as `entry`, but the versioned payload elects to pay gas in the fungible asset `fa`. +fn entry_paying_gas_in_fa( + name: &'static str, + args: Vec>, + fa: AccountAddress, +) -> TransactionPayload { + let mut payload = entry(name, args); if let TransactionPayload::Payload(TransactionPayloadInner::V1 { extra_config, .. }) = &mut payload { @@ -71,9 +70,260 @@ fn gas_fa_coin_is_visible_in_transaction_context() { *gas_fa_coin = Some(fa); } } + payload +} + +fn fa_pool_balance(h: &mut MoveHarness, metadata: AccountAddress) -> u64 { + let out = h.execute_view_function( + str::parse("0x1::governed_gas_pool::get_fa_balance").unwrap(), + vec![], + vec![bcs::to_bytes(&metadata).unwrap()], + ); + bcs::from_bytes::(&out.values.expect("view failed")[0]).unwrap() +} + +fn account_fa_balance(h: &mut MoveHarness, owner: AccountAddress, metadata: AccountAddress) -> u64 { + let out = h.execute_view_function( + str::parse("0x1::transaction_context_test::fa_balance").unwrap(), + vec![], + vec![ + bcs::to_bytes(&owner).unwrap(), + bcs::to_bytes(&metadata).unwrap(), + ], + ); + bcs::from_bytes::(&out.values.expect("view failed")[0]).unwrap() +} + +/// The metadata address of the FA created by `create_gas_fa` for `owner`. +fn gas_fa_metadata_address(h: &mut MoveHarness, owner: AccountAddress) -> AccountAddress { + let out = h.execute_view_function( + str::parse("0x1::transaction_context_test::gas_fa_metadata_address").unwrap(), + vec![], + vec![bcs::to_bytes(&owner).unwrap()], + ); + bcs::from_bytes::(&out.values.expect("view failed")[0]).unwrap() +} + +/// Creates a gas FA owned by `who`, minting `amount` to it, and returns its metadata address. +fn create_gas_fa(h: &mut MoveHarness, who: &Account, amount: u64) -> AccountAddress { + assert_success!(h.run_entry_function( + who, + str::parse("0x1::transaction_context_test::create_gas_fa").unwrap(), + vec![], + vec![bcs::to_bytes(&amount).unwrap()], + )); + gas_fa_metadata_address(h, *who.address()) +} + +/// Governance accepts `metadata` for gas payment at `gas_price` FA units per gas unit. +fn accept_gas_fa(h: &mut MoveHarness, admin: &Account, metadata: AccountAddress, gas_price: u64) { + assert_success!(h.run_entry_function( + admin, + str::parse("0x1::governed_gas_pool::add_accepted_gas_fungible_asset").unwrap(), + vec![], + vec![bcs::to_bytes(&metadata).unwrap(), bcs::to_bytes(&gas_price).unwrap()], + )); +} + +/// Full flow: a transaction electing to pay gas in an accepted fungible asset passes the prologue +/// (accepted + sufficient FA balance), executes with the accessor reporting that FA, and has its gas +/// fee collected into that FA's governed gas pool. +#[test] +fn gas_paid_in_accepted_fa_routes_to_its_pool() { + let (mut h, admin) = setup(); + let sender = h.new_account_with_key_pair(); + + // 1. Sender creates a fungible asset and mints itself a large balance (this txn pays gas in APT). + assert_success!(h.run_entry_function( + &sender, + str::parse("0x1::transaction_context_test::create_gas_fa").unwrap(), + vec![], + vec![bcs::to_bytes(&1_000_000_000_000_000u64).unwrap()], + )); + + // 2. Look up the created FA's metadata address. + let metadata = { + let out = h.execute_view_function( + str::parse("0x1::transaction_context_test::gas_fa_metadata_address").unwrap(), + vec![], + vec![bcs::to_bytes(sender.address()).unwrap()], + ); + bcs::from_bytes::(&out.values.expect("view failed")[0]).unwrap() + }; + + // 3. Governance accepts the FA for gas payment with a gas price of 2 FA units per gas unit + // (admin == 0x1 == @aptos_framework). + let fa_gas_price = 2u64; + assert_success!(h.run_entry_function( + &admin, + str::parse("0x1::governed_gas_pool::add_accepted_gas_fungible_asset").unwrap(), + vec![], + vec![bcs::to_bytes(&metadata).unwrap(), bcs::to_bytes(&fa_gas_price).unwrap()], + )); + let pool_before = fa_pool_balance(&mut h, metadata); + + // 4. Sender submits a versioned txn paying gas in the FA. The entry function asserts the accessor + // reports that FA (aborts otherwise), and the epilogue routes the gas fee into its pool. + let payload = entry_paying_gas_in_fa( + "assert_gas_payment_fungible_asset", + vec![bcs::to_bytes(&metadata).unwrap()], + metadata, + ); let txn = h.create_transaction_payload(&sender, payload); - assert_success!(h.run_raw(txn).status().clone()); + let output = h.run_raw(txn); + let gas_used = output.gas_used(); + assert_success!(output.status().clone()); + + // 5. The FA's governed gas pool grew by exactly gas_used * the FA's gas price. + let pool_after = fa_pool_balance(&mut h, metadata); + assert_eq!( + pool_after - pool_before, + gas_used * fa_gas_price, + "FA pool should grow by gas_used ({}) * price ({})", + gas_used, + fa_gas_price + ); + assert!( + pool_after > pool_before, + "expected the FA governed gas pool to grow, before={} after={}", + pool_before, + pool_after + ); +} + +/// A transaction electing to pay gas in a fungible asset that governance has NOT accepted is +/// rejected by the prologue (discarded), so it never executes. +#[test] +fn gas_paid_in_unaccepted_fa_is_rejected() { + let (mut h, _admin) = setup(); + let sender = h.new_account_with_key_pair(); + assert_success!(h.run_entry_function( + &sender, + str::parse("0x1::transaction_context_test::create_gas_fa").unwrap(), + vec![], + vec![bcs::to_bytes(&1_000_000_000_000_000u64).unwrap()], + )); + let metadata = { + let out = h.execute_view_function( + str::parse("0x1::transaction_context_test::gas_fa_metadata_address").unwrap(), + vec![], + vec![bcs::to_bytes(sender.address()).unwrap()], + ); + bcs::from_bytes::(&out.values.expect("view failed")[0]).unwrap() + }; + + // Not accepted by governance -> prologue discards the transaction. + let payload = entry_paying_gas_in_fa( + "assert_gas_payment_fungible_asset", + vec![bcs::to_bytes(&metadata).unwrap()], + metadata, + ); + let txn = h.create_transaction_payload(&sender, payload); + let status = h.run_raw(txn).status().clone(); + assert!( + matches!( + status, + aptos_types::transaction::TransactionStatus::Discard(_) + ), + "expected an unaccepted gas FA to be discarded, got: {:?}", + status + ); +} + +/// With account abstraction DISABLED, a versioned fee-payer transaction electing to pay gas in an FA +/// takes the legacy `epilogue_gas_payer_extended` path (not the unified one). That path is now FA- +/// aware: the fee payer is charged in the FA, the fee lands in the FA's governed gas pool, and the +/// fee payer's APT is untouched. +#[test] +fn fa_fee_payer_gas_is_charged_in_fa_via_legacy_epilogue() { + // AA off -> epilogue dispatch uses the legacy epilogue_gas_payer_extended, not unified_epilogue_v2. + let mut h = MoveHarness::new_with_features( + vec![ + FeatureFlag::TRANSACTION_PAYLOAD_V2, + FeatureFlag::GAS_PAYABLE_FA, + FeatureFlag::GAS_PAYER_ENABLED, + ], + vec![ + FeatureFlag::ACCOUNT_ABSTRACTION, + FeatureFlag::DERIVABLE_ACCOUNT_ABSTRACTION, + ], + ); + let admin = h.new_account_at(AccountAddress::ONE); + let path = common::test_dir_path("transaction_context.data/pack"); + assert_success!(h.publish_package_cache_building(&admin, &path)); + + let alice = h.new_account_with_key_pair(); // sender + let bob = h.new_account_with_key_pair(); // fee payer, will hold + pay in the FA + + // bob creates an FA and mints itself a large balance (APT-paid txn). + assert_success!(h.run_entry_function( + &bob, + str::parse("0x1::transaction_context_test::create_gas_fa").unwrap(), + vec![], + vec![bcs::to_bytes(&1_000_000_000_000_000u64).unwrap()], + )); + let metadata = { + let out = h.execute_view_function( + str::parse("0x1::transaction_context_test::gas_fa_metadata_address").unwrap(), + vec![], + vec![bcs::to_bytes(bob.address()).unwrap()], + ); + bcs::from_bytes::(&out.values.expect("view failed")[0]).unwrap() + }; + let fa_gas_price = 2u64; + assert_success!(h.run_entry_function( + &admin, + str::parse("0x1::governed_gas_pool::add_accepted_gas_fungible_asset").unwrap(), + vec![], + vec![bcs::to_bytes(&metadata).unwrap(), bcs::to_bytes(&fa_gas_price).unwrap()], + )); + + let bob_fa_before = account_fa_balance(&mut h, *bob.address(), metadata); + let bob_apt_before = h.read_aptos_balance(bob.address()); + let pool_before = fa_pool_balance(&mut h, metadata); + + // alice sends a versioned txn; bob sponsors gas and elects to pay it in the FA. + let payload = entry_paying_gas_in_fa( + "assert_gas_payment_fungible_asset", + vec![bcs::to_bytes(&metadata).unwrap()], + metadata, + ); + let txn = TransactionBuilder::new(alice.clone()) + .fee_payer(bob.clone()) + .payload(payload) + .sequence_number(h.sequence_number(alice.address())) + .max_gas_amount(1_000_000) + .gas_unit_price(1) + .sign_fee_payer(); + let output = h.run_raw(txn); + let gas_used = output.gas_used(); + let status = output.status().clone(); + + let bob_fa_after = account_fa_balance(&mut h, *bob.address(), metadata); + let bob_apt_after = h.read_aptos_balance(bob.address()); + let pool_after = fa_pool_balance(&mut h, metadata); + + assert_success!(status); + // The fee payer was charged in the FA at gas_used * the FA's gas price, and that exact amount + // landed in the FA's governed gas pool. + assert_eq!( + bob_fa_before - bob_fa_after, + gas_used * fa_gas_price, + "fee payer's FA charge should be gas_used ({}) * price ({})", + gas_used, + fa_gas_price + ); + assert_eq!( + bob_fa_before - bob_fa_after, + pool_after - pool_before, + "the FA charged to the fee payer should equal the FA pool increase" + ); + // APT was not used to pay this transaction's gas. + assert_eq!( + bob_apt_before, bob_apt_after, + "fee payer's APT should be untouched when paying gas in an FA" + ); } /// A versioned transaction with no `gas_fa_coin` makes the accessor return `None`. @@ -107,3 +357,129 @@ fn accessor_aborts_when_feature_disabled() { ); assert_abort!(status, 196611); } + +/// A transaction electing to pay gas in an accepted FA the payer cannot afford (max fee exceeds the +/// payer's FA balance) is discarded by the prologue. +#[test] +fn gas_fa_insufficient_balance_is_rejected() { + let (mut h, admin) = setup(); + let sender = h.new_account_with_key_pair(); + // Mint the sender only a tiny FA balance. + let metadata = create_gas_fa(&mut h, &sender, 100); + accept_gas_fa(&mut h, &admin, metadata, 1); + + // max_gas 1000 at price 1 => max FA fee 1000 > balance 100 => prologue discard. + let payload = entry_paying_gas_in_fa( + "assert_gas_payment_fungible_asset", + vec![bcs::to_bytes(&metadata).unwrap()], + metadata, + ); + let txn = TransactionBuilder::new(sender.clone()) + .payload(payload) + .sequence_number(h.sequence_number(sender.address())) + .max_gas_amount(1000) + .gas_unit_price(1) + .sign(); + let status = h.run_raw(txn).status().clone(); + assert!( + matches!( + status, + aptos_types::transaction::TransactionStatus::Discard(_) + ), + "expected an underfunded FA gas payer to be discarded, got: {:?}", + status + ); +} + +/// With account abstraction DISABLED and no fee payer, a versioned transaction paying gas in an FA +/// takes the legacy `epilogue_extended` -> `epilogue_gas_payer_extended` path, charging the sender +/// (its own gas payer) in the FA at gas_used * price. +#[test] +fn fa_regular_sender_charged_via_legacy_epilogue_extended() { + let mut h = MoveHarness::new_with_features( + vec![ + FeatureFlag::TRANSACTION_PAYLOAD_V2, + FeatureFlag::GAS_PAYABLE_FA, + ], + vec![ + FeatureFlag::ACCOUNT_ABSTRACTION, + FeatureFlag::DERIVABLE_ACCOUNT_ABSTRACTION, + ], + ); + let admin = h.new_account_at(AccountAddress::ONE); + let path = common::test_dir_path("transaction_context.data/pack"); + assert_success!(h.publish_package_cache_building(&admin, &path)); + + let sender = h.new_account_with_key_pair(); + let metadata = create_gas_fa(&mut h, &sender, 1_000_000_000_000_000); + let fa_gas_price = 2u64; + accept_gas_fa(&mut h, &admin, metadata, fa_gas_price); + + let fa_before = account_fa_balance(&mut h, *sender.address(), metadata); + let apt_before = h.read_aptos_balance(sender.address()); + let pool_before = fa_pool_balance(&mut h, metadata); + + let payload = entry_paying_gas_in_fa( + "assert_gas_payment_fungible_asset", + vec![bcs::to_bytes(&metadata).unwrap()], + metadata, + ); + let txn = h.create_transaction_payload(&sender, payload); + let output = h.run_raw(txn); + let gas_used = output.gas_used(); + assert_success!(output.status().clone()); + + let fa_after = account_fa_balance(&mut h, *sender.address(), metadata); + let apt_after = h.read_aptos_balance(sender.address()); + let pool_after = fa_pool_balance(&mut h, metadata); + + assert_eq!( + fa_before - fa_after, + gas_used * fa_gas_price, + "regular sender's FA charge should be gas_used ({}) * price ({})", + gas_used, + fa_gas_price + ); + assert_eq!(fa_before - fa_after, pool_after - pool_before); + assert_eq!( + apt_before, apt_after, + "sender's APT should be untouched when paying gas in an FA" + ); +} + +/// A transaction that aborts during execution but is kept still has its FA gas charged by the +/// epilogue (gas is collected even on failure). +#[test] +fn aborted_transaction_still_charges_fa_gas() { + let (mut h, admin) = setup(); + let sender = h.new_account_with_key_pair(); + let metadata = create_gas_fa(&mut h, &sender, 1_000_000_000_000_000); + let fa_gas_price = 2u64; + accept_gas_fa(&mut h, &admin, metadata, fa_gas_price); + + let fa_before = account_fa_balance(&mut h, *sender.address(), metadata); + let pool_before = fa_pool_balance(&mut h, metadata); + + // gas_fa_coin is set correctly, but the entry function asserts the accessor equals a DIFFERENT + // address, so it aborts (code 1000) — while gas is still charged in the FA. + let wrong = AccountAddress::from_hex_literal("0xdead").unwrap(); + let payload = entry_paying_gas_in_fa( + "assert_gas_payment_fungible_asset", + vec![bcs::to_bytes(&wrong).unwrap()], + metadata, + ); + let txn = h.create_transaction_payload(&sender, payload); + let output = h.run_raw(txn); + let gas_used = output.gas_used(); + let status = output.status().clone(); + + assert_abort!(status, 1000); + let fa_after = account_fa_balance(&mut h, *sender.address(), metadata); + let pool_after = fa_pool_balance(&mut h, metadata); + assert_eq!( + fa_before - fa_after, + gas_used * fa_gas_price, + "FA gas should be charged even though the transaction aborted" + ); + assert_eq!(pool_after - pool_before, gas_used * fa_gas_price); +} diff --git a/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move b/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move index 18ec4402c95..0c3c6aba3e8 100644 --- a/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move +++ b/aptos-move/e2e-move-tests/src/tests/transaction_context.data/pack/sources/transaction_context_test.move @@ -7,6 +7,14 @@ module admin::transaction_context_test { use aptos_std::type_info; use aptos_framework::transaction_context; use aptos_framework::multisig_account; + use aptos_framework::fungible_asset::{Self, Metadata}; + use aptos_framework::primary_fungible_store; + use aptos_framework::object::{Self, Object}; + + /// Records a fungible asset created by `create_gas_fa` so its metadata can be looked up. + struct GasFa has key { + metadata: Object, + } /// Since tests in e2e-move-tests/ can only call entry functions which don't have return values, we must store /// the results we are interested in inside this (rather-artificial) resource, which we can read back in our @@ -97,6 +105,37 @@ module admin::transaction_context_test { assert!(option::is_none(&transaction_context::gas_payment_fungible_asset()), 1001); } + /// Creates a primary-store-enabled fungible asset owned by `s`, mints `amount` of it to `s`, and + /// records its metadata so a later transaction can elect to pay gas in it. + public entry fun create_gas_fa(s: &signer, amount: u64) { + let constructor_ref = object::create_named_object(s, b"GASFA"); + primary_fungible_store::create_primary_store_enabled_fungible_asset( + &constructor_ref, + option::none(), + string::utf8(b"GasFA"), + string::utf8(b"GFA"), + 8, + string::utf8(b""), + string::utf8(b""), + ); + let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref); + let metadata = object::object_from_constructor_ref(&constructor_ref); + primary_fungible_store::deposit(signer::address_of(s), fungible_asset::mint(&mint_ref, amount)); + move_to(s, GasFa { metadata }); + } + + #[view] + /// The metadata object address of the fungible asset created by `create_gas_fa` for `owner`. + public fun gas_fa_metadata_address(owner: address): address acquires GasFa { + object::object_address(&borrow_global(owner).metadata) + } + + #[view] + /// `owner`'s primary-store balance of the fungible asset identified by `metadata`. + public fun fa_balance(owner: address, metadata: address): u64 { + primary_fungible_store::balance(owner, object::address_to_object(metadata)) + } + entry fun store_entry_function_payload_from_native_txn_context( _s: &signer, arg0: u64, diff --git a/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md b/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md index 8514231f1c8..7425b6e626c 100644 --- a/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md +++ b/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md @@ -8,8 +8,13 @@ - [Struct `WithdrawStakingRewardEvent`](#0x1_governed_gas_pool_WithdrawStakingRewardEvent) - [Resource `GovernedGasPool`](#0x1_governed_gas_pool_GovernedGasPool) - [Resource `GovernedGasPoolExtension`](#0x1_governed_gas_pool_GovernedGasPoolExtension) +- [Struct `AcceptedGasFa`](#0x1_governed_gas_pool_AcceptedGasFa) +- [Resource `AcceptedGasFungibleAssets`](#0x1_governed_gas_pool_AcceptedGasFungibleAssets) +- [Struct `AcceptedGasFungibleAssetUpdate`](#0x1_governed_gas_pool_AcceptedGasFungibleAssetUpdate) +- [Struct `GasFungibleAssetPriceUpdate`](#0x1_governed_gas_pool_GasFungibleAssetPriceUpdate) +- [Struct `FungibleAssetGasFeeDeposit`](#0x1_governed_gas_pool_FungibleAssetGasFeeDeposit) - [Constants](#@Constants_0) -- [Function `primary_fungible_store_address`](#0x1_governed_gas_pool_primary_fungible_store_address) +- [Function `primary_fungible_store_address_for`](#0x1_governed_gas_pool_primary_fungible_store_address_for) - [Function `create_resource_account_seed`](#0x1_governed_gas_pool_create_resource_account_seed) - [Function `initialize`](#0x1_governed_gas_pool_initialize) - [Function `initialize_governed_gas_pool_extension`](#0x1_governed_gas_pool_initialize_governed_gas_pool_extension) @@ -21,8 +26,21 @@ - [Function `deposit`](#0x1_governed_gas_pool_deposit) - [Function `deposit_from`](#0x1_governed_gas_pool_deposit_from) - [Function `deposit_from_fungible_store`](#0x1_governed_gas_pool_deposit_from_fungible_store) +- [Function `deposit_from_fungible_store_for`](#0x1_governed_gas_pool_deposit_from_fungible_store_for) - [Function `deposit_gas_fee`](#0x1_governed_gas_pool_deposit_gas_fee) - [Function `deposit_gas_fee_v2`](#0x1_governed_gas_pool_deposit_gas_fee_v2) +- [Function `ensure_accepted_registry`](#0x1_governed_gas_pool_ensure_accepted_registry) +- [Function `find_accepted_index`](#0x1_governed_gas_pool_find_accepted_index) +- [Function `add_accepted_gas_fungible_asset`](#0x1_governed_gas_pool_add_accepted_gas_fungible_asset) +- [Function `set_gas_fungible_asset_price`](#0x1_governed_gas_pool_set_gas_fungible_asset_price) +- [Function `remove_accepted_gas_fungible_asset`](#0x1_governed_gas_pool_remove_accepted_gas_fungible_asset) +- [Function `is_accepted_gas_fungible_asset`](#0x1_governed_gas_pool_is_accepted_gas_fungible_asset) +- [Function `accepted_gas_fungible_assets`](#0x1_governed_gas_pool_accepted_gas_fungible_assets) +- [Function `get_gas_fungible_asset_price`](#0x1_governed_gas_pool_get_gas_fungible_asset_price) +- [Function `gas_fee_in_fa`](#0x1_governed_gas_pool_gas_fee_in_fa) +- [Function `get_fa_balance`](#0x1_governed_gas_pool_get_fa_balance) +- [Function `deposit_gas_fee_fa`](#0x1_governed_gas_pool_deposit_gas_fee_fa) +- [Function `fund_fa`](#0x1_governed_gas_pool_fund_fa) - [Function `deposit_treasury`](#0x1_governed_gas_pool_deposit_treasury) - [Function `get_balance`](#0x1_governed_gas_pool_get_balance) - [Function `withdraw_staking_reward`](#0x1_governed_gas_pool_withdraw_staking_reward) @@ -43,6 +61,7 @@ use 0x1::features; use 0x1::fungible_asset; use 0x1::object; +use 0x1::primary_fungible_store; use 0x1::signer; use 0x1::system_addresses; use 0x1::vector; @@ -139,6 +158,184 @@ Contains added variable needed for the GovernedGasPool staking reward update. + + + + +## Struct `AcceptedGasFa` + +A fungible asset accepted for gas payment, together with its gas price: the number of FA base +units charged per unit of gas consumed. The FA gas fee for a transaction is +gas_units_used * gas_price. + + +
struct AcceptedGasFa has drop, store
+
+ + + +
+Fields + + +
+
+metadata: address +
+
+ +
+
+gas_price: u64 +
+
+ +
+
+ + +
+ + + +## Resource `AcceptedGasFungibleAssets` + +Registry of fungible assets accepted for gas payment. Each accepted FA is held in the +governed gas pool account's own primary store for that metadata object (a separate per-FA +pool that shares the single pool resource account), and stores its gas price alongside. + + +
struct AcceptedGasFungibleAssets has key
+
+ + + +
+Fields + + +
+
+entries: vector<governed_gas_pool::AcceptedGasFa> +
+
+ +
+
+ + +
+ + + +## Struct `AcceptedGasFungibleAssetUpdate` + +Emitted when a fungible asset is added to (accepted = true) or removed from +(accepted = false) the set accepted for gas payment. + + +
#[event]
+struct AcceptedGasFungibleAssetUpdate has drop, store
+
+ + + +
+Fields + + +
+
+metadata: address +
+
+ +
+
+accepted: bool +
+
+ +
+
+ + +
+ + + +## Struct `GasFungibleAssetPriceUpdate` + +Emitted when a fungible asset's gas price is set or updated. + + +
#[event]
+struct GasFungibleAssetPriceUpdate has drop, store
+
+ + + +
+Fields + + +
+
+metadata: address +
+
+ +
+
+gas_price: u64 +
+
+ +
+
+ + +
+ + + +## Struct `FungibleAssetGasFeeDeposit` + +Emitted when gas fees are deposited into a per-FA governed gas pool. + + +
#[event]
+struct FungibleAssetGasFeeDeposit has drop, store
+
+ + + +
+Fields + + +
+
+gas_payer: address +
+
+ +
+
+metadata: address +
+
+ +
+
+amount: u64 +
+
+ Fungible asset base units deposited. +
+
+ +
@@ -146,6 +343,46 @@ Contains added variable needed for the GovernedGasPool staking reward update. ## Constants + + +Maximum u64 value, used to guard fungible asset gas fee computation against overflow. + + +
const MAX_U64: u128 = 18446744073709551615;
+
+ + + + + +The fungible asset is not accepted for gas payment. + + +
const EFA_NOT_ACCEPTED: u64 = 5;
+
+ + + + + +The computed fungible asset gas fee does not fit in a u64. + + +
const EGAS_FA_FEE_OVERFLOW: u64 = 7;
+
+ + + + + +A gas fungible asset's gas price must be non-zero. + + +
const EINVALID_GAS_FA_PRICE: u64 = 6;
+
+ + + No longer supported. @@ -165,14 +402,14 @@ No longer supported. - + -## Function `primary_fungible_store_address` +## Function `primary_fungible_store_address_for` -Address of APT Primary Fungible Store +Address of account's primary fungible store for the FA identified by metadata. -
fun primary_fungible_store_address(account: address): address
+
fun primary_fungible_store_address_for(account: address, metadata: address): address
 
@@ -181,8 +418,8 @@ Address of APT Primary Fungible Store Implementation -
inline fun primary_fungible_store_address(account: address): address {
-    object::create_user_derived_object_address(account, @aptos_fungible_asset)
+
inline fun primary_fungible_store_address_for(account: address, metadata: address): address {
+    object::create_user_derived_object_address(account, metadata)
 }
 
@@ -516,10 +753,9 @@ Deposits some coin from an account to the governed gas pool. ## Function `deposit_from_fungible_store` -Deposits some FA from the fungible store. -@param aptos_framework The signer of the aptos_framework module. -@param account The account from which the FA is to be deposited. -@param amount The amount of FA to be deposited. +Deposits APT from the fungible store into the governed gas pool. +@param account The account from which the APT FA is to be deposited. +@param amount The amount of APT FA to be deposited.
fun deposit_from_fungible_store(account: address, amount: u64)
@@ -532,19 +768,41 @@ Deposits some FA from the fungible store.
 
 
 
fun deposit_from_fungible_store(account: address, amount: u64) acquires GovernedGasPool {
-    if (amount > 0){
-        // compute the governed gas pool store address
-        let governed_gas_pool_address = governed_gas_pool_address();
-        let governed_gas_pool_store_address = primary_fungible_store_address(governed_gas_pool_address);
+    deposit_from_fungible_store_for(account, @aptos_fungible_asset, amount);
+}
+
+ + + + + + + +## Function `deposit_from_fungible_store_for` + +Deposits amount of the fungible asset identified by metadata from account's primary +store into the governed gas pool account's own primary store for that FA (its per-FA pool). +Uses the unchecked (VM-privileged) withdraw/deposit path, as gas collection is not authorized +by the payer's signer. + + +
fun deposit_from_fungible_store_for(account: address, metadata: address, amount: u64)
+
+ + + +
+Implementation + - // compute the account store address - let account_store_address = primary_fungible_store_address(account); +
fun deposit_from_fungible_store_for(account: address, metadata: address, amount: u64) acquires GovernedGasPool {
+    if (amount > 0) {
+        let pool_store_address =
+            primary_fungible_store_address_for(governed_gas_pool_address(), metadata);
+        let account_store_address = primary_fungible_store_address_for(account, metadata);
         fungible_asset::unchecked_deposit(
-            governed_gas_pool_store_address,
-            fungible_asset::unchecked_withdraw(
-                account_store_address,
-                amount
-            )
+            pool_store_address,
+            fungible_asset::unchecked_withdraw(account_store_address, amount)
         );
     }
 }
@@ -610,6 +868,433 @@ Deposits gas fees into the governed gas pool.
 
 
 
+
+ + + +## Function `ensure_accepted_registry` + +Creates the accepted-FA registry under @aptos_framework if it does not yet exist. This lets +the feature roll out onto an already-initialized governed gas pool without re-running +initialize. + + +
fun ensure_accepted_registry(aptos_framework: &signer)
+
+ + + +
+Implementation + + +
fun ensure_accepted_registry(aptos_framework: &signer) {
+    if (!exists<AcceptedGasFungibleAssets>(signer::address_of(aptos_framework))) {
+        move_to(aptos_framework, AcceptedGasFungibleAssets { entries: vector::empty<AcceptedGasFa>() });
+    };
+}
+
+ + + +
+ + + +## Function `find_accepted_index` + +Index of the accepted-FA entry for metadata, if present. + + +
fun find_accepted_index(entries: &vector<governed_gas_pool::AcceptedGasFa>, metadata: address): (bool, u64)
+
+ + + +
+Implementation + + +
fun find_accepted_index(entries: &vector<AcceptedGasFa>, metadata: address): (bool, u64) {
+    let len = vector::length(entries);
+    let i = 0;
+    while (i < len) {
+        if (vector::borrow(entries, i).metadata == metadata) {
+            return (true, i)
+        };
+        i = i + 1;
+    };
+    (false, 0)
+}
+
+ + + +
+ + + +## Function `add_accepted_gas_fungible_asset` + +Adds a fungible asset to the set accepted for gas payment with its gas price (FA base units +charged per unit of gas consumed), and ensures the pool has a primary store to hold it. +Governance-gated (requires the @aptos_framework signer). Re-adding an existing FA updates its +gas price. +@param aptos_framework The signer of the aptos_framework module. +@param metadata The metadata object of the fungible asset to accept. +@param gas_price FA base units charged per unit of gas consumed; must be non-zero. + + +
public entry fun add_accepted_gas_fungible_asset(aptos_framework: &signer, metadata: object::Object<fungible_asset::Metadata>, gas_price: u64)
+
+ + + +
+Implementation + + +
public entry fun add_accepted_gas_fungible_asset(
+    aptos_framework: &signer,
+    metadata: Object<Metadata>,
+    gas_price: u64,
+) acquires GovernedGasPool, AcceptedGasFungibleAssets {
+    system_addresses::assert_aptos_framework(aptos_framework);
+    assert!(gas_price > 0, error::invalid_argument(EINVALID_GAS_FA_PRICE));
+    ensure_accepted_registry(aptos_framework);
+    let metadata_address = object::object_address(&metadata);
+    let accepted = borrow_global_mut<AcceptedGasFungibleAssets>(@aptos_framework);
+    let (found, i) = find_accepted_index(&accepted.entries, metadata_address);
+    if (found) {
+        vector::borrow_mut(&mut accepted.entries, i).gas_price = gas_price;
+    } else {
+        vector::push_back(&mut accepted.entries, AcceptedGasFa { metadata: metadata_address, gas_price });
+        // Ensure the pool has a primary store for this FA so unchecked deposits succeed.
+        primary_fungible_store::ensure_primary_store_exists(governed_gas_pool_address(), metadata);
+        event::emit(AcceptedGasFungibleAssetUpdate { metadata: metadata_address, accepted: true });
+    };
+    event::emit(GasFungibleAssetPriceUpdate { metadata: metadata_address, gas_price });
+}
+
+ + + +
+ + + +## Function `set_gas_fungible_asset_price` + +Sets (updates) the gas price of an already-accepted fungible asset. Governance-gated. +@param aptos_framework The signer of the aptos_framework module. +@param metadata The metadata object of the fungible asset. +@param gas_price FA base units charged per unit of gas consumed; must be non-zero. + + +
public entry fun set_gas_fungible_asset_price(aptos_framework: &signer, metadata: object::Object<fungible_asset::Metadata>, gas_price: u64)
+
+ + + +
+Implementation + + +
public entry fun set_gas_fungible_asset_price(
+    aptos_framework: &signer,
+    metadata: Object<Metadata>,
+    gas_price: u64,
+) acquires AcceptedGasFungibleAssets {
+    system_addresses::assert_aptos_framework(aptos_framework);
+    assert!(gas_price > 0, error::invalid_argument(EINVALID_GAS_FA_PRICE));
+    assert!(exists<AcceptedGasFungibleAssets>(@aptos_framework), error::invalid_argument(EFA_NOT_ACCEPTED));
+    let metadata_address = object::object_address(&metadata);
+    let accepted = borrow_global_mut<AcceptedGasFungibleAssets>(@aptos_framework);
+    let (found, i) = find_accepted_index(&accepted.entries, metadata_address);
+    assert!(found, error::invalid_argument(EFA_NOT_ACCEPTED));
+    vector::borrow_mut(&mut accepted.entries, i).gas_price = gas_price;
+    event::emit(GasFungibleAssetPriceUpdate { metadata: metadata_address, gas_price });
+}
+
+ + + +
+ + + +## Function `remove_accepted_gas_fungible_asset` + +Removes a fungible asset from the set accepted for gas payment. Any balance already held in +its pool remains and can still be withdrawn via fund_fa. Governance-gated. +@param aptos_framework The signer of the aptos_framework module. +@param metadata The metadata object of the fungible asset to stop accepting. + + +
public entry fun remove_accepted_gas_fungible_asset(aptos_framework: &signer, metadata: object::Object<fungible_asset::Metadata>)
+
+ + + +
+Implementation + + +
public entry fun remove_accepted_gas_fungible_asset(
+    aptos_framework: &signer,
+    metadata: Object<Metadata>,
+) acquires AcceptedGasFungibleAssets {
+    system_addresses::assert_aptos_framework(aptos_framework);
+    if (!exists<AcceptedGasFungibleAssets>(@aptos_framework)) {
+        return
+    };
+    let metadata_address = object::object_address(&metadata);
+    let accepted = borrow_global_mut<AcceptedGasFungibleAssets>(@aptos_framework);
+    let (found, i) = find_accepted_index(&accepted.entries, metadata_address);
+    if (found) {
+        vector::remove(&mut accepted.entries, i);
+        event::emit(AcceptedGasFungibleAssetUpdate { metadata: metadata_address, accepted: false });
+    };
+}
+
+ + + +
+ + + +## Function `is_accepted_gas_fungible_asset` + +Whether the fungible asset identified by metadata is accepted for gas payment. + + +
#[view]
+public fun is_accepted_gas_fungible_asset(metadata: address): bool
+
+ + + +
+Implementation + + +
public fun is_accepted_gas_fungible_asset(metadata: address): bool acquires AcceptedGasFungibleAssets {
+    if (!exists<AcceptedGasFungibleAssets>(@aptos_framework)) {
+        return false
+    };
+    let (found, _) = find_accepted_index(&borrow_global<AcceptedGasFungibleAssets>(@aptos_framework).entries, metadata);
+    found
+}
+
+ + + +
+ + + +## Function `accepted_gas_fungible_assets` + +The full set of fungible asset metadata addresses accepted for gas payment. + + +
#[view]
+public fun accepted_gas_fungible_assets(): vector<address>
+
+ + + +
+Implementation + + +
public fun accepted_gas_fungible_assets(): vector<address> acquires AcceptedGasFungibleAssets {
+    let result = vector::empty<address>();
+    if (exists<AcceptedGasFungibleAssets>(@aptos_framework)) {
+        let entries = &borrow_global<AcceptedGasFungibleAssets>(@aptos_framework).entries;
+        let len = vector::length(entries);
+        let i = 0;
+        while (i < len) {
+            vector::push_back(&mut result, vector::borrow(entries, i).metadata);
+            i = i + 1;
+        };
+    };
+    result
+}
+
+ + + +
+ + + +## Function `get_gas_fungible_asset_price` + +The gas price (FA base units per unit of gas) of an accepted fungible asset. Aborts if the +fungible asset is not accepted for gas payment. + + +
#[view]
+public fun get_gas_fungible_asset_price(metadata: address): u64
+
+ + + +
+Implementation + + +
public fun get_gas_fungible_asset_price(metadata: address): u64 acquires AcceptedGasFungibleAssets {
+    assert!(exists<AcceptedGasFungibleAssets>(@aptos_framework), error::invalid_argument(EFA_NOT_ACCEPTED));
+    let accepted = borrow_global<AcceptedGasFungibleAssets>(@aptos_framework);
+    let (found, i) = find_accepted_index(&accepted.entries, metadata);
+    assert!(found, error::invalid_argument(EFA_NOT_ACCEPTED));
+    vector::borrow(&accepted.entries, i).gas_price
+}
+
+ + + +
+ + + +## Function `gas_fee_in_fa` + +The fungible asset gas fee for gas_units of gas consumed under metadata's gas price, i.e. +gas_units * gas_price. Aborts if the FA is not accepted or the fee overflows u64. + + +
#[view]
+public fun gas_fee_in_fa(metadata: address, gas_units: u64): u64
+
+ + + +
+Implementation + + +
public fun gas_fee_in_fa(metadata: address, gas_units: u64): u64 acquires AcceptedGasFungibleAssets {
+    let price = get_gas_fungible_asset_price(metadata);
+    let fee = (gas_units as u128) * (price as u128);
+    assert!(fee <= MAX_U64, error::out_of_range(EGAS_FA_FEE_OVERFLOW));
+    (fee as u64)
+}
+
+ + + +
+ + + +## Function `get_fa_balance` + +The governed gas pool's balance of the fungible asset identified by metadata. + + +
#[view]
+public fun get_fa_balance(metadata: address): u64
+
+ + + +
+Implementation + + +
public fun get_fa_balance(metadata: address): u64 acquires GovernedGasPool {
+    primary_fungible_store::balance(
+        governed_gas_pool_address(),
+        object::address_to_object<Metadata>(metadata)
+    )
+}
+
+ + + +
+ + + +## Function `deposit_gas_fee_fa` + +Deposits fa_amount base units of a selected gas fungible asset (already converted from gas +via that FA's gas price) into its governed gas pool. Aborts if the FA is not accepted. +@param gas_payer The address that paid the gas fees. +@param metadata The metadata object address of the fungible asset. +@param fa_amount The fungible asset base units to deposit. + + +
public(friend) fun deposit_gas_fee_fa(gas_payer: address, metadata: address, fa_amount: u64)
+
+ + + +
+Implementation + + +
public(friend) fun deposit_gas_fee_fa(
+    gas_payer: address,
+    metadata: address,
+    fa_amount: u64,
+) acquires GovernedGasPool, AcceptedGasFungibleAssets {
+    assert!(is_accepted_gas_fungible_asset(metadata), error::invalid_argument(EFA_NOT_ACCEPTED));
+    deposit_from_fungible_store_for(gas_payer, metadata, fa_amount);
+    if (fa_amount > 0) {
+        event::emit(FungibleAssetGasFeeDeposit { gas_payer, metadata, amount: fa_amount });
+    };
+}
+
+ + + +
+ + + +## Function `fund_fa` + +Withdraws amount of the fungible asset identified by metadata from its governed gas pool +and deposits it to account. Governance-gated; the mirror of fund for fungible assets. +@param aptos_framework The signer of the aptos_framework module. +@param account The recipient account. +@param metadata The metadata object address of the fungible asset. +@param amount The amount to withdraw from the pool. + + +
public fun fund_fa(aptos_framework: &signer, account: address, metadata: address, amount: u64)
+
+ + + +
+Implementation + + +
public fun fund_fa(
+    aptos_framework: &signer,
+    account: address,
+    metadata: address,
+    amount: u64,
+) acquires GovernedGasPool {
+    system_addresses::assert_aptos_framework(aptos_framework);
+    let pool_signer = governed_gas_signer();
+    let fa = primary_fungible_store::withdraw(
+        &pool_signer,
+        object::address_to_object<Metadata>(metadata),
+        amount
+    );
+    primary_fungible_store::deposit(account, fa);
+}
+
+ + +
diff --git a/aptos-move/framework/aptos-framework/doc/transaction_validation.md b/aptos-move/framework/aptos-framework/doc/transaction_validation.md index 5fed0cd9f0f..5d928eaa0e2 100644 --- a/aptos-move/framework/aptos-framework/doc/transaction_validation.md +++ b/aptos-move/framework/aptos-framework/doc/transaction_validation.md @@ -13,6 +13,7 @@ - [Function `revoke_gas_permission`](#0x1_transaction_validation_revoke_gas_permission) - [Function `initialize`](#0x1_transaction_validation_initialize) - [Function `allow_missing_txn_authentication_key`](#0x1_transaction_validation_allow_missing_txn_authentication_key) +- [Function `gas_fa_metadata`](#0x1_transaction_validation_gas_fa_metadata) - [Function `prologue_common`](#0x1_transaction_validation_prologue_common) - [Function `check_for_replay_protection_regular_txn`](#0x1_transaction_validation_check_for_replay_protection_regular_txn) - [Function `check_for_replay_protection_orderless_txn`](#0x1_transaction_validation_check_for_replay_protection_orderless_txn) @@ -62,13 +63,17 @@ use 0x1::create_signer; use 0x1::error; use 0x1::features; +use 0x1::fungible_asset; use 0x1::governed_gas_pool; use 0x1::nonce_validation; +use 0x1::object; use 0x1::option; use 0x1::permissioned_signer; +use 0x1::primary_fungible_store; use 0x1::signer; use 0x1::system_addresses; use 0x1::timestamp; +use 0x1::transaction_context; use 0x1::transaction_fee; use 0x1::vector;
@@ -503,6 +508,37 @@ Only called during genesis to initialize system resources for this module. + + + + +## Function `gas_fa_metadata` + +The fungible asset metadata address this transaction elected to pay gas in, or None if it +pays in the default currency (APT). Returns None when the GAS_PAYABLE_FA feature is off, +so the accessor (which is gated on that feature) is only called when it is enabled. + + +
fun gas_fa_metadata(): option::Option<address>
+
+ + + +
+Implementation + + +
inline fun gas_fa_metadata(): Option<address> {
+    if (features::is_gas_payable_fa_enabled()) {
+        transaction_context::gas_payment_fungible_asset()
+    } else {
+        option::none<address>()
+    }
+}
+
+ + +
@@ -595,7 +631,27 @@ Only called during genesis to initialize system resources for this module. ), error::permission_denied(PROLOGUE_PERMISSIONED_GAS_LIMIT_INSUFFICIENT) ); - if (features::operations_default_to_fa_apt_store_enabled()) { + let gas_fa_metadata = gas_fa_metadata(); + if (option::is_some(&gas_fa_metadata)) { + // Gas is paid in a selected fungible asset: it must be accepted by the governed gas + // pool and the payer must hold enough of it. + let metadata = *option::borrow(&gas_fa_metadata); + assert!( + governed_gas_pool::is_accepted_gas_fungible_asset(metadata), + error::invalid_argument(PROLOGUE_ECANT_PAY_GAS_DEPOSIT) + ); + // The FA gas fee is charged as gas_used * per-FA gas price; check the payer can + // cover the maximum (all of txn_max_gas_units). + let max_fa_fee = governed_gas_pool::gas_fee_in_fa(metadata, txn_max_gas_units); + assert!( + primary_fungible_store::is_balance_at_least( + gas_payer_address, + object::address_to_object<Metadata>(metadata), + max_fa_fee + ), + error::invalid_argument(PROLOGUE_ECANT_PAY_GAS_DEPOSIT) + ); + } else if (features::operations_default_to_fa_apt_store_enabled()) { assert!( aptos_account::is_fungible_balance_at_least(gas_payer_address, max_transaction_fee), error::invalid_argument(PROLOGUE_ECANT_PAY_GAS_DEPOSIT) @@ -1017,38 +1073,56 @@ Called by the Adapter // it's important to maintain the error code consistent with vm // to do failed transaction cleanup. if (!skip_gas_payment(is_simulation, gas_payer)) { - if (features::operations_default_to_fa_apt_store_enabled()) { + let gas_fa_metadata = gas_fa_metadata(); + if (option::is_some(&gas_fa_metadata)) { + // Gas paid in a selected fungible asset is charged as gas_used * the FA's gas price + // and collected into that FA's governed gas pool. NOTE: the storage-fee refund is + // not netted for FA payers; that is a follow-up. + let metadata = *option::borrow(&gas_fa_metadata); + let fa_fee = governed_gas_pool::gas_fee_in_fa(metadata, gas_used); assert!( - aptos_account::is_fungible_balance_at_least(gas_payer, transaction_fee_amount), + primary_fungible_store::is_balance_at_least( + gas_payer, + object::address_to_object<Metadata>(metadata), + fa_fee + ), error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), ); + governed_gas_pool::deposit_gas_fee_fa(gas_payer, metadata, fa_fee); } else { - assert!( - coin::is_balance_at_least<AptosCoin>(gas_payer, transaction_fee_amount), - error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), - ); - }; + if (features::operations_default_to_fa_apt_store_enabled()) { + assert!( + aptos_account::is_fungible_balance_at_least(gas_payer, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + } else { + assert!( + coin::is_balance_at_least<AptosCoin>(gas_payer, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + }; - if (features::storage_deletion_refund_enabled()){ - if (transaction_fee_amount > storage_fee_refunded) { - let burn_amount = transaction_fee_amount - storage_fee_refunded; + if (features::storage_deletion_refund_enabled()){ + if (transaction_fee_amount > storage_fee_refunded) { + let burn_amount = transaction_fee_amount - storage_fee_refunded; + if (features::governed_gas_pool_enabled()){ + governed_gas_pool::deposit_gas_fee_v2(gas_payer, burn_amount); + } else { + transaction_fee::burn_fee(gas_payer, burn_amount); + } + } else if (transaction_fee_amount < storage_fee_refunded) { + let mint_amount = storage_fee_refunded - transaction_fee_amount; + // TODO: we cannot mint to do storage refund. We need to have a storage refund pool + if (!features::governed_gas_pool_enabled()){ + transaction_fee::mint_and_refund(gas_payer, mint_amount); + } + }; + } else { if (features::governed_gas_pool_enabled()){ - governed_gas_pool::deposit_gas_fee_v2(gas_payer, burn_amount); + governed_gas_pool::deposit_gas_fee_v2(gas_payer, transaction_fee_amount); } else { - transaction_fee::burn_fee(gas_payer, burn_amount); - } - } else if (transaction_fee_amount < storage_fee_refunded) { - let mint_amount = storage_fee_refunded - transaction_fee_amount; - // TODO: we cannot mint to do storage refund. We need to have a storage refund pool - if (!features::governed_gas_pool_enabled()){ - transaction_fee::mint_and_refund(gas_payer, mint_amount); + transaction_fee::burn_fee(gas_payer, transaction_fee_amount); } - }; - } else { - if (features::governed_gas_pool_enabled()){ - governed_gas_pool::deposit_gas_fee_v2(gas_payer, transaction_fee_amount); - } else { - transaction_fee::burn_fee(gas_payer, transaction_fee_amount); } } }; @@ -1405,41 +1479,64 @@ If there is no fee_payer, fee_payer = sender is_simulation, gas_payer_address )) { - if (features::operations_default_to_fa_apt_store_enabled()) { + let gas_fa_metadata = gas_fa_metadata(); + if (option::is_some(&gas_fa_metadata)) { + // Gas paid in a selected fungible asset is charged as gas_used * the FA's gas price + // and collected into that FA's governed gas pool. NOTE: the storage-fee refund is + // not netted for FA payers; that is a follow-up. + let metadata = *option::borrow(&gas_fa_metadata); + let fa_fee = governed_gas_pool::gas_fee_in_fa(metadata, gas_used); assert!( - aptos_account::is_fungible_balance_at_least(gas_payer_address, transaction_fee_amount), + primary_fungible_store::is_balance_at_least( + gas_payer_address, + object::address_to_object<Metadata>(metadata), + fa_fee + ), error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), ); - } else { - assert!( - coin::is_balance_at_least<AptosCoin>(gas_payer_address, transaction_fee_amount), - error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), - ); - }; - - if (transaction_fee_amount > storage_fee_refunded) { - let burn_amount = transaction_fee_amount - storage_fee_refunded; - if (features::governed_gas_pool_enabled()){ - governed_gas_pool::deposit_gas_fee_v2(gas_payer_address, burn_amount); - } else { - transaction_fee::burn_fee(gas_payer_address, burn_amount); - }; + governed_gas_pool::deposit_gas_fee_fa(gas_payer_address, metadata, fa_fee); permissioned_signer::check_permission_consume( &gas_payer, - (burn_amount as u256), + (transaction_fee_amount as u256), GasPermission {} ); } else { - let mint_amount = storage_fee_refunded - transaction_fee_amount; - // TODO: we cannot mint to do storage refund. We need to have a storage refund pool - if (!features::governed_gas_pool_enabled()){ - transaction_fee::mint_and_refund(gas_payer_address, mint_amount); + if (features::operations_default_to_fa_apt_store_enabled()) { + assert!( + aptos_account::is_fungible_balance_at_least(gas_payer_address, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + } else { + assert!( + coin::is_balance_at_least<AptosCoin>(gas_payer_address, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + }; + + if (transaction_fee_amount > storage_fee_refunded) { + let burn_amount = transaction_fee_amount - storage_fee_refunded; + if (features::governed_gas_pool_enabled()){ + governed_gas_pool::deposit_gas_fee_v2(gas_payer_address, burn_amount); + } else { + transaction_fee::burn_fee(gas_payer_address, burn_amount); + }; + permissioned_signer::check_permission_consume( + &gas_payer, + (burn_amount as u256), + GasPermission {} + ); + } else { + let mint_amount = storage_fee_refunded - transaction_fee_amount; + // TODO: we cannot mint to do storage refund. We need to have a storage refund pool + if (!features::governed_gas_pool_enabled()){ + transaction_fee::mint_and_refund(gas_payer_address, mint_amount); + }; + permissioned_signer::increase_limit( + &gas_payer, + (mint_amount as u256), + GasPermission {} + ); }; - permissioned_signer::increase_limit( - &gas_payer, - (mint_amount as u256), - GasPermission {} - ); }; }; diff --git a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move index ce3d6a81f9f..3e9b1ffff7a 100644 --- a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move +++ b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move @@ -5,9 +5,9 @@ module aptos_framework::governed_gas_pool { use std::vector; use aptos_framework::account::{Self, SignerCapability, create_signer_with_capability}; use aptos_framework::system_addresses::{Self}; - // use aptos_framework::primary_fungible_store::{Self}; - use aptos_framework::fungible_asset::{Self}; - use aptos_framework::object::{Self}; + use aptos_framework::primary_fungible_store::{Self}; + use aptos_framework::fungible_asset::{Self, Metadata}; + use aptos_framework::object::{Self, Object}; use aptos_framework::aptos_coin::AptosCoin; use aptos_framework::coin::{Self, Coin}; use aptos_framework::event::{Self, EventHandle}; @@ -27,6 +27,18 @@ module aptos_framework::governed_gas_pool { /// No longer supported. const ENO_LONGER_SUPPORTED: u64 = 4; + /// The fungible asset is not accepted for gas payment. + const EFA_NOT_ACCEPTED: u64 = 5; + + /// A gas fungible asset's gas price must be non-zero. + const EINVALID_GAS_FA_PRICE: u64 = 6; + + /// The computed fungible asset gas fee does not fit in a u64. + const EGAS_FA_FEE_OVERFLOW: u64 = 7; + + /// Maximum u64 value, used to guard fungible asset gas fee computation against overflow. + const MAX_U64: u128 = 18446744073709551615; + const MODULE_SALT: vector = b"aptos_framework::governed_gas_pool"; /// Event emitted when token are withdraw from the pool @@ -47,9 +59,48 @@ module aptos_framework::governed_gas_pool { withdraw_staking_reward_events: EventHandle, } - /// Address of APT Primary Fungible Store - inline fun primary_fungible_store_address(account: address): address { - object::create_user_derived_object_address(account, @aptos_fungible_asset) + /// A fungible asset accepted for gas payment, together with its gas price: the number of FA base + /// units charged per unit of gas consumed. The FA gas fee for a transaction is + /// `gas_units_used * gas_price`. + struct AcceptedGasFa has store, drop { + metadata: address, + gas_price: u64, + } + + /// Registry of fungible assets accepted for gas payment. Each accepted FA is held in the + /// governed gas pool account's own primary store for that metadata object (a separate per-FA + /// pool that shares the single pool resource account), and stores its gas price alongside. + struct AcceptedGasFungibleAssets has key { + entries: vector, + } + + #[event] + /// Emitted when a fungible asset is added to (`accepted = true`) or removed from + /// (`accepted = false`) the set accepted for gas payment. + struct AcceptedGasFungibleAssetUpdate has drop, store { + metadata: address, + accepted: bool, + } + + #[event] + /// Emitted when a fungible asset's gas price is set or updated. + struct GasFungibleAssetPriceUpdate has drop, store { + metadata: address, + gas_price: u64, + } + + #[event] + /// Emitted when gas fees are deposited into a per-FA governed gas pool. + struct FungibleAssetGasFeeDeposit has drop, store { + gas_payer: address, + metadata: address, + /// Fungible asset base units deposited. + amount: u64, + } + + /// Address of `account`'s primary fungible store for the FA identified by `metadata`. + inline fun primary_fungible_store_address_for(account: address, metadata: address): address { + object::create_user_derived_object_address(account, metadata) } /// Create the seed to derive the resource account address. @@ -174,24 +225,25 @@ module aptos_framework::governed_gas_pool { deposit(asset); } - /// Deposits some FA from the fungible store. - /// @param aptos_framework The signer of the aptos_framework module. - /// @param account The account from which the FA is to be deposited. - /// @param amount The amount of FA to be deposited. + /// Deposits APT from the fungible store into the governed gas pool. + /// @param account The account from which the APT FA is to be deposited. + /// @param amount The amount of APT FA to be deposited. fun deposit_from_fungible_store(account: address, amount: u64) acquires GovernedGasPool { - if (amount > 0){ - // compute the governed gas pool store address - let governed_gas_pool_address = governed_gas_pool_address(); - let governed_gas_pool_store_address = primary_fungible_store_address(governed_gas_pool_address); - - // compute the account store address - let account_store_address = primary_fungible_store_address(account); + deposit_from_fungible_store_for(account, @aptos_fungible_asset, amount); + } + + /// Deposits `amount` of the fungible asset identified by `metadata` from `account`'s primary + /// store into the governed gas pool account's own primary store for that FA (its per-FA pool). + /// Uses the unchecked (VM-privileged) withdraw/deposit path, as gas collection is not authorized + /// by the payer's signer. + fun deposit_from_fungible_store_for(account: address, metadata: address, amount: u64) acquires GovernedGasPool { + if (amount > 0) { + let pool_store_address = + primary_fungible_store_address_for(governed_gas_pool_address(), metadata); + let account_store_address = primary_fungible_store_address_for(account, metadata); fungible_asset::unchecked_deposit( - governed_gas_pool_store_address, - fungible_asset::unchecked_withdraw( - account_store_address, - amount - ) + pool_store_address, + fungible_asset::unchecked_withdraw(account_store_address, amount) ); } } @@ -214,6 +266,193 @@ module aptos_framework::governed_gas_pool { }; } + /// Creates the accepted-FA registry under @aptos_framework if it does not yet exist. This lets + /// the feature roll out onto an already-initialized governed gas pool without re-running + /// `initialize`. + fun ensure_accepted_registry(aptos_framework: &signer) { + if (!exists(signer::address_of(aptos_framework))) { + move_to(aptos_framework, AcceptedGasFungibleAssets { entries: vector::empty() }); + }; + } + + /// Index of the accepted-FA entry for `metadata`, if present. + fun find_accepted_index(entries: &vector, metadata: address): (bool, u64) { + let len = vector::length(entries); + let i = 0; + while (i < len) { + if (vector::borrow(entries, i).metadata == metadata) { + return (true, i) + }; + i = i + 1; + }; + (false, 0) + } + + /// Adds a fungible asset to the set accepted for gas payment with its gas price (FA base units + /// charged per unit of gas consumed), and ensures the pool has a primary store to hold it. + /// Governance-gated (requires the @aptos_framework signer). Re-adding an existing FA updates its + /// gas price. + /// @param aptos_framework The signer of the aptos_framework module. + /// @param metadata The metadata object of the fungible asset to accept. + /// @param gas_price FA base units charged per unit of gas consumed; must be non-zero. + public entry fun add_accepted_gas_fungible_asset( + aptos_framework: &signer, + metadata: Object, + gas_price: u64, + ) acquires GovernedGasPool, AcceptedGasFungibleAssets { + system_addresses::assert_aptos_framework(aptos_framework); + assert!(gas_price > 0, error::invalid_argument(EINVALID_GAS_FA_PRICE)); + ensure_accepted_registry(aptos_framework); + let metadata_address = object::object_address(&metadata); + let accepted = borrow_global_mut(@aptos_framework); + let (found, i) = find_accepted_index(&accepted.entries, metadata_address); + if (found) { + vector::borrow_mut(&mut accepted.entries, i).gas_price = gas_price; + } else { + vector::push_back(&mut accepted.entries, AcceptedGasFa { metadata: metadata_address, gas_price }); + // Ensure the pool has a primary store for this FA so unchecked deposits succeed. + primary_fungible_store::ensure_primary_store_exists(governed_gas_pool_address(), metadata); + event::emit(AcceptedGasFungibleAssetUpdate { metadata: metadata_address, accepted: true }); + }; + event::emit(GasFungibleAssetPriceUpdate { metadata: metadata_address, gas_price }); + } + + /// Sets (updates) the gas price of an already-accepted fungible asset. Governance-gated. + /// @param aptos_framework The signer of the aptos_framework module. + /// @param metadata The metadata object of the fungible asset. + /// @param gas_price FA base units charged per unit of gas consumed; must be non-zero. + public entry fun set_gas_fungible_asset_price( + aptos_framework: &signer, + metadata: Object, + gas_price: u64, + ) acquires AcceptedGasFungibleAssets { + system_addresses::assert_aptos_framework(aptos_framework); + assert!(gas_price > 0, error::invalid_argument(EINVALID_GAS_FA_PRICE)); + assert!(exists(@aptos_framework), error::invalid_argument(EFA_NOT_ACCEPTED)); + let metadata_address = object::object_address(&metadata); + let accepted = borrow_global_mut(@aptos_framework); + let (found, i) = find_accepted_index(&accepted.entries, metadata_address); + assert!(found, error::invalid_argument(EFA_NOT_ACCEPTED)); + vector::borrow_mut(&mut accepted.entries, i).gas_price = gas_price; + event::emit(GasFungibleAssetPriceUpdate { metadata: metadata_address, gas_price }); + } + + /// Removes a fungible asset from the set accepted for gas payment. Any balance already held in + /// its pool remains and can still be withdrawn via `fund_fa`. Governance-gated. + /// @param aptos_framework The signer of the aptos_framework module. + /// @param metadata The metadata object of the fungible asset to stop accepting. + public entry fun remove_accepted_gas_fungible_asset( + aptos_framework: &signer, + metadata: Object, + ) acquires AcceptedGasFungibleAssets { + system_addresses::assert_aptos_framework(aptos_framework); + if (!exists(@aptos_framework)) { + return + }; + let metadata_address = object::object_address(&metadata); + let accepted = borrow_global_mut(@aptos_framework); + let (found, i) = find_accepted_index(&accepted.entries, metadata_address); + if (found) { + vector::remove(&mut accepted.entries, i); + event::emit(AcceptedGasFungibleAssetUpdate { metadata: metadata_address, accepted: false }); + }; + } + + #[view] + /// Whether the fungible asset identified by `metadata` is accepted for gas payment. + public fun is_accepted_gas_fungible_asset(metadata: address): bool acquires AcceptedGasFungibleAssets { + if (!exists(@aptos_framework)) { + return false + }; + let (found, _) = find_accepted_index(&borrow_global(@aptos_framework).entries, metadata); + found + } + + #[view] + /// The full set of fungible asset metadata addresses accepted for gas payment. + public fun accepted_gas_fungible_assets(): vector
acquires AcceptedGasFungibleAssets { + let result = vector::empty
(); + if (exists(@aptos_framework)) { + let entries = &borrow_global(@aptos_framework).entries; + let len = vector::length(entries); + let i = 0; + while (i < len) { + vector::push_back(&mut result, vector::borrow(entries, i).metadata); + i = i + 1; + }; + }; + result + } + + #[view] + /// The gas price (FA base units per unit of gas) of an accepted fungible asset. Aborts if the + /// fungible asset is not accepted for gas payment. + public fun get_gas_fungible_asset_price(metadata: address): u64 acquires AcceptedGasFungibleAssets { + assert!(exists(@aptos_framework), error::invalid_argument(EFA_NOT_ACCEPTED)); + let accepted = borrow_global(@aptos_framework); + let (found, i) = find_accepted_index(&accepted.entries, metadata); + assert!(found, error::invalid_argument(EFA_NOT_ACCEPTED)); + vector::borrow(&accepted.entries, i).gas_price + } + + #[view] + /// The fungible asset gas fee for `gas_units` of gas consumed under `metadata`'s gas price, i.e. + /// `gas_units * gas_price`. Aborts if the FA is not accepted or the fee overflows u64. + public fun gas_fee_in_fa(metadata: address, gas_units: u64): u64 acquires AcceptedGasFungibleAssets { + let price = get_gas_fungible_asset_price(metadata); + let fee = (gas_units as u128) * (price as u128); + assert!(fee <= MAX_U64, error::out_of_range(EGAS_FA_FEE_OVERFLOW)); + (fee as u64) + } + + #[view] + /// The governed gas pool's balance of the fungible asset identified by `metadata`. + public fun get_fa_balance(metadata: address): u64 acquires GovernedGasPool { + primary_fungible_store::balance( + governed_gas_pool_address(), + object::address_to_object(metadata) + ) + } + + /// Deposits `fa_amount` base units of a selected gas fungible asset (already converted from gas + /// via that FA's gas price) into its governed gas pool. Aborts if the FA is not accepted. + /// @param gas_payer The address that paid the gas fees. + /// @param metadata The metadata object address of the fungible asset. + /// @param fa_amount The fungible asset base units to deposit. + public(friend) fun deposit_gas_fee_fa( + gas_payer: address, + metadata: address, + fa_amount: u64, + ) acquires GovernedGasPool, AcceptedGasFungibleAssets { + assert!(is_accepted_gas_fungible_asset(metadata), error::invalid_argument(EFA_NOT_ACCEPTED)); + deposit_from_fungible_store_for(gas_payer, metadata, fa_amount); + if (fa_amount > 0) { + event::emit(FungibleAssetGasFeeDeposit { gas_payer, metadata, amount: fa_amount }); + }; + } + + /// Withdraws `amount` of the fungible asset identified by `metadata` from its governed gas pool + /// and deposits it to `account`. Governance-gated; the mirror of `fund` for fungible assets. + /// @param aptos_framework The signer of the aptos_framework module. + /// @param account The recipient account. + /// @param metadata The metadata object address of the fungible asset. + /// @param amount The amount to withdraw from the pool. + public fun fund_fa( + aptos_framework: &signer, + account: address, + metadata: address, + amount: u64, + ) acquires GovernedGasPool { + system_addresses::assert_aptos_framework(aptos_framework); + let pool_signer = governed_gas_signer(); + let fa = primary_fungible_store::withdraw( + &pool_signer, + object::address_to_object(metadata), + amount + ); + primary_fungible_store::deposit(account, fa); + } + /// Deposits from the treasury account. Treasury deposit are recorded. /// @param treasury_account The address of the account that paid the treasury. /// @param amount The amount of treasury to be deposited. @@ -494,4 +733,166 @@ module aptos_framework::governed_gas_pool { coin::deposit(@0xdddd, withdraw); } + #[test(aptos_framework = @aptos_framework, payer = @0xcafe)] + /// A selected fungible asset gets its own pool: gas paid in it is collected separately from APT, + /// and can be funded back out by governance. + fun test_per_fa_gas_pool(aptos_framework: &signer, payer: &signer) + acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + + // Create a test fungible asset and mint some to the payer. + let (creator_ref, token) = fungible_asset::create_test_token(payer); + let (mint_ref, _transfer_ref, _burn_ref) = + primary_fungible_store::init_test_metadata_with_primary_store_enabled(&creator_ref); + let payer_address = signer::address_of(payer); + // The test FA has a max supply of 100. + primary_fungible_store::mint(&mint_ref, payer_address, 100); + let metadata_address = object::object_address(&token); + let metadata = object::address_to_object(metadata_address); + + // Not accepted yet. + assert!(!is_accepted_gas_fungible_asset(metadata_address), 1); + + // Accept it with a gas price of 2 FA units per gas unit (governance). + add_accepted_gas_fungible_asset(aptos_framework, metadata, 2); + assert!(is_accepted_gas_fungible_asset(metadata_address), 2); + assert!(vector::contains(&accepted_gas_fungible_assets(), &metadata_address), 3); + assert!(get_gas_fungible_asset_price(metadata_address) == 2, 11); + // gas_used * price: 5 gas units at price 2 = 10 FA units. + assert!(gas_fee_in_fa(metadata_address, 5) == 10, 12); + + // Governance can update the gas price via a function. + set_gas_fungible_asset_price(aptos_framework, metadata, 3); + assert!(get_gas_fungible_asset_price(metadata_address) == 3, 13); + assert!(gas_fee_in_fa(metadata_address, 5) == 15, 14); + + // Pay gas in the FA -> goes into that FA's pool, separate from the APT pool. + deposit_gas_fee_fa(payer_address, metadata_address, 30); + assert!(primary_fungible_store::balance(payer_address, metadata) == 70, 4); + assert!(get_fa_balance(metadata_address) == 30, 5); + // The APT pool is untouched. + assert!(coin::balance(governed_gas_pool_address()) == 0, 6); + + // Governance withdraws from the FA pool back to an account. + fund_fa(aptos_framework, payer_address, metadata_address, 10); + assert!(get_fa_balance(metadata_address) == 20, 7); + assert!(primary_fungible_store::balance(payer_address, metadata) == 80, 8); + + // Removing it from the accepted set leaves the residual balance intact. + remove_accepted_gas_fungible_asset(aptos_framework, metadata); + assert!(!is_accepted_gas_fungible_asset(metadata_address), 9); + assert!(get_fa_balance(metadata_address) == 20, 10); + } + + #[test(aptos_framework = @aptos_framework, payer = @0xcafe)] + #[expected_failure(abort_code = 65541, location = Self)] + /// Depositing gas in a fungible asset that is not accepted aborts with EFA_NOT_ACCEPTED. + fun test_deposit_gas_fee_fa_aborts_when_not_accepted(aptos_framework: &signer, payer: &signer) + acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + let (creator_ref, token) = fungible_asset::create_test_token(payer); + let (mint_ref, _transfer_ref, _burn_ref) = + primary_fungible_store::init_test_metadata_with_primary_store_enabled(&creator_ref); + primary_fungible_store::mint(&mint_ref, signer::address_of(payer), 100); + // Never accepted -> abort. + deposit_gas_fee_fa(signer::address_of(payer), object::object_address(&token), 30); + } + + #[test(aptos_framework = @aptos_framework, intruder = @0xbad)] + #[expected_failure(abort_code = 327683, location = aptos_framework::system_addresses)] + /// Accepting a gas FA requires the @aptos_framework (governance) signer. + fun test_add_accepted_requires_framework(aptos_framework: &signer, intruder: &signer) + acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + let (creator_ref, token) = fungible_asset::create_test_token(intruder); + let (_m, _t, _b) = primary_fungible_store::init_test_metadata_with_primary_store_enabled(&creator_ref); + add_accepted_gas_fungible_asset( + intruder, + object::address_to_object(object::object_address(&token)), + 1, + ); + } + + #[test(aptos_framework = @aptos_framework, intruder = @0xbad)] + #[expected_failure(abort_code = 327683, location = aptos_framework::system_addresses)] + /// Updating a gas FA's price requires the @aptos_framework (governance) signer. + fun test_set_price_requires_framework(aptos_framework: &signer, intruder: &signer) + acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + let (creator_ref, token) = fungible_asset::create_test_token(intruder); + let (_m, _t, _b) = primary_fungible_store::init_test_metadata_with_primary_store_enabled(&creator_ref); + let metadata = object::address_to_object(object::object_address(&token)); + add_accepted_gas_fungible_asset(aptos_framework, metadata, 1); + // Non-governance caller -> abort. + set_gas_fungible_asset_price(intruder, metadata, 2); + } + + #[test(aptos_framework = @aptos_framework, creator = @0xcafe)] + #[expected_failure(abort_code = 65542, location = Self)] + /// A gas FA cannot be accepted with a zero gas price (would make gas free). + fun test_add_rejects_zero_price(aptos_framework: &signer, creator: &signer) + acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + let (creator_ref, token) = fungible_asset::create_test_token(creator); + let (_m, _t, _b) = primary_fungible_store::init_test_metadata_with_primary_store_enabled(&creator_ref); + add_accepted_gas_fungible_asset( + aptos_framework, + object::address_to_object(object::object_address(&token)), + 0, + ); + } + + #[test(aptos_framework = @aptos_framework, creator = @0xcafe)] + #[expected_failure(abort_code = 65542, location = Self)] + /// A gas FA's price cannot be set to zero. + fun test_set_rejects_zero_price(aptos_framework: &signer, creator: &signer) + acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + let (creator_ref, token) = fungible_asset::create_test_token(creator); + let (_m, _t, _b) = primary_fungible_store::init_test_metadata_with_primary_store_enabled(&creator_ref); + let metadata = object::address_to_object(object::object_address(&token)); + add_accepted_gas_fungible_asset(aptos_framework, metadata, 5); + set_gas_fungible_asset_price(aptos_framework, metadata, 0); + } + + #[test(aptos_framework = @aptos_framework, payer = @0xcafe, creator_b = @0xd00d)] + /// Each accepted FA is a separate pool with its own price: depositing into one FA's pool does + /// not touch another's, and their prices/fees are independent. + fun test_multiple_fa_pools_are_independent( + aptos_framework: &signer, + payer: &signer, + creator_b: &signer, + ) acquires GovernedGasPool, AcceptedGasFungibleAssets { + initialize_for_test(aptos_framework); + let payer_addr = signer::address_of(payer); + + // FA A (created by payer) and FA B (created by creator_b), each minted to the payer. + let (a_ref, a_token) = fungible_asset::create_test_token(payer); + let (a_mint, _at, _ab) = primary_fungible_store::init_test_metadata_with_primary_store_enabled(&a_ref); + primary_fungible_store::mint(&a_mint, payer_addr, 100); + let a = object::object_address(&a_token); + + let (b_ref, b_token) = fungible_asset::create_test_token(creator_b); + let (b_mint, _bt, _bb) = primary_fungible_store::init_test_metadata_with_primary_store_enabled(&b_ref); + primary_fungible_store::mint(&b_mint, payer_addr, 100); + let b = object::object_address(&b_token); + + // Accept both with different gas prices. + add_accepted_gas_fungible_asset(aptos_framework, object::address_to_object(a), 2); + add_accepted_gas_fungible_asset(aptos_framework, object::address_to_object(b), 5); + + // Prices and fees are independent per FA. + assert!(get_gas_fungible_asset_price(a) == 2, 1); + assert!(get_gas_fungible_asset_price(b) == 5, 2); + assert!(gas_fee_in_fa(a, 10) == 20, 3); + assert!(gas_fee_in_fa(b, 10) == 50, 4); + + // Depositing into A's pool leaves B's pool (and the payer's B balance) untouched. + deposit_gas_fee_fa(payer_addr, a, 30); + assert!(get_fa_balance(a) == 30, 5); + assert!(get_fa_balance(b) == 0, 6); + assert!(primary_fungible_store::balance(payer_addr, object::address_to_object(a)) == 70, 7); + assert!(primary_fungible_store::balance(payer_addr, object::address_to_object(b)) == 100, 8); + } + } diff --git a/aptos-move/framework/aptos-framework/sources/transaction_validation.move b/aptos-move/framework/aptos-framework/sources/transaction_validation.move index 991782fc4dd..1780c625187 100644 --- a/aptos-move/framework/aptos-framework/sources/transaction_validation.move +++ b/aptos-move/framework/aptos-framework/sources/transaction_validation.move @@ -13,10 +13,14 @@ module aptos_framework::transaction_validation { use aptos_framework::chain_id; use aptos_framework::coin; use aptos_framework::create_signer; + use aptos_framework::fungible_asset::Metadata; use aptos_framework::governed_gas_pool; + use aptos_framework::object; use aptos_framework::permissioned_signer; + use aptos_framework::primary_fungible_store; use aptos_framework::system_addresses; use aptos_framework::timestamp; + use aptos_framework::transaction_context; use aptos_framework::transaction_fee; use aptos_framework::nonce_validation; @@ -123,6 +127,17 @@ module aptos_framework::transaction_validation { || (features::is_account_abstraction_enabled() && account_abstraction::using_dispatchable_authenticator(transaction_sender)) } + /// The fungible asset metadata address this transaction elected to pay gas in, or `None` if it + /// pays in the default currency (APT). Returns `None` when the `GAS_PAYABLE_FA` feature is off, + /// so the accessor (which is gated on that feature) is only called when it is enabled. + inline fun gas_fa_metadata(): Option
{ + if (features::is_gas_payable_fa_enabled()) { + transaction_context::gas_payment_fungible_asset() + } else { + option::none
() + } + } + fun prologue_common( sender: &signer, gas_payer: &signer, @@ -198,7 +213,27 @@ module aptos_framework::transaction_validation { ), error::permission_denied(PROLOGUE_PERMISSIONED_GAS_LIMIT_INSUFFICIENT) ); - if (features::operations_default_to_fa_apt_store_enabled()) { + let gas_fa_metadata = gas_fa_metadata(); + if (option::is_some(&gas_fa_metadata)) { + // Gas is paid in a selected fungible asset: it must be accepted by the governed gas + // pool and the payer must hold enough of it. + let metadata = *option::borrow(&gas_fa_metadata); + assert!( + governed_gas_pool::is_accepted_gas_fungible_asset(metadata), + error::invalid_argument(PROLOGUE_ECANT_PAY_GAS_DEPOSIT) + ); + // The FA gas fee is charged as gas_used * per-FA gas price; check the payer can + // cover the maximum (all of txn_max_gas_units). + let max_fa_fee = governed_gas_pool::gas_fee_in_fa(metadata, txn_max_gas_units); + assert!( + primary_fungible_store::is_balance_at_least( + gas_payer_address, + object::address_to_object(metadata), + max_fa_fee + ), + error::invalid_argument(PROLOGUE_ECANT_PAY_GAS_DEPOSIT) + ); + } else if (features::operations_default_to_fa_apt_store_enabled()) { assert!( aptos_account::is_fungible_balance_at_least(gas_payer_address, max_transaction_fee), error::invalid_argument(PROLOGUE_ECANT_PAY_GAS_DEPOSIT) @@ -460,38 +495,56 @@ module aptos_framework::transaction_validation { // it's important to maintain the error code consistent with vm // to do failed transaction cleanup. if (!skip_gas_payment(is_simulation, gas_payer)) { - if (features::operations_default_to_fa_apt_store_enabled()) { + let gas_fa_metadata = gas_fa_metadata(); + if (option::is_some(&gas_fa_metadata)) { + // Gas paid in a selected fungible asset is charged as gas_used * the FA's gas price + // and collected into that FA's governed gas pool. NOTE: the storage-fee refund is + // not netted for FA payers; that is a follow-up. + let metadata = *option::borrow(&gas_fa_metadata); + let fa_fee = governed_gas_pool::gas_fee_in_fa(metadata, gas_used); assert!( - aptos_account::is_fungible_balance_at_least(gas_payer, transaction_fee_amount), + primary_fungible_store::is_balance_at_least( + gas_payer, + object::address_to_object(metadata), + fa_fee + ), error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), ); + governed_gas_pool::deposit_gas_fee_fa(gas_payer, metadata, fa_fee); } else { - assert!( - coin::is_balance_at_least(gas_payer, transaction_fee_amount), - error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), - ); - }; + if (features::operations_default_to_fa_apt_store_enabled()) { + assert!( + aptos_account::is_fungible_balance_at_least(gas_payer, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + } else { + assert!( + coin::is_balance_at_least(gas_payer, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + }; - if (features::storage_deletion_refund_enabled()){ - if (transaction_fee_amount > storage_fee_refunded) { - let burn_amount = transaction_fee_amount - storage_fee_refunded; + if (features::storage_deletion_refund_enabled()){ + if (transaction_fee_amount > storage_fee_refunded) { + let burn_amount = transaction_fee_amount - storage_fee_refunded; + if (features::governed_gas_pool_enabled()){ + governed_gas_pool::deposit_gas_fee_v2(gas_payer, burn_amount); + } else { + transaction_fee::burn_fee(gas_payer, burn_amount); + } + } else if (transaction_fee_amount < storage_fee_refunded) { + let mint_amount = storage_fee_refunded - transaction_fee_amount; + // TODO: we cannot mint to do storage refund. We need to have a storage refund pool + if (!features::governed_gas_pool_enabled()){ + transaction_fee::mint_and_refund(gas_payer, mint_amount); + } + }; + } else { if (features::governed_gas_pool_enabled()){ - governed_gas_pool::deposit_gas_fee_v2(gas_payer, burn_amount); + governed_gas_pool::deposit_gas_fee_v2(gas_payer, transaction_fee_amount); } else { - transaction_fee::burn_fee(gas_payer, burn_amount); + transaction_fee::burn_fee(gas_payer, transaction_fee_amount); } - } else if (transaction_fee_amount < storage_fee_refunded) { - let mint_amount = storage_fee_refunded - transaction_fee_amount; - // TODO: we cannot mint to do storage refund. We need to have a storage refund pool - if (!features::governed_gas_pool_enabled()){ - transaction_fee::mint_and_refund(gas_payer, mint_amount); - } - }; - } else { - if (features::governed_gas_pool_enabled()){ - governed_gas_pool::deposit_gas_fee_v2(gas_payer, transaction_fee_amount); - } else { - transaction_fee::burn_fee(gas_payer, transaction_fee_amount); } } }; @@ -695,41 +748,64 @@ module aptos_framework::transaction_validation { is_simulation, gas_payer_address )) { - if (features::operations_default_to_fa_apt_store_enabled()) { - assert!( - aptos_account::is_fungible_balance_at_least(gas_payer_address, transaction_fee_amount), - error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), - ); - } else { + let gas_fa_metadata = gas_fa_metadata(); + if (option::is_some(&gas_fa_metadata)) { + // Gas paid in a selected fungible asset is charged as gas_used * the FA's gas price + // and collected into that FA's governed gas pool. NOTE: the storage-fee refund is + // not netted for FA payers; that is a follow-up. + let metadata = *option::borrow(&gas_fa_metadata); + let fa_fee = governed_gas_pool::gas_fee_in_fa(metadata, gas_used); assert!( - coin::is_balance_at_least(gas_payer_address, transaction_fee_amount), + primary_fungible_store::is_balance_at_least( + gas_payer_address, + object::address_to_object(metadata), + fa_fee + ), error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), ); - }; - - if (transaction_fee_amount > storage_fee_refunded) { - let burn_amount = transaction_fee_amount - storage_fee_refunded; - if (features::governed_gas_pool_enabled()){ - governed_gas_pool::deposit_gas_fee_v2(gas_payer_address, burn_amount); - } else { - transaction_fee::burn_fee(gas_payer_address, burn_amount); - }; + governed_gas_pool::deposit_gas_fee_fa(gas_payer_address, metadata, fa_fee); permissioned_signer::check_permission_consume( &gas_payer, - (burn_amount as u256), + (transaction_fee_amount as u256), GasPermission {} ); } else { - let mint_amount = storage_fee_refunded - transaction_fee_amount; - // TODO: we cannot mint to do storage refund. We need to have a storage refund pool - if (!features::governed_gas_pool_enabled()){ - transaction_fee::mint_and_refund(gas_payer_address, mint_amount); + if (features::operations_default_to_fa_apt_store_enabled()) { + assert!( + aptos_account::is_fungible_balance_at_least(gas_payer_address, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + } else { + assert!( + coin::is_balance_at_least(gas_payer_address, transaction_fee_amount), + error::out_of_range(PROLOGUE_ECANT_PAY_GAS_DEPOSIT), + ); + }; + + if (transaction_fee_amount > storage_fee_refunded) { + let burn_amount = transaction_fee_amount - storage_fee_refunded; + if (features::governed_gas_pool_enabled()){ + governed_gas_pool::deposit_gas_fee_v2(gas_payer_address, burn_amount); + } else { + transaction_fee::burn_fee(gas_payer_address, burn_amount); + }; + permissioned_signer::check_permission_consume( + &gas_payer, + (burn_amount as u256), + GasPermission {} + ); + } else { + let mint_amount = storage_fee_refunded - transaction_fee_amount; + // TODO: we cannot mint to do storage refund. We need to have a storage refund pool + if (!features::governed_gas_pool_enabled()){ + transaction_fee::mint_and_refund(gas_payer_address, mint_amount); + }; + permissioned_signer::increase_limit( + &gas_payer, + (mint_amount as u256), + GasPermission {} + ); }; - permissioned_signer::increase_limit( - &gas_payer, - (mint_amount as u256), - GasPermission {} - ); }; }; From eaaf5a7dd28756a9958746e7ec4e5bd198a9be7c Mon Sep 17 00:00:00 2001 From: Sean Young Date: Wed, 12 Aug 2026 12:48:57 +0100 Subject: [PATCH 5/8] APT -> MOVE --- .../aptos-framework/sources/governed_gas_pool.move | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move index 3e9b1ffff7a..c0a5ca1fe9e 100644 --- a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move +++ b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move @@ -225,9 +225,9 @@ module aptos_framework::governed_gas_pool { deposit(asset); } - /// Deposits APT from the fungible store into the governed gas pool. - /// @param account The account from which the APT FA is to be deposited. - /// @param amount The amount of APT FA to be deposited. + /// Deposits FA from the fungible store into the governed gas pool. + /// @param account The account from which the FA is to be deposited. + /// @param amount The amount of FA to be deposited. fun deposit_from_fungible_store(account: address, amount: u64) acquires GovernedGasPool { deposit_from_fungible_store_for(account, @aptos_fungible_asset, amount); } @@ -766,11 +766,11 @@ module aptos_framework::governed_gas_pool { assert!(get_gas_fungible_asset_price(metadata_address) == 3, 13); assert!(gas_fee_in_fa(metadata_address, 5) == 15, 14); - // Pay gas in the FA -> goes into that FA's pool, separate from the APT pool. + // Pay gas in the FA -> goes into that FA's pool, separate from the MOVE pool. deposit_gas_fee_fa(payer_address, metadata_address, 30); assert!(primary_fungible_store::balance(payer_address, metadata) == 70, 4); assert!(get_fa_balance(metadata_address) == 30, 5); - // The APT pool is untouched. + // The MOVE pool is untouched. assert!(coin::balance(governed_gas_pool_address()) == 0, 6); // Governance withdraws from the FA pool back to an account. From cd7be6f2b52539b4df518fcbe8afa323a4978152 Mon Sep 17 00:00:00 2001 From: Sean Young Date: Thu, 13 Aug 2026 14:11:07 +0100 Subject: [PATCH 6/8] comment --- .../framework/aptos-framework/doc/governed_gas_pool.md | 10 +++++++--- .../aptos-framework/sources/governed_gas_pool.move | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md b/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md index 7425b6e626c..54746c0dd10 100644 --- a/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md +++ b/aptos-move/framework/aptos-framework/doc/governed_gas_pool.md @@ -204,6 +204,10 @@ Registry of fungible assets accepted for gas payment. Each accepted FA is held i governed gas pool account's own primary store for that metadata object (a separate per-FA pool that shares the single pool resource account), and stores its gas price alongside. +Note that vector does mean we have to iterate the list, but if we replace it with Table +or SmartTable then we have access storage a lot more which is much more expensive than +iterating a few items. Therefore if we only a few entries, vector<> is much faster. +
struct AcceptedGasFungibleAssets has key
 
@@ -753,9 +757,9 @@ Deposits some coin from an account to the governed gas pool. ## Function `deposit_from_fungible_store` -Deposits APT from the fungible store into the governed gas pool. -@param account The account from which the APT FA is to be deposited. -@param amount The amount of APT FA to be deposited. +Deposits FA from the fungible store into the governed gas pool. +@param account The account from which the FA is to be deposited. +@param amount The amount of FA to be deposited.
fun deposit_from_fungible_store(account: address, amount: u64)
diff --git a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move
index c0a5ca1fe9e..12c4613a5d9 100644
--- a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move
+++ b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move
@@ -70,6 +70,10 @@ module aptos_framework::governed_gas_pool {
     /// Registry of fungible assets accepted for gas payment. Each accepted FA is held in the
     /// governed gas pool account's own primary store for that metadata object (a separate per-FA
     /// pool that shares the single pool resource account), and stores its gas price alongside.
+    ///
+    /// Note that vector does mean we have to iterate the list, but if we replace it with Table
+    /// or SmartTable then we have access storage a lot more which is much more expensive than
+    /// iterating a few items. Therefore if we only a few entries, vector<> is much faster.
     struct AcceptedGasFungibleAssets has key {
         entries: vector,
     }

From 35bbe5a563452cf95d278d60db4591fee382a881 Mon Sep 17 00:00:00 2001
From: Sean Young 
Date: Fri, 14 Aug 2026 10:46:16 +0100
Subject: [PATCH 7/8] remove trailing whitespace

---
 .../aptos-framework/sources/governed_gas_pool.move          | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move
index 12c4613a5d9..24b3e47d0a4 100644
--- a/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move
+++ b/aptos-move/framework/aptos-framework/sources/governed_gas_pool.move
@@ -497,7 +497,7 @@ module aptos_framework::governed_gas_pool {
                 amount,
             },
         );
-        
+
         // Withdraw reward coin.
         coin::withdraw(&governed_gas_signer(), amount)
     }
@@ -709,10 +709,10 @@ module aptos_framework::governed_gas_pool {
     ///
     /// @param aptos_framework is the signer of the aptos_framework module.
     fun test_deposite_treasury_and_counter(aptos_framework: &signer, treasury: &signer) acquires GovernedGasPool, GovernedGasPoolExtension, AptosCoinMintCapability {
-       
+
         // initialize the modules
         initialize_for_test(aptos_framework);
-    
+
         // create the depositor account and fund it
         aptos_account::create_account(signer::address_of(treasury));
         mint_for_test(signer::address_of(treasury), 1000);

From 24130970fa2c4df4d77f1646a18760d2f4f6d119 Mon Sep 17 00:00:00 2001
From: Sean Young 
Date: Fri, 14 Aug 2026 10:53:42 +0100
Subject: [PATCH 8/8] Fix corpus

---
 testsuite/generate-format/tests/staged/api.yaml | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/testsuite/generate-format/tests/staged/api.yaml b/testsuite/generate-format/tests/staged/api.yaml
index bfcaceda30d..7c0bfe03559 100644
--- a/testsuite/generate-format/tests/staged/api.yaml
+++ b/testsuite/generate-format/tests/staged/api.yaml
@@ -739,6 +739,17 @@ TransactionExtraConfig:
                 TYPENAME: AccountAddress
           - replay_protection_nonce:
               OPTION: U64
+    1:
+      V2:
+        STRUCT:
+          - multisig_address:
+              OPTION:
+                TYPENAME: AccountAddress
+          - replay_protection_nonce:
+              OPTION: U64
+          - gas_fa_coin:
+              OPTION:
+                TYPENAME: AccountAddress
 TransactionInfo:
   ENUM:
     0: