Repository: stellar/stellar-disbursement-platform-backend
Pull request #641: SDP-1609: Implement disbursements to contract accounts
Author: login: philipliu
Created: 2025-04-25T20:19:57Z | Merged: 2025-04-29T22:16:41Z

### What

This implements disbursements to known wallet contract accounts by:
- Remove restrictions on a receiver's wallet address type
- Implements payments to contract accounts using the Go SDK's `NewPaymentToContract` helper function

### Why

Adding contract account support

### Known limitations

This PR does not add integration tests. A disbursement to a contract account was manually tested against a local deployment
 
![image](https://github.com/user-attachments/assets/e818d76f-b919-417a-9bda-9c8fff950e59)

https://stellar.expert/explorer/testnet/tx/0ca97203e9adaa5b4b71490e9a2806fdf3d2cf9aac05733f4d8c4839c3dbab89

### Checklist

- [x] Title follows `SDP-1234: Add new feature` or `Chore: Refactor package xyz` format. The Jira ticket code was included if available.
- [x] PR has a focused scope and doesn't mix features with refactoring
- [x] Tests are included (if applicable)
- [ ] `CHANGELOG.md` is updated (if applicable)
- [ ] CONFIG/SECRETS changes are updated in helmcharts and deployments (if applicable)
- [ ] Preview deployment works as expected
- [ ] Ready for production



Diff:
diff --git a/go.list b/go.list
index ead2ce82e..1f9f0df00 100644
--- a/go.list
+++ b/go.list
@@ -105,7 +105,7 @@ github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe
 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
 github.com/golang/mock v1.6.0
 github.com/golang/protobuf v1.5.4
-github.com/google/go-cmp v0.6.0
+github.com/google/go-cmp v0.7.0
 github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1
 github.com/google/renameio/v2 v2.0.0
@@ -212,7 +212,7 @@ github.com/spf13/cast v1.7.1
 github.com/spf13/cobra v1.9.1
 github.com/spf13/pflag v1.0.6
 github.com/spf13/viper v1.20.0
-github.com/stellar/go v0.0.0-20250311234916-385ac5aca1a4
+github.com/stellar/go v0.0.0-20250423200217-8eabd901dc82
 github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2
 github.com/stellar/throttled v2.2.3-0.20190823235211-89d75816f59d+incompatible
 github.com/stretchr/objx v0.5.2
diff --git a/go.mod b/go.mod
index 355a7afb9..8f70de7c2 100644
--- a/go.mod
+++ b/go.mod
@@ -35,7 +35,7 @@ require (
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/cobra v1.9.1
 	github.com/spf13/viper v1.20.0
-	github.com/stellar/go v0.0.0-20250311234916-385ac5aca1a4
+	github.com/stellar/go v0.0.0-20250423200217-8eabd901dc82
 	github.com/stretchr/testify v1.10.0
 	github.com/twilio/twilio-go v1.24.0
 	golang.org/x/crypto v0.36.0
diff --git a/go.sum b/go.sum
index 99d4290a7..c70a13ce8 100644
--- a/go.sum
+++ b/go.sum
@@ -93,8 +93,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
 github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5 h1:oERTZ1buOUYlpmKaqlO5fYmz8cZ1rYu5DieJzF4ZVmU=
 github.com/google/go-querystring v0.0.0-20160401233042-9235644dd9e5/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -201,8 +201,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
 github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
-github.com/stellar/go v0.0.0-20250311234916-385ac5aca1a4 h1:t+rjWVnE/S886yqdvmsx5fvXgG9LDuu6SIDXEDGVfIc=
-github.com/stellar/go v0.0.0-20250311234916-385ac5aca1a4/go.mod h1:pIh0xYFVnaMP9fLnjFT7URY9Zhwy5wuc6tQkYybKkUY=
+github.com/stellar/go v0.0.0-20250423200217-8eabd901dc82 h1:6o3MtqTJyx16bf+JKmBKHsnS97VWR6ySWtV6wRtMTWs=
+github.com/stellar/go v0.0.0-20250423200217-8eabd901dc82/go.mod h1:wE/ZDmjys55VprPR5qx5Ojx0cUi3f7MJ+dc5gzM+03k=
 github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE=
 github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
diff --git a/internal/data/receivers_wallet.go b/internal/data/receivers_wallet.go
index 99e064654..9d73c97a3 100644
--- a/internal/data/receivers_wallet.go
+++ b/internal/data/receivers_wallet.go
@@ -615,7 +615,7 @@ func (rwu ReceiverWalletUpdate) Validate() error {
 	}
 
 	if rwu.StellarAddress != "" {
-		if !strkey.IsValidEd25519PublicKey(rwu.StellarAddress) {
+		if !strkey.IsValidEd25519PublicKey(rwu.StellarAddress) && !strkey.IsValidContractAddress(rwu.StellarAddress) {
 			return fmt.Errorf("invalid stellar address")
 		}
 	}
diff --git a/internal/data/receivers_wallet_test.go b/internal/data/receivers_wallet_test.go
index dc7c9ea37..a3a55db85 100644
--- a/internal/data/receivers_wallet_test.go
+++ b/internal/data/receivers_wallet_test.go
@@ -1661,7 +1661,7 @@ func Test_ReceiverWalletUpdate_Validate(t *testing.T) {
 			err: "OTPConfirmedAt is required when OTPConfirmedWith is provided",
 		},
 		{
-			name: "valid update",
+			name: "valid update with public key",
 			update: ReceiverWalletUpdate{
 				Status:           RegisteredReceiversWalletStatus,
 				StellarAddress:   "GBLTXF46JTCGMWFJASQLVXMMA36IPYTDCN4EN73HRXCGDCGYBZM3A444",
@@ -1670,6 +1670,16 @@ func Test_ReceiverWalletUpdate_Validate(t *testing.T) {
 			},
 			err: "",
 		},
+		{
+			name: "valid update with contract address",
+			update: ReceiverWalletUpdate{
+				Status:           RegisteredReceiversWalletStatus,
+				StellarAddress:   "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+				OTPConfirmedAt:   time.Now(),
+				OTPConfirmedWith: "test@email.com",
+			},
+			err: "",
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/internal/serve/validators/disbursement_instructions_validator.go b/internal/serve/validators/disbursement_instructions_validator.go
index 49b3b4ab8..b06b7e9dd 100644
--- a/internal/serve/validators/disbursement_instructions_validator.go
+++ b/internal/serve/validators/disbursement_instructions_validator.go
@@ -48,12 +48,15 @@ func (iv *DisbursementInstructionsValidator) ValidateInstruction(instruction *da
 	if iv.contactType.IncludesWalletAddress {
 		iv.Check(instruction.WalletAddress != "", fmt.Sprintf("line %d - wallet address", lineNumber), "wallet address cannot be empty")
 		if instruction.WalletAddress != "" {
-			iv.Check(strkey.IsValidEd25519PublicKey(instruction.WalletAddress), fmt.Sprintf("line %d - wallet address", lineNumber), "invalid wallet address. Must be a valid Stellar public key")
+			iv.Check(strkey.IsValidEd25519PublicKey(instruction.WalletAddress) || strkey.IsValidContractAddress(instruction.WalletAddress), fmt.Sprintf("line %d - wallet address", lineNumber), "invalid wallet address. Must be a valid Stellar public key or contract address")
 		}
-		if instruction.WalletAddressMemo != "" {
+		if instruction.WalletAddressMemo != "" && strkey.IsValidEd25519PublicKey(instruction.WalletAddress) {
 			_, _, err := schema.ParseMemo(instruction.WalletAddressMemo)
 			iv.CheckError(err, fmt.Sprintf("line %d - wallet address memo", lineNumber), "invalid wallet address memo. For more information, visit https://docs.stellar.org/learn/encyclopedia/transactions-specialized/memos")
 		}
+		if instruction.WalletAddressMemo != "" && strkey.IsValidContractAddress(instruction.WalletAddress) {
+			iv.AddError(fmt.Sprintf("line %d - wallet address memo", lineNumber), "wallet address memo is not supported for contract addresses")
+		}
 	} else {
 		// 4. Validate verification field
 		verification := instruction.VerificationValue
diff --git a/internal/serve/validators/disbursement_instructions_validator_test.go b/internal/serve/validators/disbursement_instructions_validator_test.go
index 8b50768f6..6eefe7780 100644
--- a/internal/serve/validators/disbursement_instructions_validator_test.go
+++ b/internal/serve/validators/disbursement_instructions_validator_test.go
@@ -269,11 +269,11 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing
 			contactType: data.RegistrationContactTypePhoneAndWalletAddress,
 			hasErrors:   true,
 			expectedErrors: map[string]interface{}{
-				"line 3 - wallet address": "invalid wallet address. Must be a valid Stellar public key",
+				"line 3 - wallet address": "invalid wallet address. Must be a valid Stellar public key or contract address",
 			},
 		},
 		{
-			name: "🔴 WalletAddressMemo is not valid for WalletAddress contact type",
+			name: "🔴 WalletAddressMemo is not valid for G WalletAddress contact type",
 			instruction: &data.DisbursementInstruction{
 				WalletAddress:     "GB3SAK22KSTIFQAV5GCDNPW7RTQCWGFDKALBY5KJ3JRF2DLSED3E7PVH",
 				WalletAddressMemo: "this-string-is-not-a-valid-memo-because-it's-not-uint-and-too-long-for-a-text-and-not-a-valid-hex",
@@ -288,6 +288,22 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing
 				"line 3 - wallet address memo": "invalid wallet address memo. For more information, visit https://docs.stellar.org/learn/encyclopedia/transactions-specialized/memos",
 			},
 		},
+		{
+			name: "🔴 WalletAddressMemo is not valid for C WalletAddress contact type",
+			instruction: &data.DisbursementInstruction{
+				WalletAddress:     "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+				WalletAddressMemo: "1234",
+				Phone:             "+380445555555",
+				ID:                "123456789",
+				Amount:            "100.5",
+			},
+			lineNumber:  3,
+			contactType: data.RegistrationContactTypePhoneAndWalletAddress,
+			hasErrors:   true,
+			expectedErrors: map[string]interface{}{
+				"line 3 - wallet address memo": "wallet address memo is not supported for contract addresses",
+			},
+		},
 
 		// VALID CASES
 		{
@@ -368,6 +384,18 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing
 			verificationField: data.VerificationTypePin,
 			hasErrors:         false,
 		},
+		{
+			name: "🟢 successfully validates instructions (C WalletAddress)",
+			instruction: &data.DisbursementInstruction{
+				WalletAddress: "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+				Phone:         "+380445555555",
+				ID:            "123456789",
+				Amount:        "100.5",
+			},
+			lineNumber:  3,
+			contactType: data.RegistrationContactTypePhoneAndWalletAddress,
+			hasErrors:   false,
+		},
 	}
 
 	memos := []schema.Memo{
@@ -379,7 +407,7 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing
 
 	for _, memo := range memos {
 		tests = append(tests, TestCase{
-			name: fmt.Sprintf("🟢 successfully validates instructions (WalletAddress,%s)", memo.Type),
+			name: fmt.Sprintf("🟢 successfully validates instructions (G WalletAddress,%s)", memo.Type),
 			instruction: &data.DisbursementInstruction{
 				WalletAddress:     "GB3SAK22KSTIFQAV5GCDNPW7RTQCWGFDKALBY5KJ3JRF2DLSED3E7PVH",
 				WalletAddressMemo: memo.Value,
diff --git a/internal/transactionsubmission/store/transactions.go b/internal/transactionsubmission/store/transactions.go
index f8651e2de..d749d6bf8 100644
--- a/internal/transactionsubmission/store/transactions.go
+++ b/internal/transactionsubmission/store/transactions.go
@@ -93,8 +93,8 @@ func (tx *Transaction) validate() error {
 	if tx.Amount <= 0 {
 		return fmt.Errorf("amount must be positive")
 	}
-	if !strkey.IsValidEd25519PublicKey(tx.Destination) {
-		return fmt.Errorf("destination %q is not a valid ed25519 public key", tx.Destination)
+	if !strkey.IsValidEd25519PublicKey(tx.Destination) && !strkey.IsValidContractAddress(tx.Destination) {
+		return fmt.Errorf("destination %q is not a valid ed25519 public key or contract address", tx.Destination)
 	}
 	if tx.TenantID == "" {
 		return fmt.Errorf("tenant ID is required")
diff --git a/internal/transactionsubmission/store/transactions_test.go b/internal/transactionsubmission/store/transactions_test.go
index 6c4c47c8f..a23ba4538 100644
--- a/internal/transactionsubmission/store/transactions_test.go
+++ b/internal/transactionsubmission/store/transactions_test.go
@@ -708,27 +708,44 @@ func Test_Transaction_validate(t *testing.T) {
 			},
 			wantErrContains: `tenant ID is required`,
 		},
-		{
-			name: "🎉 successfully validate USDC transaction",
+	}
+
+	validAddresses := []string{
+		"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
+		"CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+	}
+
+	for _, address := range validAddresses {
+		testCases = append(testCases, struct {
+			name            string
+			transaction     Transaction
+			wantErrContains string
+		}{
+			name: fmt.Sprintf("🎉 successfully validate XLM transaction with (%c) destination", address[0]),
 			transaction: Transaction{
 				ExternalID:  "123",
-				AssetCode:   "USDC",
-				AssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
+				AssetCode:   "XLM",
 				Amount:      100.0,
-				Destination: "GDUCE34WW5Z34GMCEPURYANUCUP47J6NORJLKC6GJNMDLN4ZI4PMI2MG",
+				Destination: address,
 				TenantID:    "tenant-id",
 			},
-		},
-		{
-			name: "🎉 successfully validate XLM transaction",
+		})
+
+		testCases = append(testCases, struct {
+			name            string
+			transaction     Transaction
+			wantErrContains string
+		}{
+			name: fmt.Sprintf("🎉 successfully validate USDC transaction with (%c) destination", address[0]),
 			transaction: Transaction{
 				ExternalID:  "123",
-				AssetCode:   "xLm",
+				AssetCode:   "USDC",
+				AssetIssuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
 				Amount:      100.0,
-				Destination: "GDUCE34WW5Z34GMCEPURYANUCUP47J6NORJLKC6GJNMDLN4ZI4PMI2MG",
+				Destination: address,
 				TenantID:    "tenant-id",
 			},
-		},
+		})
 	}
 
 	for _, tc := range testCases {
diff --git a/internal/transactionsubmission/transaction_worker.go b/internal/transactionsubmission/transaction_worker.go
index 591be3364..a6d3c506e 100644
--- a/internal/transactionsubmission/transaction_worker.go
+++ b/internal/transactionsubmission/transaction_worker.go
@@ -521,35 +521,8 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob
 		return nil, utils.NewHorizonErrorWrapper(err)
 	}
 
-	memo, err := txJob.Transaction.BuildMemo()
-	if err != nil {
-		return nil, fmt.Errorf("building memo: %w", err)
-	}
-
 	// build the inner payment transaction
-	paymentTx, err := txnbuild.NewTransaction(
-		txnbuild.TransactionParams{
-			SourceAccount: &txnbuild.SimpleAccount{
-				AccountID: txJob.ChannelAccount.PublicKey,
-				Sequence:  horizonAccount.Sequence,
-			},
-			Operations: []txnbuild.Operation{
-				&txnbuild.Payment{
-					SourceAccount: distributionAccount.Address,
-					Amount:        strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32),
-					Destination:   txJob.Transaction.Destination,
-					Asset:         asset,
-				},
-			},
-			Memo:    memo,
-			BaseFee: int64(tw.engine.MaxBaseFee),
-			Preconditions: txnbuild.Preconditions{
-				TimeBounds:   txnbuild.NewTimeout(300),                                                 // maximum 5 minutes
-				LedgerBounds: &txnbuild.LedgerBounds{MaxLedger: uint32(txJob.LockedUntilLedgerNumber)}, // currently, 8-10 ledgers in the future
-			},
-			IncrementSequenceNum: true,
-		},
-	)
+	paymentTx, err := tw.buildInnerTxn(txJob, horizonAccount.Sequence, distributionAccount.Address, asset)
 	if err != nil {
 		return nil, fmt.Errorf("building transaction for job %v: %w", txJob, err)
 	}
@@ -585,6 +558,66 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob
 	return feeBumpTx, nil
 }
 
+func (tw *TransactionWorker) buildInnerTxn(txJob *TxJob, channelAccountSequenceNum int64, distributionAccount string, asset txnbuild.Asset) (*txnbuild.Transaction, error) {
+	var operation txnbuild.Operation
+	var txMemo txnbuild.Memo
+	amount := strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32)
+
+	if strkey.IsValidEd25519PublicKey(txJob.Transaction.Destination) {
+		memo, err := txJob.Transaction.BuildMemo()
+		if err != nil {
+			return nil, fmt.Errorf("building memo: %w", err)
+		}
+		txMemo = memo
+
+		operation = &txnbuild.Payment{
+			SourceAccount: distributionAccount,
+			Amount:        amount,
+			Destination:   txJob.Transaction.Destination,
+			Asset:         asset,
+		}
+	} else if strkey.IsValidContractAddress(txJob.Transaction.Destination) {
+		if txJob.Transaction.Memo != "" {
+			return nil, fmt.Errorf("memo is not supported for contract destination (%s)", txJob.Transaction.Destination)
+		}
+		params := txnbuild.PaymentToContractParams{
+			NetworkPassphrase: tw.engine.SignatureService.NetworkPassphrase(),
+			Destination:       txJob.Transaction.Destination,
+			Amount:            amount,
+			Asset:             asset,
+			SourceAccount:     distributionAccount,
+		}
+		op, err := txnbuild.NewPaymentToContract(params)
+		if err != nil {
+			return nil, fmt.Errorf("building payment to contract operation: %w", err)
+		}
+		operation = &op
+	} else {
+		return nil, fmt.Errorf("invalid destination account (%s)", txJob.Transaction.Destination)
+	}
+
+	paymentTx, err := txnbuild.NewTransaction(
+		txnbuild.TransactionParams{
+			SourceAccount: &txnbuild.SimpleAccount{
+				AccountID: txJob.ChannelAccount.PublicKey,
+				Sequence:  channelAccountSequenceNum,
+			},
+			Operations: []txnbuild.Operation{
+				operation,
+			},
+			Memo:    txMemo,
+			BaseFee: int64(tw.engine.MaxBaseFee),
+			Preconditions: txnbuild.Preconditions{
+				TimeBounds:   txnbuild.NewTimeout(300),                                                 // maximum 5 minutes
+				LedgerBounds: &txnbuild.LedgerBounds{MaxLedger: uint32(txJob.LockedUntilLedgerNumber)}, // currently, 8-10 ledgers in the future
+			},
+			IncrementSequenceNum: true,
+		},
+	)
+
+	return paymentTx, err
+}
+
 func (tw *TransactionWorker) submit(ctx context.Context, txJob *TxJob, feeBumpTx *txnbuild.FeeBumpTransaction) error {
 	resp, err := tw.engine.HorizonClient.SubmitFeeBumpTransactionWithOptions(feeBumpTx, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true})
 	if err != nil {
diff --git a/internal/transactionsubmission/transaction_worker_test.go b/internal/transactionsubmission/transaction_worker_test.go
index a8c6329a4..2d85dfd04 100644
--- a/internal/transactionsubmission/transaction_worker_test.go
+++ b/internal/transactionsubmission/transaction_worker_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/stellar/go/keypair"
 	"github.com/stellar/go/network"
 	"github.com/stellar/go/protocols/horizon"
+	"github.com/stellar/go/strkey"
 	"github.com/stellar/go/support/log"
 	"github.com/stellar/go/support/render/problem"
 	"github.com/stellar/go/txnbuild"
@@ -1670,6 +1671,7 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) {
 		getAccountResponseObj   horizon.Account
 		getAccountResponseError *horizonclient.Error
 		wantErrorContains       string
+		destinationAddress      string
 		memoType                schema.MemoType
 		memoValue               string
 		wantMemo                txnbuild.Memo
@@ -1693,26 +1695,55 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) {
 			wantErrorContains:       "horizon response error: ",
 		},
 		{
-			name:                  "🎉 successfully build and sign a transaction",
+			name:                  "returns an error if memo is present for C destination",
 			assetCode:             "USDC",
 			assetIssuer:           "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
 			getAccountResponseObj: horizon.Account{Sequence: accountSequence},
+			destinationAddress:    "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+			memoType:              schema.MemoTypeText,
+			memoValue:             "HelloWorld!",
+			wantErrorContains:     "memo is not supported for contract destination",
+		},
+		{
+			name:                  "🎉 successfully build and sign a payment transaction for G destination",
+			assetCode:             "USDC",
+			assetIssuer:           "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
+			destinationAddress:    "GCBIRB7Q5T53H4L6P5QSI3O6LPD5MBWGM5GHE7A5NY4XT5OT4VCOEZFX",
+			getAccountResponseObj: horizon.Account{Sequence: accountSequence},
 		},
 		{
-			name:                  "🎉 successfully build and sign a transaction with native asset",
+			name:                  "🎉 successfully build and sign a payment transaction with native asset for G destination",
 			assetCode:             "XLM",
 			assetIssuer:           "",
 			getAccountResponseObj: horizon.Account{Sequence: accountSequence},
+			destinationAddress:    "GCBIRB7Q5T53H4L6P5QSI3O6LPD5MBWGM5GHE7A5NY4XT5OT4VCOEZFX",
 		},
 		{
-			name:                  "🎉 successfully build and sign a transaction with memo",
+			name:                  "🎉 successfully build and sign a payment transaction with memo for G destination",
 			assetCode:             "USDC",
 			assetIssuer:           "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
 			getAccountResponseObj: horizon.Account{Sequence: accountSequence},
+			destinationAddress:    "GCBIRB7Q5T53H4L6P5QSI3O6LPD5MBWGM5GHE7A5NY4XT5OT4VCOEZFX",
 			memoType:              schema.MemoTypeText,
 			memoValue:             "HelloWorld!",
 			wantMemo:              txnbuild.MemoText("HelloWorld!"),
 		},
+		{
+			name:                  "🎉 successfully build and sign a SAC transfer transaction for C destination",
+			assetCode:             "USDC",
+			assetIssuer:           "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
+			destinationAddress:    "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+			getAccountResponseObj: horizon.Account{Sequence: accountSequence},
+			wantMemo:              nil,
+		},
+		{
+			name:                  "🎉 successfully build and sign a SAC transfer transaction with native asset for C destination",
+			assetCode:             "XLM",
+			assetIssuer:           "",
+			getAccountResponseObj: horizon.Account{Sequence: accountSequence},
+			destinationAddress:    "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53",
+			wantMemo:              nil,
+		},
 	}
 
 	for _, tc := range testCases {
@@ -1725,6 +1756,7 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) {
 			txJob := createTxJobFixture(t, ctx, dbConnectionPool, true, currentLedger, lockedToLedger, tnt.ID)
 			txJob.Transaction.AssetCode = tc.assetCode
 			txJob.Transaction.AssetIssuer = tc.assetIssuer
+			txJob.Transaction.Destination = tc.destinationAddress
 			txJob.Transaction.Memo = tc.memoValue
 			txJob.Transaction.MemoType = tc.memoType
 
@@ -1772,22 +1804,37 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) {
 						Issuer: txJob.Transaction.AssetIssuer,
 					}
 				}
+
+				var operation txnbuild.Operation
+				amount := strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32)
+				if strkey.IsValidEd25519PublicKey(tc.destinationAddress) {
+					operation = &txnbuild.Payment{
+						SourceAccount: distributionKP.Address(),
+						Amount:        amount,
+						Destination:   txJob.Transaction.Destination,
+						Asset:         wantAsset,
+					}
+				} else if strkey.IsValidContractAddress(tc.destinationAddress) {
+					params := txnbuild.PaymentToContractParams{
+						NetworkPassphrase: network.TestNetworkPassphrase,
+						Destination:       txJob.Transaction.Destination,
+						Amount:            amount,
+						Asset:             wantAsset,
+						SourceAccount:     distributionKP.Address(),
+					}
+					op, _ := txnbuild.NewPaymentToContract(params)
+					operation = &op
+				}
+
 				wantInnerTx, err := txnbuild.NewTransaction(
 					txnbuild.TransactionParams{
 						SourceAccount: &txnbuild.SimpleAccount{
 							AccountID: txJob.ChannelAccount.PublicKey,
 							Sequence:  accountSequence,
 						},
-						Memo: tc.wantMemo,
-						Operations: []txnbuild.Operation{
-							&txnbuild.Payment{
-								SourceAccount: distributionKP.Address(),
-								Amount:        strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32), // TODO find a better way to do this
-								Destination:   txJob.Transaction.Destination,
-								Asset:         wantAsset,
-							},
-						},
-						BaseFee: int64(transactionWorker.engine.MaxBaseFee),
+						Memo:       tc.wantMemo,
+						Operations: []txnbuild.Operation{operation},
+						BaseFee:    int64(transactionWorker.engine.MaxBaseFee),
 						Preconditions: txnbuild.Preconditions{
 							TimeBounds:   txnbuild.NewTimeout(300),
 							LedgerBounds: &txnbuild.LedgerBounds{MaxLedger: uint32(txJob.LockedUntilLedgerNumber)},


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR implements disbursement support to contract accounts by removing restrictions on destination wallet address types and by using a dedicated helper for payments to contract accounts. Key changes include updating tests to include contract address scenarios, refactoring the transaction building logic to support both native and contract destinations, and extending validation functions to accept contract addresses.

### Reviewed Changes

Copilot reviewed 9 out of 11 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| internal/transactionsubmission/transaction_worker_test.go | Updated test cases with a new destinationAddress field and refined test names for contract versus native transactions. |
| internal/transactionsubmission/transaction_worker.go | Refactored transaction building into a new buildInnerTxn function that handles both Ed25519 and contract addresses. |
| internal/transactionsubmission/store/transactions_test.go | Enhanced tests to validate transactions with both types of addresses. |
| internal/transactionsubmission/store/transactions.go | Updated destination address validation to allow contract addresses. |
| internal/serve/validators/disbursement_instructions_validator*.go | Modified error messages and validation logic to support contract addresses. |
| internal/data/receivers_wallet*.go | Adjusted validation in wallet update and test files to accept contract addresses. |
</details>


<details>
<summary>Files not reviewed (2)</summary>

* **go.list**: Language not supported
* **go.mod**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T15:20:02Z
user: login: marwen-abid
body: LGTM! This is great! Feel free to merge after addressing the comments. 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T19:42:48Z
user: login: philipliu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T19:56:35Z
user: login: marcelosalloum
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T21:55:28Z

Review comments:
diff_hunk: @@ -93,8 +93,8 @@ func (tx *Transaction) validate() error {
 	if tx.Amount <= 0 {
 		return fmt.Errorf("amount must be positive")
 	}
-	if !strkey.IsValidEd25519PublicKey(tx.Destination) {
-		return fmt.Errorf("destination %q is not a valid ed25519 public key", tx.Destination)
+	if !strkey.IsValidEd25519PublicKey(tx.Destination) && !strkey.IsValidContractAddress(tx.Destination) {
+		return fmt.Errorf("destination %q is not a valid ed25519 public key or contract addresss", tx.Destination)
path: internal/transactionsubmission/store/transactions.go
user: login: Copilot
body: There is a typo in the error message: 'contract addresss' should be corrected to 'contract address'.
```suggestion
		return fmt.Errorf("destination %q is not a valid ed25519 public key or contract address", tx.Destination)
```
created_at: 2025-04-29T15:20:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -585,6 +558,66 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob
 	return feeBumpTx, nil
 }
 
+func (tw *TransactionWorker) buildInnerTxn(txJob *TxJob, channelAccountSequenceNum int64, distributionAccount string, asset txnbuild.Asset) (*txnbuild.Transaction, error) {
+	var operation txnbuild.Operation
+	var txMemo txnbuild.Memo
+	amount := strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32)
+
+	if strkey.IsValidEd25519PublicKey(txJob.Transaction.Destination) {
+		memo, err := txJob.Transaction.BuildMemo()
+		if err != nil {
+			return nil, fmt.Errorf("building memo: %w", err)
+		}
+		txMemo = memo
+
+		operation = &txnbuild.Payment{
+			SourceAccount: distributionAccount,
+			Amount:        amount,
+			Destination:   txJob.Transaction.Destination,
+			Asset:         asset,
+		}
+	} else if strkey.IsValidContractAddress(txJob.Transaction.Destination) {
+		if txJob.Transaction.Memo != "" {
+			return nil, fmt.Errorf("memo is not supported for contract destination: %s", txJob.Transaction.Destination)
+		}
+		params := txnbuild.PaymentToContractParams{
+			NetworkPassphrase: tw.engine.SignatureService.NetworkPassphrase(),
+			Destination:       txJob.Transaction.Destination,
+			Amount:            amount,
+			Asset:             asset,
+			SourceAccount:     distributionAccount,
+		}
+		op, err := txnbuild.NewPaymentToContract(params)
+		if err != nil {
+			return nil, fmt.Errorf("building payment to contract operation: %w", err)
+		}
+		operation = &op
+	} else {
+		return nil, fmt.Errorf("invalid destination account: %v", txJob.Transaction.Destination)
path: internal/transactionsubmission/transaction_worker.go
user: login: marwen-abid
body: In golang, we generally reserve the `:` in error messages for wrapping errors. 
https://go.dev/blog/go1.13-errors
created_at: 2025-04-29T19:39:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -585,6 +558,66 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob
 	return feeBumpTx, nil
 }
 
+func (tw *TransactionWorker) buildInnerTxn(txJob *TxJob, channelAccountSequenceNum int64, distributionAccount string, asset txnbuild.Asset) (*txnbuild.Transaction, error) {
+	var operation txnbuild.Operation
+	var txMemo txnbuild.Memo
+	amount := strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32)
+
+	if strkey.IsValidEd25519PublicKey(txJob.Transaction.Destination) {
+		memo, err := txJob.Transaction.BuildMemo()
+		if err != nil {
+			return nil, fmt.Errorf("building memo: %w", err)
+		}
+		txMemo = memo
+
+		operation = &txnbuild.Payment{
+			SourceAccount: distributionAccount,
+			Amount:        amount,
+			Destination:   txJob.Transaction.Destination,
+			Asset:         asset,
+		}
+	} else if strkey.IsValidContractAddress(txJob.Transaction.Destination) {
+		if txJob.Transaction.Memo != "" {
+			return nil, fmt.Errorf("memo is not supported for contract destination: %s", txJob.Transaction.Destination)
path: internal/transactionsubmission/transaction_worker.go
user: login: marwen-abid
body: In golang, we generally reserve the : in error messages for wrapping errors.
https://go.dev/blog/go1.13-errors
created_at: 2025-04-29T19:40:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -585,6 +558,66 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob
 	return feeBumpTx, nil
 }
 
+func (tw *TransactionWorker) buildInnerTxn(txJob *TxJob, channelAccountSequenceNum int64, distributionAccount string, asset txnbuild.Asset) (*txnbuild.Transaction, error) {
+	var operation txnbuild.Operation
+	var txMemo txnbuild.Memo
+	amount := strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32)
+
+	if strkey.IsValidEd25519PublicKey(txJob.Transaction.Destination) {
+		memo, err := txJob.Transaction.BuildMemo()
+		if err != nil {
+			return nil, fmt.Errorf("building memo: %w", err)
+		}
+		txMemo = memo
+
+		operation = &txnbuild.Payment{
+			SourceAccount: distributionAccount,
+			Amount:        amount,
+			Destination:   txJob.Transaction.Destination,
+			Asset:         asset,
+		}
+	} else if strkey.IsValidContractAddress(txJob.Transaction.Destination) {
+		if txJob.Transaction.Memo != "" {
+			return nil, fmt.Errorf("memo is not supported for contract destination: %s", txJob.Transaction.Destination)
+		}
+		params := txnbuild.PaymentToContractParams{
+			NetworkPassphrase: tw.engine.SignatureService.NetworkPassphrase(),
+			Destination:       txJob.Transaction.Destination,
+			Amount:            amount,
+			Asset:             asset,
+			SourceAccount:     distributionAccount,
+		}
+		op, err := txnbuild.NewPaymentToContract(params)
+		if err != nil {
+			return nil, fmt.Errorf("building payment to contract operation: %w", err)
+		}
+		operation = &op
+	} else {
+		return nil, fmt.Errorf("invalid destination account: %v", txJob.Transaction.Destination)
path: internal/transactionsubmission/transaction_worker.go
user: login: philipliu
body: TIL, thanks!
created_at: 2025-04-29T19:56:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: stellar-jenkins
created_at: 2025-04-25T20:22:58Z
body: **stellar-disbursement-platform-backend-preview is available here:**<br>SDP:      https://sdp-backend-pr641.previews.kube001.services.stellar-ops.com/health<br>AP:       https://sdp-ap-pr641.previews.kube001.services.stellar-ops.com/health<br>Frontend: https://sdp-backend-dashboard-pr641.previews.kube001.services.stellar-ops.com
author_association: NONE
user: login: stellar-jenkins
created_at: 2025-04-25T20:30:12Z
body: **stellar-disbursement-platform-backend-preview is available here:**<br>SDP:      https://sdp-backend-pr641.previews.kube001.services.stellar-ops.com/health<br>AP:       https://sdp-ap-pr641.previews.kube001.services.stellar-ops.com/health<br>Frontend: https://sdp-backend-dashboard-pr641.previews.kube001.services.stellar-ops.com
author_association: NONE
user: login: stellar-jenkins
created_at: 2025-04-25T20:42:01Z
body: **stellar-disbursement-platform-backend-preview is available here:**<br>SDP:      https://sdp-backend-pr641.previews.kube001.services.stellar-ops.com/health<br>AP:       https://sdp-ap-pr641.previews.kube001.services.stellar-ops.com/health<br>Frontend: https://sdp-backend-dashboard-pr641.previews.kube001.services.stellar-ops.com
author_association: NONE
user: login: socket-security[bot]
created_at: 2025-04-25T20:49:54Z
body: **Review the following changes in direct dependencies.** Learn more about [Socket for GitHub](https://socket.dev?utm_medium=gh).

<table>
<thead>
<tr>
<th>Diff</th>
<th width="200px">Package</th>
<th align="left" width="100px">Supply Chain<br/>Security</th>
<th align="left" width="100px">Vulnerability</th>
<th align="left" width="100px">Quality</th>
<th align="left" width="100px">Maintenance</th>
<th align="left" width="100px">License</th>
</tr>
</thead>
<tbody>
<tr><td align="center"><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441"><img src="https://github-app-statics.socket.dev/diff-updated.svg" title="Updated" alt="Updated" width="20" height="20"></a></td><td><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441">golang/​github.com/​stellar/​go@​v0.0.0-20250311234916-385ac5aca1a4 ⏵ v0.0.0-20250423200217-8eabd901dc82</a></td><td align="left"><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441"><img src="https://github-app-statics.socket.dev/score-74.svg" title="Supply Chain Security" width="40" height="40" alt="74"></a> <sup>-1</sup></td><td align="left"><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Vulnerability" width="40" height="40" alt="100"></a></td><td align="left"><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Quality" width="40" height="40" alt="100"></a></td><td align="left"><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Maintenance" width="40" height="40" alt="100"></a></td><td align="left"><a href="https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies&dependency_item_key=25473253441"><img src="https://github-app-statics.socket.dev/score-100.svg" title="License" width="40" height="40" alt="100"></a></td></tr>
</tbody>
</table>

[View full report](https://socket.dev/dashboard/org/stellar/diff/53879598-f206-47c7-a1e8-48bbf14897ac/c3deac4e-769c-4a3c-b971-55d32ee6a708?tab=dependencies)

<!-- overview-comment -->
author_association: NONE
user: login: stellar-jenkins
created_at: 2025-04-29T15:23:36Z
body: **stellar-disbursement-platform-backend-preview is available here:**<br>SDP:      https://sdp-backend-pr641.previews.kube001.services.stellar-ops.com/health<br>AP:       https://sdp-ap-pr641.previews.kube001.services.stellar-ops.com/health<br>Frontend: https://sdp-backend-dashboard-pr641.previews.kube001.services.stellar-ops.com
author_association: NONE
user: login: stellar-jenkins
created_at: 2025-04-29T20:01:58Z
body: **stellar-disbursement-platform-backend-preview is available here:**<br>SDP:      https://sdp-backend-pr641.previews.kube001.services.stellar-ops.com/health<br>AP:       https://sdp-ap-pr641.previews.kube001.services.stellar-ops.com/health<br>Frontend: https://sdp-backend-dashboard-pr641.previews.kube001.services.stellar-ops.com
author_association: NONE