diff --git a/universalClient/chains/common/types.go b/universalClient/chains/common/types.go index 727a21d2..d0526d59 100644 --- a/universalClient/chains/common/types.go +++ b/universalClient/chains/common/types.go @@ -33,10 +33,15 @@ type FundMigrationData struct { L1GasFee *big.Int // Extra L1 data-availability fee (wei); 0 for non-L2 chains } -// UnsignedSigningReq contains the request for signing an outbound transaction +// UnsignedSigningReq contains the request for signing an outbound or fund-migration transaction. type UnsignedSigningReq struct { SigningHash []byte // Hash to be signed by TSS Nonce uint64 // evm - TSS Address nonce | svm - PDA nonce + + // TSSFundMigrationAmount is the native value swept for a fund-migration tx, fixed at + // signing time. Nil for outbound. Must be reused verbatim at broadcast — re-querying + // balance there races with a successful sweep from another validator. + TSSFundMigrationAmount *big.Int `json:"TSSFundMigrationAmount,omitempty"` } // TxBuilder builds and broadcasts transactions for outbound transfers diff --git a/universalClient/chains/evm/tx_builder.go b/universalClient/chains/evm/tx_builder.go index 00c47348..8f225e25 100644 --- a/universalClient/chains/evm/tx_builder.go +++ b/universalClient/chains/evm/tx_builder.go @@ -518,16 +518,17 @@ func (tb *TxBuilder) GetFundMigrationSigningRequest(ctx context.Context, data *c signer := types.NewEIP155Signer(big.NewInt(tb.chainIDInt)) txHash := signer.Hash(tx).Bytes() + // TSSFundMigrationAmount rides alongside Nonce in the req — both are signing-time-decided + // values that must reach broadcast unchanged so the signed tx is reproduced exactly. return &common.UnsignedSigningReq{ - SigningHash: txHash, - Nonce: nonce, + SigningHash: txHash, + Nonce: nonce, + TSSFundMigrationAmount: new(big.Int).Set(maxTransfer), }, nil } // BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction. -// The sweep amount must be recomputed here using the same formula as signing -// (balance - gasPrice*gasLimit - l1GasFee); otherwise the broadcast tx hash -// diverges from the signed hash. +// Uses req.TSSFundMigrationAmount fixed at signing time — do not re-query balance. func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.UnsignedSigningReq, data *common.FundMigrationData, signature []byte) (string, error) { if len(signature) != 65 { return "", fmt.Errorf("signature must be 65 bytes [r(32)|s(32)|v(1)], got %d", len(signature)) @@ -540,18 +541,13 @@ func (tb *TxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *common.U return "", fmt.Errorf("gas limit must be provided for fund migration") } - fromAddr := ethcommon.HexToAddress(data.From) - toAddr := ethcommon.HexToAddress(data.To) - - balance, err := tb.rpcClient.GetBalance(ctx, fromAddr) - if err != nil { - return "", fmt.Errorf("failed to get balance of %s: %w", data.From, err) - } - - maxTransfer, err := computeFundMigrationTransfer(balance, data.GasPrice, data.GasLimit, data.L1GasFee) - if err != nil { - return "", err + // Use the exact amount fixed at signing time. Re-querying balance here would race + // with a successful broadcast from another validator (balance goes to 0 post-sweep). + if req.TSSFundMigrationAmount == nil || req.TSSFundMigrationAmount.Sign() <= 0 { + return "", fmt.Errorf("req.TSSFundMigrationAmount must be set for fund migration broadcast") } + toAddr := ethcommon.HexToAddress(data.To) + maxTransfer := new(big.Int).Set(req.TSSFundMigrationAmount) tx := types.NewTransaction( req.Nonce, diff --git a/universalClient/chains/evm/tx_builder_test.go b/universalClient/chains/evm/tx_builder_test.go index 6145856d..e68f952f 100644 --- a/universalClient/chains/evm/tx_builder_test.go +++ b/universalClient/chains/evm/tx_builder_test.go @@ -1143,3 +1143,73 @@ func TestGetFundMigrationSigningRequest_RejectsZeroGasLimit(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "gas limit must be provided") } + +// TestBroadcastFundMigrationTx_RejectsMissingAmount verifies broadcast refuses +// to assemble a tx without the signing-time amount. +func TestBroadcastFundMigrationTx_RejectsMissingAmount(t *testing.T) { + tb := newTestTxBuilder(t) + data := &common.FundMigrationData{ + From: "0x1111111111111111111111111111111111111111", + To: "0x2222222222222222222222222222222222222222", + GasPrice: big.NewInt(20_000_000_000), + GasLimit: 21000, + } + sig := make([]byte, 65) // valid length; bytes don't have to be a real ECDSA sig + + t.Run("nil amount rejected", func(t *testing.T) { + req := &common.UnsignedSigningReq{ + SigningHash: []byte{0x01}, + Nonce: 0, + // TSSFundMigrationAmount intentionally nil + } + _, err := tb.BroadcastFundMigrationTx(context.Background(), req, data, sig) + require.Error(t, err) + assert.Contains(t, err.Error(), "TSSFundMigrationAmount must be set") + }) + + t.Run("zero amount rejected", func(t *testing.T) { + req := &common.UnsignedSigningReq{ + SigningHash: []byte{0x01}, + Nonce: 0, + TSSFundMigrationAmount: big.NewInt(0), + } + _, err := tb.BroadcastFundMigrationTx(context.Background(), req, data, sig) + require.Error(t, err) + assert.Contains(t, err.Error(), "TSSFundMigrationAmount must be set") + }) + + t.Run("negative amount rejected", func(t *testing.T) { + req := &common.UnsignedSigningReq{ + SigningHash: []byte{0x01}, + Nonce: 0, + TSSFundMigrationAmount: big.NewInt(-1), + } + _, err := tb.BroadcastFundMigrationTx(context.Background(), req, data, sig) + require.Error(t, err) + assert.Contains(t, err.Error(), "TSSFundMigrationAmount must be set") + }) +} + +// TestBroadcastFundMigrationTx_DoesNotQueryBalance asserts broadcast never +// calls GetBalance. Fails loudly if a balance lookup is reintroduced. +func TestBroadcastFundMigrationTx_DoesNotQueryBalance(t *testing.T) { + tb := newTestTxBuilder(t) + data := &common.FundMigrationData{ + From: "0x1111111111111111111111111111111111111111", + To: "0x2222222222222222222222222222222222222222", + GasPrice: big.NewInt(20_000_000_000), + GasLimit: 21000, + L1GasFee: big.NewInt(0), + } + req := &common.UnsignedSigningReq{ + SigningHash: []byte{0x01}, + Nonce: 0, + TSSFundMigrationAmount: big.NewInt(1_000_000_000_000_000), // 0.001 ETH + } + sig := make([]byte, 65) + + _, err := tb.BroadcastFundMigrationTx(context.Background(), req, data, sig) + require.Error(t, err) + 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") +} diff --git a/universalClient/tss/sessionmanager/sessionmanager.go b/universalClient/tss/sessionmanager/sessionmanager.go index db96991d..00c9f7d7 100644 --- a/universalClient/tss/sessionmanager/sessionmanager.go +++ b/universalClient/tss/sessionmanager/sessionmanager.go @@ -927,12 +927,23 @@ 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)). Str("old_tss_addr", oldTSSAddr). Str("current_tss_addr", currentTSSAddr). - Msg("fund migration sign metadata verified - hash matches") + Msg("fund migration sign metadata verified - hash and amount match") return nil } @@ -947,7 +958,8 @@ func (sm *SessionManager) getTSSAddress(ctx context.Context) (string, error) { } // handleSigningComplete handles post-sign steps. EVM: set status SIGNED and store payload (txlifecycle/signed runs BroadcastOutboundSigningRequest). Solana: enqueue for sequential per-chain broadcast (PDA nonce order). -// signingReq is the cached signing request from the coordinator setup message. +// signingReq is the cached signing request from the coordinator setup message; for FUND_MIGRATE +// its TSSFundMigrationAmount is populated by verifyFundMigrationSigningRequest and persisted here. func (sm *SessionManager) handleSigningComplete(_ context.Context, eventID string, eventData []byte, signature []byte, signingReq *common.UnsignedSigningReq) error { if signingReq == nil { return fmt.Errorf("signing request is nil - cannot persist signing data") @@ -959,6 +971,9 @@ func (sm *SessionManager) handleSigningComplete(_ context.Context, eventID strin "signing_hash": hex.EncodeToString(signingReq.SigningHash), "nonce": signingReq.Nonce, } + if signingReq.TSSFundMigrationAmount != nil && signingReq.TSSFundMigrationAmount.Sign() > 0 { + signingData["tss_fund_migration_amount"] = signingReq.TSSFundMigrationAmount + } // Unmarshal original event data, add signing_data, re-marshal var raw map[string]any diff --git a/universalClient/tss/sessionmanager/sessionmanager_test.go b/universalClient/tss/sessionmanager/sessionmanager_test.go index ee7ecd20..596e00a2 100644 --- a/universalClient/tss/sessionmanager/sessionmanager_test.go +++ b/universalClient/tss/sessionmanager/sessionmanager_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/big" "reflect" "testing" "time" @@ -696,10 +697,10 @@ func TestVerifyFundMigrationSigningRequest_Validation(t *testing.T) { EventData: eventDataBytes, } sm.chains = nil - err := sm.verifyFundMigrationSigningRequest(ctx, event, &common.UnsignedSigningReq{ - SigningHash: []byte{0x01, 0x02}, - }) + req := &common.UnsignedSigningReq{SigningHash: []byte{0x01, 0x02}} + err := sm.verifyFundMigrationSigningRequest(ctx, event, req) assert.NoError(t, err) + assert.Nil(t, req.TSSFundMigrationAmount, "amount stays nil when chain/builder is skipped") }) } @@ -1047,6 +1048,43 @@ func TestHandleSigningComplete(t *testing.T) { assert.Equal(t, "beef", signingData["signature"]) assert.Equal(t, "dead", signingData["signing_hash"]) assert.Equal(t, float64(99), signingData["nonce"]) + _, hasAmount := signingData["tss_fund_migration_amount"] + assert.False(t, hasAmount, "tss_fund_migration_amount is omitted for outbound events") + }) + + t.Run("fund migration signing complete persists tss_fund_migration_amount", func(t *testing.T) { + event := store.Event{ + EventID: "fm-complete-1", + BlockHeight: 250, + Type: store.EventTypeSignFundMigrate, + Status: store.StatusInProgress, + EventData: []byte(`{"migration_id":7,"chain":"eip155:1"}`), + } + require.NoError(t, testDB.Create(&event).Error) + + req := &common.UnsignedSigningReq{ + SigningHash: []byte{0xca, 0xfe}, + Nonce: 3, + TSSFundMigrationAmount: new(big.Int).SetUint64(123456789), + } + err := sm.handleSigningComplete(context.Background(), "fm-complete-1", event.EventData, []byte{0xbe, 0xef}, req) + require.NoError(t, err) + + var updated store.Event + require.NoError(t, testDB.Where("event_id = ?", "fm-complete-1").First(&updated).Error) + assert.Equal(t, store.StatusSigned, updated.Status) + + // Decode the field into *big.Int directly — unmarshalling into map[string]any + // would coerce the JSON number into float64 and lose precision for wei values. + var decoded struct { + SigningData struct { + TSSFundMigrationAmount *big.Int `json:"tss_fund_migration_amount"` + } `json:"signing_data"` + } + require.NoError(t, json.Unmarshal(updated.EventData, &decoded)) + require.NotNil(t, decoded.SigningData.TSSFundMigrationAmount, + "tss_fund_migration_amount must survive the sign→broadcast handoff so broadcast reproduces the signed tx") + assert.Equal(t, "123456789", decoded.SigningData.TSSFundMigrationAmount.String()) }) } diff --git a/universalClient/tss/txbroadcaster/broadcaster.go b/universalClient/tss/txbroadcaster/broadcaster.go index dde29a18..d4f3bd59 100644 --- a/universalClient/tss/txbroadcaster/broadcaster.go +++ b/universalClient/tss/txbroadcaster/broadcaster.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math/big" "time" "github.com/rs/zerolog" @@ -24,9 +25,10 @@ import ( // SigningData holds the signing parameters persisted by sessionManager when marking SIGNED. type SigningData struct { - Signature string `json:"signature"` // hex-encoded 64/65 byte signature - SigningHash string `json:"signing_hash"` // hex-encoded signing hash - Nonce uint64 `json:"nonce"` + Signature string `json:"signature"` // hex-encoded 64/65 byte signature + SigningHash string `json:"signing_hash"` // hex-encoded signing hash + Nonce uint64 `json:"nonce"` + TSSFundMigrationAmount *big.Int `json:"tss_fund_migration_amount,omitempty"` } // SignedOutboundData wraps OutboundCreatedEvent with signing data. @@ -208,8 +210,9 @@ func decodeSigningData(sd *SigningData) (*common.UnsignedSigningReq, []byte, err } return &common.UnsignedSigningReq{ - SigningHash: signingHash, - Nonce: sd.Nonce, + SigningHash: signingHash, + Nonce: sd.Nonce, + TSSFundMigrationAmount: sd.TSSFundMigrationAmount, }, signature, nil } diff --git a/universalClient/tss/txbroadcaster/broadcaster_test.go b/universalClient/tss/txbroadcaster/broadcaster_test.go index 060333ff..6404119b 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math/big" "reflect" "testing" "time" @@ -451,6 +452,11 @@ const testOldTSSPubkey = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2 const testNewTSSPubkey = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" func makeSignedFundMigrationData(t *testing.T, chainID string, nonce uint64) []byte { + t.Helper() + return makeSignedFundMigrationDataWithTransfer(t, chainID, nonce, nil) +} + +func makeSignedFundMigrationDataWithTransfer(t *testing.T, chainID string, nonce uint64, transferAmount *big.Int) []byte { t.Helper() sig := hex.EncodeToString(make([]byte, 65)) hash := hex.EncodeToString(make([]byte, 32)) @@ -467,9 +473,10 @@ func makeSignedFundMigrationData(t *testing.T, chainID string, nonce uint64) []b L1GasFee: "150", }, SigningData: &SigningData{ - Signature: sig, - SigningHash: hash, - Nonce: nonce, + Signature: sig, + SigningHash: hash, + Nonce: nonce, + TSSFundMigrationAmount: transferAmount, }, } b, err := json.Marshal(data) @@ -522,6 +529,45 @@ func TestFundMigrationEVM_BroadcastSuccess(t *testing.T) { builder.AssertExpectations(t) } +// TestFundMigrationEVM_TSSFundMigrationAmountThreaded asserts the tss_fund_migration_amount captured +// at signing time is decoded onto the signing req passed to BroadcastFundMigrationTx. Without +// this, the second validator's broadcast queries balance=0 (post-sweep) and the assembler +// returns "insufficient balance" — leaving the event stuck in SIGNED forever and blocking +// migration consensus. +func TestFundMigrationEVM_TSSFundMigrationAmountThreaded(t *testing.T) { + evtStore, db := setupTestDB(t) + builder := &mockTxBuilder{} + client := &mockChainClient{builder: builder} + ch := newTestChains(t, "eip155:1", uregistrytypes.VmType_EVM, client) + + event := store.Event{ + EventID: "fm-transfer", + BlockHeight: 100, + ExpiryBlockHeight: 99999, + Type: store.EventTypeSignFundMigrate, + ConfirmationType: "INSTANT", + Status: store.StatusSigned, + EventData: makeSignedFundMigrationDataWithTransfer(t, "eip155:1", 0, new(big.Int).SetUint64(777_000_000_000_000_000)), + } + require.NoError(t, db.Create(&event).Error) + + builder.On("BroadcastFundMigrationTx", + mock.Anything, + mock.MatchedBy(func(req *common.UnsignedSigningReq) bool { + return req.TSSFundMigrationAmount != nil && req.TSSFundMigrationAmount.String() == "777000000000000000" + }), + mock.Anything, + mock.Anything). + Return("0xmigrate777", nil) + + b := newBroadcaster(evtStore, ch, "") + b.processSigned(context.Background()) + + ev := getEvent(t, db, "fm-transfer") + require.Equal(t, store.StatusBroadcasted, ev.Status) + builder.AssertExpectations(t) +} + func TestFundMigrationEVM_BroadcastFails_NonceConsumed(t *testing.T) { evtStore, db := setupTestDB(t) builder := &mockTxBuilder{}