diff --git a/universalClient/chains/evm/client.go b/universalClient/chains/evm/client.go index 83c0a1a6..c3691634 100644 --- a/universalClient/chains/evm/client.go +++ b/universalClient/chains/evm/client.go @@ -386,23 +386,23 @@ func parseEVMChainID(caip2 string) (int64, error) { return chainID, nil } -// FetchVaultAddress calls the gateway's VAULT() public getter to retrieve the vault address. +// FetchVaultAddress calls the gateway's vault() public getter to retrieve the vault address. func FetchVaultAddress(ctx context.Context, rpcClient *RPCClient, gatewayAddress ethcommon.Address) (ethcommon.Address, error) { - // vaultCallSelector is the 4-byte selector for VAULT() public getter - vaultCallSelector := crypto.Keccak256([]byte("VAULT()"))[:4] + // vaultCallSelector is the 4-byte selector for vault() public getter + vaultCallSelector := crypto.Keccak256([]byte("vault()"))[:4] result, err := rpcClient.CallContract(ctx, gatewayAddress, vaultCallSelector, nil) if err != nil { - return ethcommon.Address{}, fmt.Errorf("VAULT() call failed: %w", err) + return ethcommon.Address{}, fmt.Errorf("vault() call failed: %w", err) } if len(result) < 32 { - return ethcommon.Address{}, fmt.Errorf("VAULT() returned invalid data (len=%d)", len(result)) + return ethcommon.Address{}, fmt.Errorf("vault() returned invalid data (len=%d)", len(result)) } addr := ethcommon.BytesToAddress(result[12:32]) if addr == (ethcommon.Address{}) { - return ethcommon.Address{}, fmt.Errorf("VAULT() returned zero address") + return ethcommon.Address{}, fmt.Errorf("vault() returned zero address") } return addr, nil diff --git a/universalClient/chains/evm/client_test.go b/universalClient/chains/evm/client_test.go index 1ea67b10..0f0f45a5 100644 --- a/universalClient/chains/evm/client_test.go +++ b/universalClient/chains/evm/client_test.go @@ -160,7 +160,7 @@ func TestClientStartStop(t *testing.T) { bodyStr := string(body) if strings.Contains(bodyStr, "eth_call") { - // Return a mock vault address for VAULT() call + // Return a mock vault address for vault() call w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"` + mockVaultResult + `"}`)) } else { // Default: eth_chainId response diff --git a/universalClient/chains/evm/tx_builder_test.go b/universalClient/chains/evm/tx_builder_test.go index 6145856d..8ae921fe 100644 --- a/universalClient/chains/evm/tx_builder_test.go +++ b/universalClient/chains/evm/tx_builder_test.go @@ -22,7 +22,7 @@ import ( const testVaultAddress = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // newTestTxBuilder creates a TxBuilder for unit tests by directly setting the -// vault address, bypassing the constructor's RPC call to VAULT(). +// vault address, bypassing the constructor's RPC call to vault(). func newTestTxBuilder(t *testing.T) *TxBuilder { t.Helper() logger := zerolog.Nop() @@ -834,15 +834,15 @@ func TestSimulateBSC_FetchVaultFromGateway(t *testing.T) { defer cancel() gwAddr := ethcommon.HexToAddress(bscGatewayAddress) - vaultCallSelector := crypto.Keccak256([]byte("VAULT()"))[:4] + vaultCallSelector := crypto.Keccak256([]byte("vault()"))[:4] result, err := rpcClient.CallContract(ctx, gwAddr, vaultCallSelector, nil) - require.NoError(t, err, "VAULT() call should succeed") - require.True(t, len(result) >= 32, "VAULT() should return at least 32 bytes") + require.NoError(t, err, "vault() call should succeed") + require.True(t, len(result) >= 32, "vault() should return at least 32 bytes") vaultAddr := ethcommon.BytesToAddress(result[12:32]) - assert.NotEqual(t, ethcommon.Address{}, vaultAddr, "VAULT() should not return zero address") - assert.Equal(t, ethcommon.HexToAddress(bscVaultAddress), vaultAddr, "VAULT() should match expected vault address") - t.Logf("VAULT() returned: %s", vaultAddr.Hex()) + assert.NotEqual(t, ethcommon.Address{}, vaultAddr, "vault() should not return zero address") + assert.Equal(t, ethcommon.HexToAddress(bscVaultAddress), vaultAddr, "vault() should match expected vault address") + t.Logf("vault() returned: %s", vaultAddr.Hex()) } func TestSimulateBSC_RevertUniversalTx_Native(t *testing.T) { diff --git a/universalClient/chains/svm/event_parser.go b/universalClient/chains/svm/event_parser.go index 00f5d332..4c25625d 100644 --- a/universalClient/chains/svm/event_parser.go +++ b/universalClient/chains/svm/event_parser.go @@ -104,7 +104,10 @@ func parseSendFundsEvent(log string, signature string, slot uint64, logIndex uin // - discriminator (8 bytes) // - sub_tx_id (32 bytes) // - universal_tx_id (32 bytes) -// - gas_fee (8 bytes, u64 lamports) +// - gas_fee (8 bytes, u64 lamports) — prepaid budget +// - gas_used (8 bytes, u64 lamports) — actual lamports consumed +// - gas_to_refund (8 bytes, u64 lamports) — gas_fee - gas_used returned to caller +// - ata_created (1 byte, bool) — true if SPL ATA was newly created // - push_account (20 bytes) // - target (32 bytes, Pubkey) // - token (32 bytes, Pubkey) @@ -121,11 +124,12 @@ func parseOutboundObservationEvent(log string, signature string, slot uint64, lo return nil } - // Minimum: 8 disc + 32 sub_tx_id + 32 universal_tx_id + 8 gas_fee = 80 bytes - if len(decoded) < 80 { + // Minimum: 8 disc + 32 sub_tx_id + 32 universal_tx_id + 8 gas_fee + 8 gas_used + // + 8 gas_to_refund + 1 ata_created = 97 bytes. + if len(decoded) < 97 { logger.Warn(). Int("data_len", len(decoded)). - Msg("data too short for outboundObservation event; need at least 80 bytes") + Msg("data too short for outboundObservation event; need at least 97 bytes") return nil } @@ -150,14 +154,18 @@ func parseOutboundObservationEvent(log string, signature string, slot uint64, lo universalTxID := "0x" + hex.EncodeToString(decoded[offset:offset+32]) offset += 32 - // Extract gas_fee (8 bytes, u64 little-endian lamports) - gasFee := binary.LittleEndian.Uint64(decoded[offset : offset+8]) + // Skip gas_fee (prepaid budget, 8 bytes); the audited finalize event reports + // gas_used separately and that's the value we want to surface as GasFeeUsed. + offset += 8 + + // Extract gas_used (8 bytes, u64 little-endian lamports) — actual gas consumed. + gasUsed := binary.LittleEndian.Uint64(decoded[offset : offset+8]) // Create OutboundEvent payload payload := common.OutboundEvent{ TxID: txID, UniversalTxID: universalTxID, - GasFeeUsed: fmt.Sprintf("%d", gasFee), + GasFeeUsed: fmt.Sprintf("%d", gasUsed), } // Marshal payload to JSON @@ -185,7 +193,7 @@ func parseOutboundObservationEvent(log string, signature string, slot uint64, lo Str("event_id", eventID). Str("tx_id", txID). Str("universal_tx_id", universalTxID). - Str("gas_fee", fmt.Sprintf("%d", gasFee)). + Str("gas_used", fmt.Sprintf("%d", gasUsed)). Msg("parsed outboundObservation event") return event diff --git a/universalClient/chains/svm/event_parser_test.go b/universalClient/chains/svm/event_parser_test.go index f8782a18..da11ce0c 100644 --- a/universalClient/chains/svm/event_parser_test.go +++ b/universalClient/chains/svm/event_parser_test.go @@ -86,13 +86,19 @@ func wrapAsLog(data []byte) string { return "Program data: " + base64.StdEncoding.EncodeToString(data) } -// buildOutboundPayload builds the minimum 80-byte outbound event data. -func buildOutboundPayload(txID [32]byte, universalTxID [32]byte, gasFee uint64) []byte { - data := make([]byte, 80) +// buildOutboundPayload builds the minimum 97-byte outbound event data. +// The audited finalize event surfaces gas_used (offset 80..88) as the value +// the parser reports as GasFeeUsed; gas_fee (offset 72..80) is the prepaid +// budget and is skipped. Tests pass `gasUsed` to match what the parser will +// extract; gas_fee in the payload is left zero. +func buildOutboundPayload(txID [32]byte, universalTxID [32]byte, gasUsed uint64) []byte { + data := make([]byte, 97) // discriminator (8 bytes, zeroed is fine) copy(data[8:40], txID[:]) copy(data[40:72], universalTxID[:]) - binary.LittleEndian.PutUint64(data[72:80], gasFee) + // gas_fee at 72..80 (prepaid budget, left zero in tests) + binary.LittleEndian.PutUint64(data[80:88], gasUsed) + // gas_to_refund at 88..96 (left zero); ata_created at 96 (left zero) return data } @@ -406,20 +412,21 @@ func TestParseOutboundObservationEvent(t *testing.T) { }) t.Run("returns nil for data too short", func(t *testing.T) { - shortData := make([]byte, 72) // needs 80 + shortData := make([]byte, 96) // needs 97 event := ParseEvent(wrapAsLog(shortData), signature, 12345, 0, EventTypeFinalizeUniversalTx, chainID, logger) assert.Nil(t, event) }) - t.Run("parses minimum valid data (exactly 80 bytes)", func(t *testing.T) { - data := make([]byte, 80) + t.Run("parses minimum valid data (exactly 97 bytes)", func(t *testing.T) { + data := make([]byte, 97) for i := 8; i < 40; i++ { data[i] = 0x11 } for i := 40; i < 72; i++ { data[i] = 0x22 } - binary.LittleEndian.PutUint64(data[72:80], 12345) + // gas_used at 80..88 + binary.LittleEndian.PutUint64(data[80:88], 12345) event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) @@ -431,7 +438,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { assert.Equal(t, "12345", outbound.GasFeeUsed) }) - t.Run("handles data longer than 80 bytes", func(t *testing.T) { + t.Run("handles data longer than 97 bytes", func(t *testing.T) { var txID, utxID [32]byte for i := range txID { txID[i] = 0xAA diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index b8ecdbce..52a56b53 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -322,6 +322,7 @@ func (tb *TxBuilder) GetOutboundSigningRequest( var ixData []byte var revertRecipient [32]byte var revertMint [32]byte + var revertMsg []byte if txType == uetypes.TxType_INBOUND_REVERT || txType == uetypes.TxType_RESCUE_FUNDS { // Revert (id=3) and rescue (id=4): instruction_id determined by TxType, no payload decode @@ -333,6 +334,14 @@ func (tb *TxBuilder) GetOutboundSigningRequest( if !isNative { copy(revertMint[:], token[:]) } + // Only revert (id=3) binds keccak256(revert_msg) in the TSS message. + // Rescue (id=4) doesn't carry a revert reason. Treat decode failure + // as empty so the signing hash is still deterministic. + if instructionID == 3 { + if decoded, decErr := hex.DecodeString(removeHexPrefix(data.RevertMsg)); decErr == nil { + revertMsg = decoded + } + } } else { // Non-revert flows: decode payload to get instruction_id. // Payload format: [accounts][ixData][instruction_id][target_program] @@ -399,7 +408,7 @@ func (tb *TxBuilder) GetOutboundSigningRequest( instructionID, chainID, amount.Uint64(), txID, universalTxID, sender, token, gasFee, targetProgram, accounts, ixData, - revertRecipient, revertMint, + revertRecipient, revertMint, revertMsg, ) if err != nil { return nil, fmt.Errorf("failed to construct TSS message: %w", err) @@ -710,7 +719,7 @@ func (tb *TxBuilder) BuildOutboundTransaction( return nil, 0, fmt.Errorf("failed to derive vault PDA: %w", err) } - tssPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("tsspda_v2")}, tb.gatewayAddress) + tssPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("final_tss_pda")}, tb.gatewayAddress) if err != nil { return nil, 0, fmt.Errorf("failed to derive TSS PDA: %w", err) } @@ -893,9 +902,9 @@ func removeHexPrefix(s string) string { // - tss_eth_address: the 20-byte Ethereum address of the TSS signing group // - chain_id: identifies this Solana cluster (for cross-chain replay protection) // -// Seed: ["tsspda_v2"] — must match the Rust constant TSS_SEED in state.rs +// Seed: ["final_tss_pda"] — must match the Rust constant TSS_SEED in state.rs func (tb *TxBuilder) deriveTSSPDA() (solana.PublicKey, error) { - seeds := [][]byte{[]byte("tsspda_v2")} + seeds := [][]byte{[]byte("final_tss_pda")} address, _, err := solana.FindProgramAddress(seeds, tb.gatewayAddress) return address, err } @@ -909,8 +918,7 @@ func (tb *TxBuilder) deriveTSSPDA() (solana.PublicKey, error) { // 8 20 tss_eth_address [u8; 20] // 28 4 chain_id length (u32, little-endian) — Borsh String prefix // 32 N chain_id bytes (UTF-8, variable length) -// 32+N 32 authority (Pubkey) -// 32+N+32 1 bump +// 32+N 1 bump func (tb *TxBuilder) fetchTSSChainID(ctx context.Context, tssPDA solana.PublicKey) (string, error) { accountData, err := tb.rpcClient.GetAccountData(ctx, tssPDA) if err != nil { @@ -926,7 +934,7 @@ func (tb *TxBuilder) fetchTSSChainID(ctx context.Context, tssPDA solana.PublicKe // This is NOT fixed-length — different clusters have different chain IDs. chainIDLen := binary.LittleEndian.Uint32(accountData[28:32]) - requiredLen := 32 + int(chainIDLen) + 32 + 1 + requiredLen := 32 + int(chainIDLen) + 1 if len(accountData) < requiredLen { return "", fmt.Errorf("invalid TSS PDA account data: too short for chain_id length %d (%d bytes)", chainIDLen, len(accountData)) } @@ -1012,6 +1020,7 @@ func (tb *TxBuilder) constructTSSMessage( ixData []byte, revertRecipient [32]byte, revertMint [32]byte, + revertMsg []byte, ) ([]byte, error) { message := []byte("PUSH_CHAIN_SVM") message = append(message, instructionID) @@ -1060,7 +1069,21 @@ func (tb *TxBuilder) constructTSSMessage( message = append(message, ixDataLen...) message = append(message, ixData...) - case 3, 4: // revert (id=3) or rescue (id=4) — same message format + case 3: // revert + message = append(message, txID[:]...) + message = append(message, universalTxID[:]...) + if revertMint != ([32]byte{}) { + // SPL: include mint before recipient + message = append(message, revertMint[:]...) + } + message = append(message, revertRecipient[:]...) + message = append(message, gasFeeBytes...) + // revert_universal_tx binds keccak256(revert_msg) as the trailing + // additional-data element so a forged revert reason cannot be + // substituted under the same TSS signature. + message = append(message, crypto.Keccak256(revertMsg)...) + + case 4: // rescue — same wire format as revert minus the revert_msg binding message = append(message, txID[:]...) message = append(message, universalTxID[:]...) if revertMint != ([32]byte{}) { @@ -1076,9 +1099,7 @@ func (tb *TxBuilder) constructTSSMessage( // Hash with keccak256. Solana's keccak::hash is the same algorithm as Ethereum's keccak256. // NOT sha256 — Anchor uses sha256 for discriminators, but TSS messages use keccak256. - messageHash := crypto.Keccak256(message) - - return messageHash, nil + return crypto.Keccak256(message), nil } // ============================================================================= diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index a67a0e3d..ee5b3062 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -55,9 +55,9 @@ func makeSender(fill byte) [20]byte { } // buildMockTSSPDAData builds a raw byte slice simulating a TssPda account. -// Layout: discriminator(8) + tss_eth_address(20) + chain_id(Borsh String: 4 LE len + bytes) + authority(32) + bump(1) -func buildMockTSSPDAData(tssAddr [20]byte, chainID string, authority [32]byte, bump byte) []byte { - data := make([]byte, 0, 8+20+4+len(chainID)+32+1) +// Layout: discriminator(8) + tss_eth_address(20) + chain_id(Borsh String: 4 LE len + bytes) + bump(1) +func buildMockTSSPDAData(tssAddr [20]byte, chainID string, bump byte) []byte { + data := make([]byte, 0, 8+20+4+len(chainID)+1) // discriminator (8 bytes of zeros) data = append(data, make([]byte, 8)...) // tss_eth_address (20 bytes) @@ -67,8 +67,6 @@ func buildMockTSSPDAData(tssAddr [20]byte, chainID string, authority [32]byte, b binary.LittleEndian.PutUint32(chainIDLenBytes, uint32(len(chainID))) data = append(data, chainIDLenBytes...) data = append(data, []byte(chainID)...) - // authority (32 bytes) - data = append(data, authority[:]...) // bump (1 byte) data = append(data, bump) return data @@ -215,21 +213,23 @@ func TestDeriveTSSPDA(t *testing.T) { require.NoError(t, err) assert.False(t, pda.IsZero(), "TSS PDA should be non-zero") - // Verify it matches FindProgramAddress with seed "tsspda_v2" - expected, _, err := solana.FindProgramAddress([][]byte{[]byte("tsspda_v2")}, builder.gatewayAddress) + // Verify it matches FindProgramAddress with seed "final_tss_pda" + expected, _, err := solana.FindProgramAddress([][]byte{[]byte("final_tss_pda")}, builder.gatewayAddress) require.NoError(t, err) assert.Equal(t, expected, pda) - // Verify it does NOT match the old seed "tsspda" - oldPDA, _, err := solana.FindProgramAddress([][]byte{[]byte("tsspda")}, builder.gatewayAddress) - require.NoError(t, err) - assert.NotEqual(t, oldPDA, pda, "TSS PDA must NOT use old seed 'tsspda'") + // Verify it does NOT match any prior seed + for _, stale := range []string{"tsspda", "tsspda_v2"} { + stalePDA, _, err := solana.FindProgramAddress([][]byte{[]byte(stale)}, builder.gatewayAddress) + require.NoError(t, err) + assert.NotEqual(t, stalePDA, pda, "TSS PDA must NOT use old seed %q", stale) + } } func TestFetchTSSChainID(t *testing.T) { t.Run("parses valid TssPda with short chain_id", func(t *testing.T) { chainIDStr := "devnet" - data := buildMockTSSPDAData([20]byte{}, chainIDStr, [32]byte{}, 255) + data := buildMockTSSPDAData([20]byte{}, chainIDStr, 255) chainID, err := parseTSSPDAData(data) require.NoError(t, err) @@ -238,7 +238,7 @@ func TestFetchTSSChainID(t *testing.T) { t.Run("parses valid TssPda with mainnet cluster pubkey", func(t *testing.T) { chainIDStr := "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" - data := buildMockTSSPDAData([20]byte{}, chainIDStr, [32]byte{}, 1) + data := buildMockTSSPDAData([20]byte{}, chainIDStr, 1) chainID, err := parseTSSPDAData(data) require.NoError(t, err) @@ -251,7 +251,7 @@ func TestFetchTSSChainID(t *testing.T) { assert.Contains(t, err.Error(), "too short") }) - t.Run("rejects data too short for chain_id + authority", func(t *testing.T) { + t.Run("rejects data too short for chain_id + bump", func(t *testing.T) { // Build header with chain_id_len = 100, but only provide 40 total bytes data := make([]byte, 40) binary.LittleEndian.PutUint32(data[28:32], 100) // chain_id_len = 100 @@ -263,7 +263,7 @@ func TestFetchTSSChainID(t *testing.T) { t.Run("chain_id at correct offset after variable-length chain_id", func(t *testing.T) { // Two different chain_id lengths — verify parsing is dynamic for _, cid := range []string{"a", "abcdefghij"} { - data := buildMockTSSPDAData([20]byte{}, cid, [32]byte{}, 0) + data := buildMockTSSPDAData([20]byte{}, cid, 0) chainID, err := parseTSSPDAData(data) require.NoError(t, err, "chain_id=%q", cid) assert.Equal(t, cid, chainID) @@ -278,7 +278,7 @@ func parseTSSPDAData(accountData []byte) (string, error) { return "", fmt.Errorf("invalid TSS PDA account data: too short (%d bytes)", len(accountData)) } chainIDLen := binary.LittleEndian.Uint32(accountData[28:32]) - requiredLen := 32 + int(chainIDLen) + 32 + 1 + requiredLen := 32 + int(chainIDLen) + 1 if len(accountData) < requiredLen { return "", fmt.Errorf("invalid TSS PDA account data: too short for chain_id length %d (%d bytes)", chainIDLen, len(accountData)) } @@ -362,7 +362,7 @@ func TestConstructTSSMessage(t *testing.T) { txID, utxID, sender, token, 0, // gasFee target, nil, nil, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) assert.Len(t, hash, 32, "message hash must be 32 bytes (keccak256)") @@ -399,7 +399,7 @@ func TestConstructTSSMessage(t *testing.T) { txID, utxID, sender, token, 100, // gasFee target, accs, ixData, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) assert.Len(t, hash, 32) @@ -445,7 +445,7 @@ func TestConstructTSSMessage(t *testing.T) { 3, "devnet", 500000, txID, utxID, sender, token, 0, [32]byte{}, nil, nil, - revertRecipient, [32]byte{}, + revertRecipient, [32]byte{}, nil, ) require.NoError(t, err) @@ -455,12 +455,14 @@ func TestConstructTSSMessage(t *testing.T) { amountBE := make([]byte, 8) binary.BigEndian.PutUint64(amountBE, 500000) msg = append(msg, amountBE...) - // additional: tx_id, utx_id, recipient, gas_fee + // additional: tx_id, utx_id, recipient, gas_fee, keccak256(revert_msg) msg = append(msg, txID[:]...) msg = append(msg, utxID[:]...) msg = append(msg, revertRecipient[:]...) gasBE := make([]byte, 8) msg = append(msg, gasBE...) + // revert_universal_tx binds keccak256(revert_msg); nil revert_msg here. + msg = append(msg, crypto.Keccak256(nil)...) expected := crypto.Keccak256(msg) assert.Equal(t, expected, hash, "revert SOL message hash mismatch") @@ -473,7 +475,7 @@ func TestConstructTSSMessage(t *testing.T) { 3, "devnet", 750000, txID, utxID, sender, token, 0, [32]byte{}, nil, nil, - revertRecipient, revertMint, + revertRecipient, revertMint, nil, ) require.NoError(t, err) @@ -483,25 +485,72 @@ func TestConstructTSSMessage(t *testing.T) { amountBE := make([]byte, 8) binary.BigEndian.PutUint64(amountBE, 750000) msg = append(msg, amountBE...) - // additional: tx_id, utx_id, mint, recipient, gas_fee + // additional: tx_id, utx_id, mint, recipient, gas_fee, keccak256(revert_msg) msg = append(msg, txID[:]...) msg = append(msg, utxID[:]...) msg = append(msg, revertMint[:]...) msg = append(msg, revertRecipient[:]...) gasBE := make([]byte, 8) msg = append(msg, gasBE...) + // revert_universal_tx binds keccak256(revert_msg); nil revert_msg here. + msg = append(msg, crypto.Keccak256(nil)...) expected := crypto.Keccak256(msg) assert.Equal(t, expected, hash, "revert SPL message hash mismatch") }) + t.Run("revert (id=3) binds revert_msg into the signed hash", func(t *testing.T) { + // Two otherwise-identical revert signing requests with different + // revert_msg values must hash to different messages — the trailing + // keccak256(revert_msg) prevents a forged reason being swapped under + // the same TSS signature. + revertRecipient := makeTxID(0xEE) + hashA, err := builder.constructTSSMessage( + 3, "devnet", 500000, + txID, utxID, sender, token, + 0, [32]byte{}, nil, nil, + revertRecipient, [32]byte{}, []byte("reason A"), + ) + require.NoError(t, err) + hashB, err := builder.constructTSSMessage( + 3, "devnet", 500000, + txID, utxID, sender, token, + 0, [32]byte{}, nil, nil, + revertRecipient, [32]byte{}, []byte("reason B"), + ) + require.NoError(t, err) + assert.NotEqual(t, hashA, hashB, "different revert_msg values must produce different hashes") + }) + + t.Run("rescue (id=4) does not bind revert_msg", func(t *testing.T) { + // Rescue uses the same message prefix as revert but does NOT bind + // revert_msg. Two rescue messages with different revertMsg args must + // still produce the same hash. + rescueRecipient := makeTxID(0xEE) + hashA, err := builder.constructTSSMessage( + 4, "devnet", 300000, + txID, utxID, sender, token, + 50, [32]byte{}, nil, nil, + rescueRecipient, [32]byte{}, []byte("reason A"), + ) + require.NoError(t, err) + hashB, err := builder.constructTSSMessage( + 4, "devnet", 300000, + txID, utxID, sender, token, + 50, [32]byte{}, nil, nil, + rescueRecipient, [32]byte{}, []byte("reason B"), + ) + require.NoError(t, err) + assert.Equal(t, hashA, hashB, "rescue must not bind revert_msg") + }) + t.Run("rescue SOL (id=4) message format", func(t *testing.T) { rescueRecipient := makeTxID(0xEE) hash, err := builder.constructTSSMessage( 4, "devnet", 300000, txID, utxID, sender, token, 50, [32]byte{}, nil, nil, - rescueRecipient, [32]byte{}, + rescueRecipient, [32]byte{}, nil, ) require.NoError(t, err) @@ -529,7 +578,7 @@ func TestConstructTSSMessage(t *testing.T) { 4, "devnet", 400000, txID, utxID, sender, token, 75, [32]byte{}, nil, nil, - rescueRecipient, rescueMint, + rescueRecipient, rescueMint, nil, ) require.NoError(t, err) @@ -558,7 +607,7 @@ func TestConstructTSSMessage(t *testing.T) { 1, chainID, 0, [32]byte{}, [32]byte{}, [20]byte{}, [32]byte{}, 0, [32]byte{}, nil, nil, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) @@ -583,7 +632,7 @@ func TestConstructTSSMessage(t *testing.T) { 99, "devnet", 0, [32]byte{}, [32]byte{}, [20]byte{}, [32]byte{}, 0, [32]byte{}, nil, nil, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown instruction ID") @@ -598,7 +647,7 @@ func TestConstructTSSMessage_HashIsKeccak256(t *testing.T) { 1, "x", 0, [32]byte{}, [32]byte{}, [20]byte{}, [32]byte{}, 0, [32]byte{}, nil, nil, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) @@ -1155,6 +1204,48 @@ func TestBuildSetComputeUnitLimitInstruction(t *testing.T) { assert.Equal(t, uint32(300000), binary.LittleEndian.Uint32(data[1:5])) } +// TestSVMFinalizeTx_SingleSignerProtocolAssumption pins the contract-side +// protocol assumption that UV's finalize transactions are single-signature +// with the relayer as sole fee payer. The audited gateway charges +// SIGNATURE_FEE_LAMPORTS once per signer, so any future change that +// introduces a co-signer (guardian, multi-sig payer, etc.) would also need +// the contract-side accounting updated — this test fails loudly if the +// shape drifts. +// +// The pattern mirrors BuildOutboundTransaction: TransactionPayer is the +// relayer pubkey, and the signing closure only ever returns the relayer's +// private key. +func TestSVMFinalizeTx_SingleSignerProtocolAssumption(t *testing.T) { + relayer, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + instr := solana.NewInstruction( + solana.SystemProgramID, + solana.AccountMetaSlice{ + solana.NewAccountMeta(relayer.PublicKey(), true, true), + }, + []byte{0}, + ) + tx, err := solana.NewTransaction( + []solana.Instruction{instr}, + solana.Hash{}, + solana.TransactionPayer(relayer.PublicKey()), + ) + require.NoError(t, err) + + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(relayer.PublicKey()) { + return &relayer + } + return nil + }) + require.NoError(t, err) + + require.Len(t, tx.Signatures, 1, "SVM finalize tx must have exactly one signature (relayer/fee-payer)") + require.NotEmpty(t, tx.Message.AccountKeys, "tx must have at least one account key") + assert.Equal(t, relayer.PublicKey(), tx.Message.AccountKeys[0], "relayer must be the fee payer (account[0])") +} + func TestGatewayAccountMetaStruct(t *testing.T) { var pk [32]byte for i := range pk { @@ -1180,7 +1271,7 @@ func TestEndToEndWithdrawMessageAndData(t *testing.T) { 1, "devnet", 1000000, txID, utxID, sender, token, 0, target, nil, nil, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) @@ -1226,7 +1317,7 @@ func TestEndToEndWithRealSignature(t *testing.T) { 1, "devnet", amount, txID, utxID, sender, token, 0, target, nil, nil, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) @@ -1259,7 +1350,7 @@ func TestEndToEndWithRealSignature(t *testing.T) { 2, "devnet", amount, txID, utxID, sender, token, 0, target, accs, ixData, - [32]byte{}, [32]byte{}, + [32]byte{}, [32]byte{}, nil, ) require.NoError(t, err) @@ -1288,7 +1379,7 @@ func TestEndToEndWithRealSignature(t *testing.T) { 3, "devnet", amount, txID, utxID, sender, token, 0, [32]byte{}, nil, nil, - revertRecipient, [32]byte{}, + revertRecipient, [32]byte{}, nil, ) require.NoError(t, err) @@ -1314,7 +1405,7 @@ func TestEndToEndWithRealSignature(t *testing.T) { 4, "devnet", amount, txID, utxID, sender, token, 50, [32]byte{}, nil, nil, - rescueRecipient, [32]byte{}, + rescueRecipient, [32]byte{}, nil, ) require.NoError(t, err) @@ -1614,7 +1705,7 @@ func buildAndSimulateRescue(t *testing.T, rpcClient *RPCClient, builder *TxBuild 4, chainID, amount, txID, universalTxID, sender, token, gasFee, [32]byte{}, nil, nil, - revertRecipient, revertMint, + revertRecipient, revertMint, nil, ) require.NoError(t, err)