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
12 changes: 6 additions & 6 deletions universalClient/chains/evm/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion universalClient/chains/evm/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions universalClient/chains/evm/tx_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 16 additions & 8 deletions universalClient/chains/svm/event_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 16 additions & 9 deletions universalClient/chains/svm/event_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
43 changes: 32 additions & 11 deletions universalClient/chains/svm/tx_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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))
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{}) {
Expand All @@ -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
}

// =============================================================================
Expand Down
Loading
Loading