Skip to content

Commit c0c2877

Browse files
authored
Merge pull request #292 from pushchain/fix/svm-outbound-sender
fix: allow sender to be solana public key only in case of inbound revert
2 parents 02f2f96 + 980e462 commit c0c2877

2 files changed

Lines changed: 62 additions & 17 deletions

File tree

‎universalClient/chains/svm/tx_builder.go‎

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,28 @@ func NewTxBuilder(
183183
// sign it, and the 64-byte signature (r||s) is passed to BroadcastOutboundSigningRequest.
184184
// =============================================================================
185185

186+
// parseOutboundSender decodes data.Sender into a 20-byte push account. The
187+
// finalize/export paths derive the CEA PDA from it, so they require exactly 20
188+
// bytes. An automatic INBOUND_REVERT can instead carry a 32-byte Solana pubkey
189+
// (the original Solana sender), which the revert path never reads (it routes to
190+
// revert_recipient). In that case the 32-byte value is DISCARDED (not truncated)
191+
// and the zero account is returned — the refund destination comes from recipient.
192+
func parseOutboundSender(senderHex string, txType uetypes.TxType) ([20]byte, error) {
193+
var sender [20]byte
194+
senderBytes, err := hex.DecodeString(removeHexPrefix(senderHex))
195+
if err != nil {
196+
return sender, fmt.Errorf("invalid sender: %s", senderHex)
197+
}
198+
if len(senderBytes) == 20 {
199+
copy(sender[:], senderBytes)
200+
} else if txType != uetypes.TxType_INBOUND_REVERT {
201+
// Non-revert paths consume sender (CEA PDA seed) and must be exactly 20 bytes.
202+
return sender, fmt.Errorf("invalid sender length: expected 20 bytes, got %d", len(senderBytes))
203+
}
204+
// INBOUND_REVERT with a non-20-byte sender: leave sender zero; it is unused.
205+
return sender, nil
206+
}
207+
186208
// GetOutboundSigningRequest creates a signing request from an outbound event.
187209
// Returns a 32-byte keccak256 hash that the TSS nodes need to sign.
188210
func (tb *TxBuilder) GetOutboundSigningRequest(
@@ -262,16 +284,9 @@ func (tb *TxBuilder) GetOutboundSigningRequest(
262284
copy(universalTxID[32-len(utxIDBytes):], utxIDBytes)
263285
}
264286

265-
// sender: the 20-byte EVM address of the original sender on the source chain
266-
var sender [20]byte
267-
senderBytes, err := hex.DecodeString(removeHexPrefix(data.Sender))
287+
sender, err := parseOutboundSender(data.Sender, txType)
268288
if err != nil {
269-
return nil, fmt.Errorf("invalid sender: %s", data.Sender)
270-
}
271-
if len(senderBytes) == 20 {
272-
copy(sender[:], senderBytes)
273-
} else {
274-
return nil, fmt.Errorf("invalid sender length: expected 20 bytes, got %d", len(senderBytes))
289+
return nil, err
275290
}
276291

277292
// PC20 export: core flags it via IsPc20; AssetAddr carries the 20-byte Push
@@ -800,15 +815,9 @@ func (tb *TxBuilder) BuildOutboundTransaction(
800815
copy(universalTxID[32-len(utxIDBytes):], utxIDBytes)
801816
}
802817

803-
var sender [20]byte
804-
senderBytes, err := hex.DecodeString(removeHexPrefix(data.Sender))
818+
sender, err := parseOutboundSender(data.Sender, txType)
805819
if err != nil {
806-
return nil, 0, fmt.Errorf("invalid sender: %s", data.Sender)
807-
}
808-
if len(senderBytes) == 20 {
809-
copy(sender[:], senderBytes)
810-
} else {
811-
return nil, 0, fmt.Errorf("invalid sender length: expected 20 bytes, got %d", len(senderBytes))
820+
return nil, 0, err
812821
}
813822

814823
// PC20 export: mirror of the signing-path branch — must rebuild the exact signed inputs.

‎universalClient/chains/svm/tx_builder_test.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,42 @@ func TestParseTxType(t *testing.T) {
13621362
}
13631363
}
13641364

1365+
func TestParseOutboundSender(t *testing.T) {
1366+
evm := makeSender(0xCC)
1367+
evmHex := "0x" + hex.EncodeToString(evm[:])
1368+
// A 32-byte Solana pubkey (as core sends it for an automatic inbound revert).
1369+
solPubkey := solana.NewWallet().PublicKey()
1370+
solHex := "0x" + hex.EncodeToString(solPubkey.Bytes())
1371+
1372+
t.Run("20-byte EVM sender parses for any tx type", func(t *testing.T) {
1373+
got, err := parseOutboundSender(evmHex, uetypes.TxType_FUNDS)
1374+
require.NoError(t, err)
1375+
assert.Equal(t, evm, got)
1376+
})
1377+
1378+
t.Run("32-byte Solana sender tolerated for INBOUND_REVERT (unused → zero)", func(t *testing.T) {
1379+
got, err := parseOutboundSender(solHex, uetypes.TxType_INBOUND_REVERT)
1380+
require.NoError(t, err)
1381+
assert.Equal(t, [20]byte{}, got)
1382+
})
1383+
1384+
t.Run("32-byte sender rejected for non-revert paths", func(t *testing.T) {
1385+
for _, tt := range []uetypes.TxType{
1386+
uetypes.TxType_FUNDS, uetypes.TxType_FUNDS_AND_PAYLOAD, uetypes.TxType_RESCUE_FUNDS,
1387+
} {
1388+
_, err := parseOutboundSender(solHex, tt)
1389+
require.Error(t, err, "txType %s", tt)
1390+
assert.Contains(t, err.Error(), "invalid sender length")
1391+
}
1392+
})
1393+
1394+
t.Run("non-hex sender errors even for INBOUND_REVERT", func(t *testing.T) {
1395+
_, err := parseOutboundSender("not-hex-zz", uetypes.TxType_INBOUND_REVERT)
1396+
require.Error(t, err)
1397+
assert.Contains(t, err.Error(), "invalid sender")
1398+
})
1399+
}
1400+
13651401
func TestBuildSetComputeUnitLimitInstruction(t *testing.T) {
13661402
builder := newTestBuilder(t)
13671403
ix := builder.buildSetComputeUnitLimitInstruction(300000)

0 commit comments

Comments
 (0)