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
8 changes: 7 additions & 1 deletion internal/services/transaction_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
25 changes: 25 additions & 0 deletions internal/services/transaction_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion pkg/sorobanauth/sorobanauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
}

Expand Down
41 changes: 41 additions & 0 deletions pkg/sorobanauth/sorobanauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
13 changes: 13 additions & 0 deletions pkg/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +121 to +126
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for ResolveToGAddress doesn’t mention that an empty string is treated as a valid input and returns "" with a nil error. Consider documenting this behavior (or returning an error) so callers don’t accidentally treat missing source accounts as successfully-resolved addresses. Also, the muxed account’s 64-bit value is an ID; calling it a “memo ID” here may be misleading—“muxed account ID” would be clearer.

Copilot uses AI. Check for mistakes.
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,
Expand Down
51 changes: 51 additions & 0 deletions pkg/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading