Skip to content

Commit ac9f2d1

Browse files
authored
Merge pull request #206 from pushchain/feat/tss-fund-migration-fixes
feat: added dynamic gas limit changes in fund migration
2 parents a773429 + 484b3b4 commit ac9f2d1

28 files changed

Lines changed: 944 additions & 135 deletions

File tree

api/utss/v1/types.pulsar.go

Lines changed: 119 additions & 45 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/upgrades.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
aiauditfixes2 "github.com/pushchain/push-chain-node/app/upgrades/ai-audit-fixes-2"
1111
purgeexpiredoutbounds "github.com/pushchain/push-chain-node/app/upgrades/purge-expired-outbounds"
1212
removeutxverifier "github.com/pushchain/push-chain-node/app/upgrades/remove-utxverifier"
13+
tssfundmigrationfixes "github.com/pushchain/push-chain-node/app/upgrades/tss-fund-migration-fixes"
1314
tssmigration "github.com/pushchain/push-chain-node/app/upgrades/tss-migration"
1415
ueamigration "github.com/pushchain/push-chain-node/app/upgrades/uea-migration"
1516
ceagasandpayload "github.com/pushchain/push-chain-node/app/upgrades/cea-gas-and-payload"
@@ -63,6 +64,7 @@ var Upgrades = []upgrades.Upgrade{
6364
tssmigration.NewUpgrade(),
6465
purgeexpiredoutbounds.NewUpgrade(),
6566
removeutxverifier.NewUpgrade(),
67+
tssfundmigrationfixes.NewUpgrade(),
6668
}
6769

6870
// RegisterUpgradeHandlers registers the chain upgrade handlers
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package tssfundmigrationfixes
2+
3+
import (
4+
"context"
5+
6+
storetypes "cosmossdk.io/store/types"
7+
upgradetypes "cosmossdk.io/x/upgrade/types"
8+
9+
sdk "github.com/cosmos/cosmos-sdk/types"
10+
"github.com/cosmos/cosmos-sdk/types/module"
11+
12+
"github.com/pushchain/push-chain-node/app/upgrades"
13+
)
14+
15+
const UpgradeName = "tss-fund-migration-fixes"
16+
17+
func NewUpgrade() upgrades.Upgrade {
18+
return upgrades.Upgrade{
19+
UpgradeName: UpgradeName,
20+
CreateUpgradeHandler: CreateUpgradeHandler,
21+
StoreUpgrades: storetypes.StoreUpgrades{
22+
Added: []string{},
23+
Deleted: []string{},
24+
},
25+
}
26+
}
27+
28+
// CreateUpgradeHandler runs the utss v3 → v4 migration which backfills
29+
// FundMigration.l1_gas_fee on records stored before the field existed.
30+
// The new gas_limit and l1_gas_fee values used by InitiateFundMigration are
31+
// sourced from UniversalCore's tssFundMigrationGasLimitByChainNamespace and
32+
// l1GasFeeByChainNamespace mappings at call time — no state seeding required.
33+
func CreateUpgradeHandler(
34+
mm upgrades.ModuleManager,
35+
configurator module.Configurator,
36+
ak *upgrades.AppKeepers,
37+
) upgradetypes.UpgradeHandler {
38+
return func(ctx context.Context, plan upgradetypes.Plan, fromVM module.VersionMap) (module.VersionMap, error) {
39+
sdkCtx := sdk.UnwrapSDKContext(ctx)
40+
logger := sdkCtx.Logger().With("upgrade", UpgradeName)
41+
logger.Info("Starting upgrade handler")
42+
logger.Info("Feature: FundMigration.gas_limit and l1_gas_fee now sourced from UniversalCore per-chain mappings")
43+
44+
versionMap, err := mm.RunMigrations(ctx, configurator, fromVM)
45+
if err != nil {
46+
logger.Error("RunMigrations failed", "error", err)
47+
return nil, err
48+
}
49+
50+
logger.Info("Upgrade complete", "upgrade", UpgradeName)
51+
return versionMap, nil
52+
}
53+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package tssfundmigrationfixes_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
tssfundmigrationfixes "github.com/pushchain/push-chain-node/app/upgrades/tss-fund-migration-fixes"
9+
)
10+
11+
// TestNewUpgrade_Identity verifies the upgrade descriptor carries the expected
12+
// name, wires up a non-nil handler factory, and declares no store additions or
13+
// deletions (the migration is in-place on existing kv keys).
14+
func TestNewUpgrade_Identity(t *testing.T) {
15+
u := tssfundmigrationfixes.NewUpgrade()
16+
17+
require.Equal(t, "tss-fund-migration-fixes", u.UpgradeName)
18+
require.NotNil(t, u.CreateUpgradeHandler, "upgrade must expose a handler factory")
19+
require.Empty(t, u.StoreUpgrades.Added, "no new KV stores expected")
20+
require.Empty(t, u.StoreUpgrades.Deleted, "no KV stores deleted")
21+
require.Empty(t, u.StoreUpgrades.Renamed, "no KV stores renamed")
22+
}

