diff --git a/internal/services/transaction_service.go b/internal/services/transaction_service.go index f087dd4a9..f0b2f5501 100644 --- a/internal/services/transaction_service.go +++ b/internal/services/transaction_service.go @@ -125,7 +125,13 @@ func (t *transactionService) BuildAndSignTransactionWithChannelAccount(ctx conte operations := clientTx.Operations() for _, op := range operations { // Prevent bad actors from using the channel account as a source account directly. - if op.GetSourceAccount() == channelAccountPublicKey { + // Resolve the operation source to a G-address to handle muxed accounts (M-addresses) + // that wrap the same underlying Ed25519 key as the channel account. + opSourceGAddress, resolveErr := pkgUtils.ResolveToGAddress(op.GetSourceAccount()) + if resolveErr != nil { + return nil, fmt.Errorf("resolving operation source account: %w", resolveErr) + } + if opSourceGAddress == channelAccountPublicKey { return nil, fmt.Errorf("%w: %s", ErrInvalidOperationChannelAccount, channelAccountPublicKey) } // Prevent bad actors from using the channel account as a source account (inherited from the parent transaction). diff --git a/internal/services/transaction_service_test.go b/internal/services/transaction_service_test.go index dc3567ac5..ba73cc9f8 100644 --- a/internal/services/transaction_service_test.go +++ b/internal/services/transaction_service_test.go @@ -298,6 +298,31 @@ func TestBuildAndSignTransactionWithChannelAccount(t *testing.T) { assert.ErrorContains(t, err, "invalid operation: operation source account cannot be the channel account") }) + t.Run("🚨operation_source_account_cannot_be_channel_account_via_muxed_address", func(t *testing.T) { + channelAccount := keypair.MustRandom() + mChannelAccountSignatureClient. + On("GetAccountPublicKey", context.Background(), 30). + Return(channelAccount.Address(), nil). + Once() + + // Create a muxed M-address from the channel account's G-address. + muxedAccount, err := xdr.MuxedAccountFromAccountId(channelAccount.Address(), 12345) + require.NoError(t, err) + + operations := []txnbuild.Operation{&txnbuild.AccountMerge{ + Destination: keypair.MustRandom().Address(), + SourceAccount: muxedAccount.Address(), + }} + signedTx := buildTransactionForTest(t, operations, txnbuild.Preconditions{ + TimeBounds: txnbuild.NewTimeout(30), + }) + tx, err := txService.BuildAndSignTransactionWithChannelAccount(context.Background(), signedTx.ToGenericTransaction(), nil) + + mChannelAccountSignatureClient.AssertExpectations(t) + assert.Empty(t, tx) + assert.ErrorContains(t, err, "invalid operation: operation source account cannot be the channel account") + }) + t.Run("🚨operation_source_account_cannot_be_empty", func(t *testing.T) { channelAccount := keypair.MustRandom() mChannelAccountSignatureClient. diff --git a/pkg/sorobanauth/sorobanauth.go b/pkg/sorobanauth/sorobanauth.go index 701b2f167..d89ef0e04 100644 --- a/pkg/sorobanauth/sorobanauth.go +++ b/pkg/sorobanauth/sorobanauth.go @@ -12,6 +12,7 @@ import ( "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/wallet-backend/internal/entities" + pkgUtils "github.com/stellar/wallet-backend/pkg/utils" ) var ErrForbiddenSigner = errors.New("the provided operation relies on a forbidden signer") @@ -137,11 +138,18 @@ func CheckForForbiddenSigners( opSourceAccount string, forbiddenSigners ...string, ) error { + // Resolve the operation source to a G-address to handle muxed accounts (M-addresses) + // that wrap the same underlying Ed25519 key as a forbidden signer. + opSourceGAddress, err := pkgUtils.ResolveToGAddress(opSourceAccount) + if err != nil { + return fmt.Errorf("resolving operation source account: %w", err) + } + for _, res := range simulationResponseResults { for _, auth := range res.Auth { switch auth.Credentials.Type { case xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount: - if slices.Contains(append(forbiddenSigners, ""), opSourceAccount) { + if slices.Contains(append(forbiddenSigners, ""), opSourceGAddress) { return fmt.Errorf("handling %s: %w", auth.Credentials.Type.String(), ErrForbiddenSigner) } diff --git a/pkg/sorobanauth/sorobanauth_test.go b/pkg/sorobanauth/sorobanauth_test.go index 6a64d89ab..39cb119c3 100644 --- a/pkg/sorobanauth/sorobanauth_test.go +++ b/pkg/sorobanauth/sorobanauth_test.go @@ -231,6 +231,47 @@ func Test_CheckForForbiddenSigners(t *testing.T) { opSourceAccount: forbiddenSigner1, forbiddenSigners: forbiddenSigners, }, + { + name: "🔴CredentialsSourceAccount/opSourceAccount_muxed_forbidden_signer", + simulationResponseResults: []entities.RPCSimulateHostFunctionResult{ + { + Auth: []xdr.SorobanAuthorizationEntry{ + { + Credentials: xdr.SorobanCredentials{ + Type: xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount, + }, + }, + }, + }, + }, + opSourceAccount: func() string { + muxed, err := xdr.MuxedAccountFromAccountId(forbiddenSigner1, 12345) + require.NoError(t, err) + return muxed.Address() + }(), + forbiddenSigners: forbiddenSigners, + wantErrContains: "handling SorobanCredentialsTypeSorobanCredentialsSourceAccount: " + ErrForbiddenSigner.Error(), + }, + { + name: "🟢CredentialsSourceAccount/opSourceAccount_muxed_allowed_signer", + simulationResponseResults: []entities.RPCSimulateHostFunctionResult{ + { + Auth: []xdr.SorobanAuthorizationEntry{ + { + Credentials: xdr.SorobanCredentials{ + Type: xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount, + }, + }, + }, + }, + }, + opSourceAccount: func() string { + muxed, err := xdr.MuxedAccountFromAccountId(allowedSigner1, 99999) + require.NoError(t, err) + return muxed.Address() + }(), + forbiddenSigners: forbiddenSigners, + }, { name: "🔴CredentialsUnsupported", simulationResponseResults: []entities.RPCSimulateHostFunctionResult{ diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index a6d7d7f08..e32ed68c3 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -118,6 +118,19 @@ func IsSorobanXDROp(opXDR xdr.Operation) bool { return slices.Contains(SorobanOpTypes, opXDR.Body.Type) } +// ResolveToGAddress resolves a Stellar address (G-address or M-address) to its underlying G-address. +// For G-addresses, it returns the address unchanged. For M-addresses (SEP-23), it strips the memo ID. +func ResolveToGAddress(address string) (string, error) { + if address == "" { + return "", nil + } + muxed, err := xdr.AddressToMuxedAccount(address) + if err != nil { + return "", fmt.Errorf("parsing address %q: %w", address, err) + } + return muxed.ToAccountId().Address(), nil +} + func IsSorobanTxnbuildOp(op txnbuild.Operation) bool { switch op.(type) { case *txnbuild.InvokeHostFunction, diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 17b436ea2..bb42fdabe 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -374,6 +374,57 @@ func Test_IsSorobanXDROp(t *testing.T) { } } +func Test_ResolveToGAddress(t *testing.T) { + gAddress := keypair.MustRandom().Address() + + // Build a muxed M-address from the same G-address with a memo ID. + muxedAccount, err := xdr.MuxedAccountFromAccountId(gAddress, 12345) + require.NoError(t, err) + mAddress := muxedAccount.Address() + require.NotEqual(t, gAddress, mAddress) + + testCases := []struct { + name string + address string + wantAddress string + wantErr string + }{ + { + name: "empty_string", + address: "", + wantAddress: "", + }, + { + name: "g_address_returns_same", + address: gAddress, + wantAddress: gAddress, + }, + { + name: "m_address_returns_underlying_g_address", + address: mAddress, + wantAddress: gAddress, + }, + { + name: "invalid_address_returns_error", + address: "INVALID", + wantErr: `parsing address "INVALID"`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ResolveToGAddress(tc.address) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tc.wantAddress, result) + } + }) + } +} + func Test_IsSorobanTxnbuildOp(t *testing.T) { nonSorobanOps := []txnbuild.Operation{ &txnbuild.CreateAccount{},