Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion universalClient/chains/evm/tx_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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).
Expand Down
84 changes: 82 additions & 2 deletions universalClient/chains/evm/tx_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"context"
"encoding/hex"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
})
}
26 changes: 15 additions & 11 deletions universalClient/tss/sessionmanager/sessionmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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)).
Expand Down
Loading