diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index f55873ff..539d4c28 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -491,7 +491,8 @@ func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *c } var balance *big.Int - if data.Balance != nil { + pinned := data.Balance != nil + if pinned { balance = new(big.Int).Set(data.Balance) } else { queried, err := tb.rpcClient.GetBalance(ctx, fromAddr) @@ -506,6 +507,19 @@ func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *c return nil, err } + // A pinned balance comes from the coordinator's claimed amount. Reject if it + // exceeds the live balance — the sweep must be backed by real funds. Dust + // inflows only raise the live balance, so they never trip this. + if pinned { + live, err := tb.rpcClient.GetBalance(ctx, fromAddr) + if err != nil { + return nil, fmt.Errorf("failed to get balance of %s: %w", data.From, err) + } + if balance.Cmp(live) > 0 { + return nil, fmt.Errorf("pinned balance %s exceeds live balance %s for %s", balance, live, data.From) + } + } + tb.logger.Debug(). Str("from", data.From). Str("to", data.To). diff --git a/universalClient/chains/evm/tx_builder_test.go b/universalClient/chains/evm/tx_builder_test.go index bd9d545a..eb57bbeb 100644 --- a/universalClient/chains/evm/tx_builder_test.go +++ b/universalClient/chains/evm/tx_builder_test.go @@ -4,6 +4,9 @@ import ( "context" "encoding/hex" "math/big" + "net/http" + "net/http/httptest" + "strings" "testing" "time" @@ -1198,14 +1201,15 @@ func TestBroadcastFundMigrationTx_RejectsMissingAmount(t *testing.T) { // GetBalance call. This is the determinism guarantee the coordinator's // verification path depends on. func TestGetFundMigrationSigningRequest_UsesProvidedBalance(t *testing.T) { - tb := newTestTxBuilder(t) - gasPrice := big.NewInt(20_000_000_000) gasLimit := uint64(21000) expectedAmount := big.NewInt(1_000_000_000_000_000) gasCost := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) balance := new(big.Int).Add(expectedAmount, gasCost) + // Live balance is sufficient (equal to the provided balance). + tb := txBuilderWithBalance(t, new(big.Int).Set(balance)) + data := &common.FundMigrationData{ From: "0x1111111111111111111111111111111111111111", To: "0x2222222222222222222222222222222222222222", @@ -1263,3 +1267,79 @@ func TestBroadcastFundMigrationTx_DoesNotQueryBalance(t *testing.T) { assert.NotContains(t, err.Error(), "get_balance", "broadcast must not call GetBalance") assert.NotContains(t, err.Error(), "failed to get balance", "broadcast must not call GetBalance") } + +// txBuilderWithBalance returns a TxBuilder whose RPC pool answers eth_getBalance +// with the given wei value (Sepolia chain id). +func txBuilderWithBalance(t *testing.T, balanceWei *big.Int) *TxBuilder { + t.Helper() + balHex := "0x" + balanceWei.Text(16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := make([]byte, r.ContentLength) + r.Body.Read(body) + switch { + case strings.Contains(string(body), "eth_chainId"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0xaa36a7"}`)) // 11155111 + case strings.Contains(string(body), "eth_getBalance"): + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"` + balHex + `"}`)) + default: + w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":null}`)) + } + })) + t.Cleanup(server.Close) + + rc, err := NewRPCClient([]string{server.URL}, 11155111, zerolog.Nop()) + require.NoError(t, err) + t.Cleanup(func() { rc.Close() }) + + return &TxBuilder{ + rpcClient: rc, + chainID: "eip155:11155111", + chainIDInt: 11155111, + logger: zerolog.Nop(), + } +} + +// A dust transfer to the old TSS EOA between the coordinator's build and a +// follower's verify must not change the pinned-balance signing hash, and a +// pinned amount larger than the live balance must be rejected. +func TestGetFundMigrationSigningRequest_PinnedBalance(t *testing.T) { + from := "0x1111111111111111111111111111111111111111" + to := "0x2222222222222222222222222222222222222222" + gasPrice := big.NewInt(20_000_000_000) + gasLimit := uint64(21000) + amount := big.NewInt(1_000_000_000_000_000) // 0.001 ETH + fees := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) + pinned := new(big.Int).Add(amount, fees) // balance = amount + fees + + data := func() *common.FundMigrationData { + return &common.FundMigrationData{ + From: from, + To: to, + GasPrice: gasPrice, + GasLimit: gasLimit, + Balance: new(big.Int).Set(pinned), + } + } + + t.Run("hash unchanged by +1 wei dust inflow", func(t *testing.T) { + tbExact := txBuilderWithBalance(t, new(big.Int).Set(pinned)) + reqExact, err := tbExact.GetFundMigrationSigningRequest(context.Background(), data(), 7) + require.NoError(t, err) + + tbDust := txBuilderWithBalance(t, new(big.Int).Add(pinned, big.NewInt(1))) + reqDust, err := tbDust.GetFundMigrationSigningRequest(context.Background(), data(), 7) + require.NoError(t, err) + + assert.Equal(t, reqExact.SigningHash, reqDust.SigningHash) + assert.Equal(t, 0, reqExact.TSSFundMigrationAmount.Cmp(amount)) + assert.Equal(t, 0, reqDust.TSSFundMigrationAmount.Cmp(amount)) + }) + + t.Run("rejects pinned amount above live balance", func(t *testing.T) { + tbShort := txBuilderWithBalance(t, new(big.Int).Sub(pinned, big.NewInt(1))) + _, err := tbShort.GetFundMigrationSigningRequest(context.Background(), data(), 7) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds live balance") + }) +} diff --git a/universalClient/tss/sessionmanager/sessionmanager.go b/universalClient/tss/sessionmanager/sessionmanager.go index 8257d7a0..ba98fb46 100644 --- a/universalClient/tss/sessionmanager/sessionmanager.go +++ b/universalClient/tss/sessionmanager/sessionmanager.go @@ -1029,6 +1029,10 @@ func (sm *SessionManager) verifyFundMigrationSigningRequest(ctx context.Context, req.Nonce, finalizedNonce, oldTSSAddr) } + if req.TSSFundMigrationAmount == nil { + return fmt.Errorf("coordinator's signing request is missing TSSFundMigrationAmount") + } + // Rebuild fund migration signing request with coordinator's nonce. // Parsing must match what the coordinator did; otherwise the reconstructed // hash on OP-stack chains diverges and the verification below rejects it. @@ -1038,12 +1042,23 @@ func (sm *SessionManager) verifyFundMigrationSigningRequest(ctx context.Context, l1GasFee := new(big.Int) l1GasFee.SetString(migrationData.L1GasFee, 10) + // Pin balance from the coordinator's amount (balance = amount + gas + l1) + // instead of re-querying the live tip, so dust sent to the old TSS EOA between + // the coordinator's build and this verify cannot desync the hash. The builder + // still checks the pinned amount is backed by the live balance. + pinnedBalance := new(big.Int).Set(req.TSSFundMigrationAmount) + pinnedBalance.Add(pinnedBalance, new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(migrationData.GasLimit))) + if l1GasFee.Sign() > 0 { + pinnedBalance.Add(pinnedBalance, l1GasFee) + } + migrationFundData := &common.FundMigrationData{ From: oldTSSAddr, To: currentTSSAddr, GasPrice: gasPrice, GasLimit: migrationData.GasLimit, L1GasFee: l1GasFee, + Balance: pinnedBalance, } signingReq, err := builder.GetFundMigrationSigningRequest(ctx, migrationFundData, req.Nonce) if err != nil { @@ -1060,17 +1075,6 @@ func (sm *SessionManager) verifyFundMigrationSigningRequest(ctx context.Context, return fmt.Errorf("fund migration signing hash mismatch: our computed hash does not match coordinator's hash") } - // Defense-in-depth: hash match implies amount match, but cross-check explicitly so - // a wire-format bug, coordinator bug, or missing amount surfaces here rather than - // as a nil-deref / insufficient-balance error later in broadcast. - if req.TSSFundMigrationAmount == nil { - return fmt.Errorf("coordinator's signing request is missing TSSFundMigrationAmount") - } - if req.TSSFundMigrationAmount.Cmp(signingReq.TSSFundMigrationAmount) != 0 { - return fmt.Errorf("TSSFundMigrationAmount mismatch: coordinator=%s ours=%s", - req.TSSFundMigrationAmount.String(), signingReq.TSSFundMigrationAmount.String()) - } - sm.logger.Debug(). Str("event_id", event.EventID). Str("signing_hash", hex.EncodeToString(req.SigningHash)).