Skip to content

Commit 254f94e

Browse files
authored
Support additional fees for payment requests (#53)
* Update request stores to include fee metadata * Add basis points to payment request fee model * Ensure consistent fee ordering from DB store * Pull in v1.10.0 protobuf APIs * Support additional fee takers in SubmitIntent * Add edge case tests for SubmitIntent when using additional fee takers * Ensure additional third party fees are subtracted from RECEIVED chat messages * Support additional fee takers in messaging service * Add happy path tests for additional fees in messaging service * Add edge case tests for additional fees in messaging service * Improve error messaging to distinguish between fee taker and payment destination * Update merchant chats to consider fees to Code accounts * Be more strict about wasteful fees * Max fee basis points is now configurable * Add tests for new fee edge cases in messaging service * Update scheduler test to ensure multiple fees are handled * Add local simulation test to ensure we catch invalid fee structures due to fluctuating exchange rates * Add client-side assertions on fee server parameter expectations * Add a test for getMicroPaymentReceiveExchangeDataByOwner * Fix micropayment check in getMicroPaymentReceiveExchangeDataByOwner
1 parent 638f62f commit 254f94e

26 files changed

+1348
-251
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
firebase.google.com/go/v4 v4.8.0
77
github.com/aws/aws-sdk-go-v2 v0.17.0
88
github.com/bits-and-blooms/bloom/v3 v3.1.0
9-
github.com/code-payments/code-protobuf-api v1.9.0
9+
github.com/code-payments/code-protobuf-api v1.10.0
1010
github.com/emirpasic/gods v1.12.0
1111
github.com/envoyproxy/protoc-gen-validate v0.1.0
1212
github.com/golang-jwt/jwt/v5 v5.0.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
108108
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
109109
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
110110
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
111-
github.com/code-payments/code-protobuf-api v1.9.0 h1:V8v/yAlZnVtcqh1LZ+zBIYC+Z04kOcCr8GRvwka69C0=
112-
github.com/code-payments/code-protobuf-api v1.9.0/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU=
111+
github.com/code-payments/code-protobuf-api v1.10.0 h1:0I/lCHUtuQdbSKj02miQZlwSEdEUqVtTR0LxPTxA15w=
112+
github.com/code-payments/code-protobuf-api v1.10.0/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU=
113113
github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw=
114114
github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
115115
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=

pkg/code/async/sequencer/scheduler_test.go

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,17 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) {
610610
}
611611
}
612612

