diff --git a/go.mod b/go.mod index 9bd618f8..b5aa33cd 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( firebase.google.com/go/v4 v4.8.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/bits-and-blooms/bloom/v3 v3.1.0 - github.com/code-payments/code-protobuf-api v1.9.0 + github.com/code-payments/code-protobuf-api v1.10.0 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v0.1.0 github.com/golang-jwt/jwt/v5 v5.0.0 diff --git a/go.sum b/go.sum index fe762d9a..e6f3aa66 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/code-payments/code-protobuf-api v1.9.0 h1:V8v/yAlZnVtcqh1LZ+zBIYC+Z04kOcCr8GRvwka69C0= -github.com/code-payments/code-protobuf-api v1.9.0/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU= +github.com/code-payments/code-protobuf-api v1.10.0 h1:0I/lCHUtuQdbSKj02miQZlwSEdEUqVtTR0LxPTxA15w= +github.com/code-payments/code-protobuf-api v1.10.0/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= diff --git a/pkg/code/async/sequencer/scheduler_test.go b/pkg/code/async/sequencer/scheduler_test.go index d2e5c703..6c2dd1aa 100644 --- a/pkg/code/async/sequencer/scheduler_test.go +++ b/pkg/code/async/sequencer/scheduler_test.go @@ -610,7 +610,17 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) { } } - forceSimulateFeePayment := func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { + forceSimulateOneFeePayments := func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { + for _, fulfillmentRecord := range fulfillmentRecords { + if fulfillmentRecord.FulfillmentType == fulfillment.NoPrivacyTransferWithAuthority && fulfillmentRecord.Source == transfer.Source { + fulfillmentRecord.State = fulfillment.StateConfirmed + env.data.UpdateFulfillment(env.ctx, fulfillmentRecord) + break + } + } + } + + forceSimulateAllFeePayments := func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { for _, fulfillmentRecord := range fulfillmentRecords { if fulfillmentRecord.FulfillmentType == fulfillment.NoPrivacyTransferWithAuthority && fulfillmentRecord.Source == transfer.Source { fulfillmentRecord.State = fulfillment.StateConfirmed @@ -632,7 +642,7 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) { simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { simulateOpeningSourceAccount(env, transfer, fulfillmentRecords) simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords) - forceSimulateFeePayment(env, transfer, fulfillmentRecords) + forceSimulateAllFeePayments(env, transfer, fulfillmentRecords) }, expected: false, }, @@ -642,7 +652,7 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) { simulateOpeningSourceAccount(env, transfer, fulfillmentRecords) simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords) simulateOneTreasuryPayment(env, transfer, fulfillmentRecords) - forceSimulateFeePayment(env, transfer, fulfillmentRecords) + forceSimulateAllFeePayments(env, transfer, fulfillmentRecords) }, expected: false, }, @@ -651,7 +661,7 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) { simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { simulateOpeningSourceAccount(env, transfer, fulfillmentRecords) simulateAllTreasuryPayments(env, transfer, fulfillmentRecords) - forceSimulateFeePayment(env, transfer, fulfillmentRecords) + forceSimulateAllFeePayments(env, transfer, fulfillmentRecords) }, expected: false, }, @@ -659,13 +669,13 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) { // Source user account not opened simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { forceSimulateAllTreasuryPayments(env, transfer, fulfillmentRecords) - forceSimulateFeePayment(env, transfer, fulfillmentRecords) + forceSimulateAllFeePayments(env, transfer, fulfillmentRecords) simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords) }, expected: false, }, { - // Fee payment not confirmed + // All fee payments not confirmed simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { simulateOpeningSourceAccount(env, transfer, fulfillmentRecords) simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords) @@ -674,11 +684,21 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) { expected: false, }, { + // Subset of fee payments not confirmed simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { simulateOpeningSourceAccount(env, transfer, fulfillmentRecords) simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords) simulateAllTreasuryPayments(env, transfer, fulfillmentRecords) - forceSimulateFeePayment(env, transfer, fulfillmentRecords) + forceSimulateOneFeePayments(env, transfer, fulfillmentRecords) + }, + expected: false, + }, + { + simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) { + simulateOpeningSourceAccount(env, transfer, fulfillmentRecords) + simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords) + simulateAllTreasuryPayments(env, transfer, fulfillmentRecords) + forceSimulateAllFeePayments(env, transfer, fulfillmentRecords) }, expected: true, }, @@ -2306,14 +2326,21 @@ func (e *schedulerTestEnv) setupSchedulerTest(t *testing.T, intentRecords []*int bucketActionRecords = append(bucketActionRecords, bucketReorganization) } - var feePaymentAction *action.Record + var feePaymentActions []*action.Record if intentRecord.SendPrivatePaymentMetadata.IsMicroPayment { - feePaymentAction = &action.Record{ - ActionType: action.NoPrivacyTransfer, - - Source: fmt.Sprintf("%s-outgoing-%d", intentRecord.InitiatorOwnerAccount, currentOutgoingByUser[intentRecord.InitiatorOwnerAccount]), - Destination: &codeFeeCollector, - Quantity: &feeAmount, + feePaymentActions = []*action.Record{ + { + ActionType: action.NoPrivacyTransfer, + Source: fmt.Sprintf("%s-outgoing-%d", intentRecord.InitiatorOwnerAccount, currentOutgoingByUser[intentRecord.InitiatorOwnerAccount]), + Destination: &codeFeeCollector, + Quantity: &feeAmount, + }, + { + ActionType: action.NoPrivacyTransfer, + Source: fmt.Sprintf("%s-outgoing-%d", intentRecord.InitiatorOwnerAccount, currentOutgoingByUser[intentRecord.InitiatorOwnerAccount]), + Destination: pointer.String(testutil.NewRandomAccount(t).PublicKey().ToBase58()), + Quantity: &feeAmount, + }, } } @@ -2343,8 +2370,8 @@ func (e *schedulerTestEnv) setupSchedulerTest(t *testing.T, intentRecords []*int newActionRecords, bucketActionRecords..., ) - if feePaymentAction != nil { - newActionRecords = append(newActionRecords, feePaymentAction) + if feePaymentActions != nil { + newActionRecords = append(newActionRecords, feePaymentActions...) } newActionRecords = append( newActionRecords, diff --git a/pkg/code/chat/message_merchant.go b/pkg/code/chat/message_merchant.go index 1636d917..50871100 100644 --- a/pkg/code/chat/message_merchant.go +++ b/pkg/code/chat/message_merchant.go @@ -49,7 +49,6 @@ func SendMerchantExchangeMessage(ctx context.Context, data code_data.Provider, i if !ok { return nil, nil } - exchangeDataMinusFees := getExchangeDataMinusFees(exchangeData, intentRecord, actionRecords) type verbAndExchangeData struct { verb chatpb.ExchangeDataContent_Verb @@ -74,10 +73,14 @@ func SendMerchantExchangeMessage(ctx context.Context, data code_data.Provider, i verb: chatpb.ExchangeDataContent_SPENT, exchangeData: exchangeData, } - if len(intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount) > 0 { - verbAndExchangeDataByMessageReceiver[intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount] = &verbAndExchangeData{ + receiveByOwner, err := getMicroPaymentReceiveExchangeDataByOwner(ctx, data, exchangeData, intentRecord, actionRecords) + if err != nil { + return nil, err + } + for owner, exchangeData := range receiveByOwner { + verbAndExchangeDataByMessageReceiver[owner] = &verbAndExchangeData{ verb: chatpb.ExchangeDataContent_RECEIVED, - exchangeData: exchangeDataMinusFees, + exchangeData: exchangeData, } } } else if intentRecord.SendPrivatePaymentMetadata.IsWithdrawal { diff --git a/pkg/code/chat/util.go b/pkg/code/chat/util.go index fd87dbee..a6aa0f7c 100644 --- a/pkg/code/chat/util.go +++ b/pkg/code/chat/util.go @@ -1,16 +1,18 @@ package chat import ( + "context" "time" "github.com/mr-tron/base58/base58" "github.com/pkg/errors" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" chatpb "github.com/code-payments/code-protobuf-api/generated/go/chat/v1" transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" + code_data "github.com/code-payments/code-server/pkg/code/data" + "github.com/code-payments/code-server/pkg/code/data/account" "github.com/code-payments/code-server/pkg/code/data/action" "github.com/code-payments/code-server/pkg/code/data/intent" currency_lib "github.com/code-payments/code-server/pkg/currency" @@ -96,17 +98,18 @@ func getExchangeDataFromIntent(intentRecord *intent.Record) (*transactionpb.Exch return nil, false } -func getExchangeDataMinusFees(exchangeData *transactionpb.ExchangeData, intentRecord *intent.Record, actionRecords []*action.Record) *transactionpb.ExchangeData { - cloned := proto.Clone(exchangeData).(*transactionpb.ExchangeData) - - if intentRecord.IntentType != intent.SendPrivatePayment { - return cloned - } - - if !intentRecord.SendPrivatePaymentMetadata.IsMicroPayment { - return cloned +func getMicroPaymentReceiveExchangeDataByOwner( + ctx context.Context, + data code_data.Provider, + exchangeData *transactionpb.ExchangeData, + intentRecord *intent.Record, + actionRecords []*action.Record, +) (map[string]*transactionpb.ExchangeData, error) { + if intentRecord.IntentType != intent.SendPrivatePayment || !intentRecord.SendPrivatePaymentMetadata.IsMicroPayment { + return nil, errors.New("intent is not a micro payment") } + // Find the action record where the final payment is made var thirdPartyPaymentAction *action.Record for _, actionRecord := range actionRecords { if actionRecord.ActionType != action.NoPrivacyWithdraw { @@ -119,12 +122,70 @@ func getExchangeDataMinusFees(exchangeData *transactionpb.ExchangeData, intentRe } } - // Should never happen + // Should never happen if the intent is a micropayment if thirdPartyPaymentAction == nil { - return cloned + return nil, errors.New("payment action is missing") + } + + quarksByTokenAccount := make(map[string]uint64) + quarksByTokenAccount[*thirdPartyPaymentAction.Destination] = *thirdPartyPaymentAction.Quantity + + // Find and consolidate all fee payments into a quark amount by token account + var foundCodeFee bool + for _, actionRecord := range actionRecords { + if actionRecord.ActionType != action.NoPrivacyTransfer { + continue + } + + if actionRecord.Source != thirdPartyPaymentAction.Source { + continue + } + + // The first fee is always Code, and can be skipped + if !foundCodeFee { + foundCodeFee = true + continue + } + + quarksByTokenAccount[*actionRecord.Destination] += *actionRecord.Quantity + } + + // Consolidate quark amount by owner account + quarksByOwnerAccount := make(map[string]uint64) + for tokenAccount, quarks := range quarksByTokenAccount { + if tokenAccount == intentRecord.SendPrivatePaymentMetadata.DestinationTokenAccount { + if len(intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount) > 0 { + quarksByOwnerAccount[intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount] += quarks + } + continue + } + + accountInfoRecord, err := data.GetAccountInfoByTokenAddress(ctx, tokenAccount) + if err == nil { + quarksByOwnerAccount[accountInfoRecord.OwnerAccount] += quarks + } else if err != account.ErrAccountInfoNotFound { + return nil, err + } + } + + // Map result to an exchange data + res := make(map[string]*transactionpb.ExchangeData) + for ownerAccount, quarks := range quarksByOwnerAccount { + res[ownerAccount] = getExchangeDataInOtherQuarkAmount(exchangeData, quarks) } + return res, nil +} - cloned.Quarks = *thirdPartyPaymentAction.Quantity - cloned.NativeAmount = cloned.ExchangeRate * float64(cloned.Quarks) / float64(kin.QuarksPerKin) - return cloned +func getExchangeDataInOtherQuarkAmount(original *transactionpb.ExchangeData, quarks uint64) *transactionpb.ExchangeData { + nativeAmount := original.NativeAmount + if original.Quarks != quarks { + nativeAmount = original.ExchangeRate * float64(quarks) / float64(kin.QuarksPerKin) + } + + return &transactionpb.ExchangeData{ + Currency: original.Currency, + ExchangeRate: original.ExchangeRate, + NativeAmount: nativeAmount, + Quarks: quarks, + } } diff --git a/pkg/code/chat/util_test.go b/pkg/code/chat/util_test.go new file mode 100644 index 00000000..1d177ca4 --- /dev/null +++ b/pkg/code/chat/util_test.go @@ -0,0 +1,115 @@ +package chat + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1" + + "github.com/code-payments/code-server/pkg/code/common" + "github.com/code-payments/code-server/pkg/code/data/account" + "github.com/code-payments/code-server/pkg/code/data/action" + "github.com/code-payments/code-server/pkg/code/data/intent" + currency_lib "github.com/code-payments/code-server/pkg/currency" + "github.com/code-payments/code-server/pkg/kin" + "github.com/code-payments/code-server/pkg/pointer" + "github.com/code-payments/code-server/pkg/testutil" +) + +func TestGetMicroPaymentReceiveExchangeDataByOwner(t *testing.T) { + env := setup(t) + + micropaymentDestinationOwner := testutil.NewRandomAccount(t) + additionalCodeUserDestinationOwner := testutil.NewRandomAccount(t) + tempOutgoingAccount := testutil.NewRandomAccount(t) + + intentRecord := &intent.Record{ + IntentId: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + IntentType: intent.SendPrivatePayment, + + SendPrivatePaymentMetadata: &intent.SendPrivatePaymentMetadata{ + DestinationOwnerAccount: micropaymentDestinationOwner.PublicKey().ToBase58(), + DestinationTokenAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + + ExchangeCurrency: currency_lib.USD, + ExchangeRate: 0.1, + NativeAmount: 100, + Quantity: kin.ToQuarks(1000), + + IsMicroPayment: true, + }, + } + + actionRecords := []*action.Record{ + { + ActionType: action.NoPrivacyTransfer, + Source: tempOutgoingAccount.PublicKey().ToBase58(), + Destination: pointer.String(testutil.NewRandomAccount(t).PublicKey().ToBase58()), + Quantity: pointer.Uint64(kin.ToQuarks(50)), + }, + { + ActionType: action.NoPrivacyTransfer, + Source: tempOutgoingAccount.PublicKey().ToBase58(), + Destination: pointer.String(testutil.NewRandomAccount(t).PublicKey().ToBase58()), + Quantity: pointer.Uint64(kin.ToQuarks(35)), + }, + { + ActionType: action.NoPrivacyTransfer, + Source: tempOutgoingAccount.PublicKey().ToBase58(), + Destination: pointer.String(testutil.NewRandomAccount(t).PublicKey().ToBase58()), + Quantity: pointer.Uint64(kin.ToQuarks(10)), + }, + { + ActionType: action.NoPrivacyTransfer, + Source: tempOutgoingAccount.PublicKey().ToBase58(), + Destination: pointer.String(testutil.NewRandomAccount(t).PublicKey().ToBase58()), + Quantity: pointer.Uint64(kin.ToQuarks(5)), + }, + { + ActionType: action.NoPrivacyWithdraw, + Source: tempOutgoingAccount.PublicKey().ToBase58(), + Destination: &intentRecord.SendPrivatePaymentMetadata.DestinationTokenAccount, + Quantity: pointer.Uint64(kin.ToQuarks(900)), + }, + } + + require.NoError(t, env.data.CreateAccountInfo(env.ctx, &account.Record{ + OwnerAccount: intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount, + AuthorityAccount: intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount, + TokenAccount: *actionRecords[1].Destination, + MintAccount: common.KinMintAccount.PublicKey().ToBase58(), + AccountType: commonpb.AccountType_PRIMARY, + })) + + require.NoError(t, env.data.CreateAccountInfo(env.ctx, &account.Record{ + OwnerAccount: additionalCodeUserDestinationOwner.PublicKey().ToBase58(), + AuthorityAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + TokenAccount: *actionRecords[2].Destination, + MintAccount: common.KinMintAccount.PublicKey().ToBase58(), + AccountType: commonpb.AccountType_RELATIONSHIP, + RelationshipTo: pointer.String("example.com"), + })) + + originalExchangeData, ok := getExchangeDataFromIntent(intentRecord) + require.True(t, ok) + + exchangeDataByOwner, err := getMicroPaymentReceiveExchangeDataByOwner(env.ctx, env.data, originalExchangeData, intentRecord, actionRecords) + require.NoError(t, err) + require.Len(t, exchangeDataByOwner, 2) + + actualExchangeData, ok := exchangeDataByOwner[micropaymentDestinationOwner.PublicKey().ToBase58()] + require.True(t, ok) + assert.Equal(t, originalExchangeData.Currency, actualExchangeData.Currency) + assert.Equal(t, originalExchangeData.ExchangeRate, actualExchangeData.ExchangeRate) + assert.Equal(t, 93.5, actualExchangeData.NativeAmount) + assert.Equal(t, kin.ToQuarks(935), actualExchangeData.Quarks) + + actualExchangeData, ok = exchangeDataByOwner[additionalCodeUserDestinationOwner.PublicKey().ToBase58()] + require.True(t, ok) + assert.Equal(t, originalExchangeData.Currency, actualExchangeData.Currency) + assert.Equal(t, originalExchangeData.ExchangeRate, actualExchangeData.ExchangeRate) + assert.Equal(t, 1.0, actualExchangeData.NativeAmount) + assert.Equal(t, kin.ToQuarks(10), actualExchangeData.Quarks) +} diff --git a/pkg/code/common/account.go b/pkg/code/common/account.go index dbd052ea..d48d971d 100644 --- a/pkg/code/common/account.go +++ b/pkg/code/common/account.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/ed25519" + "fmt" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -664,7 +665,7 @@ func ValidateExternalKinTokenAccount(ctx context.Context, data code_data.Provide // safety precaution. _, err := data.GetAccountInfoByTokenAddress(ctx, tokenAccount.publicKey.ToBase58()) if err == nil { - return false, "destination is not an external account", nil + return false, fmt.Sprintf("%s is not an external account", tokenAccount.publicKey.ToBase58()), nil } else if err == account.ErrAccountInfoNotFound { return true, "", nil } else if err != nil { @@ -672,9 +673,9 @@ func ValidateExternalKinTokenAccount(ctx context.Context, data code_data.Provide } return true, "", nil case solana.ErrNoAccountInfo, token.ErrAccountNotFound: - return false, "destination doesn't exist on the blockchain", nil + return false, fmt.Sprintf("%s doesn't exist on the blockchain", tokenAccount.publicKey.ToBase58()), nil case token.ErrInvalidTokenAccount: - return false, "destination is not a kin token account", nil + return false, fmt.Sprintf("%s is not a kin token account", tokenAccount.publicKey.ToBase58()), nil default: // Unfortunate if Solana is down, but this only impacts withdraw flows, // and we need to guarantee this isn't going to something that's not diff --git a/pkg/code/data/paymentrequest/memory/store.go b/pkg/code/data/paymentrequest/memory/store.go index 58ead4ed..8c86ca20 100644 --- a/pkg/code/data/paymentrequest/memory/store.go +++ b/pkg/code/data/paymentrequest/memory/store.go @@ -41,6 +41,15 @@ func (s *store) Put(_ context.Context, data *paymentrequest.Record) error { if item := s.find(data); item != nil { return paymentrequest.ErrPaymentRequestAlreadyExists } else { + seenDestinations := make(map[string]any) + for _, fee := range data.Fees { + _, ok := seenDestinations[fee.DestinationTokenAccount] + if ok { + return paymentrequest.ErrInvalidPaymentRequest + } + seenDestinations[fee.DestinationTokenAccount] = true + } + if data.Id == 0 { data.Id = s.last } diff --git a/pkg/code/data/paymentrequest/payment_request.go b/pkg/code/data/paymentrequest/payment_request.go index 03bf3dff..de9b7696 100644 --- a/pkg/code/data/paymentrequest/payment_request.go +++ b/pkg/code/data/paymentrequest/payment_request.go @@ -21,6 +21,7 @@ type Record struct { NativeAmount *float64 ExchangeRate *float64 Quantity *uint64 + Fees []*Fee // Login fields Domain *string @@ -29,6 +30,11 @@ type Record struct { CreatedAt time.Time } +type Fee struct { + DestinationTokenAccount string + BasisPoints uint16 +} + func (r *Record) Validate() error { if len(r.Intent) == 0 { return errors.New("intent id is required") @@ -66,6 +72,16 @@ func (r *Record) Validate() error { return errors.New("exchange rate and quantity presence must match") } + if len(r.Fees) > 0 && r.DestinationTokenAccount == nil { + return errors.New("fees cannot be present without payment") + } + + for _, fee := range r.Fees { + if err := fee.Validate(); err != nil { + return err + } + } + if r.Domain != nil && len(*r.Domain) == 0 { return errors.New("domain cannot be empty when provided") } @@ -82,6 +98,12 @@ func (r *Record) Validate() error { } func (r *Record) Clone() Record { + fees := make([]*Fee, len(r.Fees)) + for i, fee := range r.Fees { + copied := fee.Clone() + fees[i] = &copied + } + return Record{ Id: r.Id, @@ -92,6 +114,7 @@ func (r *Record) Clone() Record { NativeAmount: pointer.Float64Copy(r.NativeAmount), ExchangeRate: pointer.Float64Copy(r.ExchangeRate), Quantity: pointer.Uint64Copy(r.Quantity), + Fees: fees, Domain: pointer.StringCopy(r.Domain), IsVerified: r.IsVerified, @@ -115,6 +138,12 @@ func (r *Record) CopyTo(dst *Record) { dst.IsVerified = r.IsVerified dst.CreatedAt = r.CreatedAt + + dst.Fees = make([]*Fee, len(r.Fees)) + for i, fee := range r.Fees { + copied := fee.Clone() + dst.Fees[i] = &copied + } } func (r *Record) RequiresPayment() bool { @@ -124,3 +153,31 @@ func (r *Record) RequiresPayment() bool { func (r *Record) HasLogin() bool { return r.Domain != nil && r.IsVerified } + +func (f *Fee) Validate() error { + if len(f.DestinationTokenAccount) == 0 { + return errors.New("fee destination token account is required") + } + + if f.BasisPoints == 0 { + return errors.New("fee percentage is required") + } + + if f.BasisPoints > 10000 { + return errors.New("fee percentage cannot exceed 100%") + } + + return nil +} + +func (f *Fee) Clone() Fee { + return Fee{ + DestinationTokenAccount: f.DestinationTokenAccount, + BasisPoints: f.BasisPoints, + } +} + +func (f *Fee) CopyTo(dst *Fee) { + dst.DestinationTokenAccount = f.DestinationTokenAccount + dst.BasisPoints = f.BasisPoints +} diff --git a/pkg/code/data/paymentrequest/postgres/model.go b/pkg/code/data/paymentrequest/postgres/model.go index 5c05e674..8d4bdf1d 100644 --- a/pkg/code/data/paymentrequest/postgres/model.go +++ b/pkg/code/data/paymentrequest/postgres/model.go @@ -13,10 +13,11 @@ import ( ) const ( - tableName = "codewallet__core_paymentrequest" + requestTableName = "codewallet__core_paymentrequest" + feesTableName = "codewallet__core_paymentrequestfees" ) -type model struct { +type requestModel struct { Id sql.NullInt64 `db:"id"` Intent string `db:"intent"` @@ -26,6 +27,7 @@ type model struct { NativeAmount sql.NullFloat64 `db:"native_amount"` ExchangeRate sql.NullFloat64 `db:"exchange_rate"` Quantity sql.NullInt64 `db:"quantity"` + Fees []*feeModel Domain sql.NullString `db:"domain"` IsVerified bool `db:"is_verified"` @@ -33,7 +35,14 @@ type model struct { CreatedAt time.Time `db:"created_at"` } -func toModel(obj *paymentrequest.Record) (*model, error) { +type feeModel struct { + Id sql.NullInt64 `db:"id"` + Intent string `db:"intent"` + DestinationTokenAccount string `db:"destination_token_account"` + BasisPoints uint16 `db:"bps"` +} + +func toRequestModel(obj *paymentrequest.Record) (*requestModel, error) { if err := obj.Validate(); err != nil { return nil, err } @@ -42,7 +51,16 @@ func toModel(obj *paymentrequest.Record) (*model, error) { obj.CreatedAt = time.Now().UTC() } - return &model{ + fees := make([]*feeModel, len(obj.Fees)) + for i, fee := range obj.Fees { + fees[i] = &feeModel{ + Intent: obj.Intent, + DestinationTokenAccount: fee.DestinationTokenAccount, + BasisPoints: fee.BasisPoints, + } + } + + return &requestModel{ Id: sql.NullInt64{Int64: int64(obj.Id), Valid: true}, Intent: obj.Intent, DestinationTokenAccount: sql.NullString{ @@ -65,6 +83,7 @@ func toModel(obj *paymentrequest.Record) (*model, error) { Valid: obj.Quantity != nil, Int64: int64(*pointer.Uint64OrDefault(obj.Quantity, 0)), }, + Fees: fees, Domain: sql.NullString{ Valid: obj.Domain != nil, String: *pointer.StringOrDefault(obj.Domain, ""), @@ -74,7 +93,15 @@ func toModel(obj *paymentrequest.Record) (*model, error) { }, nil } -func fromModel(obj *model) *paymentrequest.Record { +func fromRequestModel(obj *requestModel) *paymentrequest.Record { + fees := make([]*paymentrequest.Fee, len(obj.Fees)) + for i, fee := range obj.Fees { + fees[i] = &paymentrequest.Fee{ + DestinationTokenAccount: fee.DestinationTokenAccount, + BasisPoints: fee.BasisPoints, + } + } + return &paymentrequest.Record{ Id: uint64(obj.Id.Int64), Intent: obj.Intent, @@ -83,15 +110,16 @@ func fromModel(obj *model) *paymentrequest.Record { NativeAmount: pointer.Float64IfValid(obj.NativeAmount.Valid, obj.NativeAmount.Float64), ExchangeRate: pointer.Float64IfValid(obj.ExchangeRate.Valid, obj.ExchangeRate.Float64), Quantity: pointer.Uint64IfValid(obj.Quantity.Valid, uint64(obj.Quantity.Int64)), + Fees: fees, Domain: pointer.StringIfValid(obj.Domain.Valid, obj.Domain.String), IsVerified: obj.IsVerified, CreatedAt: obj.CreatedAt.UTC(), } } -func (m *model) dbPut(ctx context.Context, db *sqlx.DB) error { +func (m *requestModel) dbPut(ctx context.Context, db *sqlx.DB) error { return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { - query := `INSERT INTO ` + tableName + ` + query := `INSERT INTO ` + requestTableName + ` (intent, destination_token_account, exchange_currency, exchange_rate, native_amount, quantity, domain, is_verified, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, intent, destination_token_account, exchange_currency, exchange_rate, native_amount, quantity, domain, is_verified, created_at` @@ -110,14 +138,39 @@ func (m *model) dbPut(ctx context.Context, db *sqlx.DB) error { m.CreatedAt, ).StructScan(m) - return pgutil.CheckUniqueViolation(err, paymentrequest.ErrPaymentRequestAlreadyExists) + err = pgutil.CheckUniqueViolation(err, paymentrequest.ErrPaymentRequestAlreadyExists) + if err != nil { + return err + } + + for _, fee := range m.Fees { + query := `INSERT INTO ` + feesTableName + ` + (intent, destination_token_account, bps) + VALUES ($1, $2, $3) + RETURNING id, intent, destination_token_account, bps` + + err := tx.QueryRowxContext( + ctx, + query, + fee.Intent, + fee.DestinationTokenAccount, + fee.BasisPoints, + ).StructScan(fee) + + err = pgutil.CheckUniqueViolation(err, paymentrequest.ErrInvalidPaymentRequest) + if err != nil { + return err + } + } + + return nil }) } -func dbGet(ctx context.Context, db *sqlx.DB, intent string) (*model, error) { - res := &model{} +func dbGet(ctx context.Context, db *sqlx.DB, intent string) (*requestModel, error) { + res := &requestModel{} - query := `SELECT id, intent, destination_token_account, exchange_currency, exchange_rate, native_amount, quantity, domain, is_verified, created_at FROM ` + tableName + ` + query := `SELECT id, intent, destination_token_account, exchange_currency, exchange_rate, native_amount, quantity, domain, is_verified, created_at FROM ` + requestTableName + ` WHERE intent = $1` err := db.GetContext( @@ -129,5 +182,19 @@ func dbGet(ctx context.Context, db *sqlx.DB, intent string) (*model, error) { if err != nil { return nil, pgutil.CheckNoRows(err, paymentrequest.ErrPaymentRequestNotFound) } + + var fees []*feeModel + query = `SELECT id, intent, destination_token_account, bps FROM ` + feesTableName + ` + WHERE intent = $1 + ORDER BY id ASC` + + err = db.SelectContext(ctx, &fees, query, intent) + err = pgutil.CheckNoRows(err, nil) + if err != nil { + return nil, err + } + + res.Fees = fees + return res, nil } diff --git a/pkg/code/data/paymentrequest/postgres/store.go b/pkg/code/data/paymentrequest/postgres/store.go index 3c48b0eb..eb2bef74 100644 --- a/pkg/code/data/paymentrequest/postgres/store.go +++ b/pkg/code/data/paymentrequest/postgres/store.go @@ -21,7 +21,7 @@ func New(db *sql.DB) paymentrequest.Store { // Put implements paymentrequest.Store.Put func (s *store) Put(ctx context.Context, record *paymentrequest.Record) error { - m, err := toModel(record) + m, err := toRequestModel(record) if err != nil { return err } @@ -31,7 +31,7 @@ func (s *store) Put(ctx context.Context, record *paymentrequest.Record) error { return err } - res := fromModel(m) + res := fromRequestModel(m) res.CopyTo(record) return nil @@ -43,5 +43,5 @@ func (s *store) Get(ctx context.Context, intentId string) (*paymentrequest.Recor if err != nil { return nil, err } - return fromModel(m), nil + return fromRequestModel(m), nil } diff --git a/pkg/code/data/paymentrequest/postgres/store_test.go b/pkg/code/data/paymentrequest/postgres/store_test.go index 965b94a3..70813466 100644 --- a/pkg/code/data/paymentrequest/postgres/store_test.go +++ b/pkg/code/data/paymentrequest/postgres/store_test.go @@ -35,11 +35,23 @@ const ( created_at TIMESTAMP WITH TIME ZONE NOT NULL ); + + CREATE TABLE codewallet__core_paymentrequestfees( + id SERIAL NOT NULL PRIMARY KEY, + + intent TEXT NOT NULL, + + destination_token_account TEXT NULL, + bps INTEGER NOT NULL, + + CONSTRAINT codewallet__core_paymentrequestfees__uniq__intent__and__destination_token_account UNIQUE (intent, destination_token_account) + ); ` // Used for testing ONLY, the table and migrations are external to this repository tableDestroy = ` DROP TABLE codewallet__core_paymentrequest; + DROP TABLE codewallet__core_paymentrequestfees; ` ) diff --git a/pkg/code/data/paymentrequest/store.go b/pkg/code/data/paymentrequest/store.go index 8fc78944..1158ce88 100644 --- a/pkg/code/data/paymentrequest/store.go +++ b/pkg/code/data/paymentrequest/store.go @@ -12,6 +12,7 @@ import ( var ( ErrPaymentRequestAlreadyExists = errors.New("payment request record already exists") ErrPaymentRequestNotFound = errors.New("no payment request records could be found") + ErrInvalidPaymentRequest = errors.New("payment request is invalid") ) type Store interface { diff --git a/pkg/code/data/paymentrequest/tests/tests.go b/pkg/code/data/paymentrequest/tests/tests.go index 435638a9..ec17f495 100644 --- a/pkg/code/data/paymentrequest/tests/tests.go +++ b/pkg/code/data/paymentrequest/tests/tests.go @@ -17,6 +17,7 @@ import ( func RunTests(t *testing.T, s paymentrequest.Store, teardown func()) { for _, tf := range []func(t *testing.T, s paymentrequest.Store){ testRoundTrip, + testInvalidRecord, } { tf(t, s) teardown() @@ -25,41 +26,89 @@ func RunTests(t *testing.T, s paymentrequest.Store, teardown func()) { func testRoundTrip(t *testing.T, s paymentrequest.Store) { t.Run("testRoundTrip", func(t *testing.T) { - ctx := context.Background() + for i, fees := range [][]*paymentrequest.Fee{ + nil, + { + { + DestinationTokenAccount: "destination2", + BasisPoints: 50, + }, + { + DestinationTokenAccount: "destination3", + BasisPoints: 1234, + }, + }, + } { + ctx := context.Background() - actual, err := s.Get(ctx, "test_intent") - require.Error(t, err) - assert.Equal(t, paymentrequest.ErrPaymentRequestNotFound, err) - assert.Nil(t, actual) + intentId := fmt.Sprintf("test_intent%d", i) + + actual, err := s.Get(ctx, intentId) + require.Error(t, err) + assert.Equal(t, paymentrequest.ErrPaymentRequestNotFound, err) + assert.Nil(t, actual) + + expected := &paymentrequest.Record{ + Intent: intentId, + DestinationTokenAccount: pointer.String("destination1"), + ExchangeCurrency: pointer.String("usd"), + NativeAmount: pointer.Float64(2.46), + ExchangeRate: pointer.Float64(1.23), + Quantity: pointer.Uint64(kin.ToQuarks(2)), + Fees: fees, + Domain: pointer.String("example.com"), + IsVerified: true, + CreatedAt: time.Now(), + } + cloned := expected.Clone() + err = s.Put(ctx, expected) + require.NoError(t, err) + + assert.Equal(t, paymentrequest.ErrPaymentRequestAlreadyExists, s.Put(ctx, expected)) + require.NoError(t, err) + + actual, err = s.Get(ctx, intentId) + require.NoError(t, err) + assertEquivalentRecords(t, &cloned, actual) + assert.True(t, actual.Id > 0) + } + }) +} + +func testInvalidRecord(t *testing.T, s paymentrequest.Store) { + t.Run("testInvalidRecord", func(t *testing.T) { + ctx := context.Background() expected := &paymentrequest.Record{ Intent: "test_intent", - DestinationTokenAccount: pointer.String("destination"), + DestinationTokenAccount: pointer.String("destination1"), ExchangeCurrency: pointer.String("usd"), NativeAmount: pointer.Float64(2.46), ExchangeRate: pointer.Float64(1.23), Quantity: pointer.Uint64(kin.ToQuarks(2)), - Domain: pointer.String("example.com"), - IsVerified: true, - CreatedAt: time.Now(), + Fees: []*paymentrequest.Fee{ + { + DestinationTokenAccount: "destination2", + BasisPoints: 1, + }, + { + DestinationTokenAccount: "destination2", + BasisPoints: 2, + }, + }, + Domain: pointer.String("example.com"), + IsVerified: true, + CreatedAt: time.Now(), } - cloned := expected.Clone() - err = s.Put(ctx, expected) - require.NoError(t, err) - assert.Equal(t, paymentrequest.ErrPaymentRequestAlreadyExists, s.Put(ctx, expected)) - require.NoError(t, err) + assert.Equal(t, paymentrequest.ErrInvalidPaymentRequest, s.Put(ctx, expected)) - actual, err = s.Get(ctx, "test_intent") - require.NoError(t, err) - assertEquivalentRecords(t, &cloned, actual) - assert.EqualValues(t, 1, actual.Id) + _, err := s.Get(ctx, "test_intent") + assert.Equal(t, paymentrequest.ErrPaymentRequestNotFound, err) }) } func assertEquivalentRecords(t *testing.T, obj1, obj2 *paymentrequest.Record) { - fmt.Println(obj1) - fmt.Println(obj2) assert.Equal(t, obj1.Intent, obj2.Intent) assert.EqualValues(t, obj1.DestinationTokenAccount, obj2.DestinationTokenAccount) assert.EqualValues(t, obj1.ExchangeCurrency, obj2.ExchangeCurrency) @@ -69,4 +118,10 @@ func assertEquivalentRecords(t *testing.T, obj1, obj2 *paymentrequest.Record) { assert.EqualValues(t, obj1.Domain, obj2.Domain) assert.Equal(t, obj1.IsVerified, obj2.IsVerified) assert.Equal(t, obj1.CreatedAt.Unix(), obj2.CreatedAt.Unix()) + + require.Equal(t, len(obj1.Fees), len(obj2.Fees)) + for i := 0; i < len(obj1.Fees); i++ { + assert.Equal(t, obj1.Fees[i].DestinationTokenAccount, obj2.Fees[i].DestinationTokenAccount) + assert.Equal(t, obj1.Fees[i].BasisPoints, obj2.Fees[i].BasisPoints) + } } diff --git a/pkg/code/server/grpc/messaging/config.go b/pkg/code/server/grpc/messaging/config.go index e65e28a0..ffb6d7c6 100644 --- a/pkg/code/server/grpc/messaging/config.go +++ b/pkg/code/server/grpc/messaging/config.go @@ -12,10 +12,14 @@ const ( DisableBlockchainChecksConfigEnvName = envConfigPrefix + "DISABLE_BLOCKCHAIN_CHECKS" defaultDisableBlockchainChecks = false + + MaxFeeBasisPointsConfigEnvName = envConfigPrefix + "MAX_FEE_BASIS_POINTS" + defaultMaxFeeBasisPoints = 5000 ) type conf struct { disableBlockchainChecks config.Bool + maxFeeBasisPoints config.Uint64 } // ConfigProvider defines how config values are pulled @@ -26,6 +30,7 @@ func WithEnvConfigs() ConfigProvider { return func() *conf { return &conf{ disableBlockchainChecks: env.NewBoolConfig(DisableBlockchainChecksConfigEnvName, defaultDisableBlockchainChecks), + maxFeeBasisPoints: env.NewUint64Config(MaxFeeBasisPointsConfigEnvName, defaultMaxFeeBasisPoints), } } } @@ -37,6 +42,7 @@ func withManualTestOverrides(overrides *testOverrides) ConfigProvider { return func() *conf { return &conf{ disableBlockchainChecks: wrapper.NewBoolConfig(memory.NewConfig(true), true), + maxFeeBasisPoints: wrapper.NewUint64Config(memory.NewConfig(defaultMaxFeeBasisPoints), defaultMaxFeeBasisPoints), } } } diff --git a/pkg/code/server/grpc/messaging/message_handler.go b/pkg/code/server/grpc/messaging/message_handler.go index 2d22348e..080cf18d 100644 --- a/pkg/code/server/grpc/messaging/message_handler.go +++ b/pkg/code/server/grpc/messaging/message_handler.go @@ -161,6 +161,14 @@ func (h *RequestToReceiveBillMessageHandler) Validate(ctx context.Context, rende return newMessageValidationError("exchange data is nil") } + var additionalFees []*paymentrequest.Fee + for _, additionalFee := range typedMessage.AdditionalFees { + additionalFees = append(additionalFees, &paymentrequest.Fee{ + DestinationTokenAccount: base58.Encode(additionalFee.Destination.Value), + BasisPoints: uint16(additionalFee.FeeBps), + }) + } + // // Part 1: Validate the intent doesn't exist // @@ -234,37 +242,27 @@ func (h *RequestToReceiveBillMessageHandler) Validate(ctx context.Context, rende return newMessageValidationError("original request isn't verified") } + if len(existingRequestRecord.Fees) != len(additionalFees) { + return newMessageValidationErrorf("original request configured %d fee takers", len(existingRequestRecord.Fees)) + } + for i, existingFee := range existingRequestRecord.Fees { + if existingFee.DestinationTokenAccount != additionalFees[i].DestinationTokenAccount { + return newMessageValidationErrorf("destination for fee at index %d mismatches original request", i) + } + + if existingFee.BasisPoints != additionalFees[i].BasisPoints { + return newMessageValidationErrorf("basis points for fee at index %d mismatches original request", i) + } + } + h.recordAlreadyExists = true case paymentrequest.ErrPaymentRequestNotFound: // - // Part 2.1: Requestor account must be a primary account (for trial use cases) - // or an external account (for real production use cases) + // Part 2.1: Requestor account must be a deposit or an external account // - accountInfoRecord, err := h.data.GetAccountInfoByTokenAddress(ctx, requestorAccount.PublicKey().ToBase58()) - switch err { - case nil: - switch accountInfoRecord.AccountType { - case commonpb.AccountType_PRIMARY: - case commonpb.AccountType_RELATIONSHIP: - if typedMessage.Verifier == nil { - return newMessageValidationError("domain verification is required when requestor account is a relationship account") - } - - if *accountInfoRecord.RelationshipTo != asciiBaseDomain { - return newMessageValidationErrorf("requestor account must have a relationship with %s", asciiBaseDomain) - } - default: - return newMessageValidationError("requestor account must be a code deposit account") - } - case account.ErrAccountInfoNotFound: - if !h.conf.disableBlockchainChecks.Get(ctx) { - err := validateExternalKinTokenAccountWithinMessage(ctx, h.data, requestorAccount) - if err != nil { - return err - } - } - default: + err = h.validateDestinationAccount(ctx, requestorAccount, typedMessage.Verifier != nil, asciiBaseDomain) + if err != nil { return err } @@ -287,6 +285,41 @@ func (h *RequestToReceiveBillMessageHandler) Validate(ctx context.Context, rende return err } } + + // + // Part 2.3: Fee structure validation + // + + var totalFeeBps uint32 + seenFeeTakers := make(map[string]interface{}) + for i, additionalFee := range additionalFees { + feeTaker, err := common.NewAccountFromPublicKeyString(additionalFee.DestinationTokenAccount) + if err != nil { + return err + } + + totalFeeBps += uint32(additionalFee.BasisPoints) + + if additionalFee.DestinationTokenAccount == requestorAccount.PublicKey().ToBase58() { + return newMessageValidationErrorf("fee taker at index %d is the payment destination and should be omitted", i) + } + + _, ok := seenFeeTakers[additionalFee.DestinationTokenAccount] + if ok { + return newMessageValidationErrorf("fee taker at index %d appears multiple times and should be merged", i) + } + seenFeeTakers[additionalFee.DestinationTokenAccount] = struct{}{} + + err = h.validateDestinationAccount(ctx, feeTaker, typedMessage.Verifier != nil, asciiBaseDomain) + if err != nil { + return err + } + } + + maxFeeBps := h.conf.maxFeeBasisPoints.Get(ctx) + if totalFeeBps > uint32(maxFeeBps) { + return newMessageValidationErrorf("total fee percentage cannot exceed %d basis points", maxFeeBps) + } default: return err } @@ -366,6 +399,7 @@ func (h *RequestToReceiveBillMessageHandler) Validate(ctx context.Context, rende NativeAmount: pointer.Float64(nativeAmount), ExchangeRate: exchangeRate, Quantity: quarks, + Fees: additionalFees, CreatedAt: time.Now(), } @@ -390,6 +424,42 @@ func (h *RequestToReceiveBillMessageHandler) OnSuccess(ctx context.Context) erro return h.data.CreateRequest(ctx, h.recordToSave) } +func (h *RequestToReceiveBillMessageHandler) validateDestinationAccount( + ctx context.Context, + accountToValidate *common.Account, + isVerified bool, + asciiBaseDomain string, +) error { + accountInfoRecord, err := h.data.GetAccountInfoByTokenAddress(ctx, accountToValidate.PublicKey().ToBase58()) + switch err { + case nil: + switch accountInfoRecord.AccountType { + case commonpb.AccountType_PRIMARY: + case commonpb.AccountType_RELATIONSHIP: + if !isVerified { + return newMessageValidationError("domain verification is required when using a relationship account") + } + + if *accountInfoRecord.RelationshipTo != asciiBaseDomain { + return newMessageValidationErrorf("relationship account %s is not associated with %s", accountToValidate.PublicKey().ToBase58(), asciiBaseDomain) + } + default: + return newMessageValidationErrorf("code account %s is not a deposit account", accountToValidate.PublicKey().ToBase58()) + } + case account.ErrAccountInfoNotFound: + if !h.conf.disableBlockchainChecks.Get(ctx) { + err := validateExternalKinTokenAccountWithinMessage(ctx, h.data, accountToValidate) + if err != nil { + return err + } + } + default: + return err + } + + return nil +} + type ClientRejectedPaymentMessageHandler struct { } diff --git a/pkg/code/server/grpc/messaging/server_test.go b/pkg/code/server/grpc/messaging/server_test.go index 3bc9bf9a..47056a0f 100644 --- a/pkg/code/server/grpc/messaging/server_test.go +++ b/pkg/code/server/grpc/messaging/server_test.go @@ -255,7 +255,11 @@ func TestSendMessage_RequestToReceiveBill_KinValue_HappyPath(t *testing.T) { defer cleanup() rendezvousKey := testutil.NewRandomAccount(t) - sendMessageCall := env.client2.sendRequestToReceiveKinBillMessage(t, rendezvousKey, tc.usePrimary, tc.useRelationship, tc.disableDomainVerification) + sendMessageCall := env.client2.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + usePrimaryAccount: tc.usePrimary, + useRelationshipAccount: tc.useRelationship, + disableDomainVerification: tc.disableDomainVerification, + }) sendMessageCall.requireSuccess(t) records := env.server1.getMessages(t, rendezvousKey) @@ -283,6 +287,11 @@ func TestSendMessage_RequestToReceiveBill_KinValue_HappyPath(t *testing.T) { assert.Equal(t, sendMessageCall.req.Message.GetRequestToReceiveBill().Signature.Value, savedProtoMessage.GetRequestToReceiveBill().Signature.Value) assert.Equal(t, sendMessageCall.req.Message.GetRequestToReceiveBill().RendezvousKey.Value, savedProtoMessage.GetRequestToReceiveBill().RendezvousKey.Value) } + require.Len(t, savedProtoMessage.GetRequestToReceiveBill().AdditionalFees, len(sendMessageCall.req.Message.GetRequestToReceiveBill().AdditionalFees)) + for i, expectedFee := range sendMessageCall.req.Message.GetRequestToReceiveBill().AdditionalFees { + assert.Equal(t, expectedFee.Destination.Value, savedProtoMessage.GetRequestToReceiveBill().AdditionalFees[i].Destination.Value) + assert.Equal(t, expectedFee.FeeBps, savedProtoMessage.GetRequestToReceiveBill().AdditionalFees[i].FeeBps) + } assert.Equal(t, sendMessageCall.req.Signature.Value, savedProtoMessage.SendMessageRequestSignature.Value) env.server1.assertPaymentRequestRecordSaved(t, rendezvousKey, sendMessageCall.req.Message.GetRequestToReceiveBill()) @@ -326,7 +335,11 @@ func TestSendMessage_RequestToReceiveBill_FiatValue_HappyPath(t *testing.T) { defer cleanup() rendezvousKey := testutil.NewRandomAccount(t) - sendMessageCall := env.client2.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, tc.usePrimary, tc.useRelationship, tc.disableDomainVerification) + sendMessageCall := env.client2.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + usePrimaryAccount: tc.usePrimary, + useRelationshipAccount: tc.useRelationship, + disableDomainVerification: tc.disableDomainVerification, + }) sendMessageCall.requireSuccess(t) records := env.server1.getMessages(t, rendezvousKey) @@ -352,6 +365,11 @@ func TestSendMessage_RequestToReceiveBill_FiatValue_HappyPath(t *testing.T) { assert.Equal(t, sendMessageCall.req.Message.GetRequestToReceiveBill().Signature.Value, savedProtoMessage.GetRequestToReceiveBill().Signature.Value) assert.Equal(t, sendMessageCall.req.Message.GetRequestToReceiveBill().RendezvousKey.Value, savedProtoMessage.GetRequestToReceiveBill().RendezvousKey.Value) } + require.Len(t, savedProtoMessage.GetRequestToReceiveBill().AdditionalFees, len(sendMessageCall.req.Message.GetRequestToReceiveBill().AdditionalFees)) + for i, expectedFee := range sendMessageCall.req.Message.GetRequestToReceiveBill().AdditionalFees { + assert.Equal(t, expectedFee.Destination.Value, savedProtoMessage.GetRequestToReceiveBill().AdditionalFees[i].Destination.Value) + assert.Equal(t, expectedFee.FeeBps, savedProtoMessage.GetRequestToReceiveBill().AdditionalFees[i].FeeBps) + } assert.Equal(t, sendMessageCall.req.Signature.Value, savedProtoMessage.SendMessageRequestSignature.Value) env.server1.assertPaymentRequestRecordSaved(t, rendezvousKey, sendMessageCall.req.Message.GetRequestToReceiveBill()) @@ -376,15 +394,19 @@ func TestSendMessage_RequestToReceiveBill_KinValue_Validation(t *testing.T) { env.client1.resetConf() env.client1.conf.simulateInvalidAccountType = true - sendMessageCall := env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) - sendMessageCall.assertInvalidMessageError(t, "requestor account must be a code deposit account") + sendMessageCall := env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) + sendMessageCall.assertInvalidMessageError(t, "is not a deposit account") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateInvalidRelationship = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, true, false) - sendMessageCall.assertInvalidMessageError(t, "requestor account must have a relationship with getcode.com") + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + useRelationshipAccount: true, + }) + sendMessageCall.assertInvalidMessageError(t, "is not associated with getcode.com") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) @@ -394,49 +416,63 @@ func TestSendMessage_RequestToReceiveBill_KinValue_Validation(t *testing.T) { env.client1.resetConf() env.client1.conf.simulateInvalidExchangeRate = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "kin exchange rate must be 1") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateInvalidNativeAmount = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "payment native amount and quark value mismatch") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateSmallNativeAmount = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "kin currency has a minimum amount of 5000.00") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateLargeNativeAmount = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "kin currency has a maximum amount of 100000.00") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateFractionalNativeAmount = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "native amount can't include fractional kin") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateFractionalQuarkAmount = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "quark amount can't include fractional kin") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateInvalidCurrency = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "exact exchange data is reserved for kin only") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) @@ -447,54 +483,96 @@ func TestSendMessage_RequestToReceiveBill_KinValue_Validation(t *testing.T) { env.client1.resetConf() env.client1.conf.simulateInvalidDomain = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertInvalidMessageError(t, "domain is invalid") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateDoesntOwnDomain = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertPermissionDeniedError(t, "does not own domain getcode.com") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, true, true) - sendMessageCall.assertInvalidMessageError(t, "domain verification is required when requestor account is a relationship account") + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + useRelationshipAccount: true, + disableDomainVerification: true, + }) + sendMessageCall.assertInvalidMessageError(t, "domain verification is required when using a relationship account") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + // + // Part 4: Fee structure validation + // + + env.client1.resetConf() + env.client1.conf.simulateLargeFeePercentage = true + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "total fee percentage cannot exceed") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateDuplicatedFeeTaker = true + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "fee taker at index 2 appears multiple times and should be merged") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateFeeTakerIsPaymentDestination = true + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "fee taker at index 0 is the payment destination and should be omitted") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateInvalidFeeCodeAccount = true + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "is not a deposit account") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateInvalidFeeRelationship = true + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "is not associated with getcode.com") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) // - // Part 4: Signature validation + // Part 5: Signature validation // env.client1.resetConf() env.client1.conf.simulateInvalidMessageSignature = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertUnauthenticatedError(t, "") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) // - // Part 5: Rendezvous key validation + // Part 6: Rendezvous key validation // env.client1.resetConf() env.client1.conf.simulateInvalidRendezvousKey = true - sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertInvalidMessageError(t, "rendezvous key mismatch") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) // - // Part 6: Upgrading request with a payment requirement + // Part 7: Upgrading request with a payment requirement // env.client1.resetConf() originalSendMessageRequest := env.client1.sendRequestToLoginMessage(t, rendezvousKey) originalSendMessageRequest.requireSuccess(t) - env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, false, false, false).assertInvalidMessageError(t, "original request doesn't require payment") + env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}).assertInvalidMessageError(t, "original request doesn't require payment") env.server1.assertLoginRequestRecordSaved(t, rendezvousKey, originalSendMessageRequest.req.Message.GetRequestToLogin()) messages := env.client1.pollForMessages(t, rendezvousKey) require.Len(t, messages, 1) @@ -513,15 +591,19 @@ func TestSendMessage_RequestToReceiveBill_FiatValue_Validation(t *testing.T) { env.client1.resetConf() env.client1.conf.simulateInvalidAccountType = true - sendMessageCall := env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, true) - sendMessageCall.assertInvalidMessageError(t, "requestor account must be a code deposit account") + sendMessageCall := env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) + sendMessageCall.assertInvalidMessageError(t, "is not a deposit account") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateInvalidRelationship = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, true, false) - sendMessageCall.assertInvalidMessageError(t, "requestor account must have a relationship with getcode.com") + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + useRelationshipAccount: true, + }) + sendMessageCall.assertInvalidMessageError(t, "is not associated with getcode.com") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) @@ -531,21 +613,27 @@ func TestSendMessage_RequestToReceiveBill_FiatValue_Validation(t *testing.T) { env.client1.resetConf() env.client1.conf.simulateSmallNativeAmount = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "usd currency has a minimum amount of 0.05") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateLargeNativeAmount = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "usd currency has a maximum amount of 1.00") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateInvalidCurrency = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, true) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + disableDomainVerification: true, + }) sendMessageCall.assertInvalidMessageError(t, "partial exchange data is reserved for fiat currencies") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) @@ -556,54 +644,96 @@ func TestSendMessage_RequestToReceiveBill_FiatValue_Validation(t *testing.T) { env.client1.resetConf() env.client1.conf.simulateInvalidDomain = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertInvalidMessageError(t, "domain is invalid") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() env.client1.conf.simulateDoesntOwnDomain = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertPermissionDeniedError(t, "does not own domain getcode.com") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) env.client1.resetConf() - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, true, true) - sendMessageCall.assertInvalidMessageError(t, "domain verification is required when requestor account is a relationship account") + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{ + useRelationshipAccount: true, + disableDomainVerification: true, + }) + sendMessageCall.assertInvalidMessageError(t, "domain verification is required when using a relationship account") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + // + // Part 4: Fee structure validation + // + + env.client1.resetConf() + env.client1.conf.simulateLargeFeePercentage = true + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "total fee percentage cannot exceed") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateDuplicatedFeeTaker = true + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "fee taker at index 2 appears multiple times and should be merged") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateFeeTakerIsPaymentDestination = true + sendMessageCall = env.client1.sendRequestToReceiveKinBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "fee taker at index 0 is the payment destination and should be omitted") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateInvalidFeeCodeAccount = true + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "is not a deposit account") + env.server1.assertNoMessages(t, rendezvousKey) + env.server1.assertRequestRecordNotSaved(t, rendezvousKey) + + env.client1.resetConf() + env.client1.conf.simulateInvalidFeeRelationship = true + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) + sendMessageCall.assertInvalidMessageError(t, "is not associated with getcode.com") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) // - // Part 4: Signature validation + // Part 5: Signature validation // env.client1.resetConf() env.client1.conf.simulateInvalidMessageSignature = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertUnauthenticatedError(t, "") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) // - // Part 5: Rendezvous key validation + // Part 6: Rendezvous key validation // env.client1.resetConf() env.client1.conf.simulateInvalidRendezvousKey = true - sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, false) + sendMessageCall = env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) sendMessageCall.assertInvalidMessageError(t, "rendezvous key mismatch") env.server1.assertNoMessages(t, rendezvousKey) env.server1.assertRequestRecordNotSaved(t, rendezvousKey) // - // Part 6: Upgrading request with a payment requirement + // Part 7: Upgrading request with a payment requirement // env.client1.resetConf() originalSendMessageRequest := env.client1.sendRequestToLoginMessage(t, rendezvousKey) originalSendMessageRequest.requireSuccess(t) - env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, false).assertInvalidMessageError(t, "original request doesn't require payment") + env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}).assertInvalidMessageError(t, "original request doesn't require payment") env.server1.assertLoginRequestRecordSaved(t, rendezvousKey, originalSendMessageRequest.req.Message.GetRequestToLogin()) messages := env.client1.pollForMessages(t, rendezvousKey) require.Len(t, messages, 1) @@ -693,7 +823,7 @@ func TestSendMessage_RequestToLogin_Validation(t *testing.T) { // env.client1.resetConf() - originalSendMessageRequest := env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, false, false, false) + originalSendMessageRequest := env.client1.sendRequestToReceiveFiatBillMessage(t, rendezvousKey, &testRequestToReceiveBillConf{}) originalSendMessageRequest.requireSuccess(t) env.client1.sendRequestToLoginMessage(t, rendezvousKey).assertInvalidMessageError(t, "original request requires payment") env.server1.assertPaymentRequestRecordSaved(t, rendezvousKey, originalSendMessageRequest.req.Message.GetRequestToReceiveBill()) diff --git a/pkg/code/server/grpc/messaging/testutil.go b/pkg/code/server/grpc/messaging/testutil.go index 7eb822c6..0a3c8e73 100644 --- a/pkg/code/server/grpc/messaging/testutil.go +++ b/pkg/code/server/grpc/messaging/testutil.go @@ -170,6 +170,12 @@ func (s *serverEnv) assertPaymentRequestRecordSaved(t *testing.T, rendezvousKey assert.Equal(t, asciiBaseDomain, *requestRecord.Domain) assert.Equal(t, msg.Verifier != nil, requestRecord.IsVerified) } + + require.Len(t, requestRecord.Fees, len(msg.AdditionalFees)) + for i, expectedFee := range msg.AdditionalFees { + assert.Equal(t, base58.Encode(expectedFee.Destination.Value), requestRecord.Fees[i].DestinationTokenAccount) + assert.EqualValues(t, expectedFee.FeeBps, requestRecord.Fees[i].BasisPoints) + } } func (s *serverEnv) assertLoginRequestRecordSaved(t *testing.T, rendezvousKey *common.Account, msg *messagingpb.RequestToLogin) { @@ -276,6 +282,13 @@ type clientConf struct { // Simulations for invalid rendezvous keys simulateInvalidRendezvousKey bool + + // Simulations for invalid fee structures + simulateLargeFeePercentage bool + simulateInvalidFeeCodeAccount bool + simulateInvalidFeeRelationship bool + simulateDuplicatedFeeTaker bool + simulateFeeTakerIsPaymentDestination bool } type clientEnv struct { @@ -563,8 +576,18 @@ func (c *clientEnv) sendRequestToGrabBillMessage(t *testing.T, rendezvousKey *co return c.sendMessage(t, req, rendezvousKey) } +type testRequestToReceiveBillConf struct { + usePrimaryAccount bool + useRelationshipAccount bool + disableDomainVerification bool +} + // todo: code duplication with fiat variant -func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousKey *common.Account, usePrimaryAccount, useRelationship, disableDomainVerification bool) *sendMessageCallMetadata { +func (c *clientEnv) sendRequestToReceiveKinBillMessage( + t *testing.T, + rendezvousKey *common.Account, + conf *testRequestToReceiveBillConf, +) *sendMessageCallMetadata { authority, err := common.NewAccountFromPrivateKeyString("dr2MUzL4NCS45qyp16vDXiSdHqqdg2DF79xKaYMB1vzVtDDjPvyQ8xTH4VsTWXSDP3NFzsdCV6gEoChKftzwLno") require.NoError(t, err) @@ -574,7 +597,7 @@ func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousK destination := testutil.NewRandomAccount(t) - if usePrimaryAccount { + if conf.usePrimaryAccount { owner := testutil.NewRandomAccount(t) accountInfoRecord := &account.Record{ OwnerAccount: owner.PublicKey().ToBase58(), @@ -585,7 +608,7 @@ func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousK Index: 0, } require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, accountInfoRecord)) - } else if useRelationship { + } else if conf.useRelationshipAccount { accountInfoRecord := &account.Record{ OwnerAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), AuthorityAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), @@ -645,6 +668,62 @@ func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousK exchangeData.Quarks += kin.QuarksPerKin / 10 } + additionalFees := []*transactionpb.AdditionalFeePayment{ + { + Destination: testutil.NewRandomAccount(t).ToProto(), + FeeBps: 1234, + }, + { + Destination: testutil.NewRandomAccount(t).ToProto(), + FeeBps: 56, + }, + { + Destination: testutil.NewRandomAccount(t).ToProto(), + FeeBps: 789, + }, + } + + if c.conf.simulateLargeFeePercentage { + additionalFees[0].FeeBps = 8000 + } + if c.conf.simulateDuplicatedFeeTaker { + additionalFees[0].Destination = additionalFees[2].Destination + } + if c.conf.simulateFeeTakerIsPaymentDestination { + additionalFees[0].Destination = destination.ToProto() + } + + feeCodeAccountOwner := testutil.NewRandomAccount(t) + feeCodeAccountAuthority := feeCodeAccountOwner + feeCodeAccountType := commonpb.AccountType_PRIMARY + if c.conf.simulateInvalidFeeCodeAccount { + feeCodeAccountType = commonpb.AccountType_TEMPORARY_INCOMING + feeCodeAccountAuthority = testutil.NewRandomAccount(t) + } + require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, &account.Record{ + OwnerAccount: feeCodeAccountOwner.PublicKey().ToBase58(), + AuthorityAccount: feeCodeAccountAuthority.PublicKey().ToBase58(), + TokenAccount: base58.Encode(additionalFees[0].Destination.Value), + MintAccount: common.KinMintAccount.PublicKey().ToBase58(), + AccountType: feeCodeAccountType, + Index: 0, + })) + if !conf.disableDomainVerification { + feeRelationship := "getcode.com" + if c.conf.simulateInvalidFeeRelationship { + feeRelationship = "example.com" + } + require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, &account.Record{ + OwnerAccount: feeCodeAccountOwner.PublicKey().ToBase58(), + AuthorityAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + TokenAccount: base58.Encode(additionalFees[1].Destination.Value), + MintAccount: common.KinMintAccount.PublicKey().ToBase58(), + AccountType: commonpb.AccountType_RELATIONSHIP, + Index: 0, + RelationshipTo: &feeRelationship, + })) + } + msg := &messagingpb.RequestToReceiveBill{ RequestorAccount: destination.ToProto(), ExchangeData: &messagingpb.RequestToReceiveBill_Exact{ @@ -657,9 +736,10 @@ func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousK RendezvousKey: &messagingpb.RendezvousKey{ Value: rendezvousKey.PublicKey().ToBytes(), }, + AdditionalFees: additionalFees, } - if disableDomainVerification { + if conf.disableDomainVerification { msg.Verifier = nil msg.RendezvousKey = nil } @@ -675,7 +755,7 @@ func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousK messageBytes, err := proto.Marshal(msg) require.NoError(t, err) - if !disableDomainVerification { + if !conf.disableDomainVerification { signer := authority if c.conf.simulateInvalidMessageSignature { signer = testutil.NewRandomAccount(t) @@ -700,7 +780,11 @@ func (c *clientEnv) sendRequestToReceiveKinBillMessage(t *testing.T, rendezvousK } // todo: code duplication with kin variant -func (c *clientEnv) sendRequestToReceiveFiatBillMessage(t *testing.T, rendezvousKey *common.Account, usePrimaryAccount, useRelationship, disableDomainVerification bool) *sendMessageCallMetadata { +func (c *clientEnv) sendRequestToReceiveFiatBillMessage( + t *testing.T, + rendezvousKey *common.Account, + conf *testRequestToReceiveBillConf, +) *sendMessageCallMetadata { authority, err := common.NewAccountFromPrivateKeyString("dr2MUzL4NCS45qyp16vDXiSdHqqdg2DF79xKaYMB1vzVtDDjPvyQ8xTH4VsTWXSDP3NFzsdCV6gEoChKftzwLno") require.NoError(t, err) @@ -710,18 +794,17 @@ func (c *clientEnv) sendRequestToReceiveFiatBillMessage(t *testing.T, rendezvous destination := testutil.NewRandomAccount(t) - if usePrimaryAccount { + if conf.usePrimaryAccount { owner := testutil.NewRandomAccount(t) - accountInfoRecord := &account.Record{ + require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, &account.Record{ OwnerAccount: owner.PublicKey().ToBase58(), AuthorityAccount: owner.PublicKey().ToBase58(), TokenAccount: destination.PublicKey().ToBase58(), MintAccount: common.KinMintAccount.PublicKey().ToBase58(), AccountType: commonpb.AccountType_PRIMARY, Index: 0, - } - require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, accountInfoRecord)) - } else if useRelationship { + })) + } else if conf.useRelationshipAccount { accountInfoRecord := &account.Record{ OwnerAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), AuthorityAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), @@ -763,6 +846,62 @@ func (c *clientEnv) sendRequestToReceiveFiatBillMessage(t *testing.T, rendezvous exchangeData.NativeAmount = 1.01 } + additionalFees := []*transactionpb.AdditionalFeePayment{ + { + Destination: testutil.NewRandomAccount(t).ToProto(), + FeeBps: 1234, + }, + { + Destination: testutil.NewRandomAccount(t).ToProto(), + FeeBps: 56, + }, + { + Destination: testutil.NewRandomAccount(t).ToProto(), + FeeBps: 789, + }, + } + + if c.conf.simulateLargeFeePercentage { + additionalFees[0].FeeBps = 8000 + } + if c.conf.simulateDuplicatedFeeTaker { + additionalFees[0].Destination = additionalFees[2].Destination + } + if c.conf.simulateFeeTakerIsPaymentDestination { + additionalFees[0].Destination = destination.ToProto() + } + + feeCodeAccountOwner := testutil.NewRandomAccount(t) + feeCodeAccountAuthority := feeCodeAccountOwner + feeCodeAccountType := commonpb.AccountType_PRIMARY + if c.conf.simulateInvalidFeeCodeAccount { + feeCodeAccountType = commonpb.AccountType_TEMPORARY_INCOMING + feeCodeAccountAuthority = testutil.NewRandomAccount(t) + } + require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, &account.Record{ + OwnerAccount: feeCodeAccountOwner.PublicKey().ToBase58(), + AuthorityAccount: feeCodeAccountAuthority.PublicKey().ToBase58(), + TokenAccount: base58.Encode(additionalFees[0].Destination.Value), + MintAccount: common.KinMintAccount.PublicKey().ToBase58(), + AccountType: feeCodeAccountType, + Index: 0, + })) + if !conf.disableDomainVerification { + feeRelationship := "getcode.com" + if c.conf.simulateInvalidFeeRelationship { + feeRelationship = "example.com" + } + require.NoError(t, c.directDataAccess.CreateAccountInfo(c.ctx, &account.Record{ + OwnerAccount: feeCodeAccountOwner.PublicKey().ToBase58(), + AuthorityAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + TokenAccount: base58.Encode(additionalFees[1].Destination.Value), + MintAccount: common.KinMintAccount.PublicKey().ToBase58(), + AccountType: commonpb.AccountType_RELATIONSHIP, + Index: 0, + RelationshipTo: &feeRelationship, + })) + } + msg := &messagingpb.RequestToReceiveBill{ RequestorAccount: destination.ToProto(), ExchangeData: &messagingpb.RequestToReceiveBill_Partial{ @@ -775,9 +914,10 @@ func (c *clientEnv) sendRequestToReceiveFiatBillMessage(t *testing.T, rendezvous RendezvousKey: &messagingpb.RendezvousKey{ Value: rendezvousKey.PublicKey().ToBytes(), }, + AdditionalFees: additionalFees, } - if disableDomainVerification { + if conf.disableDomainVerification { msg.Verifier = nil msg.RendezvousKey = nil } @@ -793,7 +933,7 @@ func (c *clientEnv) sendRequestToReceiveFiatBillMessage(t *testing.T, rendezvous messageBytes, err := proto.Marshal(msg) require.NoError(t, err) - if !disableDomainVerification { + if !conf.disableDomainVerification { signer := authority if c.conf.simulateInvalidMessageSignature { signer = testutil.NewRandomAccount(t) diff --git a/pkg/code/server/grpc/transaction/v2/action_handler.go b/pkg/code/server/grpc/transaction/v2/action_handler.go index f500e091..441814eb 100644 --- a/pkg/code/server/grpc/transaction/v2/action_handler.go +++ b/pkg/code/server/grpc/transaction/v2/action_handler.go @@ -417,10 +417,11 @@ func (h *CloseDormantAccountActionHandler) OnSaveToDB(ctx context.Context) error } type NoPrivacyTransferActionHandler struct { - source *common.TimelockAccounts - destination *common.Account - amount uint64 - isFeePayment bool // Internally, the mechanics of a fee payment are exactly the same + source *common.TimelockAccounts + destination *common.Account + amount uint64 + isFeePayment bool // Internally, the mechanics of a fee payment are exactly the same + isCodeFeePayment bool } func NewNoPrivacyTransferActionHandler(protoAction *transactionpb.NoPrivacyTransferAction) (CreateActionHandler, error) { @@ -458,11 +459,24 @@ func NewFeePaymentActionHandler(protoAction *transactionpb.FeePaymentAction, fee return nil, err } + var destination *common.Account + var isCodeFeePayment bool + if protoAction.Type == transactionpb.FeePaymentAction_CODE { + destination = feeCollector + isCodeFeePayment = true + } else { + destination, err = common.NewAccountFromProto(protoAction.Destination) + if err != nil { + return nil, err + } + } + return &NoPrivacyTransferActionHandler{ - source: source, - destination: feeCollector, - amount: protoAction.Amount, - isFeePayment: true, + source: source, + destination: destination, + amount: protoAction.Amount, + isFeePayment: true, + isCodeFeePayment: isCodeFeePayment, }, nil } @@ -484,10 +498,15 @@ func (h *NoPrivacyTransferActionHandler) PopulateMetadata(actionRecord *action.R } func (h *NoPrivacyTransferActionHandler) GetServerParameter() *transactionpb.ServerParameter { if h.isFeePayment { + var codeDestination *commonpb.SolanaAccountId + if h.isCodeFeePayment { + codeDestination = h.destination.ToProto() + } + return &transactionpb.ServerParameter{ Type: &transactionpb.ServerParameter_FeePayment{ FeePayment: &transactionpb.FeePaymentServerParameter{ - Destination: h.destination.ToProto(), + CodeDestination: codeDestination, }, }, } diff --git a/pkg/code/server/grpc/transaction/v2/history_test.go b/pkg/code/server/grpc/transaction/v2/history_test.go index 06ee2c1e..f818c021 100644 --- a/pkg/code/server/grpc/transaction/v2/history_test.go +++ b/pkg/code/server/grpc/transaction/v2/history_test.go @@ -109,8 +109,12 @@ func TestPaymentHistory_HappyPath(t *testing.T) { sendingPhone.privatelyWithdraw777KinToCodeUser(t, receivingPhone).requireSuccess(t) receivingPhone.deposit777KinIntoOrganizer(t).requireSuccess(t) + sendingPhone.resetConfig() + sendingPhone.conf.simulatePaymentRequest = true + sendingPhone.conf.simulateAdditionalFees = true + // [Verified Merchant] sendingPhone SPENT $32.1 USD of Kin - // [Verified Merchant] receivingPhone RECEIVED $32.09 USD of Kin + // [Verified Merchant] receivingPhone RECEIVED $29.69213 USD of Kin sendingPhone.privatelyWithdraw321KinToCodeUserRelationshipAccount(t, receivingPhone, merchantDomain).requireSuccess(t) // [Verified Merchant] sendingPhone SPENT 123 Kin @@ -352,8 +356,8 @@ func TestPaymentHistory_HappyPath(t *testing.T) { assert.Equal(t, chatpb.ExchangeDataContent_RECEIVED, protoChatMessage.Content[0].GetExchangeData().Verb) assert.EqualValues(t, currency_lib.USD, protoChatMessage.Content[0].GetExchangeData().GetExact().Currency) assert.Equal(t, 0.1, protoChatMessage.Content[0].GetExchangeData().GetExact().ExchangeRate) - assert.Equal(t, 32.09, protoChatMessage.Content[0].GetExchangeData().GetExact().NativeAmount) - assert.EqualValues(t, 32090000, protoChatMessage.Content[0].GetExchangeData().GetExact().Quarks) + assert.Equal(t, 29.69213, protoChatMessage.Content[0].GetExchangeData().GetExact().NativeAmount) + assert.EqualValues(t, 29692130, protoChatMessage.Content[0].GetExchangeData().GetExact().Quarks) protoChatMessage = getProtoChatMessage(t, chatMessageRecords[2]) require.Len(t, protoChatMessage.Content, 1) diff --git a/pkg/code/server/grpc/transaction/v2/intent_handler.go b/pkg/code/server/grpc/transaction/v2/intent_handler.go index ed41d994..aa0404ef 100644 --- a/pkg/code/server/grpc/transaction/v2/intent_handler.go +++ b/pkg/code/server/grpc/transaction/v2/intent_handler.go @@ -238,7 +238,7 @@ func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRec // Part 6: Validate fee payments // - return validateFeePayment(ctx, h.data, intentRecord, simResult) + return validateFeePayments(ctx, h.data, intentRecord, simResult) } func (h *OpenAccountsIntentHandler) validateActions(initiatiorOwnerAccount *common.Account, actions []*transactionpb.Action) error { @@ -572,7 +572,7 @@ func (h *SendPrivatePaymentIntentHandler) AllowCreation(ctx context.Context, int // Part 5: Validate fee payments // - err = validateFeePayment(ctx, h.data, intentRecord, simResult) + err = validateFeePayments(ctx, h.data, intentRecord, simResult) if err != nil { return err } @@ -616,13 +616,12 @@ func (h *SendPrivatePaymentIntentHandler) validateActions( // Part 1.1: Check destination and fee collection accounts are paid exact quark amount from latest temp outgoing account // - // Minimal validation required here since validateFeePayment generically handles + expectedDestinationPayment := int64(metadata.ExchangeData.Quarks) + + // Minimal validation required here since validateFeePayments generically handles // most metadata that isn't specific to an intent - var feePayment *TransferSimulation feePayments := simResult.GetFeePayments() - expectedDestinationPayment := int64(metadata.ExchangeData.Quarks) - if len(feePayments) > 0 { - feePayment = &feePayments[0] + for _, feePayment := range feePayments { expectedDestinationPayment += feePayment.DeltaQuarks } @@ -648,8 +647,10 @@ func (h *SendPrivatePaymentIntentHandler) validateActions( return newActionValidationErrorf(destinationSimulation.Transfers[0].Action, "destination account must be paid by temporary outgoing account %s", tempOutgoing) } - if feePayment != nil && base58.Encode(feePayment.Action.GetFeePayment().Source.Value) != tempOutgoing { - return newActionValidationErrorf(feePayment.Action, "fee payment must come from temporary outgoing account %s", tempOutgoing) + for _, feePayment := range feePayments { + if base58.Encode(feePayment.Action.GetFeePayment().Source.Value) != tempOutgoing { + return newActionValidationErrorf(feePayment.Action, "fee payment must come from temporary outgoing account %s", tempOutgoing) + } } if tempOutgoingSimulation.GetDeltaQuarks() != 0 { @@ -845,7 +846,8 @@ func (h *SendPrivatePaymentIntentHandler) validateActions( case commonpb.AccountType_TEMPORARY_OUTGOING: expectedPublicTransfers := 1 if intentRecord.SendPrivatePaymentMetadata.IsMicroPayment { - expectedPublicTransfers = 2 + // Code fee, plus any additional configured fee takers + expectedPublicTransfers += len(h.cachedPaymentRequestRequest.Fees) + 1 } if simulation.CountPublicTransfers() != expectedPublicTransfers { return newIntentValidationErrorf("temporary outgoing account can only have %d public transfers", expectedPublicTransfers) @@ -1092,7 +1094,7 @@ func (h *ReceivePaymentsPrivatelyIntentHandler) AllowCreation(ctx context.Contex // Part 5: Validate fee payments // - err = validateFeePayment(ctx, h.data, intentRecord, simResult) + err = validateFeePayments(ctx, h.data, intentRecord, simResult) if err != nil { return err } @@ -1758,7 +1760,7 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte // Part 6: Validate fee payments // - err = validateFeePayment(ctx, h.data, intentRecord, simResult) + err = validateFeePayments(ctx, h.data, intentRecord, simResult) if err != nil { return err } @@ -2103,7 +2105,7 @@ func (h *ReceivePaymentsPubliclyIntentHandler) AllowCreation(ctx context.Context // Part 6: Validate fee payments // - err = validateFeePayment(ctx, h.data, intentRecord, simResult) + err = validateFeePayments(ctx, h.data, intentRecord, simResult) if err != nil { return err } @@ -2364,7 +2366,7 @@ func (h *EstablishRelationshipIntentHandler) AllowCreation(ctx context.Context, // Part 7: Validate fee payments // - err = validateFeePayment(ctx, h.data, intentRecord, simResult) + err = validateFeePayments(ctx, h.data, intentRecord, simResult) if err != nil { return err } @@ -2893,7 +2895,7 @@ func validateExchangeDataWithinIntent(ctx context.Context, data code_data.Provid requestRecord, err := data.GetRequest(ctx, intentId) if err == nil { if !requestRecord.RequiresPayment() { - return newIntentValidationError("request does not require payment") + return newIntentValidationError("request doesn't require payment") } if proto.Currency != string(*requestRecord.ExchangeCurrency) { @@ -2929,7 +2931,7 @@ func validateExchangeDataWithinIntent(ctx context.Context, data code_data.Provid // Generically validates fee payments as much as possible, but won't cover any // intent-specific nuances (eg. where the fee payment comes from) -func validateFeePayment( +func validateFeePayments( ctx context.Context, data code_data.Provider, intentRecord *intent.Record, @@ -2953,16 +2955,39 @@ func validateFeePayment( return nil } + requestRecord, err := data.GetRequest(ctx, intentRecord.IntentId) + if err != nil { + return err + } + + if !requestRecord.RequiresPayment() { + return newIntentValidationError("request doesn't require payment") + } + + additionalRequestedFees := requestRecord.Fees + feePayments := simResult.GetFeePayments() - if len(feePayments) > 1 { - return newIntentValidationError("fee payment must be done in a single action") + if len(feePayments) != len(additionalRequestedFees)+1 { + return newIntentValidationErrorf("expected %d fee payment action", len(additionalRequestedFees)+1) + } + + codeFeePayment := feePayments[0] + + if codeFeePayment.Action.GetFeePayment().Type != transactionpb.FeePaymentAction_CODE { + return newActionValidationError(codeFeePayment.Action, "fee payment type must be CODE") + } + + if codeFeePayment.Action.GetFeePayment().Destination != nil { + return newActionValidationError(codeFeePayment.Action, "code fee payment destination is configured by server") } - feeAmount := feePayments[0].DeltaQuarks + + feeAmount := codeFeePayment.DeltaQuarks if feeAmount >= 0 { - return newIntentValidationError("fee payment is not a payment to code") + return newActionValidationError(codeFeePayment.Action, "fee payment amount is negative") } feeAmount = -feeAmount // Because it's coming out of a user account in this simulation + var foundUsdExchangeRecord bool usdExchangeRecords, err := exchange_rate_util.GetPotentialClientExchangeRates(ctx, data, currency_lib.USD) if err != nil { return err @@ -2975,11 +3000,43 @@ func validateFeePayment( // todo: Hardcoded as a penny USD, but might want a dynamic amount if we // have use cases with different fee amounts. if usdValue > 0.0099 && usdValue < 0.0101 { - return nil + foundUsdExchangeRecord = true + break + } + } + + if !foundUsdExchangeRecord { + return newActionValidationError(codeFeePayment.Action, "code fee payment amount must be $0.01 USD") + } + + for i, additionalFee := range feePayments[1:] { + if additionalFee.Action.GetFeePayment().Type != transactionpb.FeePaymentAction_THIRD_PARTY { + return newActionValidationError(additionalFee.Action, "fee payment type must be THIRD_PARTY") + } + + destination := additionalFee.Action.GetFeePayment().Destination + if destination == nil { + return newActionValidationError(additionalFee.Action, "fee payment destination is required") + } + + // The destination should already be validated as a valid payment destination + if base58.Encode(destination.Value) != additionalRequestedFees[i].DestinationTokenAccount { + return newActionValidationErrorf(additionalFee.Action, "fee payment destination must be %s", additionalRequestedFees[i].DestinationTokenAccount) + } + + feeAmount := additionalFee.DeltaQuarks + if feeAmount >= 0 { + return newActionValidationError(additionalFee.Action, "fee payment amount is negative") + } + feeAmount = -feeAmount // Because it's coming out of a user account in this simulation + + requestedAmount := (uint64(additionalRequestedFees[i].BasisPoints) * intentRecord.SendPrivatePaymentMetadata.Quantity) / 10000 + if feeAmount != int64(requestedAmount) { + return newActionValidationErrorf(additionalFee.Action, "fee payment amount must be for %d bps of total amount", additionalRequestedFees[i].BasisPoints) } } - return newActionValidationError(feePayments[0].Action, "fee payment must be $0.01 USD") + return nil } func validateMinimalTempIncomingAccountUsage(ctx context.Context, data code_data.Provider, accountInfo *account.Record) error { diff --git a/pkg/code/server/grpc/transaction/v2/intent_test.go b/pkg/code/server/grpc/transaction/v2/intent_test.go index b62ecfb1..515d4de8 100644 --- a/pkg/code/server/grpc/transaction/v2/intent_test.go +++ b/pkg/code/server/grpc/transaction/v2/intent_test.go @@ -243,7 +243,7 @@ func TestSubmitIntent_OpenAccounts_Validation_Actions(t *testing.T) { // phone.resetConfig() - phone.conf.simulateFeePaid = true + phone.conf.simulateCodeFeePaid = true submitIntentCall = phone.openAccounts(t) submitIntentCall.assertInvalidIntentResponse(t, "expected 19 total actions") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) @@ -365,19 +365,23 @@ func TestSubmitIntent_SendPrivatePayment_PaymentRequest_HappyPath(t *testing.T) receivingPhone.openAccounts(t).requireSuccess(t) receivingPhone.establishRelationshipWithMerchant(t, domain).requireSuccess(t) - sendingPhone.conf.simulatePaymentRequest = true + for _, simulateAdditionalFees := range []bool{true, false} { + sendingPhone.resetConfig() + sendingPhone.conf.simulatePaymentRequest = true + sendingPhone.conf.simulateAdditionalFees = simulateAdditionalFees - submitIntentCall := sendingPhone.privatelyWithdraw123KinToExternalWallet(t) - submitIntentCall.requireSuccess(t) - server.assertIntentSubmitted(t, submitIntentCall.intentId, submitIntentCall.protoMetadata, submitIntentCall.protoActions, sendingPhone, nil) + submitIntentCall := sendingPhone.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.requireSuccess(t) + server.assertIntentSubmitted(t, submitIntentCall.intentId, submitIntentCall.protoMetadata, submitIntentCall.protoActions, sendingPhone, nil) - submitIntentCall = sendingPhone.privatelyWithdraw777KinToCodeUser(t, receivingPhone) - submitIntentCall.requireSuccess(t) - server.assertIntentSubmitted(t, submitIntentCall.intentId, submitIntentCall.protoMetadata, submitIntentCall.protoActions, sendingPhone, &receivingPhone) + submitIntentCall = sendingPhone.privatelyWithdraw777KinToCodeUser(t, receivingPhone) + submitIntentCall.requireSuccess(t) + server.assertIntentSubmitted(t, submitIntentCall.intentId, submitIntentCall.protoMetadata, submitIntentCall.protoActions, sendingPhone, &receivingPhone) - submitIntentCall = sendingPhone.privatelyWithdraw321KinToCodeUserRelationshipAccount(t, receivingPhone, domain) - submitIntentCall.requireSuccess(t) - server.assertIntentSubmitted(t, submitIntentCall.intentId, submitIntentCall.protoMetadata, submitIntentCall.protoActions, sendingPhone, &receivingPhone) + submitIntentCall = sendingPhone.privatelyWithdraw321KinToCodeUserRelationshipAccount(t, receivingPhone, domain) + submitIntentCall.requireSuccess(t) + server.assertIntentSubmitted(t, submitIntentCall.intentId, submitIntentCall.protoMetadata, submitIntentCall.protoActions, sendingPhone, &receivingPhone) + } } func TestSubmitIntent_SendPrivatePayment_SelfPayment(t *testing.T) { @@ -961,7 +965,7 @@ func TestSubmitIntent_SendPrivatePayment_Validation_Actions(t *testing.T) { // sendingPhone.resetConfig() - sendingPhone.conf.simulateFeePaid = true + sendingPhone.conf.simulateCodeFeePaid = true submitIntentCall = sendingPhone.send42KinToCodeUser(t, receivingPhone) submitIntentCall.assertInvalidIntentResponse(t, "intent doesn't require a fee payment") @@ -1301,7 +1305,7 @@ func TestSubmitIntent_SendPublicPayment_Validation_Actions(t *testing.T) { // sendingPhone.resetConfig() - sendingPhone.conf.simulateFeePaid = true + sendingPhone.conf.simulateCodeFeePaid = true submitIntentCall = sendingPhone.publiclyWithdraw777KinToCodeUserBetweenPrimaryAccounts(t, receivingPhone) submitIntentCall.assertInvalidIntentResponse(t, "intent doesn't require a fee payment") @@ -1815,7 +1819,7 @@ func TestSubmitIntent_ReceivePaymentsPrivately_Validation_Actions(t *testing.T) // receivingPhone.resetConfig() - receivingPhone.conf.simulateFeePaid = true + receivingPhone.conf.simulateCodeFeePaid = true submitIntentCall = receivingPhone.receive42KinFromCodeUser(t) submitIntentCall.assertInvalidIntentResponse(t, "intent doesn't require a fee payment") @@ -2210,7 +2214,7 @@ func TestSubmitIntent_ReceivePaymentsPublicly_Validation_Actions(t *testing.T) { // receivingPhone.resetConfig() - receivingPhone.conf.simulateFeePaid = true + receivingPhone.conf.simulateCodeFeePaid = true submitIntentCall = receivingPhone.receive42KinFromGiftCard(t, giftCardAccount, false) submitIntentCall.assertInvalidIntentResponse(t, "intent doesn't require a fee payment") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) @@ -2453,7 +2457,7 @@ func TestSubmitIntent_EstablishRelationship_Validation_Actions(t *testing.T) { server.assertNoRelationshipAccountRecordsSaved(t, phone, domain) phone.resetConfig() - phone.conf.simulateFeePaid = true + phone.conf.simulateCodeFeePaid = true submitIntentCall = phone.establishRelationshipWithMerchant(t, domain) submitIntentCall.assertInvalidIntentResponse(t, "intent doesn't require a fee payment") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) @@ -3188,30 +3192,85 @@ func TestSubmitIntent_PaymentRequest_Validation(t *testing.T) { phone1.resetConfig() phone1.conf.simulatePaymentRequest = true - phone1.conf.simulateNoFeesPaid = true + phone1.conf.simulateCodeFeeNotPaid = true submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) submitIntentCall.assertInvalidIntentResponse(t, "intent requires a fee payment") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) phone1.resetConfig() phone1.conf.simulatePaymentRequest = true - phone1.conf.simulateMultipleFeePayments = true + phone1.conf.simulateCodeFeeAsThirdParyFee = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "actions[3]: fee payment type must be CODE") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateMultipleCodeFeePayments = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "expected 1 fee payment action") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateSmallCodeFee = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "actions[3]: code fee payment amount must be $0.01 USD") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateLargeCodeFee = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "actions[3]: code fee payment amount must be $0.01 USD") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateAdditionalFees = true + phone1.conf.simulateTooManyThirdPartyFees = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "expected 3 fee payment action") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateAdditionalFees = true + phone1.conf.simulateThirdPartyFeeMissing = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "expected 5 fee payment action") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateAdditionalFees = true + phone1.conf.simulateInvalidThirdPartyFeeAmount = true + submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) + submitIntentCall.assertInvalidIntentResponse(t, "actions[4]: fee payment amount must be for 250 bps of total amount") + server.assertIntentNotSubmitted(t, submitIntentCall.intentId) + + phone1.resetConfig() + phone1.conf.simulatePaymentRequest = true + phone1.conf.simulateAdditionalFees = true + phone1.conf.simulateInvalidThirdPartyFeeDestination = true submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) - submitIntentCall.assertInvalidIntentResponse(t, "fee payment must be done in a single action") + submitIntentCall.assertInvalidIntentResponse(t, "actions[4]: fee payment destination must be") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) phone1.resetConfig() phone1.conf.simulatePaymentRequest = true - phone1.conf.simulateSmallFee = true + phone1.conf.simulateAdditionalFees = true + phone1.conf.simulateThirdPartyFeeDestinationMissing = true submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) - submitIntentCall.assertInvalidIntentResponse(t, "fee payment must be $0.01 USD") + submitIntentCall.assertInvalidIntentResponse(t, "actions[4]: fee payment destination is required") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) phone1.resetConfig() phone1.conf.simulatePaymentRequest = true - phone1.conf.simulateLargeFee = true + phone1.conf.simulateAdditionalFees = true + phone1.conf.simulateThirdParyFeeAsCodeFee = true submitIntentCall = phone1.privatelyWithdraw123KinToExternalWallet(t) - submitIntentCall.assertInvalidIntentResponse(t, "fee payment must be $0.01 USD") + submitIntentCall.assertInvalidIntentResponse(t, "actions[4]: fee payment type must be THIRD_PARTY") server.assertIntentNotSubmitted(t, submitIntentCall.intentId) // diff --git a/pkg/code/server/grpc/transaction/v2/local_simulation.go b/pkg/code/server/grpc/transaction/v2/local_simulation.go index 5fa4b58b..621ed14e 100644 --- a/pkg/code/server/grpc/transaction/v2/local_simulation.go +++ b/pkg/code/server/grpc/transaction/v2/local_simulation.go @@ -155,8 +155,6 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr DeltaQuarks: -int64(amount), }, }, - Closed: false, - CloseAction: action, }, TokenAccountSimulation{ TokenAccount: destination, @@ -198,9 +196,10 @@ func LocalSimulation(ctx context.Context, data code_data.Provider, actions []*tr DeltaQuarks: -int64(amount), }, }, - Closed: false, - CloseAction: action, }, + // todo: Doesn't specify destination, but that's not required yet, + // and makes other validation more complex since it's based + // on the simulation. ) case *transactionpb.Action_NoPrivacyWithdraw: source, err := common.NewAccountFromProto(typedAction.NoPrivacyWithdraw.Source) diff --git a/pkg/code/server/grpc/transaction/v2/local_simulation_test.go b/pkg/code/server/grpc/transaction/v2/local_simulation_test.go index 85bcb356..9a52d26b 100644 --- a/pkg/code/server/grpc/transaction/v2/local_simulation_test.go +++ b/pkg/code/server/grpc/transaction/v2/local_simulation_test.go @@ -534,6 +534,43 @@ func TestLocalSimulation_TransferFromClosedAccount(t *testing.T) { } } +func TestLocalSimulation_FeeStructureBecomesInvalidOverTime(t *testing.T) { + microPaymentAmount := uint64(100) + thirdPartyFeeAmount := uint64(20) + + // Simulate a test where the Code fee amount causes an insufficient balance + // because the USD exchange rate fluctuated significantly against another + // currency + for _, codeFeeAmount := range []uint64{ + microPaymentAmount - 2*thirdPartyFeeAmount, + microPaymentAmount - 2*thirdPartyFeeAmount + 1, + microPaymentAmount - 2*thirdPartyFeeAmount - 1, + } { + env := setupLocalSimulationTestEnv(t) + + bucketAccount := testutil.NewRandomAccount(t) + env.setupTimelockRecord(t, bucketAccount, timelock_token_v1.StateLocked) + env.setupCachedBalance(t, bucketAccount, microPaymentAmount) + + tempOutgoingAccount := testutil.NewRandomAccount(t) + env.setupTimelockRecord(t, tempOutgoingAccount, timelock_token_v1.StateLocked) + + externalATA := testutil.NewRandomAccount(t) + + actions := []*transactionpb.Action{ + getTemporaryPrivacyTransferActionForLocalSimulation(t, bucketAccount, getTimelockVault(t, tempOutgoingAccount), microPaymentAmount), + getFeePaymentActionForLocalSimulation(t, tempOutgoingAccount, codeFeeAmount), + getFeePaymentActionForLocalSimulation(t, tempOutgoingAccount, thirdPartyFeeAmount), + getFeePaymentActionForLocalSimulation(t, tempOutgoingAccount, thirdPartyFeeAmount), + getNoPrivacyWithdrawActionForLocalSimulation(t, tempOutgoingAccount, externalATA, 10), + } + + _, err := env.LocalSimulation(t, actions) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "insufficient balance to perform action")) + } +} + func TestLocalSimulation_NotManagedByCode(t *testing.T) { env := setupLocalSimulationTestEnv(t) diff --git a/pkg/code/server/grpc/transaction/v2/swap.go b/pkg/code/server/grpc/transaction/v2/swap.go index ec5b1d1b..23c7ac78 100644 --- a/pkg/code/server/grpc/transaction/v2/swap.go +++ b/pkg/code/server/grpc/transaction/v2/swap.go @@ -298,8 +298,8 @@ func (s *transactionServer) Swap(streamer transactionpb.Transaction_SwapServer) // Server responds back with parameters, so client can locally construct the // transaction and validate it. serverParameters := &transactionpb.SwapResponse{ - Response: &transactionpb.SwapResponse_ServerParamenters{ - ServerParamenters: &transactionpb.SwapResponse_ServerParameters{ + Response: &transactionpb.SwapResponse_ServerParameters_{ + ServerParameters: &transactionpb.SwapResponse_ServerParameters{ Payer: s.swapSubsidizer.ToProto(), RecentBlockhash: &commonpb.Blockhash{Value: blockhash[:]}, ComputeUnitLimit: computeUnitLimit, diff --git a/pkg/code/server/grpc/transaction/v2/testutil.go b/pkg/code/server/grpc/transaction/v2/testutil.go index bfc0927d..1e15fa66 100644 --- a/pkg/code/server/grpc/transaction/v2/testutil.go +++ b/pkg/code/server/grpc/transaction/v2/testutil.go @@ -70,6 +70,10 @@ import ( "github.com/code-payments/code-server/pkg/testutil" ) +const ( + defaultTestThirdPartyFeeBps = 249 // 2.49% +) + // todo: Make working with different timelock versions easier func setupTestEnv(t *testing.T, serverOverrides *testOverrides) (serverTestEnv, phoneTestEnv, phoneTestEnv, func()) { @@ -769,7 +773,11 @@ func (s serverTestEnv) assertActionRecordsSaved(t *testing.T, intentId string, p case *transactionpb.Action_FeePayment: assert.Equal(t, action.NoPrivacyTransfer, actionRecord.ActionType) assert.Equal(t, base58.Encode(typed.FeePayment.Source.Value), actionRecord.Source) - assert.Equal(t, s.service.conf.feeCollectorTokenPublicKey.Get(s.ctx), *actionRecord.Destination) + if typed.FeePayment.Type == transactionpb.FeePaymentAction_CODE { + assert.Equal(t, s.service.conf.feeCollectorTokenPublicKey.Get(s.ctx), *actionRecord.Destination) + } else { + assert.Equal(t, base58.Encode(typed.FeePayment.Destination.Value), *actionRecord.Destination) + } assert.Equal(t, typed.FeePayment.Amount, *actionRecord.Quantity) assert.Equal(t, action.StatePending, actionRecord.State) case *transactionpb.Action_NoPrivacyWithdraw: @@ -902,15 +910,21 @@ func (s serverTestEnv) assertFulfillmentRecordsSaved(t *testing.T, intentId stri authorityAccount, err := common.NewAccountFromProto(typed.FeePayment.Authority) require.NoError(t, err) - destinationAccount, err := common.NewAccountFromPublicKeyString(s.service.conf.feeCollectorTokenPublicKey.Get(s.ctx)) - require.NoError(t, err) + var destinationAccount *common.Account + if typed.FeePayment.Type == transactionpb.FeePaymentAction_CODE { + destinationAccount, err = common.NewAccountFromPublicKeyString(s.service.conf.feeCollectorTokenPublicKey.Get(s.ctx)) + require.NoError(t, err) + } else { + destinationAccount, err = common.NewAccountFromProto(typed.FeePayment.Destination) + require.NoError(t, err) + } amount := typed.FeePayment.Amount fulfillmentRecord := fulfillmentRecords[0] assert.Equal(t, fulfillment.NoPrivacyTransferWithAuthority, fulfillmentRecord.FulfillmentType) assert.Equal(t, base58.Encode(typed.FeePayment.Source.Value), actionRecord.Source) - assert.Equal(t, s.service.conf.feeCollectorTokenPublicKey.Get(s.ctx), *fulfillmentRecord.Destination) + assert.Equal(t, destinationAccount.PublicKey().ToBase58(), *fulfillmentRecord.Destination) assert.Equal(t, intentRecord.Id, fulfillmentRecord.IntentOrderingIndex) assert.Equal(t, actionRecord.ActionId, fulfillmentRecord.ActionOrderingIndex) assert.EqualValues(t, 0, fulfillmentRecord.FulfillmentOrderingIndex) @@ -1991,11 +2005,19 @@ type phoneConf struct { simulateInvalidPaymentRequestDestination bool simulateInvalidPaymentRequestExchangeCurrency bool simulateInvalidPaymentRequestNativeAmount bool - simulateFeePaid bool - simulateNoFeesPaid bool - simulateSmallFee bool - simulateLargeFee bool - simulateMultipleFeePayments bool + simulateAdditionalFees bool + simulateCodeFeePaid bool + simulateCodeFeeNotPaid bool + simulateSmallCodeFee bool + simulateLargeCodeFee bool + simulateCodeFeeAsThirdParyFee bool + simulateMultipleCodeFeePayments bool + simulateTooManyThirdPartyFees bool + simulateThirdPartyFeeMissing bool + simulateInvalidThirdPartyFeeAmount bool + simulateInvalidThirdPartyFeeDestination bool + simulateThirdPartyFeeDestinationMissing bool + simulateThirdParyFeeAsCodeFee bool } type phoneTestEnv struct { @@ -2550,6 +2572,8 @@ func (p *phoneTestEnv) publiclyWithdraw123KinToExternalWalletFromRelationshipAcc } func (p *phoneTestEnv) privatelyWithdraw123KinToExternalWallet(t *testing.T) submitIntentCallMetadata { + totalAmount := kin.ToQuarks(123) + destination := testutil.NewRandomAccount(t) nextIndex := p.currentTempOutgoingIndex + 1 @@ -2588,19 +2612,41 @@ func (p *phoneTestEnv) privatelyWithdraw123KinToExternalWallet(t *testing.T) sub }}, } - var feePayment uint64 + var feePayments uint64 if p.conf.simulatePaymentRequest { - feePayment = kin.ToQuarks(1) / 10 // 0.1 Kin + // Pay mandatory hard-coded Code $0.01 USD fee + codeFeePayment := kin.ToQuarks(1) / 10 // 0.1 Kin + feePayments += codeFeePayment actions = append(actions, &transactionpb.Action{ - // Pay any fees when applicable Type: &transactionpb.Action_FeePayment{ FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_CODE, Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), - Amount: feePayment, + Amount: codeFeePayment, }, }, }) + + // Pay additional fees as configured by the third party + if p.conf.simulateAdditionalFees { + for i := 0; i < 3; i++ { + requestedFee := (uint64(defaultTestThirdPartyFeeBps) * totalAmount) / 10000 + feePayments += requestedFee + actions = append(actions, &transactionpb.Action{ + // Pay any fees when applicable + Type: &transactionpb.Action_FeePayment{ + FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_THIRD_PARTY, + Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), + Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), + Destination: testutil.NewRandomAccount(t).ToProto(), + Amount: requestedFee, + }, + }, + }) + } + } } actions = append( @@ -2612,7 +2658,7 @@ func (p *phoneTestEnv) privatelyWithdraw123KinToExternalWallet(t *testing.T) sub Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), Destination: destination.ToProto(), - Amount: kin.ToQuarks(123) - feePayment, + Amount: kin.ToQuarks(123) - feePayments, ShouldClose: true, }, }}, @@ -3061,6 +3107,8 @@ func (p *phoneTestEnv) publiclyWithdraw777KinToCodeUserBetweenRelationshipAccoun } func (p *phoneTestEnv) privatelyWithdraw777KinToCodeUser(t *testing.T, receiver phoneTestEnv) submitIntentCallMetadata { + totalAmount := kin.ToQuarks(777) + destination := receiver.getTimelockVault(t, commonpb.AccountType_PRIMARY, 0) nextIndex := p.currentTempOutgoingIndex + 1 @@ -3099,19 +3147,41 @@ func (p *phoneTestEnv) privatelyWithdraw777KinToCodeUser(t *testing.T, receiver }}, } - var feePayment uint64 + var feePayments uint64 if p.conf.simulatePaymentRequest { - feePayment = kin.ToQuarks(1) / 10 // 0.1 Kin + // Pay mandatory hard-coded Code $0.01 USD fee + codeFeePayment := kin.ToQuarks(1) / 10 // 0.1 Kin + feePayments += codeFeePayment actions = append(actions, &transactionpb.Action{ - // Pay any fees when applicable Type: &transactionpb.Action_FeePayment{ FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_CODE, Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), - Amount: feePayment, + Amount: codeFeePayment, }, }, }) + + // Pay additional fees as configured by the third party + if p.conf.simulateAdditionalFees { + for i := 0; i < 3; i++ { + requestedFee := (uint64(defaultTestThirdPartyFeeBps) * totalAmount) / 10000 + feePayments += requestedFee + actions = append(actions, &transactionpb.Action{ + // Pay any fees when applicable + Type: &transactionpb.Action_FeePayment{ + FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_THIRD_PARTY, + Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), + Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), + Destination: testutil.NewRandomAccount(t).ToProto(), + Amount: requestedFee, + }, + }, + }) + } + } } actions = append( @@ -3123,7 +3193,7 @@ func (p *phoneTestEnv) privatelyWithdraw777KinToCodeUser(t *testing.T, receiver Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), Destination: destination.ToProto(), - Amount: kin.ToQuarks(777) - feePayment, + Amount: totalAmount - feePayments, ShouldClose: true, }, }}, @@ -3166,7 +3236,7 @@ func (p *phoneTestEnv) privatelyWithdraw777KinToCodeUser(t *testing.T, receiver Currency: "usd", ExchangeRate: 0.1, NativeAmount: 77.7, - Quarks: kin.ToQuarks(777), + Quarks: totalAmount, }, IsWithdrawal: true, }, @@ -3191,6 +3261,8 @@ func (p *phoneTestEnv) privatelyWithdraw777KinToCodeUser(t *testing.T, receiver } func (p *phoneTestEnv) privatelyWithdraw321KinToCodeUserRelationshipAccount(t *testing.T, receiver phoneTestEnv, relationship string) submitIntentCallMetadata { + totalAmount := kin.ToQuarks(321) + destination := getTimelockVault(t, receiver.getAuthorityForRelationshipAccount(t, relationship)) nextIndex := p.currentTempOutgoingIndex + 1 @@ -3229,19 +3301,41 @@ func (p *phoneTestEnv) privatelyWithdraw321KinToCodeUserRelationshipAccount(t *t }}, } - var feePayment uint64 + var feePayments uint64 if p.conf.simulatePaymentRequest { - feePayment = kin.ToQuarks(1) / 10 // 0.1 Kin + // Pay mandatory hard-coded Code $0.01 USD fee + codeFeePayment := kin.ToQuarks(1) / 10 // 0.1 Kin + feePayments += codeFeePayment actions = append(actions, &transactionpb.Action{ - // Pay any fees when applicable Type: &transactionpb.Action_FeePayment{ FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_CODE, Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), - Amount: feePayment, + Amount: codeFeePayment, }, }, }) + + // Pay additional fees as configured by the third party + if p.conf.simulateAdditionalFees { + for i := 0; i < 3; i++ { + requestedFee := (uint64(defaultTestThirdPartyFeeBps) * totalAmount) / 10000 + feePayments += requestedFee + actions = append(actions, &transactionpb.Action{ + // Pay any fees when applicable + Type: &transactionpb.Action_FeePayment{ + FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_THIRD_PARTY, + Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), + Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), + Destination: testutil.NewRandomAccount(t).ToProto(), + Amount: requestedFee, + }, + }, + }) + } + } } actions = append( @@ -3253,7 +3347,7 @@ func (p *phoneTestEnv) privatelyWithdraw321KinToCodeUserRelationshipAccount(t *t Authority: p.currentDerivedAccounts[commonpb.AccountType_TEMPORARY_OUTGOING].ToProto(), Source: p.getTimelockVault(t, commonpb.AccountType_TEMPORARY_OUTGOING, p.currentTempOutgoingIndex).ToProto(), Destination: destination.ToProto(), - Amount: kin.ToQuarks(321) - feePayment, + Amount: totalAmount - feePayments, ShouldClose: true, }, }}, @@ -3296,7 +3390,7 @@ func (p *phoneTestEnv) privatelyWithdraw321KinToCodeUserRelationshipAccount(t *t Currency: "usd", ExchangeRate: 0.1, NativeAmount: 32.1, - Quarks: kin.ToQuarks(321), + Quarks: totalAmount, }, IsWithdrawal: true, }, @@ -4678,11 +4772,41 @@ func (p *phoneTestEnv) submitIntent(t *testing.T, intentId string, metadata *tra CreatedAt: time.Now(), } + for _, action := range actions { + switch typed := action.Type.(type) { + case *transactionpb.Action_FeePayment: + if typed.FeePayment.Type == transactionpb.FeePaymentAction_THIRD_PARTY { + destination := base58.Encode(typed.FeePayment.Destination.Value) + if p.conf.simulateInvalidThirdPartyFeeDestination { + destination = testutil.NewRandomAccount(t).PublicKey().ToBase58() + } + + bps := defaultTestThirdPartyFeeBps + if p.conf.simulateInvalidThirdPartyFeeAmount { + bps += 1 + } + + paymentRequestRecord.Fees = append(paymentRequestRecord.Fees, &paymentrequest.Fee{ + DestinationTokenAccount: destination, + BasisPoints: uint16(bps), + }) + } + } + } + if p.conf.simulateTooManyThirdPartyFees { + paymentRequestRecord.Fees = paymentRequestRecord.Fees[:len(paymentRequestRecord.Fees)-1] + } else if p.conf.simulateThirdPartyFeeMissing { + paymentRequestRecord.Fees = append(paymentRequestRecord.Fees, &paymentrequest.Fee{ + DestinationTokenAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + BasisPoints: defaultTestThirdPartyFeeBps, + }) + } if p.conf.simulateLoginRequest { // Simulate a login request by downgrading the payment request to having no payment paymentRequestRecord.DestinationTokenAccount = nil paymentRequestRecord.ExchangeCurrency = nil paymentRequestRecord.NativeAmount = nil + paymentRequestRecord.Fees = nil } if p.conf.simulateUnverifiedPaymentRequest { paymentRequestRecord.IsVerified = false @@ -4702,7 +4826,7 @@ func (p *phoneTestEnv) submitIntent(t *testing.T, intentId string, metadata *tra require.NoError(t, p.directServerAccess.data.CreateRequest(p.directServerAccess.ctx, paymentRequestRecord)) - if p.conf.simulateNoFeesPaid { + if p.conf.simulateCodeFeeNotPaid { for i, action := range actions { switch typed := action.Type.(type) { case *transactionpb.Action_FeePayment: @@ -4715,11 +4839,35 @@ func (p *phoneTestEnv) submitIntent(t *testing.T, intentId string, metadata *tra } } - if p.conf.simulateMultipleFeePayments { + if p.conf.simulateCodeFeeAsThirdParyFee { + for _, action := range actions { + switch typed := action.Type.(type) { + case *transactionpb.Action_FeePayment: + if typed.FeePayment.Type == transactionpb.FeePaymentAction_CODE { + typed.FeePayment.Type = transactionpb.FeePaymentAction_THIRD_PARTY + typed.FeePayment.Destination = testutil.NewRandomAccount(t).ToProto() + } + } + } + } + if p.conf.simulateThirdParyFeeAsCodeFee { + for _, action := range actions { + switch typed := action.Type.(type) { + case *transactionpb.Action_FeePayment: + if typed.FeePayment.Type == transactionpb.FeePaymentAction_THIRD_PARTY { + typed.FeePayment.Type = transactionpb.FeePaymentAction_CODE + typed.FeePayment.Destination = nil + } + } + } + } + + if p.conf.simulateMultipleCodeFeePayments { actions = append(actions, &transactionpb.Action{ Id: uint32(len(actions)), Type: &transactionpb.Action_FeePayment{ FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_CODE, Authority: p.parentAccount.ToProto(), Source: getTimelockVault(t, p.parentAccount).ToProto(), Amount: 1, @@ -4728,25 +4876,37 @@ func (p *phoneTestEnv) submitIntent(t *testing.T, intentId string, metadata *tra }) } + if p.conf.simulateThirdPartyFeeDestinationMissing { + for _, action := range actions { + switch typed := action.Type.(type) { + case *transactionpb.Action_FeePayment: + if typed.FeePayment.Type == transactionpb.FeePaymentAction_THIRD_PARTY { + typed.FeePayment.Destination = nil + } + } + } + } + for i, action := range actions { deltaFee := kin.ToQuarks(1) / 100 // 0.01 Kin switch typed := action.Type.(type) { case *transactionpb.Action_FeePayment: - if p.conf.simulateSmallFee { + if p.conf.simulateSmallCodeFee { typed.FeePayment.Amount -= deltaFee actions[i+1].GetNoPrivacyWithdraw().Amount += deltaFee - } else if p.conf.simulateLargeFee { + } else if p.conf.simulateLargeCodeFee { typed.FeePayment.Amount += deltaFee actions[i+1].GetNoPrivacyWithdraw().Amount -= deltaFee } } } } - if p.conf.simulateFeePaid { + if p.conf.simulateCodeFeePaid { actions = append(actions, &transactionpb.Action{ Id: uint32(len(actions)), Type: &transactionpb.Action_FeePayment{ FeePayment: &transactionpb.FeePaymentAction{ + Type: transactionpb.FeePaymentAction_CODE, Authority: p.parentAccount.ToProto(), Source: getTimelockVault(t, p.parentAccount).ToProto(), Amount: 1, @@ -5504,8 +5664,16 @@ func (p *phoneTestEnv) getFeePaymentTransactionToSign( timelockAccounts, err := authority.GetTimelockAccounts(timelock_token_v1.DataVersion1, common.KinMintAccount) require.NoError(t, err) - destination, err := common.NewAccountFromProto(serverParameter.Destination) - require.NoError(t, err) + var destination *common.Account + if action.Type == transactionpb.FeePaymentAction_CODE { + require.NotNil(t, serverParameter.CodeDestination) + destination, err = common.NewAccountFromProto(serverParameter.CodeDestination) + require.NoError(t, err) + } else { + assert.Nil(t, serverParameter.CodeDestination) + destination, err = common.NewAccountFromProto(action.Destination) + require.NoError(t, err) + } txn, err := transaction_util.MakeTransferWithAuthorityTransaction( nonce,