diff --git a/universalClient/chains/svm/tx_builder.go b/universalClient/chains/svm/tx_builder.go index 784ac1d3..a297da82 100644 --- a/universalClient/chains/svm/tx_builder.go +++ b/universalClient/chains/svm/tx_builder.go @@ -214,9 +214,8 @@ func (tb *TxBuilder) GetOutboundSigningRequest( } // Determine if this is native SOL or an SPL token transfer. - // Empty or zero address = native SOL. Otherwise it's the SPL token mint address. assetAddr := data.AssetAddr - isNative := assetAddr == "" || assetAddr == "0x0" || assetAddr == "0x0000000000000000000000000000000000000000" + isNative := isNativeAsset(assetAddr) txType, err := parseTxType(data.TxType) if err != nil { @@ -707,7 +706,7 @@ func (tb *TxBuilder) BuildOutboundTransaction( } assetAddr := data.AssetAddr - isNative := assetAddr == "" || assetAddr == "0x0" || assetAddr == "0x0000000000000000000000000000000000000000" + isNative := isNativeAsset(assetAddr) txType, err := parseTxType(data.TxType) if err != nil { @@ -1052,7 +1051,7 @@ func (tb *TxBuilder) BuildRefRouteTransactions( } assetAddr := data.AssetAddr - isNative := assetAddr == "" || assetAddr == "0x0" || assetAddr == "0x0000000000000000000000000000000000000000" + isNative := isNativeAsset(assetAddr) var txID [32]byte txIDBytes, err := hex.DecodeString(removeHexPrefix(data.TxID)) @@ -1282,6 +1281,27 @@ func removeHexPrefix(s string) string { return s } +// isNativeAsset reports whether addr denotes native SOL rather than an SPL mint. +// Both encodings of the zero address reach us. Core sends the EVM zero hex on +// withdrawals (registry token address), while reverts carry the base58 zero +// pubkey, SystemProgram 11111111111111111111111111111111, copied from the +// inbound. SPL mints are always base58 and parse as an ordinary non-zero pubkey. +func isNativeAsset(addr string) bool { + switch addr { + case "", "0x0", "0x0000000000000000000000000000000000000000": + return true + } + if pubkey, err := solana.PublicKeyFromBase58(addr); err == nil { + return pubkey.IsZero() + } + // The builder also accepts hex mints, so cover a hex-encoded zero pubkey. + // Length must be checked first: PublicKeyFromBytes panics on a short slice. + if raw, err := hex.DecodeString(removeHexPrefix(addr)); err == nil && len(raw) == 32 { + return solana.PublicKeyFromBytes(raw).IsZero() + } + return false +} + // ============================================================================= // PDA Derivation & On-Chain Data // ============================================================================= diff --git a/universalClient/chains/svm/tx_builder_test.go b/universalClient/chains/svm/tx_builder_test.go index 26842fc5..ff1dcce0 100644 --- a/universalClient/chains/svm/tx_builder_test.go +++ b/universalClient/chains/svm/tx_builder_test.go @@ -2391,7 +2391,7 @@ func buildAndSimulateRescue(t *testing.T, rpcClient *RPCClient, builder *TxBuild require.NoError(t, err) copy(sender[:], senderBytes) - isNative := assetAddr == "" + isNative := isNativeAsset(assetAddr) var token [32]byte var mintPubkey solana.PublicKey if !isNative { @@ -2718,3 +2718,83 @@ func TestSimulate_RefRoute_Execute(t *testing.T) { require.NoError(t, err) requireSimulationSuccess(t, storeSim) } + +// Both encodings of native SOL reach the builder, verified against donut: +// withdrawals carry the EVM zero hex from the registry token address, reverts +// carry the base58 SystemProgram marker copied from the inbound. Missing either +// builds an SPL transfer whose ATA-create reverts, since neither is a mint. +func TestIsNativeAsset(t *testing.T) { + t.Run("core withdrawal form is native", func(t *testing.T) { + // create_outbound.go copies the registry token address verbatim. + assert.True(t, isNativeAsset("0x0000000000000000000000000000000000000000")) + }) + + t.Run("core revert form is native", func(t *testing.T) { + // build_revert_outbound.go copies inbound.AssetAddr, which the SVM parser + // sets from the pubkey, so native SOL arrives base58 encoded. + assert.True(t, isNativeAsset("11111111111111111111111111111111")) + assert.True(t, isNativeAsset(solana.SystemProgramID.String())) + assert.True(t, isNativeAsset(solana.PublicKey{}.String())) + }) + + t.Run("other zero spellings are native", func(t *testing.T) { + assert.True(t, isNativeAsset("")) + assert.True(t, isNativeAsset("0x0")) + assert.True(t, isNativeAsset("0x"+strings.Repeat("0", 64)), "hex-encoded zero pubkey") + }) + + // SPL mints are base58 in both directions, so they parse normally and must + // keep taking the token path. + t.Run("real SPL mints are not native", func(t *testing.T) { + assert.False(t, isNativeAsset("EiXDnrAg9ea2Q6vEPV7E5TpTU1vh41jcuZqKjU5Dc4ZF"), "USDT.sol") + assert.False(t, isNativeAsset("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"), "USDC.sol") + assert.False(t, isNativeAsset(solana.TokenProgramID.String())) + }) + + t.Run("malformed addresses are not native", func(t *testing.T) { + assert.False(t, isNativeAsset("not-base58-0OlI")) + assert.False(t, isNativeAsset("0x1234")) + }) +} + +// Core sent the base58 marker before switching to the EVM zero, so both forms +// are live: withdrawals carry the zero hex and reverts still carry base58. Both +// must build the identical native account layout, with no recipient ATA. +func TestNativeMarkerFormsBuildIdenticalAccounts(t *testing.T) { + builder := newTestBuilder(t) + + caller := solana.NewWallet().PublicKey() + config := solana.NewWallet().PublicKey() + vault := solana.NewWallet().PublicKey() + cea := solana.NewWallet().PublicKey() + tss := solana.NewWallet().PublicKey() + executed := solana.NewWallet().PublicKey() + recipient := solana.NewWallet().PublicKey() + + build := func(t *testing.T, assetAddr string) []*solana.AccountMeta { + t.Helper() + isNative := isNativeAsset(assetAddr) + require.True(t, isNative, "asset %q must classify as native", assetAddr) + return builder.buildWithdrawAndExecuteAccounts( + caller, config, vault, cea, tss, executed, + solana.SystemProgramID, + isNative, 1, + recipient, solana.PublicKey{}, + nil, + solana.PublicKey{}, solana.PublicKey{}, + ) + } + + withdrawForm := build(t, "0x0000000000000000000000000000000000000000") + revertForm := build(t, "11111111111111111111111111111111") + + assert.Equal(t, withdrawForm, revertForm, + "revert-form native SOL must build the same accounts as withdraw-form") + + // The old bug took the SPL path and derived an ATA for a non-mint. + ata, _, err := solana.FindAssociatedTokenAddress(recipient, solana.SystemProgramID) + require.NoError(t, err) + for _, acc := range revertForm { + assert.NotEqual(t, ata, acc.PublicKey, "native layout must not include a recipient ATA") + } +}