613-
forceSimulateFeePayment := func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
613+
forceSimulateOneFeePayments := func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
614+
for _, fulfillmentRecord := range fulfillmentRecords {
615+
if fulfillmentRecord.FulfillmentType == fulfillment.NoPrivacyTransferWithAuthority && fulfillmentRecord.Source == transfer.Source {
616+
fulfillmentRecord.State = fulfillment.StateConfirmed
617+
env.data.UpdateFulfillment(env.ctx, fulfillmentRecord)
618+
break
619+
}
620+
}
621+
}
622+
623+
forceSimulateAllFeePayments := func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
614624
for _, fulfillmentRecord := range fulfillmentRecords {
615625
if fulfillmentRecord.FulfillmentType == fulfillment.NoPrivacyTransferWithAuthority && fulfillmentRecord.Source == transfer.Source {
616626
fulfillmentRecord.State = fulfillment.StateConfirmed
@@ -632,7 +642,7 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) {
632642
simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
633643
simulateOpeningSourceAccount(env, transfer, fulfillmentRecords)
634644
simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords)
635-
forceSimulateFeePayment(env, transfer, fulfillmentRecords)
645+
forceSimulateAllFeePayments(env, transfer, fulfillmentRecords)
636646
},
637647
expected: false,
638648
},
@@ -642,7 +652,7 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) {
642652
simulateOpeningSourceAccount(env, transfer, fulfillmentRecords)
643653
simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords)
644654
simulateOneTreasuryPayment(env, transfer, fulfillmentRecords)
645-
forceSimulateFeePayment(env, transfer, fulfillmentRecords)
655+
forceSimulateAllFeePayments(env, transfer, fulfillmentRecords)
646656
},
647657
expected: false,
648658
},
@@ -651,21 +661,21 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) {
651661
simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
652662
simulateOpeningSourceAccount(env, transfer, fulfillmentRecords)
653663
simulateAllTreasuryPayments(env, transfer, fulfillmentRecords)
654-
forceSimulateFeePayment(env, transfer, fulfillmentRecords)
664+
forceSimulateAllFeePayments(env, transfer, fulfillmentRecords)
655665
},
656666
expected: false,
657667
},
658668
{
659669
// Source user account not opened
660670
simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
661671
forceSimulateAllTreasuryPayments(env, transfer, fulfillmentRecords)
662-
forceSimulateFeePayment(env, transfer, fulfillmentRecords)
672+
forceSimulateAllFeePayments(env, transfer, fulfillmentRecords)
663673
simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords)
664674
},
665675
expected: false,
666676
},
667677
{
668-
// Fee payment not confirmed
678+
// All fee payments not confirmed
669679
simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
670680
simulateOpeningSourceAccount(env, transfer, fulfillmentRecords)
671681
simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords)
@@ -674,11 +684,21 @@ func TestContextualScheduler_NoPrivacyWithdraw(t *testing.T) {
674684
expected: false,
675685
},
676686
{
687+
// Subset of fee payments not confirmed
677688
simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
678689
simulateOpeningSourceAccount(env, transfer, fulfillmentRecords)
679690
simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords)
680691
simulateAllTreasuryPayments(env, transfer, fulfillmentRecords)
681-
forceSimulateFeePayment(env, transfer, fulfillmentRecords)
692+
forceSimulateOneFeePayments(env, transfer, fulfillmentRecords)
693+
},
694+
expected: false,
695+
},
696+
{
697+
simulation: func(env *schedulerTestEnv, transfer *fulfillment.Record, fulfillmentRecords []*fulfillment.Record) {
698+
simulateOpeningSourceAccount(env, transfer, fulfillmentRecords)
699+
simulateOpeningDestinationAccount(env, transfer, fulfillmentRecords)
700+
simulateAllTreasuryPayments(env, transfer, fulfillmentRecords)
701+
forceSimulateAllFeePayments(env, transfer, fulfillmentRecords)
682702
},
683703
expected: true,
684704
},
@@ -2306,14 +2326,21 @@ func (e *schedulerTestEnv) setupSchedulerTest(t *testing.T, intentRecords []*int
23062326
bucketActionRecords = append(bucketActionRecords, bucketReorganization)
23072327
}
23082328

