Skip to content

Commit 72c43d2

Browse files
authored
Merge branch 'develop' into sdp-sep-wallet-registration
2 parents bd718c7 + 0754d82 commit 72c43d2

13 files changed

+295
-36
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
1616
- Add organization level MFA and ReCAPTCHA settings [#861](https://github.com/stellar/stellar-disbursement-platform-backend/pull/861)
1717
- Add trustlines for distribution account when provisioning tenant [#891](https://github.com/stellar/stellar-disbursement-platform-backend/pull/891)
1818
- Add support contract account disbursements [#922](https://github.com/stellar/stellar-disbursement-platform-backend/pull/922)
19+
- Add contract account support for direct payments [#924](https://github.com/stellar/stellar-disbursement-platform-backend/pull/924)
20+
- Add support for contract addresses for PATCH receiver [#925](https://github.com/stellar/stellar-disbursement-platform-backend/pull/925)
21+
- Mark tx failures due to archived entries as error [#926](https://github.com/stellar/stellar-disbursement-platform-backend/pull/926)
1922

2023
### Changed
2124
- Decommissioned Event Broker Kafka support in favor of Scheduler for background jobs. [#914](https://github.com/stellar/stellar-disbursement-platform-backend/pull/914)

internal/data/receivers_wallet.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,16 @@ func (rw *ReceiverWalletModel) Update(ctx context.Context, id string, update Rec
646646
}
647647

648648
if strkey.IsValidContractAddress(stellarAddress) {
649-
return ErrMemosNotSupportedForContractAddresses
649+
memoIsClear := update.StellarMemo != nil && *update.StellarMemo == ""
650+
memoTypeIsClear := update.StellarMemoType == nil || (update.StellarMemoType != nil && *update.StellarMemoType == "")
651+
652+
if !memoIsClear {
653+
return ErrMemosNotSupportedForContractAddresses
654+
}
655+
656+
if !memoTypeIsClear {
657+
return ErrMemosNotSupportedForContractAddresses
658+
}
650659
}
651660
}
652661

internal/serve/httphandler/receiver_wallets_handler.go

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
"github.com/stellar/stellar-disbursement-platform-backend/internal/sdpcontext"
2020
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror"
2121
"github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators"
22+
"github.com/stellar/stellar-disbursement-platform-backend/internal/utils"
23+
"github.com/stellar/stellar-disbursement-platform-backend/pkg/schema"
2224
)
2325

2426
type RetryInvitationMessageResponse struct {
@@ -138,8 +140,8 @@ func (h ReceiverWalletsHandler) validateAndUpdateStatus(ctx context.Context, rec
138140
}
139141

140142
type PatchReceiverWalletRequest struct {
141-
StellarAddress string `json:"stellar_address"`
142-
StellarMemo string `json:"stellar_memo,omitempty"`
143+
StellarAddress string `json:"stellar_address"`
144+
StellarMemo *string `json:"stellar_memo,omitempty"`
143145
}
144146

145147
// PatchReceiverWallet updates a receiver wallet's Stellar address and memo for user-managed wallets
@@ -167,14 +169,15 @@ func (h ReceiverWalletsHandler) PatchReceiverWallet(rw http.ResponseWriter, req
167169
}
168170

169171
// Validate required fields in the request body
170-
if strings.TrimSpace(patchRequest.StellarAddress) == "" {
172+
patchRequest.StellarAddress = strings.TrimSpace(patchRequest.StellarAddress)
173+
if patchRequest.StellarAddress == "" {
171174
httperror.BadRequest("stellar_address is required", nil, nil).Render(rw)
172175
return
173176
}
174177

175-
// Validate that stellar_address is a valid Stellar public key
176-
if !strkey.IsValidEd25519PublicKey(patchRequest.StellarAddress) {
177-
httperror.BadRequest("stellar_address must be a valid Stellar public key", nil, nil).Render(rw)
178+
// Validate that stellar_address is a valid Stellar address
179+
if !strkey.IsValidEd25519PublicKey(patchRequest.StellarAddress) && !strkey.IsValidContractAddress(patchRequest.StellarAddress) {
180+
httperror.BadRequest("stellar_address must be a valid Stellar account or contract address", nil, nil).Render(rw)
178181
return
179182
}
180183

@@ -198,20 +201,44 @@ func (h ReceiverWalletsHandler) PatchReceiverWallet(rw http.ResponseWriter, req
198201
StellarAddress: patchRequest.StellarAddress,
199202
}
200203

201-
memo := strings.TrimSpace(patchRequest.StellarMemo)
202-
memoType, memoErr := validators.ValidateWalletAddressMemo(patchRequest.StellarAddress, memo)
203-
if memoErr != nil {
204-
return nil, fmt.Errorf("validating memo %s: %w", patchRequest.StellarMemo, memoErr)
204+
// 3. Validate memo if provided
205+
if patchRequest.StellarMemo != nil {
206+
trimmed := strings.TrimSpace(*patchRequest.StellarMemo)
207+
*patchRequest.StellarMemo = trimmed
208+
}
209+
memoProvided := patchRequest.StellarMemo != nil
210+
if strkey.IsValidContractAddress(patchRequest.StellarAddress) {
211+
// An empty memo must be explicitly provided to clear existing memos if replacing with a contract address
212+
if (currentReceiverWallet.StellarMemo != "" || currentReceiverWallet.StellarMemoType != "") && !memoProvided {
213+
return nil, httperror.BadRequest("Clear memo before assigning a contract address", nil, nil)
214+
}
215+
216+
// Reject any non-empty memo for contract addresses
217+
if memoProvided && *patchRequest.StellarMemo != "" {
218+
return nil, httperror.BadRequest("Memos are not supported for contract addresses", nil, nil)
219+
}
220+
221+
if memoProvided {
222+
walletUpdate.StellarMemo = utils.StringPtr("")
223+
walletUpdate.StellarMemoType = utils.Ptr(schema.MemoType(""))
224+
}
225+
} else if memoProvided {
226+
// Validate the memo for non-contract addresses
227+
memoType, memoErr := validators.ValidateWalletAddressMemo(patchRequest.StellarAddress, *patchRequest.StellarMemo)
228+
if memoErr != nil {
229+
return nil, fmt.Errorf("validating memo %s: %w", *patchRequest.StellarMemo, memoErr)
230+
}
231+
232+
walletUpdate.StellarMemo = patchRequest.StellarMemo
233+
walletUpdate.StellarMemoType = &memoType
205234
}
206-
walletUpdate.StellarMemo = &memo
207-
walletUpdate.StellarMemoType = &memoType
208235

209-
// 3: Update the receiver wallet
236+
// 4: Update the receiver wallet
210237
if txErr = h.Models.ReceiverWallet.Update(ctx, receiverWalletID, walletUpdate, dbTx); txErr != nil {
211238
return nil, fmt.Errorf("updating receiver wallet %s: %w", receiverWalletID, txErr)
212239
}
213240

214-
// 4: Retrieve the updated receiver wallet
241+
// 5: Retrieve the updated receiver wallet
215242
updatedWallet, txErr := h.Models.ReceiverWallet.GetByID(ctx, dbTx, receiverWalletID)
216243
if txErr != nil {
217244
return nil, fmt.Errorf("getting updated receiver wallet %s: %w", receiverWalletID, txErr)

internal/serve/httphandler/receiver_wallets_handler_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package httphandler
22

33
import (
44
"context"
5+
crand "crypto/rand"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -15,6 +16,9 @@ import (
1516
"github.com/stretchr/testify/assert"
1617
"github.com/stretchr/testify/require"
1718

19+
"github.com/stellar/go/keypair"
20+
"github.com/stellar/go/strkey"
21+
1822
"github.com/stellar/stellar-disbursement-platform-backend/db"
1923
"github.com/stellar/stellar-disbursement-platform-backend/db/dbtest"
2024
"github.com/stellar/stellar-disbursement-platform-backend/internal/data"
@@ -398,3 +402,140 @@ func Test_ReceiverWalletsHandler_PatchReceiverWallet_DuplicateStellarAddress(t *
398402
assert.Contains(t, string(respBody), "Receiver wallet does not belong to the specified receiver")
399403
})
400404
}
405+
406+
func Test_ReceiverwalletsHandler_PatchReceiverWallet_MemoValidation(t *testing.T) {
407+
dbt := dbtest.Open(t)
408+
defer dbt.Close()
409+
410+
dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN)
411+
require.NoError(t, err)
412+
defer dbConnectionPool.Close()
413+
414+
models, err := data.NewModels(dbConnectionPool)
415+
require.NoError(t, err)
416+
tnt := schema.Tenant{ID: "tenant-id"}
417+
ctx := sdpcontext.SetTenantInContext(context.Background(), &tnt)
418+
419+
handler := ReceiverWalletsHandler{Models: models}
420+
router := chi.NewRouter()
421+
router.Patch("/receivers/{receiver_id}/wallets/{receiver_wallet_id}", handler.PatchReceiverWallet)
422+
423+
createUserManagedReceiverWallet := func(t *testing.T, status data.ReceiversWalletStatus) (*data.Receiver, *data.ReceiverWallet) {
424+
t.Helper()
425+
426+
wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "User Managed Wallet", "stellar.org", "stellar.org", "stellar://")
427+
data.MakeWalletUserManaged(t, ctx, dbConnectionPool, wallet.ID)
428+
receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{})
429+
rw := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, status)
430+
431+
return receiver, rw
432+
}
433+
434+
doPatch := func(body string, receiverID string, receiverWalletID string) (*http.Response, []byte) {
435+
req, requestErr := http.NewRequestWithContext(ctx, http.MethodPatch,
436+
fmt.Sprintf("/receivers/%s/wallets/%s", receiverID, receiverWalletID), strings.NewReader(body))
437+
require.NoError(t, requestErr)
438+
req.Header.Set("Content-Type", "application/json")
439+
440+
rr := httptest.NewRecorder()
441+
router.ServeHTTP(rr, req)
442+
443+
resp := rr.Result()
444+
payload, readErr := io.ReadAll(resp.Body)
445+
require.NoError(t, readErr)
446+
447+
return resp, payload
448+
}
449+
450+
generateAccountAddress := func(t *testing.T) string {
451+
t.Helper()
452+
return keypair.MustRandom().Address()
453+
}
454+
455+
generateContractAddress := func(t *testing.T) string {
456+
t.Helper()
457+
458+
payload := make([]byte, 32)
459+
_, randErr := crand.Read(payload)
460+
require.NoError(t, randErr)
461+
462+
addr, encodeErr := strkey.Encode(strkey.VersionByteContract, payload)
463+
require.NoError(t, encodeErr)
464+
465+
return addr
466+
}
467+
468+
t.Run("accepts contract address without memo", func(t *testing.T) {
469+
receiver, rw := createUserManagedReceiverWallet(t, data.DraftReceiversWalletStatus)
470+
contractAddress := generateContractAddress(t)
471+
472+
resp, payload := doPatch(fmt.Sprintf(`{"stellar_address": "%s"}`, contractAddress), receiver.ID, rw.ID)
473+
assert.Equal(t, http.StatusOK, resp.StatusCode)
474+
475+
var responseData map[string]interface{}
476+
unmarshalErr := json.Unmarshal(payload, &responseData)
477+
require.NoError(t, unmarshalErr)
478+
assert.Equal(t, contractAddress, responseData["stellar_address"])
479+
})
480+
481+
t.Run("allows switching from contract address to account address with memo", func(t *testing.T) {
482+
receiver, rw := createUserManagedReceiverWallet(t, data.DraftReceiversWalletStatus)
483+
currentContract := generateContractAddress(t)
484+
485+
resp, payload := doPatch(fmt.Sprintf(`{"stellar_address": "%s"}`, currentContract), receiver.ID, rw.ID)
486+
require.Equal(t, http.StatusOK, resp.StatusCode, string(payload))
487+
488+
newAccountAddress := generateAccountAddress(t)
489+
memo := "987654321"
490+
491+
resp, payload = doPatch(fmt.Sprintf(`{"stellar_address": "%s","stellar_memo":"%s"}`, newAccountAddress, memo), receiver.ID, rw.ID)
492+
assert.Equal(t, http.StatusOK, resp.StatusCode)
493+
494+
var responseData map[string]interface{}
495+
unmarshalErr := json.Unmarshal(payload, &responseData)
496+
require.NoError(t, unmarshalErr)
497+
assert.Equal(t, newAccountAddress, responseData["stellar_address"])
498+
assert.Equal(t, memo, responseData["stellar_memo"])
499+
assert.Equal(t, string(schema.MemoTypeID), responseData["stellar_memo_type"])
500+
})
501+
502+
t.Run("requires clearing memo before switching to contract address", func(t *testing.T) {
503+
receiver, rw := createUserManagedReceiverWallet(t, data.RegisteredReceiversWalletStatus)
504+
require.NotEmpty(t, rw.StellarMemo)
505+
506+
contractAddress := generateContractAddress(t)
507+
508+
resp, payload := doPatch(fmt.Sprintf(`{"stellar_address": "%s"}`, contractAddress), receiver.ID, rw.ID)
509+
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
510+
assert.JSONEq(t, `{"error":"Clear memo before assigning a contract address"}`, string(payload))
511+
})
512+
513+
t.Run("rejects memo payload when assigning contract address", func(t *testing.T) {
514+
receiver, rw := createUserManagedReceiverWallet(t, data.DraftReceiversWalletStatus)
515+
516+
contractAddress := generateContractAddress(t)
517+
518+
resp, payload := doPatch(fmt.Sprintf(`{"stellar_address": "%s","stellar_memo":"memo-value"}`, contractAddress), receiver.ID, rw.ID)
519+
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
520+
assert.JSONEq(t, `{"error":"Memos are not supported for contract addresses"}`, string(payload))
521+
})
522+
523+
t.Run("allows clearing memo when switching to contract address", func(t *testing.T) {
524+
receiver, rw := createUserManagedReceiverWallet(t, data.RegisteredReceiversWalletStatus)
525+
require.NotEmpty(t, rw.StellarMemo)
526+
527+
contractAddress := generateContractAddress(t)
528+
529+
resp, payload := doPatch(fmt.Sprintf(`{"stellar_address": "%s","stellar_memo":""}`, contractAddress), receiver.ID, rw.ID)
530+
assert.Equal(t, http.StatusOK, resp.StatusCode)
531+
532+
var responseData map[string]interface{}
533+
unmarshalErr := json.Unmarshal(payload, &responseData)
534+
require.NoError(t, unmarshalErr)
535+
assert.Equal(t, contractAddress, responseData["stellar_address"])
536+
_, memoPresent := responseData["stellar_memo"]
537+
assert.False(t, memoPresent)
538+
_, memoTypePresent := responseData["stellar_memo_type"]
539+
assert.False(t, memoTypePresent)
540+
})
541+
}

internal/serve/validators/direct_payment_validator.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,8 @@ func (v *DirectPaymentValidator) validateReceiverReference(receiver *DirectPayme
189189

190190
if hasWallet {
191191
walletAddr := strings.TrimSpace(*receiver.WalletAddress)
192-
v.Check(strkey.IsValidEd25519PublicKey(walletAddr),
193-
"receiver.wallet_address", "invalid stellar account ID format")
192+
v.Check(strkey.IsValidEd25519PublicKey(walletAddr) || strkey.IsValidContractAddress(walletAddr),
193+
"receiver.wallet_address", "invalid stellar address format")
194194
*receiver.WalletAddress = walletAddr
195195
}
196196

@@ -212,8 +212,8 @@ func (v *DirectPaymentValidator) validateWalletReference(wallet *DirectPaymentWa
212212

213213
if hasAddress {
214214
address := strings.TrimSpace(*wallet.Address)
215-
v.Check(strkey.IsValidEd25519PublicKey(address),
216-
"wallet.address", "invalid stellar account ID format")
215+
v.Check(strkey.IsValidEd25519PublicKey(address) || strkey.IsValidContractAddress(address),
216+
"wallet.address", "invalid stellar address format")
217217
*wallet.Address = address
218218
}
219219

internal/serve/validators/direct_payment_validator_test.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import (
66
"github.com/stellar/stellar-disbursement-platform-backend/internal/testutils"
77
)
88

9+
const (
10+
validStellarAccount = "GCFXHS4GXL6BVUCXBWXGTITROWLVYXQKQLF4YH5O5JT3YZXCYPAFBJZB"
11+
validContractAccount = "CAMAMZUOULVWFAB3KRROW5ELPUFHSEKPUALORCFBLFX7XBWWUCUJLR53"
12+
)
13+
914
func TestDirectPaymentValidator_ValidateCreateDirectPaymentRequest(t *testing.T) {
1015
t.Parallel()
1116

12-
validStellarAccount := "GCFXHS4GXL6BVUCXBWXGTITROWLVYXQKQLF4YH5O5JT3YZXCYPAFBJZB"
13-
1417
tests := []struct {
1518
name string
1619
reqBody *CreateDirectPaymentRequest
@@ -64,6 +67,20 @@ func TestDirectPaymentValidator_ValidateCreateDirectPaymentRequest(t *testing.T)
6467
},
6568
expectValid: true,
6669
},
70+
{
71+
name: "🟢 valid contract asset with contract wallet receiver",
72+
reqBody: &CreateDirectPaymentRequest{
73+
Amount: "500.25",
74+
Asset: DirectPaymentAsset{
75+
Type: testutils.StringPtr("contract"),
76+
ContractID: testutils.StringPtr("contract-perturabo-001"),
77+
},
78+
Receiver: DirectPaymentReceiver{WalletAddress: testutils.StringPtr(validContractAccount)},
79+
Wallet: DirectPaymentWallet{Address: testutils.StringPtr(validContractAccount)},
80+
},
81+
expectValid: true,
82+
},
83+
6784
{
6885
name: "🟢 valid fiat asset",
6986
reqBody: &CreateDirectPaymentRequest{
@@ -137,8 +154,6 @@ func TestDirectPaymentValidator_ValidateCreateDirectPaymentRequest(t *testing.T)
137154
func TestDirectPaymentValidator_validateAssetReference(t *testing.T) {
138155
t.Parallel()
139156

140-
validStellarAccount := "GCFXHS4GXL6BVUCXBWXGTITROWLVYXQKQLF4YH5O5JT3YZXCYPAFBJZB"
141-
142157
tests := []struct {
143158
name string
144159
asset *DirectPaymentAsset
@@ -267,8 +282,6 @@ func TestDirectPaymentValidator_validateAssetReference(t *testing.T) {
267282
func TestDirectPaymentValidator_validateReceiverReference(t *testing.T) {
268283
t.Parallel()
269284

270-
validStellarAccount := "GCFXHS4GXL6BVUCXBWXGTITROWLVYXQKQLF4YH5O5JT3YZXCYPAFBJZB"
271-
272285
tests := []struct {
273286
name string
274287
receiver *DirectPaymentReceiver
@@ -291,6 +304,10 @@ func TestDirectPaymentValidator_validateReceiverReference(t *testing.T) {
291304
name: "🟢 valid wallet address",
292305
receiver: &DirectPaymentReceiver{WalletAddress: testutils.StringPtr(validStellarAccount)},
293306
},
307+
{
308+
name: "🟢 valid contract wallet address",
309+
receiver: &DirectPaymentReceiver{WalletAddress: testutils.StringPtr(validContractAccount)},
310+
},
294311
{
295312
name: "🟢 fields with whitespace get trimmed",
296313
receiver: &DirectPaymentReceiver{ID: testutils.StringPtr(" corvus-corax ")},
@@ -358,8 +375,6 @@ func TestDirectPaymentValidator_validateReceiverReference(t *testing.T) {
358375
func TestDirectPaymentValidator_validateWalletReference(t *testing.T) {
359376
t.Parallel()
360377

361-
validStellarAccount := "GCFXHS4GXL6BVUCXBWXGTITROWLVYXQKQLF4YH5O5JT3YZXCYPAFBJZB"
362-
363378
tests := []struct {
364379
name string
365380
wallet *DirectPaymentWallet
@@ -374,6 +389,10 @@ func TestDirectPaymentValidator_validateWalletReference(t *testing.T) {
374389
name: "🟢 valid wallet with address",
375390
wallet: &DirectPaymentWallet{Address: testutils.StringPtr(validStellarAccount)},
376391
},
392+
{
393+
name: "🟢 valid wallet with contract address",
394+
wallet: &DirectPaymentWallet{Address: testutils.StringPtr(validContractAccount)},
395+
},
377396
{
378397
name: "🟢 fields with whitespace get trimmed",
379398
wallet: &DirectPaymentWallet{ID: testutils.StringPtr(" jaghatai-khan-wallet ")},

0 commit comments

Comments
 (0)