fix concurrency issue in governed gas pool - #389
Conversation
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
movement move prove --filter governed_gas_pool fails on this branch for me... I added some fixes locally in governed_gas_pool.spec.move to make it pass. Sharing in case you want to integrate any of these into this PR:
-
deposit/deposit_gas_fee_v2:signer::address_of(governed_gas_signer())fails boogie compilation (undeclared function create_signer) — the resource-account signer can't be modeled. Read the address from the capability instead:let pool = global<GovernedGasPool>(@aptos_framework).signer_capability.account;
-
deposit_gas_fee_v2: the postconditions only hold on the coin path (withOPERATIONS_DEFAULT_TO_FA_APT_STOREthe fee moves through the fungible store, andcoin::withdraw_fromdraws from the paired FA store underCOIN_TO_FUNGIBLE_ASSET_MIGRATION). Add:requires !features::spec_is_enabled(features::OPERATIONS_DEFAULT_TO_FA_APT_STORE); requires global<coin::CoinStore<AptosCoin>>(gas_payer).coin.value >= gas_fee;
-
init_module: add a spec soinitialize's framework-signer precondition can be discharged:spec init_module(aptos_framework: &signer) { requires system_addresses::is_aptos_framework_address(signer::address_of(aptos_framework)); }
-
deposit_from/deposit_treasury: the pool only has aCoinStorewhenNEW_ACCOUNTS_DEFAULT_TO_FA_APT_STOREis off (register_fa_and_apt), sodeposit'srequires exists<coin::CoinStore>(pool)can't be discharged at the call sites. Propagate it to both callers:spec deposit_from<CoinType>(account: address, amount: u64) { pragma aborts_if_is_partial = true; let pool = global<GovernedGasPool>(@aptos_framework).signer_capability.account; requires exists<coin::CoinStore<CoinType>>(pool); } spec deposit_treasury(treasury_account: &signer, amount: u64) { pragma aborts_if_is_partial = true; let pool = global<GovernedGasPool>(@aptos_framework).signer_capability.account; requires exists<coin::CoinStore<AptosCoin>>(pool); requires exists<GovernedGasPoolExtension>(@aptos_framework); }
-
fund: drop theaborts_withline — its listed codes don't cover the FA-migration overflow path incoin::withdraw. (Pre-existing; unrelated to this PR's changes, only surfaces once the rest passes.)
With these, movement move prove --filter governed_gas_pool returns Success.
There was a problem hiding this comment.
Thank you very much @ganymedio . I've added this in a commit.
2c45aca to
55af7b0
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
areshand
left a comment
There was a problem hiding this comment.
One thing I want to clarify before this merges: governed_gas_pool::initialize now calls upgrade_pool_store_to_concurrent(...) unconditionally in both the existing-pool and newly-created-pool branches.
From this module, there does not appear to be an explicit features::concurrent_fungible_balance_enabled() guard before the upgrade. The local helper just resolves the primary store and calls fungible_asset::upgrade_store_to_concurrent(...), so the feature check/abort is delegated to the lower-level fungible-asset helper.
If CONCURRENT_FUNGIBLE_BALANCE is disabled when this initializer runs, this looks like it can abort rather than skip or fall back. Can you either add an explicit guard/fallback here, or document/prove the rollout ordering that guarantees CONCURRENT_FUNGIBLE_BALANCE is enabled before this path can execute?
Good point. Working on it. |
I don't think we want to enable governed gas pools until What would be the best place to record this? |
This comment has been minimized.
This comment has been minimized.
we already have govern gas pool enabled both on testnet and mainnet. The usual workflow is feature gate this change, deploy to testnet and mainnet and later once coin2FA migration is done, we turn on the feature flag |
Ah I did not realise governed gas pool is already enabled. I can see that now. Yes, this needs some rework. |
0xIcarus
left a comment
There was a problem hiding this comment.
Security / Correctness Review
Focused on changes introduced in this PR only. Found one confirmed high-severity logic bug, one medium-severity defensive gap, and a few nits.
HIGH — Storage-refund minting is no longer gated by governed_gas_pool_enabled()
See inline comment below for the exact diff line. This is the most important finding.
MEDIUM — on_reconfig() has no existence guard for GovernedGasPool
// reconfiguration.move:137
governed_gas_pool::on_reconfig();on_reconfig() calls governed_gas_signer() which does borrow_global<GovernedGasPool>(@aptos_framework). If for any reason GovernedGasPool doesn't exist (e.g. fresh chain before init_module completes, or any test environment with partial setup) this aborts inside reconfiguration::reconfigure(), stalling epoch changes. On live networks where GGP is already deployed this is safe today, but the missing defensive guard is a landmine.
Fix:
public(friend) fun on_reconfig() acquires GovernedGasPool {
if (exists<GovernedGasPool>(@aptos_framework)) {
upgrade_pool_store_to_concurrent(&governed_gas_signer());
}
}LOW — upgrade_store_to_concurrent called on every epoch change
upgrade_pool_store_to_concurrent does a storage read (exists<ConcurrentFungibleBalance>) on every epoch boundary once concurrent_fungible_balance_enabled() is true. The function is idempotent so it never aborts, but the read is wasted after the first upgrade. Minor inefficiency in a hot path.
NIT — Misleading comment on withdrawal reordering in withdraw_staking_reward
The comment says the reordering prevents an insufficient-balance abort, but the assert!(balance >= amount, ...) check immediately before already handles that. The real value is reducing retry overhead in concurrent speculative execution. Worth saying that instead.
CONFIRMED CORRECT
deposit_gas_fee_v2zero-check: correct, the coin path previously had no 0 guard.upgrade_store_to_concurrentidempotency: confirmed viafungible_asset.move:1374— returns early ifConcurrentFungibleBalancealready exists.initialize_governed_gas_pool_extensionrefactor: semantically identical, just cleaner.
| // TODO: we cannot mint to do storage refund. We need to have a storage refund pool | ||
| if (!features::governed_gas_pool_enabled()){ | ||
| // TODO: Should storage refunds go to the governed gas pool or a separate storage refund pool? | ||
| if (mint_amount > 0) { |
There was a problem hiding this comment.
BUG (HIGH): Storage-refund minting no longer gated by governed_gas_pool_enabled()
The original epilogue_gas_payer path had an explicit guard:
// line ~633 (old code, still present in epilogue_gas_payer)
if (!features::governed_gas_pool_enabled()){
transaction_fee::mint_and_refund(gas_payer, mint_amount);
}The TODO comment beside it explained why: "we cannot mint to do storage refund. We need to have a storage refund pool."
unified_epilogue_v2 replaces that guard with:
if (mint_amount > 0) {
transaction_fee::mint_and_refund(gas_payer_address, mint_amount);
}Impact: On mainnet and testnet — where GOVERNED_GAS_POOL is already enabled — every transaction where storage_fee_refunded > transaction_fee_amount now mints APT out of thin air. There is no corresponding debit from the governed gas pool or any other source. This is an unbounded inflationary write that bypasses the storage-refund pool design that was explicitly left as a TODO.
Verified with a Move unit test (added to transaction_validation.move for review, not intended to merge as-is):
#[test(aptos_framework = @aptos_framework)]
fun test_unified_epilogue_v2_mints_apt_when_ggp_enabled_bug(aptos_framework: &signer) {
account::create_account_for_test(@aptos_framework);
let (burn_cap, mint_cap) = aptos_coin::initialize_for_test(aptos_framework);
transaction_fee::store_aptos_coin_mint_cap(aptos_framework, mint_cap);
// Enable GGP — live state on mainnet and testnet
features::change_feature_flags_for_testing(
aptos_framework,
vector[features::get_governed_gas_pool_feature()],
vector[],
);
let gas_payer_addr = @0xbeefdead;
account::create_account_for_test(gas_payer_addr);
coin::register<AptosCoin>(&account::create_signer_for_test(gas_payer_addr));
let balance_before = coin::balance<AptosCoin>(gas_payer_addr);
// transaction_fee_amount = 0 * 0 = 0, mint_amount = 500
unified_epilogue_v2(
account::create_signer_for_test(gas_payer_addr),
account::create_signer_for_test(gas_payer_addr),
500, 0, 0, 0, false, true,
);
// PASSES — confirms the bug: 500 octas minted despite GGP being enabled
assert!(coin::balance<AptosCoin>(gas_payer_addr) == balance_before + 500, 1);
coin::destroy_burn_cap(burn_cap);
}Test output:
[ PASS ] 0x1::transaction_validation::test_unified_epilogue_v2_mints_apt_when_ggp_enabled_bug
Fix: Restore the guard, or implement the storage-refund pool and debit it here. The easiest safe fix is:
// Only mint the storage refund when GGP is not active.
// When GGP is enabled there is no storage-refund pool yet (see TODO above),
// so minting here would inflate supply without a corresponding pool debit.
if (mint_amount > 0 && !features::governed_gas_pool_enabled()) {
transaction_fee::mint_and_refund(gas_payer_address, mint_amount);
}There was a problem hiding this comment.
On storage refunds:
if (mint_amount > 0) {
transaction_fee::mint_and_refund(gas_payer_address, mint_amount);
}
-
Storage refunds are not enabled on mainnet, feature
STORAGE_DELETION_REFUNDso this code path never executed -
The original code was this:
if (!features::governed_gas_pool_enabled()){
transaction_fee::mint_and_refund(gas_payer, mint_amount);
}
that is not correct either because with gas pools enabled, the refund simply does not occur.
Should the refund go to the gas pool instead? I don't know what the right answer is here, but this code is not executed so the decisions does not matter.
Description
The governed gas pool implementation does not scale well. The issue was that the gas pool did not use a concurrent fungible asset store.
How Has This Been Tested?
(This PR also fixes a few races in
test_multivalidator_staking_reward)Add the line ``--disable-feature GOVERNED_GAS_POOL` to see the performance without governed gas pool, which was much better that with governed gas pools enabled. Now with this change, there is no difference in performance between having ggp enabled or disabled. The difference disappears in the noise. Here is a random sample:
With gas pools enabled:
without gas pools:
Key Areas to Review
Type of Change
Which Components or Systems Does This Change Impact?