2309-
var feePaymentAction *action.Record
2329+
var feePaymentActions []*action.Record
23102330
if intentRecord.SendPrivatePaymentMetadata.IsMicroPayment {
2311-
feePaymentAction = &action.Record{
2312-
ActionType: action.NoPrivacyTransfer,
2313-
2314-
Source: fmt.Sprintf("%s-outgoing-%d", intentRecord.InitiatorOwnerAccount, currentOutgoingByUser[intentRecord.InitiatorOwnerAccount]),
2315-
Destination: &codeFeeCollector,
2316-
Quantity: &feeAmount,
2331+
feePaymentActions = []*action.Record{
2332+
{
2333+
ActionType: action.NoPrivacyTransfer,
2334+
Source: fmt.Sprintf("%s-outgoing-%d", intentRecord.InitiatorOwnerAccount, currentOutgoingByUser[intentRecord.InitiatorOwnerAccount]),
2335+
Destination: &codeFeeCollector,
2336+
Quantity: &feeAmount,
2337+
},
2338+
{
2339+
ActionType: action.NoPrivacyTransfer,
2340+
Source: fmt.Sprintf("%s-outgoing-%d", intentRecord.InitiatorOwnerAccount, currentOutgoingByUser[intentRecord.InitiatorOwnerAccount]),
2341+
Destination: pointer.String(testutil.NewRandomAccount(t).PublicKey().ToBase58()),
2342+
Quantity: &feeAmount,
2343+
},
23172344
}
23182345
}
23192346

@@ -2343,8 +2370,8 @@ func (e *schedulerTestEnv) setupSchedulerTest(t *testing.T, intentRecords []*int
23432370
newActionRecords,
23442371
bucketActionRecords...,
23452372
)
2346-
if feePaymentAction != nil {
2347-
newActionRecords = append(newActionRecords, feePaymentAction)
2373+
if feePaymentActions != nil {
2374+
newActionRecords = append(newActionRecords, feePaymentActions...)
23482375
}
23492376
newActionRecords = append(
23502377
newActionRecords,

pkg/code/chat/message_merchant.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ func SendMerchantExchangeMessage(ctx context.Context, data code_data.Provider, i
4949
if !ok {
5050
return nil, nil
5151
}
52-
exchangeDataMinusFees := getExchangeDataMinusFees(exchangeData, intentRecord, actionRecords)
5352

5453
type verbAndExchangeData struct {
5554
verb chatpb.ExchangeDataContent_Verb
@@ -74,10 +73,14 @@ func SendMerchantExchangeMessage(ctx context.Context, data code_data.Provider, i
7473
verb: chatpb.ExchangeDataContent_SPENT,
7574
exchangeData: exchangeData,
7675
}
77-
if len(intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount) > 0 {
78-
verbAndExchangeDataByMessageReceiver[intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount] = &verbAndExchangeData{
76+
receiveByOwner, err := getMicroPaymentReceiveExchangeDataByOwner(ctx, data, exchangeData, intentRecord, actionRecords)
77+
if err != nil {
78+
return nil, err
79+
}
80+
for owner, exchangeData := range receiveByOwner {
81+
verbAndExchangeDataByMessageReceiver[owner] = &verbAndExchangeData{
7982
verb: chatpb.ExchangeDataContent_RECEIVED,
80-
exchangeData: exchangeDataMinusFees,
83+
exchangeData: exchangeData,
8184
}
8285
}
8386
} else if intentRecord.SendPrivatePaymentMetadata.IsWithdrawal {

pkg/code/chat/util.go

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
package chat
22

33
import (
4+
"context"
45
"time"
56

67
"github.com/mr-tron/base58/base58"
78
"github.com/pkg/errors"
8-
"google.golang.org/protobuf/proto"
99
"google.golang.org/protobuf/types/known/timestamppb"
1010

1111
chatpb "github.com/code-payments/code-protobuf-api/generated/go/chat/v1"
1212
transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
1313

14+
code_data "github.com/code-payments/code-server/pkg/code/data"
15+
"github.com/code-payments/code-server/pkg/code/data/account"
1416
"github.com/code-payments/code-server/pkg/code/data/action"
1517
"github.com/code-payments/code-server/pkg/code/data/intent"
1618
currency_lib "github.com/code-payments/code-server/pkg/currency"
@@ -96,17 +98,18 @@ func getExchangeDataFromIntent(intentRecord *intent.Record) (*transactionpb.Exch
9698
return nil, false
9799
}
98100

99-
func getExchangeDataMinusFees(exchangeData *transactionpb.ExchangeData, intentRecord *intent.Record, actionRecords []*action.Record) *transactionpb.ExchangeData {
100-
cloned := proto.Clone(exchangeData).(*transactionpb.ExchangeData)
101-
102-
if intentRecord.IntentType != intent.SendPrivatePayment {
103-
return cloned
104-
}
105-
106-
if !intentRecord.SendPrivatePaymentMetadata.IsMicroPayment {
107-
return cloned
101+
func getMicroPaymentReceiveExchangeDataByOwner(
102+
ctx context.Context,
103+
data code_data.Provider,
104+
exchangeData *transactionpb.ExchangeData,
105+
intentRecord *intent.Record,
106+
actionRecords []*action.Record,
107+
) (map[string]*transactionpb.ExchangeData, error) {
108+
if intentRecord.IntentType != intent.SendPrivatePayment || !intentRecord.SendPrivatePaymentMetadata.IsMicroPayment {
109+
return nil, errors.New("intent is not a micro payment")
108110
}
109111

112+
// Find the action record where the final payment is made
110113
var thirdPartyPaymentAction *action.Record
111114
for _, actionRecord := range actionRecords {
112115
if actionRecord.ActionType != action.NoPrivacyWithdraw {
@@ -119,12 +122,70 @@ func getExchangeDataMinusFees(exchangeData *transactionpb.ExchangeData, intentRe
119122
}
120123
}
121124

122-
// Should never happen
125+
// Should never happen if the intent is a micropayment
123126
if thirdPartyPaymentAction == nil {
124-
return cloned
127+
return nil, errors.New("payment action is missing")
128+
}
129+
130+
quarksByTokenAccount := make(map[string]uint64)
131+
quarksByTokenAccount[*thirdPartyPaymentAction.Destination] = *thirdPartyPaymentAction.Quantity
132+
133+
// Find and consolidate all fee payments into a quark amount by token account
134+
var foundCodeFee bool
135+
for _, actionRecord := range actionRecords {
136+
if actionRecord.ActionType != action.NoPrivacyTransfer {
137+
continue
138+
}
139+
140+
if actionRecord.Source != thirdPartyPaymentAction.Source {
141+
continue
142+
}
143+
144+
// The first fee is always Code, and can be skipped
145+
if !foundCodeFee {
146+
foundCodeFee = true
147+
continue
148+
}
149+
150+
quarksByTokenAccount[*actionRecord.Destination] += *actionRecord.Quantity
151+
}
152+
153+
// Consolidate quark amount by owner account
154+
quarksByOwnerAccount := make(map[string]uint64)
155+
for tokenAccount, quarks := range quarksByTokenAccount {
156+
if tokenAccount == intentRecord.SendPrivatePaymentMetadata.DestinationTokenAccount {
157+
if len(intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount) > 0 {
158+
quarksByOwnerAccount[intentRecord.SendPrivatePaymentMetadata.DestinationOwnerAccount] += quarks
159+
}
160+
continue
161+
}
162+
163+
accountInfoRecord, err := data.GetAccountInfoByTokenAddress(ctx, tokenAccount)
164+
if err == nil {
165+
quarksByOwnerAccount[accountInfoRecord.OwnerAccount] += quarks
166+
} else if err != account.ErrAccountInfoNotFound {
167+
return nil, err
168+
}
169+
}
170+
171+
// Map result to an exchange data
172+
res := make(map[string]*transactionpb.ExchangeData)
173+
for ownerAccount, quarks := range quarksByOwnerAccount {
174+
res[ownerAccount] = getExchangeDataInOtherQuarkAmount(exchangeData, quarks)
125175
}
176+
return res, nil
177+
}
126178

127-
cloned.Quarks = *thirdPartyPaymentAction.Quantity
128-
cloned.NativeAmount = cloned.ExchangeRate * float64(cloned.Quarks) / float64(kin.QuarksPerKin)
129-
return cloned
179+
func getExchangeDataInOtherQuarkAmount(original *transactionpb.ExchangeData, quarks uint64) *transactionpb.ExchangeData {
180+
nativeAmount := original.NativeAmount
181+
if original.Quarks != quarks {
182+
nativeAmount = original.ExchangeRate * float64(quarks) / float64(kin.QuarksPerKin)
183+
}
184+
185+
return &transactionpb.ExchangeData{
186+
Currency: original.Currency,
187+
ExchangeRate: original.ExchangeRate,
188+
NativeAmount: nativeAmount,
189+
Quarks: quarks,
190+
}
130191
}

0 commit comments

Comments
 (0)