proto/utss/v1/types.proto

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,6 @@ message FundMigration {
103103
int64 completed_block = 9;
104104
string tx_hash = 10;
105105
string gas_price = 11; // gas price from oracle (wei)
106-
uint64 gas_limit = 12; // gas limit for native transfer (21000)
106+
uint64 gas_limit = 12; // gas limit sourced from UniversalCore per chain namespace
107+
string l1_gas_fee = 13; // L1 data-availability fee (wei) from UniversalCore; 0 for non-L2 chains
107108
}

test/integration/utss/fund_migration_test.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package integrationtest
22

33
import (
44
"fmt"
5+
"math/big"
56
"strconv"
7+
"strings"
68
"testing"
79

810
sdk "github.com/cosmos/cosmos-sdk/types"
11+
"github.com/ethereum/go-ethereum/accounts/abi"
12+
"github.com/ethereum/go-ethereum/common"
13+
"github.com/ethereum/go-ethereum/crypto"
914
"github.com/stretchr/testify/require"
1015

1116
"github.com/pushchain/push-chain-node/app"
@@ -18,12 +23,84 @@ import (
1823

1924
const testChain = "eip155:11155111"
2025

26+
// universalCoreSetupABI exposes the admin methods needed to configure
27+
// per-chain mappings during test setup. These are intentionally kept out of
28+
// the production ABI (x/uexecutor/types/abi.go) — Go-side keeper code never
29+
// calls them; only tests do.
30+
const universalCoreSetupABI = `[
31+
{
32+
"type": "function",
33+
"name": "grantRole",
34+
"inputs": [
35+
{ "name": "role", "type": "bytes32", "internalType": "bytes32" },
36+
{ "name": "account", "type": "address", "internalType": "address" }
37+
],
38+
"outputs": [],
39+
"stateMutability": "nonpayable"
40+
},
41+
{
42+
"type": "function",
43+
"name": "setL1GasFeeByChain",
44+
"inputs": [
45+
{ "name": "chainNamespace", "type": "string", "internalType": "string" },
46+
{ "name": "l1GasFee", "type": "uint256", "internalType": "uint256" }
47+
],
48+
"outputs": [],
49+
"stateMutability": "nonpayable"
50+
},
51+
{
52+
"type": "function",
53+
"name": "setTssFundMigrationGasLimitByChain",
54+
"inputs": [
55+
{ "name": "chainNamespace", "type": "string", "internalType": "string" },
56+
{ "name": "gasLimit", "type": "uint256", "internalType": "uint256" }
57+
],
58+
"outputs": [],
59+
"stateMutability": "nonpayable"
60+
}
61+
]`
62+
63+
// seedFundMigrationChainValues grants MANAGER_ROLE to the admin and seeds the
64+
// per-chain tss-fund-migration gas limit and L1 gas fee on UniversalCore.
65+
// InitiateFundMigration rejects a zero gas limit, so without this seeding the
66+
// keeper read returns 0 and the migration fails validation.
67+
func seedFundMigrationChainValues(
68+
t *testing.T,
69+
chainApp *app.ChainApp,
70+
ctx sdk.Context,
71+
admin common.Address,
72+
chain string,
73+
gasLimit, l1GasFee *big.Int,
74+
) {
75+
t.Helper()
76+
77+
handlerAddr := utils.GetDefaultAddresses().HandlerAddr
78+
setupABI, err := abi.JSON(strings.NewReader(universalCoreSetupABI))
79+
require.NoError(t, err)
80+
81+
managerRole := crypto.Keccak256Hash([]byte("MANAGER_ROLE"))
82+
var roleArg [32]byte
83+
copy(roleArg[:], managerRole.Bytes())
84+
85+
_, err = chainApp.EVMKeeper.CallEVM(ctx, setupABI, admin, handlerAddr, true, "grantRole", roleArg, admin)
86+
require.NoError(t, err, "grant MANAGER_ROLE")
87+
88+
_, err = chainApp.EVMKeeper.CallEVM(ctx, setupABI, admin, handlerAddr, true, "setTssFundMigrationGasLimitByChain", chain, gasLimit)
89+
require.NoError(t, err, "seed tss fund migration gas limit")
90+
91+
_, err = chainApp.EVMKeeper.CallEVM(ctx, setupABI, admin, handlerAddr, true, "setL1GasFeeByChain", chain, l1GasFee)
92+
require.NoError(t, err, "seed l1 gas fee")
93+
}
94+
2195
// setupFundMigrationTest initializes app with validators, a finalized keygen key, and a chain config.
2296
// Returns app, ctx, validator addresses, and the finalized key ID.
2397
func setupFundMigrationTest(t *testing.T, numVals int, outboundEnabled bool) (*app.ChainApp, sdk.Context, []string, string) {
2498
t.Helper()
2599

26-
app, ctx, _, validators := utils.SetAppWithMultipleValidators(t, numVals)
100+
app, ctx, baseAccounts, validators := utils.SetAppWithMultipleValidators(t, numVals)
101+
102+
admin := common.BytesToAddress(baseAccounts[0].GetAddress().Bytes())
103+
seedFundMigrationChainValues(t, app, ctx, admin, testChain, big.NewInt(21000), big.NewInt(150))
27104

28105
// Register universal validators
29106
universalVals := make([]string, len(validators))
@@ -129,7 +206,10 @@ func TestInitiateFundMigration(t *testing.T) {
129206
require.Equal(t, utsstypes.FundMigrationStatus_FUND_MIGRATION_STATUS_PENDING, migration.Status)
130207
require.Equal(t, oldKeyId, migration.OldKeyId)
131208
require.Equal(t, testChain, migration.Chain)
209+
// GasLimit and L1GasFee come from UniversalCore's per-chain mappings,
210+
// seeded by seedFundMigrationChainValues.
132211
require.Equal(t, uint64(21000), migration.GasLimit)
212+
require.Equal(t, "150", migration.L1GasFee)
133213
require.NotEmpty(t, migration.GasPrice)
134214

135215
// Verify pending index

test/utils/bytecode.go

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

universalClient/chains/common/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type FundMigrationData struct {
3030
To string // New TSS address (derived from current pubkey)
3131
GasPrice *big.Int // Gas price from the migration event
3232
GasLimit uint64 // Gas limit from the migration event
33+
L1GasFee *big.Int // Extra L1 data-availability fee (wei); 0 for non-L2 chains
3334
}
3435

3536
// UnsignedSigningReq contains the request for signing an outbound transaction

universalClient/chains/evm/tx_builder.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -471,26 +471,29 @@ func (tb *TxBuilder) GetGasFeeUsed(ctx context.Context, txHash string) (string,
471471
}
472472

473473
// GetFundMigrationSigningRequest builds a native token transfer for fund migration,
474-
// transferring the maximum possible balance (balance minus gas cost).
474+
// transferring the maximum possible balance (balance minus gas cost minus L1 fee).
475475
// Fund migration only triggers when outbound is disabled and no pending outbounds remain,
476476
// so the balance at signing time will equal the balance at broadcast time.
477+
// L1GasFee covers OP-stack sequencer data-availability charges; 0 for non-L2 chains.
477478
func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *common.FundMigrationData, nonce uint64) (*common.UnsignedSigningReq, error) {
478479
fromAddr := ethcommon.HexToAddress(data.From)
479480
toAddr := ethcommon.HexToAddress(data.To)
480481

481482
if data.GasPrice == nil || data.GasPrice.Sign() == 0 {
482483
return nil, fmt.Errorf("gas price must be provided for fund migration")
483484
}
485+
if data.GasLimit == 0 {
486+
return nil, fmt.Errorf("gas limit must be provided for fund migration")
487+
}
484488

485489
balance, err := tb.rpcClient.GetBalance(ctx, fromAddr)
486490
if err != nil {
487491
return nil, fmt.Errorf("failed to get balance of %s: %w", data.From, err)
488492
}
489493

490-
gasCost := new(big.Int).Mul(data.GasPrice, new(big.Int).SetUint64(data.GasLimit))
491-
maxTransfer := new(big.Int).Sub(balance, gasCost)
492-
if maxTransfer.Sign() <= 0 {
493-
return nil, fmt.Errorf("insufficient balance for gas: balance=%s gasCost=%s", balance.String(), gasCost.String())
494+
maxTransfer, err := computeFundMigrationTransfer(balance, data.GasPrice, data.GasLimit, data.L1GasFee)
495+
if err != nil {
496+
return nil, err
494497
}
495498

496499
tb.logger.Info().
@@ -499,6 +502,7 @@ func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *c
499502
Str("balance", balance.String()).
500503
Str("gas_price", data.GasPrice.String()).
501504
Uint64("gas_limit", data.GasLimit).
505+
Str("l1_gas_fee", l1GasFeeString(data.L1GasFee)).
502506
Str("transfer_amount", maxTransfer.String()).
503507
Msg("building fund migration tx")
504508

@@ -521,6 +525,9 @@ func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *c
521525
}
522526

523527
// BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction.
528+
// The sweep amount must be recomputed here using the same formula as signing
529+
// (balance - gasPrice*gasLimit - l1GasFee); otherwise the broadcast tx hash
530+
// diverges from the signed hash.
524531
func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.UnsignedSigningReq, data *common.FundMigrationData, signature []byte) (string, error) {
525532
if len(signature) != 65 {
526533
return "", fmt.Errorf("signature must be 65 bytes [r(32)|s(32)|v(1)], got %d", len(signature))
@@ -529,6 +536,9 @@ func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.U
529536
if data.GasPrice == nil || data.GasPrice.Sign() == 0 {
530537
return "", fmt.Errorf("gas price must be provided for fund migration")
531538
}
539+
if data.GasLimit == 0 {
540+
return "", fmt.Errorf("gas limit must be provided for fund migration")
541+
}
532542

533543
fromAddr := ethcommon.HexToAddress(data.From)
534544
toAddr := ethcommon.HexToAddress(data.To)
@@ -538,10 +548,9 @@ func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.U
538548
return "", fmt.Errorf("failed to get balance of %s: %w", data.From, err)
539549
}
540550

541-
gasCost := new(big.Int).Mul(data.GasPrice, new(big.Int).SetUint64(data.GasLimit))
542-
maxTransfer := new(big.Int).Sub(balance, gasCost)
543-
if maxTransfer.Sign() <= 0 {
544-
return "", fmt.Errorf("insufficient balance for gas during broadcast")
551+
maxTransfer, err := computeFundMigrationTransfer(balance, data.GasPrice, data.GasLimit, data.L1GasFee)
552+
if err != nil {
553+
return "", err
545554
}
546555

547556
tx := types.NewTransaction(
@@ -574,3 +583,31 @@ func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.U
574583

575584
return txHashStr, nil
576585
}
586+
587+
// computeFundMigrationTransfer returns the native amount to sweep from the old
588+
// TSS address to the new one: balance - (gasPrice * gasLimit) - l1GasFee.
589+
// The l1GasFee covers OP-stack sequencer data-availability charges (0 for
590+
// non-L2 chains). All validators must compute the same value — any drift
591+
// here breaks the TSS signing hash.
592+
func computeFundMigrationTransfer(balance, gasPrice *big.Int, gasLimit uint64, l1GasFee *big.Int) (*big.Int, error) {
593+
gasCost := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
594+
totalFee := new(big.Int).Set(gasCost)
595+
if l1GasFee != nil && l1GasFee.Sign() > 0 {
596+
totalFee.Add(totalFee, l1GasFee)
597+
}
598+
maxTransfer := new(big.Int).Sub(balance, totalFee)
599+
if maxTransfer.Sign() <= 0 {
600+
return nil, fmt.Errorf("insufficient balance for gas: balance=%s gasCost=%s l1GasFee=%s",
601+
balance.String(), gasCost.String(), l1GasFeeString(l1GasFee))
602+
}
603+
return maxTransfer, nil
604+
}
605+
606+
// l1GasFeeString returns a stable decimal representation of the L1 gas fee
607+
// for logging / error messages, treating nil as "0".
608+
func l1GasFeeString(v *big.Int) string {
609+
if v == nil {
610+
return "0"
611+
}
612+
return v.String()
613+
}

0 commit comments

Comments
 (0)