Skip to content
Merged
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
7 changes: 6 additions & 1 deletion universalClient/chains/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 12 additions & 16 deletions universalClient/chains/evm/tx_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions universalClient/chains/evm/tx_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
19 changes: 17 additions & 2 deletions universalClient/tss/sessionmanager/sessionmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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")
Expand All @@ -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
Expand Down
44 changes: 41 additions & 3 deletions universalClient/tss/sessionmanager/sessionmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"math/big"
"reflect"
"testing"
"time"
Expand Down Expand Up @@ -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")
})
}

Expand Down Expand Up @@ -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())
})
}

Expand Down
13 changes: 8 additions & 5 deletions universalClient/tss/txbroadcaster/broadcaster.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"time"

"github.com/rs/zerolog"
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down
52 changes: 49 additions & 3 deletions universalClient/tss/txbroadcaster/broadcaster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"reflect"
"testing"
"time"
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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{}
Expand Down
Loading