diff --git a/go.mod b/go.mod index e593114..367d66d 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/dynamodb v1.59.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 github.com/code-payments/code-vm-indexer v1.2.0 - github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261011002549-00ea80d00cf1 + github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.3.3 github.com/golang/protobuf v1.5.4 diff --git a/go.sum b/go.sum index decaa8d..3df4114 100644 --- a/go.sum +++ b/go.sum @@ -80,8 +80,8 @@ 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-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261011002549-00ea80d00cf1 h1:G/9uGik00/xSGQ2Xsbs33WWq7k+MjFaBgvN739P8QmU= -github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261011002549-00ea80d00cf1/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832 h1:HFY6mGBaZm8C3tJDK2PvaVQjfUS91NFDDWQSzla80hA= +github.com/code-payments/ocp-protobuf-api v1.17.1-0.20261016120110-6f70c0d8e832/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= 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/ocp/aml/guard.go b/ocp/aml/guard.go index 3a4eb51..9157b78 100644 --- a/ocp/aml/guard.go +++ b/ocp/aml/guard.go @@ -60,6 +60,13 @@ func (g *Guard) AllowMoneyMovement(ctx context.Context, intentRecord *intent.Rec usdMarketValue = intentRecord.SendPublicPaymentMetadata.UsdMarketValue consumptionCalculator = g.data.GetTransactedAmountForAntiMoneyLaundering action = actionSendPayment + case intent.SendPublicMultiPayment: + // Limits apply to the intent's total value across all payments + currency = intentRecord.SendPublicMultiPaymentMetadata.ExchangeCurrency + nativeAmount = intentRecord.SendPublicMultiPaymentMetadata.NativeAmount + usdMarketValue = intentRecord.SendPublicMultiPaymentMetadata.UsdMarketValue + consumptionCalculator = g.data.GetTransactedAmountForAntiMoneyLaundering + action = actionSendPayment case intent.ReceivePaymentsPublicly: // Public receives are always allowed return true, nil diff --git a/ocp/balance/delta.go b/ocp/balance/delta.go index e3fda2d..6f31383 100644 --- a/ocp/balance/delta.go +++ b/ocp/balance/delta.go @@ -29,6 +29,8 @@ var ErrUnsupportedBalanceChange = errors.New("unsupported balance change") // the gross amount leaving the source. A fee action carries the configured // fee's USD value and the principal carries the remainder, so an intent may // have at most one quantified principal action and at most one fee action. +// The exception is a multi payment, where each payment's action carries that +// payment's USD value and fees aren't supported. // withdrawalFeeQuarks is the configured create-on-send withdrawal fee, in // core mint quarks. func DeltasForSubmittedIntent(intentRecord *intent.Record, actionRecords []*action.Record, withdrawalFeeQuarks uint64) ([]*balance.Delta, error) { @@ -37,7 +39,7 @@ func DeltasForSubmittedIntent(intentRecord *intent.Record, actionRecords []*acti } switch intentRecord.IntentType { - case intent.OpenAccounts, intent.SendPublicPayment, intent.ReceivePaymentsPublicly: + case intent.OpenAccounts, intent.SendPublicPayment, intent.ReceivePaymentsPublicly, intent.SendPublicMultiPayment: default: return nil, fmt.Errorf("%w: %d intent is not submitted", ErrUnsupportedBalanceChange, intentRecord.IntentType) } @@ -215,6 +217,14 @@ func UsdCostBasisForIntent(intentRecord *intent.Record) (int64, error) { return balance.UsdCostBasisFromFloat(intentRecord.SendPublicPaymentMetadata.UsdMarketValue), nil case intent.ReceivePaymentsPublicly: return balance.UsdCostBasisFromFloat(intentRecord.ReceivePaymentsPubliclyMetadata.UsdMarketValue), nil + case intent.SendPublicMultiPayment: + // Summed per payment, so the gross matches what the payments' actions + // carry exactly + var gross int64 + for _, payment := range intentRecord.SendPublicMultiPaymentMetadata.Payments { + gross += balance.UsdCostBasisFromFloat(payment.UsdMarketValue) + } + return gross, nil default: return 0, fmt.Errorf("%w: %d intent", ErrUnsupportedBalanceChange, intentRecord.IntentType) } @@ -238,6 +248,10 @@ func requireSupported(intentRecord *intent.Record, actionRecords []*action.Recor // usdCostBasisByAction splits an intent's USD cost basis across its // quantified actions, keyed by action ID. func usdCostBasisByAction(intentRecord *intent.Record, actionRecords []*action.Record, withdrawalFeeQuarks uint64) (map[uint32]int64, error) { + if intentRecord.IntentType == intent.SendPublicMultiPayment { + return usdCostBasisByMultiPaymentAction(intentRecord, actionRecords) + } + gross, err := UsdCostBasisForIntent(intentRecord) if err != nil { return nil, err @@ -284,6 +298,56 @@ func usdCostBasisByAction(intentRecord *intent.Record, actionRecords []*action.R return res, nil } +// usdCostBasisByMultiPaymentAction attributes each payment's USD cost basis +// to the one action that pays it, keyed by action ID. Every action must be a +// quantified transfer from the intent's source that pays exactly one payment. +func usdCostBasisByMultiPaymentAction(intentRecord *intent.Record, actionRecords []*action.Record) (map[uint32]int64, error) { + metadata := intentRecord.SendPublicMultiPaymentMetadata + + if len(actionRecords) != len(metadata.Payments) { + return nil, fmt.Errorf("%w: multi payment must have one action per payment", ErrUnsupportedBalanceChange) + } + + unpaid := make(map[string]*intent.Payment) + for _, payment := range metadata.Payments { + unpaid[payment.DestinationTokenAccount] = payment + } + + res := make(map[uint32]int64) + for _, actionRecord := range actionRecords { + if actionRecord.ActionType != action.NoPrivacyTransfer { + return nil, fmt.Errorf("%w: multi payment action is not a transfer", ErrUnsupportedBalanceChange) + } + if actionRecord.FeeType != nil { + return nil, fmt.Errorf("%w: multi payment pays a fee", ErrUnsupportedBalanceChange) + } + if actionRecord.Quantity == nil { + return nil, fmt.Errorf("%w: multi payment action has no quantity", ErrUnsupportedBalanceChange) + } + if actionRecord.Destination == nil { + return nil, errors.New("destination is required for a transfer") + } + if actionRecord.Source != metadata.Source { + return nil, errors.New("multi payment action does not match intent source") + } + + payment, ok := unpaid[*actionRecord.Destination] + if !ok { + return nil, fmt.Errorf("%w: action does not pay exactly one payment", ErrUnsupportedBalanceChange) + } + if *actionRecord.Quantity != payment.Quantity { + return nil, errors.New("multi payment action quantity does not match payment") + } + delete(unpaid, *actionRecord.Destination) + + res[actionRecord.ActionId] = balance.UsdCostBasisFromFloat(payment.UsdMarketValue) + } + if len(unpaid) > 0 { + return nil, fmt.Errorf("%w: payment has no action", ErrUnsupportedBalanceChange) + } + return res, nil +} + func deltasForAction(actionRecord *action.Record, usdCostBasis int64) ([]*balance.Delta, error) { switch actionRecord.ActionType { case action.OpenAccount: diff --git a/ocp/balance/delta_test.go b/ocp/balance/delta_test.go index 7e7bcd1..1abcf1f 100644 --- a/ocp/balance/delta_test.go +++ b/ocp/balance/delta_test.go @@ -208,6 +208,134 @@ func TestDeltasForSubmittedIntent_Unsupported(t *testing.T) { } } +func TestDeltasForSubmittedIntent_SendPublicMultiPayment(t *testing.T) { + intentRecord := newDeltaTestSendPublicMultiPaymentIntent(t) + actionRecords := []*action.Record{ + newDeltaTestTransferAction(intentRecord, 0, "source", "destination_2", 50_000), + newDeltaTestTransferAction(intentRecord, 1, "source", "destination_1", 100_000), + } + + // Each payment's cost basis is converted on its own, so the source is + // debited exactly what the destinations are credited + deltas, err := DeltasForSubmittedIntent(intentRecord, actionRecords, testWithdrawalFeeQuarks) + require.NoError(t, err) + assert.Equal(t, []*balance.Delta{ + {TokenAccount: "destination_1", Kind: balance.DeltaCredit, Quarks: 100_000, UsdCostBasis: 1_000_000}, + {TokenAccount: "destination_2", Kind: balance.DeltaCredit, Quarks: 50_000, UsdCostBasis: 333_333}, + {TokenAccount: "source", Kind: balance.DeltaDebit, Quarks: 50_000, UsdCostBasis: 333_333}, + {TokenAccount: "source", Kind: balance.DeltaDebit, Quarks: 100_000, UsdCostBasis: 1_000_000}, + }, deltas) + + usdCostBasis, err := UsdCostBasisForIntent(intentRecord) + require.NoError(t, err) + assert.EqualValues(t, 1_333_333, usdCostBasis) +} + +func TestDeltasForSubmittedIntent_SendPublicMultiPayment_Unsupported(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*intent.Record, []*action.Record) []*action.Record + }{ + { + name: "missing payment", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + return a[:1] + }, + }, + { + name: "payment paid twice", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1].Destination = a[0].Destination + a[1].Quantity = a[0].Quantity + return a + }, + }, + { + name: "unknown destination", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1].Destination = pointer.String("other") + return a + }, + }, + { + name: "quantity mismatch", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1].Quantity = pointer.Uint64(*a[1].Quantity + 1) + return a + }, + }, + { + name: "source mismatch", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1].Source = "other" + return a + }, + }, + { + name: "withdraw", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1].ActionType = action.NoPrivacyWithdraw + return a + }, + }, + { + name: "fee", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + feeType := transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL + fee := newDeltaTestTransferAction(i, 2, "source", "fee_collector", testWithdrawalFeeQuarks) + fee.FeeType = &feeType + return append(a, fee) + }, + }, + { + name: "fee paying a payment", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + feeType := transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL + a[1].FeeType = &feeType + return a + }, + }, + { + name: "close action", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + return append(a, &action.Record{Intent: i.IntentId, ActionId: 2, ActionType: action.CloseEmptyAccount, Source: "source", State: action.StatePending}) + }, + }, + { + name: "open action", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + return append(a, &action.Record{Intent: i.IntentId, ActionId: 2, ActionType: action.OpenAccount, Source: "destination_1", State: action.StatePending}) + }, + }, + { + name: "close action replacing a payment", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1] = &action.Record{Intent: i.IntentId, ActionId: 1, ActionType: action.CloseEmptyAccount, Source: "source", State: action.StatePending} + return a + }, + }, + { + name: "transfer without quantity", + mutate: func(i *intent.Record, a []*action.Record) []*action.Record { + a[1].Quantity = nil + return a + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + intentRecord := newDeltaTestSendPublicMultiPaymentIntent(t) + actionRecords := []*action.Record{ + newDeltaTestTransferAction(intentRecord, 0, "source", "destination_1", 100_000), + newDeltaTestTransferAction(intentRecord, 1, "source", "destination_2", 50_000), + } + actionRecords = tc.mutate(intentRecord, actionRecords) + + _, err := DeltasForSubmittedIntent(intentRecord, actionRecords, testWithdrawalFeeQuarks) + assert.Error(t, err) + }) + } +} + func TestDeltasForExternalDeposit(t *testing.T) { intentRecord := &intent.Record{ IntentId: testutil.NewRandomAccount(t).PublicKey().ToBase58(), @@ -417,6 +545,28 @@ func newDeltaTestSendPublicPaymentIntent(t *testing.T, quantity uint64, usd floa } } +func newDeltaTestSendPublicMultiPaymentIntent(t *testing.T) *intent.Record { + return &intent.Record{ + IntentId: testutil.NewRandomAccount(t).PublicKey().ToBase58(), + IntentType: intent.SendPublicMultiPayment, + MintAccount: "mint", + InitiatorOwnerAccount: "owner", + SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{ + Source: "source", + Payments: []*intent.Payment{ + {DestinationOwnerAccount: "destination_owner_1", DestinationTokenAccount: "destination_1", Quantity: 100_000, NativeAmount: 1.0, UsdMarketValue: 1.0}, + {DestinationOwnerAccount: "destination_owner_2", DestinationTokenAccount: "destination_2", Quantity: 50_000, NativeAmount: 0.333333333, UsdMarketValue: 0.333333333}, + }, + Quantity: 150_000, + ExchangeCurrency: currency_lib.USD, + ExchangeRate: 1.0, + NativeAmount: 1.333333333, + UsdMarketValue: 1.333333333, + }, + State: intent.StatePending, + } +} + func newDeltaTestTransferAction(intentRecord *intent.Record, actionId uint32, source, destination string, quantity uint64) *action.Record { return &action.Record{ Intent: intentRecord.IntentId, diff --git a/ocp/data/intent/intent.go b/ocp/data/intent/intent.go index 104aafd..b4f2cc0 100644 --- a/ocp/data/intent/intent.go +++ b/ocp/data/intent/intent.go @@ -2,6 +2,7 @@ package intent import ( "errors" + "math" "time" "github.com/code-payments/ocp-server/currency" @@ -19,6 +20,10 @@ const ( type Type uint8 +// multiPaymentTotalTolerance bounds float error between a multi payment's +// totals and the sum of its payments. Values are persisted with 9 decimals. +const multiPaymentTotalTolerance = 1e-6 + const ( UnknownType Type = iota OpenAccounts @@ -26,6 +31,7 @@ const ( SendPublicPayment ReceivePaymentsPublicly PublicDistribution + SendPublicMultiPayment ) type Record struct { @@ -43,6 +49,7 @@ type Record struct { SendPublicPaymentMetadata *SendPublicPaymentMetadata ReceivePaymentsPubliclyMetadata *ReceivePaymentsPubliclyMetadata PublicDistributionMetadata *PublicDistributionMetadata + SendPublicMultiPaymentMetadata *SendPublicMultiPaymentMetadata AppMetadata []byte @@ -113,6 +120,26 @@ type Distribution struct { Quantity uint64 } +type SendPublicMultiPaymentMetadata struct { + Source string + Payments []*Payment + Quantity uint64 + + ExchangeCurrency currency.Code + ExchangeRate float64 + NativeAmount float64 + UsdMarketValue float64 +} + +type Payment struct { + DestinationOwnerAccount string + DestinationTokenAccount string + Quantity uint64 + + NativeAmount float64 + UsdMarketValue float64 +} + func (r *Record) IsCompleted() bool { return r.State == StateConfirmed } @@ -148,6 +175,12 @@ func (r *Record) Clone() Record { publicDistributionMetadata = &cloned } + var sendPublicMultiPaymentMetadata *SendPublicMultiPaymentMetadata + if r.SendPublicMultiPaymentMetadata != nil { + cloned := r.SendPublicMultiPaymentMetadata.Clone() + sendPublicMultiPaymentMetadata = &cloned + } + var appMetadata []byte if r.AppMetadata != nil { appMetadata = make([]byte, len(r.AppMetadata)) @@ -169,6 +202,7 @@ func (r *Record) Clone() Record { SendPublicPaymentMetadata: sendPublicPaymentMetadata, ReceivePaymentsPubliclyMetadata: receivePaymentsPubliclyMetadata, PublicDistributionMetadata: publicDistributionMetadata, + SendPublicMultiPaymentMetadata: sendPublicMultiPaymentMetadata, AppMetadata: appMetadata, @@ -195,6 +229,7 @@ func (r *Record) CopyTo(dst *Record) { dst.SendPublicPaymentMetadata = r.SendPublicPaymentMetadata dst.ReceivePaymentsPubliclyMetadata = r.ReceivePaymentsPubliclyMetadata dst.PublicDistributionMetadata = r.PublicDistributionMetadata + dst.SendPublicMultiPaymentMetadata = r.SendPublicMultiPaymentMetadata dst.AppMetadata = r.AppMetadata @@ -277,6 +312,17 @@ func (r *Record) Validate() error { } } + if r.IntentType == SendPublicMultiPayment { + if r.SendPublicMultiPaymentMetadata == nil { + return errors.New("send public multi payment metadata must be present") + } + + err := r.SendPublicMultiPaymentMetadata.Validate() + if err != nil { + return err + } + } + return nil } @@ -546,6 +592,143 @@ func (m *Distribution) Validate() error { return nil } +func (m *SendPublicMultiPaymentMetadata) Clone() SendPublicMultiPaymentMetadata { + clonedPayments := make([]*Payment, len(m.Payments)) + for i, payment := range m.Payments { + cloned := payment.Clone() + clonedPayments[i] = &cloned + } + + return SendPublicMultiPaymentMetadata{ + Source: m.Source, + Payments: clonedPayments, + Quantity: m.Quantity, + + ExchangeCurrency: m.ExchangeCurrency, + ExchangeRate: m.ExchangeRate, + NativeAmount: m.NativeAmount, + UsdMarketValue: m.UsdMarketValue, + } +} + +func (m *SendPublicMultiPaymentMetadata) CopyTo(dst *SendPublicMultiPaymentMetadata) { + clonedPayments := make([]*Payment, len(m.Payments)) + for i, payment := range m.Payments { + cloned := payment.Clone() + clonedPayments[i] = &cloned + } + + dst.Source = m.Source + dst.Payments = clonedPayments + dst.Quantity = m.Quantity + + dst.ExchangeCurrency = m.ExchangeCurrency + dst.ExchangeRate = m.ExchangeRate + dst.NativeAmount = m.NativeAmount + dst.UsdMarketValue = m.UsdMarketValue +} + +func (m *SendPublicMultiPaymentMetadata) Validate() error { + if len(m.Source) == 0 { + return errors.New("source is required") + } + + if len(m.Payments) == 0 { + return errors.New("payments are required") + } + var totalQuantity uint64 + var totalNativeAmount, totalUsdMarketValue float64 + destinations := make(map[string]struct{}) + for _, payment := range m.Payments { + if err := payment.Validate(); err != nil { + return err + } + + if _, ok := destinations[payment.DestinationTokenAccount]; ok { + return errors.New("payment destinations must be unique") + } + destinations[payment.DestinationTokenAccount] = struct{}{} + + totalQuantity += payment.Quantity + totalNativeAmount += payment.NativeAmount + totalUsdMarketValue += payment.UsdMarketValue + } + + if m.Quantity != totalQuantity { + return errors.New("quantity must be the sum of payment quantities") + } + + if math.Abs(m.NativeAmount-totalNativeAmount) > multiPaymentTotalTolerance { + return errors.New("native amount must be the sum of payment native amounts") + } + + if math.Abs(m.UsdMarketValue-totalUsdMarketValue) > multiPaymentTotalTolerance { + return errors.New("usd market value must be the sum of payment usd market values") + } + + if len(m.ExchangeCurrency) == 0 { + return errors.New("exchange currency is required") + } + + if m.ExchangeRate == 0 { + return errors.New("exchange rate cannot be zero") + } + + if m.NativeAmount == 0 { + return errors.New("native amount cannot be zero") + } + + if m.UsdMarketValue == 0 { + return errors.New("usd market value cannot be zero") + } + + return nil +} + +func (m *Payment) Clone() Payment { + return Payment{ + DestinationOwnerAccount: m.DestinationOwnerAccount, + DestinationTokenAccount: m.DestinationTokenAccount, + Quantity: m.Quantity, + + NativeAmount: m.NativeAmount, + UsdMarketValue: m.UsdMarketValue, + } +} + +func (m *Payment) CopyTo(dst *Payment) { + dst.DestinationOwnerAccount = m.DestinationOwnerAccount + dst.DestinationTokenAccount = m.DestinationTokenAccount + dst.Quantity = m.Quantity + + dst.NativeAmount = m.NativeAmount + dst.UsdMarketValue = m.UsdMarketValue +} + +func (m *Payment) Validate() error { + if len(m.DestinationOwnerAccount) == 0 { + return errors.New("destination owner account is required") + } + + if len(m.DestinationTokenAccount) == 0 { + return errors.New("destination token account is required") + } + + if m.Quantity == 0 { + return errors.New("quantity cannot be zero") + } + + if m.NativeAmount == 0 { + return errors.New("native amount cannot be zero") + } + + if m.UsdMarketValue == 0 { + return errors.New("usd market value cannot be zero") + } + + return nil +} + func (s State) IsTerminal() bool { switch s { case StateConfirmed: @@ -589,6 +772,8 @@ func (t Type) String() string { return "receive_payments_publicly" case PublicDistribution: return "public_distribution" + case SendPublicMultiPayment: + return "send_public_multi_payment" } return "unknown" diff --git a/ocp/data/intent/memory/store.go b/ocp/data/intent/memory/store.go index 469a267..39f3a20 100644 --- a/ocp/data/intent/memory/store.go +++ b/ocp/data/intent/memory/store.go @@ -82,6 +82,15 @@ func (s *store) findByOwner(owner string) []*intent.Record { } } } + + if item.SendPublicMultiPaymentMetadata != nil { + for _, payment := range item.SendPublicMultiPaymentMetadata.Payments { + if payment.DestinationOwnerAccount == owner { + res = append(res, item) + break + } + } + } } return res @@ -182,12 +191,15 @@ func (s *store) filterByState(items []*intent.Record, include bool, states ...in return res } -func (s *store) filterByType(items []*intent.Record, intentType intent.Type) []*intent.Record { +func (s *store) filterByType(items []*intent.Record, intentTypes ...intent.Type) []*intent.Record { var res []*intent.Record for _, item := range items { - if item.IntentType == intentType { - res = append(res, item) + for _, intentType := range intentTypes { + if item.IntentType == intentType { + res = append(res, item) + break + } } } @@ -219,6 +231,10 @@ func (s *store) filterByWithdrawalFlag(items []*intent.Record, want bool) []*int if item.SendPublicPaymentMetadata.IsWithdrawal == want { res = append(res, item) } + case intent.SendPublicMultiPayment: + if !want { + res = append(res, item) + } } } return res @@ -233,6 +249,9 @@ func sumQuarkAmount(items []*intent.Record) uint64 { if item.ReceivePaymentsPubliclyMetadata != nil { value += item.ReceivePaymentsPubliclyMetadata.Quantity } + if item.SendPublicMultiPaymentMetadata != nil { + value += item.SendPublicMultiPaymentMetadata.Quantity + } } return value } @@ -246,6 +265,9 @@ func sumUsdMarketValue(items []*intent.Record) float64 { if item.ReceivePaymentsPubliclyMetadata != nil { value += item.ReceivePaymentsPubliclyMetadata.UsdMarketValue } + if item.SendPublicMultiPaymentMetadata != nil { + value += item.SendPublicMultiPaymentMetadata.UsdMarketValue + } } return value } @@ -359,7 +381,7 @@ func (s *store) GetTransactedAmountForAntiMoneyLaundering(ctx context.Context, o defer s.mu.Unlock() items := s.findByInitiatorOwnerSinceTimestamp(owner, since) - items = s.filterByType(items, intent.SendPublicPayment) + items = s.filterByType(items, intent.SendPublicPayment, intent.SendPublicMultiPayment) items = s.filterByState(items, false, intent.StateRevoked) items = s.filterByWithdrawalFlag(items, false) return sumQuarkAmount(items), sumUsdMarketValue(items), nil @@ -415,6 +437,17 @@ func (s *store) getUsdCostBasis(owner string, mint string) float64 { if item.SendPublicPaymentMetadata.DestinationOwnerAccount == owner { costBasis += item.SendPublicPaymentMetadata.UsdMarketValue } + case intent.SendPublicMultiPayment: + // Owner as initiator is source, subtract total USD + if item.InitiatorOwnerAccount == owner { + costBasis -= item.SendPublicMultiPaymentMetadata.UsdMarketValue + } + // Owner as a payment's destination_owner is destination, add that payment's USD + for _, payment := range item.SendPublicMultiPaymentMetadata.Payments { + if payment.DestinationOwnerAccount == owner { + costBasis += payment.UsdMarketValue + } + } } } diff --git a/ocp/data/intent/postgres/model.go b/ocp/data/intent/postgres/model.go index dcfecbd..b3744c2 100644 --- a/ocp/data/intent/postgres/model.go +++ b/ocp/data/intent/postgres/model.go @@ -131,6 +131,19 @@ func toIntentModel(obj *intent.Record) (*intentModel, error) { for _, distribution := range obj.PublicDistributionMetadata.Distributions { m.Accounts = append(m.Accounts, fromDistribution(m.Id.Int64, distribution)) } + case intent.SendPublicMultiPayment: + m.Source = obj.SendPublicMultiPaymentMetadata.Source + m.Quantity = obj.SendPublicMultiPaymentMetadata.Quantity + + m.ExchangeCurrency = strings.ToLower(string(obj.SendPublicMultiPaymentMetadata.ExchangeCurrency)) + m.ExchangeRate = obj.SendPublicMultiPaymentMetadata.ExchangeRate + m.NativeAmount = obj.SendPublicMultiPaymentMetadata.NativeAmount + m.UsdMarketValue = obj.SendPublicMultiPaymentMetadata.UsdMarketValue + m.OriginalUsdMarketValue = obj.SendPublicMultiPaymentMetadata.UsdMarketValue + + for _, payment := range obj.SendPublicMultiPaymentMetadata.Payments { + m.Accounts = append(m.Accounts, fromPayment(m.Id.Int64, payment)) + } default: return nil, errors.New("unsupported intent type") } @@ -218,6 +231,20 @@ func fromIntentModel(obj *intentModel) *intent.Record { for _, account := range obj.Accounts { record.PublicDistributionMetadata.Distributions = append(record.PublicDistributionMetadata.Distributions, toDistribution(account)) } + case intent.SendPublicMultiPayment: + record.SendPublicMultiPaymentMetadata = &intent.SendPublicMultiPaymentMetadata{ + Source: obj.Source, + Quantity: obj.Quantity, + + ExchangeCurrency: currency.Code(obj.ExchangeCurrency), + ExchangeRate: obj.ExchangeRate, + NativeAmount: obj.NativeAmount, + UsdMarketValue: obj.UsdMarketValue, + } + + for _, account := range obj.Accounts { + record.SendPublicMultiPaymentMetadata.Payments = append(record.SendPublicMultiPaymentMetadata.Payments, toPayment(account)) + } } return record @@ -296,6 +323,9 @@ type intentAccountModel struct { DestinationOwner sql.NullString `db:"destination_owner"` Quantity uint64 `db:"quantity"` + + NativeAmount sql.NullFloat64 `db:"native_amount"` + UsdMarketValue sql.NullFloat64 `db:"usd_market_value"` } func fromDistribution(pagingID int64, obj *intent.Distribution) *intentAccountModel { @@ -323,19 +353,56 @@ func toDistribution(obj *intentAccountModel) *intent.Distribution { } } +func fromPayment(pagingID int64, obj *intent.Payment) *intentAccountModel { + return &intentAccountModel{ + PagingId: pagingID, + + Destination: sql.NullString{ + Valid: true, + String: obj.DestinationTokenAccount, + }, + DestinationOwner: sql.NullString{ + Valid: true, + String: obj.DestinationOwnerAccount, + }, + + Quantity: obj.Quantity, + + NativeAmount: sql.NullFloat64{ + Valid: true, + Float64: obj.NativeAmount, + }, + UsdMarketValue: sql.NullFloat64{ + Valid: true, + Float64: obj.UsdMarketValue, + }, + } +} + +func toPayment(obj *intentAccountModel) *intent.Payment { + return &intent.Payment{ + DestinationOwnerAccount: obj.DestinationOwner.String, + DestinationTokenAccount: obj.Destination.String, + Quantity: obj.Quantity, + + NativeAmount: obj.NativeAmount.Float64, + UsdMarketValue: obj.UsdMarketValue.Float64, + } +} + func dbBatchPutAccounts(ctx context.Context, tx *sqlx.Tx, models []*intentAccountModel) ([]*intentAccountModel, error) { var res []*intentAccountModel query := `INSERT INTO ` + accountsTableName + ` - (paging_id, source, source_owner, destination, destination_owner, quantity) + (paging_id, source, source_owner, destination, destination_owner, quantity, native_amount, usd_market_value) VALUES ` var parameters []any for i, m := range models { baseIndex := len(parameters) query += fmt.Sprintf( - `($%d, $%d, $%d, $%d, $%d, $%d)`, - baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, + `($%d, $%d, $%d, $%d, $%d, $%d, $%d, $%d)`, + baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8, ) if i != len(models)-1 { query += "," @@ -349,9 +416,11 @@ func dbBatchPutAccounts(ctx context.Context, tx *sqlx.Tx, models []*intentAccoun m.Destination, m.DestinationOwner, m.Quantity, + m.NativeAmount, + m.UsdMarketValue, ) } - query += ` RETURNING id, paging_id, source, source_owner, destination, destination_owner, quantity` + query += ` RETURNING id, paging_id, source, source_owner, destination, destination_owner, quantity, native_amount, usd_market_value` err := tx.SelectContext( ctx, @@ -367,11 +436,11 @@ func dbBatchPutAccounts(ctx context.Context, tx *sqlx.Tx, models []*intentAccoun func dbGetAccounts(ctx context.Context, db *sqlx.DB, intentType intent.Type, pagingID int64) ([]*intentAccountModel, error) { var res []*intentAccountModel - if intentType != intent.PublicDistribution { + if intentType != intent.PublicDistribution && intentType != intent.SendPublicMultiPayment { return res, nil } - query := `SELECT id, paging_id, source, source_owner, destination, destination_owner, quantity + query := `SELECT id, paging_id, source, source_owner, destination, destination_owner, quantity, native_amount, usd_market_value FROM ` + accountsTableName + ` WHERE paging_id = $1 ORDER BY id ASC` @@ -566,7 +635,7 @@ func dbGetTransactedAmountForAntiMoneyLaundering(ctx context.Context, db *sqlx.D }{} query := `SELECT SUM(quantity) AS total_quark_value, SUM(usd_market_value) AS total_usd_value FROM ` + intentTableName + ` - WHERE owner = $1 AND created_at >= $2 AND intent_type = $3 AND state != $4 AND is_withdraw = FALSE + WHERE owner = $1 AND created_at >= $2 AND intent_type IN ($3, $5) AND state != $4 AND is_withdraw = FALSE ` err := db.GetContext( ctx, @@ -576,6 +645,7 @@ func dbGetTransactedAmountForAntiMoneyLaundering(ctx context.Context, db *sqlx.D since, intent.SendPublicPayment, intent.StateRevoked, + intent.SendPublicMultiPayment, ) if err != nil { return 0, 0, err @@ -594,24 +664,27 @@ func dbGetUsdCostBasis(ctx context.Context, db *sqlx.DB, owner string, mint stri var mintFilter string var params []any if mint == config.CoreMintPublicKeyString { - mintFilter = "mint IS NULL" - params = []any{owner, intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment} + mintFilter = "i.mint IS NULL" + params = []any{owner, intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment, intent.SendPublicMultiPayment} } else { - mintFilter = "mint = $6" - params = []any{owner, intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment, mint} + mintFilter = "i.mint = $7" + params = []any{owner, intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment, intent.SendPublicMultiPayment, mint} } // USD received as destination: // - ExternalDeposit, ReceivePaymentsPublicly where owner is initiator // - SendPublicPayment where owner is destination_owner + // - SendPublicMultiPayment where owner is a payment's destination_owner // USD sent as source: - // - SendPublicPayment where owner is initiator + // - SendPublicPayment, SendPublicMultiPayment where owner is initiator query := fmt.Sprintf(`SELECT - (SELECT COALESCE(SUM(usd_market_value), 0) FROM %s WHERE owner = $1 AND %s AND intent_type IN ($3, $4) AND state != $2) + - (SELECT COALESCE(SUM(usd_market_value), 0) FROM %s WHERE destination_owner = $1 AND %s AND intent_type = $5 AND state != $2) - - (SELECT COALESCE(SUM(usd_market_value), 0) FROM %s WHERE owner = $1 AND %s AND intent_type = $5 AND state != $2);`, + (SELECT COALESCE(SUM(i.usd_market_value), 0) FROM %s i WHERE i.owner = $1 AND %s AND i.intent_type IN ($3, $4) AND i.state != $2) + + (SELECT COALESCE(SUM(i.usd_market_value), 0) FROM %s i WHERE i.destination_owner = $1 AND %s AND i.intent_type = $5 AND i.state != $2) + + (SELECT COALESCE(SUM(a.usd_market_value), 0) FROM %s a JOIN %s i ON i.id = a.paging_id WHERE a.destination_owner = $1 AND %s AND i.intent_type = $6 AND i.state != $2) - + (SELECT COALESCE(SUM(i.usd_market_value), 0) FROM %s i WHERE i.owner = $1 AND %s AND i.intent_type IN ($5, $6) AND i.state != $2);`, intentTableName, mintFilter, intentTableName, mintFilter, + accountsTableName, intentTableName, mintFilter, intentTableName, mintFilter, ) @@ -647,24 +720,28 @@ func dbGetUsdCostBasisBatch(ctx context.Context, db *sqlx.DB, mint string, owner var mintFilter string var params []any if mint == config.CoreMintPublicKeyString { - mintFilter = "mint IS NULL" - params = []any{intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment} + mintFilter = "i.mint IS NULL" + params = []any{intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment, intent.SendPublicMultiPayment} } else { - mintFilter = "mint = $5" - params = []any{intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment, mint} + mintFilter = "i.mint = $6" + params = []any{intent.StateRevoked, intent.ExternalDeposit, intent.ReceivePaymentsPublicly, intent.SendPublicPayment, intent.SendPublicMultiPayment, mint} } // USD received as destination (owner is initiator for ExternalDeposit, ReceivePaymentsPublicly): // USD received as destination (owner is destination_owner for SendPublicPayment): - // USD sent as source (owner is initiator for SendPublicPayment): + // USD received as destination (owner is a payment's destination_owner for SendPublicMultiPayment): + // USD sent as source (owner is initiator for SendPublicPayment, SendPublicMultiPayment): query := fmt.Sprintf( - `(SELECT owner AS owner, COALESCE(SUM(usd_market_value), 0) AS cost_basis FROM %s WHERE owner IN (%s) AND %s AND intent_type IN ($2, $3) AND state != $1 GROUP BY owner) + `(SELECT i.owner AS owner, COALESCE(SUM(i.usd_market_value), 0) AS cost_basis FROM %s i WHERE i.owner IN (%s) AND %s AND i.intent_type IN ($2, $3) AND i.state != $1 GROUP BY i.owner) + UNION ALL + (SELECT i.destination_owner AS owner, COALESCE(SUM(i.usd_market_value), 0) AS cost_basis FROM %s i WHERE i.destination_owner IN (%s) AND %s AND i.intent_type = $4 AND i.state != $1 GROUP BY i.destination_owner) UNION ALL - (SELECT destination_owner AS owner, COALESCE(SUM(usd_market_value), 0) AS cost_basis FROM %s WHERE destination_owner IN (%s) AND %s AND intent_type = $4 AND state != $1 GROUP BY destination_owner) + (SELECT a.destination_owner AS owner, COALESCE(SUM(a.usd_market_value), 0) AS cost_basis FROM %s a JOIN %s i ON i.id = a.paging_id WHERE a.destination_owner IN (%s) AND %s AND i.intent_type = $5 AND i.state != $1 GROUP BY a.destination_owner) UNION ALL - (SELECT owner AS owner, -COALESCE(SUM(usd_market_value), 0) AS cost_basis FROM %s WHERE owner IN (%s) AND %s AND intent_type = $4 AND state != $1 GROUP BY owner);`, + (SELECT i.owner AS owner, -COALESCE(SUM(i.usd_market_value), 0) AS cost_basis FROM %s i WHERE i.owner IN (%s) AND %s AND i.intent_type IN ($4, $5) AND i.state != $1 GROUP BY i.owner);`, intentTableName, ownerFilter, mintFilter, intentTableName, ownerFilter, mintFilter, + accountsTableName, intentTableName, ownerFilter, mintFilter, intentTableName, ownerFilter, mintFilter, ) diff --git a/ocp/data/intent/postgres/store_test.go b/ocp/data/intent/postgres/store_test.go index 85efedd..3a80378 100644 --- a/ocp/data/intent/postgres/store_test.go +++ b/ocp/data/intent/postgres/store_test.go @@ -68,6 +68,9 @@ const ( quantity BIGINT NULL CHECK (quantity >= 0), + native_amount NUMERIC(18, 9) NULL, + usd_market_value NUMERIC(18, 9) NULL, + CONSTRAINT ocp__core_intentaccountmetadata__uniq__paging_id__and__source UNIQUE (paging_id, source), CONSTRAINT ocp__core_intentaccountmetadata__uniq__paging_id__and__destination UNIQUE (paging_id, destination) ) diff --git a/ocp/data/intent/tests/tests.go b/ocp/data/intent/tests/tests.go index cb42c28..de049d3 100644 --- a/ocp/data/intent/tests/tests.go +++ b/ocp/data/intent/tests/tests.go @@ -20,12 +20,14 @@ func RunTests(t *testing.T, s intent.Store, teardown func()) { testSendPublicPaymentRoundTrip, testReceivePaymentsPubliclyRoundTrip, testPublicDistributionRoundTrip, + testSendPublicMultiPaymentRoundTrip, testUpdateHappyPath, testUpdateStaleRecord, testGetOriginalGiftCardIssuedIntent, testGetGiftCardClaimedIntent, testGetTransactedAmountForAntiMoneyLaundering, testGetByOwner, + testGetByOwnerWithSendPublicMultiPayment, testGetUsdCostBasis, testGetUsdCostBasisBatch, } { @@ -316,6 +318,97 @@ func testPublicDistributionRoundTrip(t *testing.T, s intent.Store) { }) } +func testSendPublicMultiPaymentRoundTrip(t *testing.T, s intent.Store) { + t.Run("testSendPublicMultiPaymentRoundTrip", func(t *testing.T) { + ctx := context.Background() + + actual, err := s.Get(ctx, "test_intent_id") + require.Error(t, err) + assert.Equal(t, intent.ErrIntentNotFound, err) + assert.Nil(t, actual) + + expected := intent.Record{ + IntentId: "test_intent_id", + IntentType: intent.SendPublicMultiPayment, + MintAccount: "test_mint", + InitiatorOwnerAccount: "test_owner", + SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{ + Source: "test_source", + Payments: []*intent.Payment{ + { + DestinationOwnerAccount: "test_owner_2", + DestinationTokenAccount: "test_destination_2", + Quantity: 12300, + NativeAmount: 1.23, + UsdMarketValue: 0.123, + }, + { + DestinationOwnerAccount: "test_owner_3", + DestinationTokenAccount: "test_destination_3", + Quantity: 45, + NativeAmount: 0.0045, + UsdMarketValue: 0.00045, + }, + }, + Quantity: 12345, + + ExchangeCurrency: currency.CAD, + ExchangeRate: 0.0001, + NativeAmount: 1.2345, + UsdMarketValue: 0.12345, + }, + AppMetadata: []byte("app_metadata"), + State: intent.StateUnknown, + CreatedAt: time.Now(), + } + cloned := expected.Clone() + err = s.Save(ctx, &expected) + require.NoError(t, err) + assert.EqualValues(t, 1, expected.Id) + assert.EqualValues(t, 1, expected.Version) + + actual, err = s.Get(ctx, "test_intent_id") + require.NoError(t, err) + assert.Equal(t, cloned.IntentId, actual.IntentId) + assert.Equal(t, cloned.IntentType, actual.IntentType) + assert.Equal(t, cloned.MintAccount, actual.MintAccount) + assert.Equal(t, cloned.InitiatorOwnerAccount, actual.InitiatorOwnerAccount) + require.NotNil(t, actual.SendPublicMultiPaymentMetadata) + assert.Equal(t, cloned.SendPublicMultiPaymentMetadata.Source, actual.SendPublicMultiPaymentMetadata.Source) + require.Len(t, actual.SendPublicMultiPaymentMetadata.Payments, len(cloned.SendPublicMultiPaymentMetadata.Payments)) + for i, expectedPayment := range cloned.SendPublicMultiPaymentMetadata.Payments { + actualPayment := actual.SendPublicMultiPaymentMetadata.Payments[i] + assert.Equal(t, expectedPayment.DestinationOwnerAccount, actualPayment.DestinationOwnerAccount) + assert.Equal(t, expectedPayment.DestinationTokenAccount, actualPayment.DestinationTokenAccount) + assert.Equal(t, expectedPayment.Quantity, actualPayment.Quantity) + assert.Equal(t, expectedPayment.NativeAmount, actualPayment.NativeAmount) + assert.Equal(t, expectedPayment.UsdMarketValue, actualPayment.UsdMarketValue) + } + assert.Equal(t, cloned.SendPublicMultiPaymentMetadata.Quantity, actual.SendPublicMultiPaymentMetadata.Quantity) + assert.Equal(t, cloned.SendPublicMultiPaymentMetadata.ExchangeCurrency, actual.SendPublicMultiPaymentMetadata.ExchangeCurrency) + assert.Equal(t, cloned.SendPublicMultiPaymentMetadata.ExchangeRate, actual.SendPublicMultiPaymentMetadata.ExchangeRate) + assert.Equal(t, cloned.SendPublicMultiPaymentMetadata.NativeAmount, actual.SendPublicMultiPaymentMetadata.NativeAmount) + assert.Equal(t, cloned.SendPublicMultiPaymentMetadata.UsdMarketValue, actual.SendPublicMultiPaymentMetadata.UsdMarketValue) + assert.Equal(t, cloned.AppMetadata, actual.AppMetadata) + assert.Equal(t, cloned.State, actual.State) + assert.Equal(t, cloned.CreatedAt.Unix(), actual.CreatedAt.Unix()) + assert.EqualValues(t, 1, actual.Id) + assert.EqualValues(t, 1, actual.Version) + + // Duplicate destinations are rejected + duplicate := cloned.Clone() + duplicate.IntentId = "test_intent_id_2" + duplicate.SendPublicMultiPaymentMetadata.Payments[1].DestinationTokenAccount = "test_destination_2" + assert.Error(t, s.Save(ctx, &duplicate)) + + // Quantity must be the sum of payment quantities + mismatched := cloned.Clone() + mismatched.IntentId = "test_intent_id_3" + mismatched.SendPublicMultiPaymentMetadata.Quantity++ + assert.Error(t, s.Save(ctx, &mismatched)) + }) +} + func testUpdateHappyPath(t *testing.T, s intent.Store) { t.Run("testUpdateHappyPath", func(t *testing.T) { ctx := context.Background() @@ -502,6 +595,9 @@ func testGetTransactedAmountForAntiMoneyLaundering(t *testing.T, s intent.Store) {IntentId: "t6", IntentType: intent.ReceivePaymentsPublicly, InitiatorOwnerAccount: "o1", ReceivePaymentsPubliclyMetadata: &intent.ReceivePaymentsPubliclyMetadata{Source: "a6", Quantity: 100000, UsdMarketValue: 200000, OriginalExchangeCurrency: currency.USD, OriginalExchangeRate: 2, OriginalNativeAmount: 200000}, State: intent.StateConfirmed, MintAccount: "mint", CreatedAt: time.Now()}, {IntentId: "t7", IntentType: intent.ExternalDeposit, InitiatorOwnerAccount: "o1", ExternalDepositMetadata: &intent.ExternalDepositMetadata{DestinationTokenAccount: "a7", ExchangeCurrency: currency.USD, ExchangeRate: 1000000, NativeAmount: 1000000, Quantity: 1000000, UsdMarketValue: 20000}, MintAccount: "mint", State: intent.StateConfirmed, CreatedAt: time.Now()}, {IntentId: "t8", IntentType: intent.SendPublicPayment, InitiatorOwnerAccount: "o1", SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{DestinationOwnerAccount: "o8", DestinationTokenAccount: "a8", Quantity: 10000000, ExchangeCurrency: currency.USD, ExchangeRate: 2, NativeAmount: 2000000, UsdMarketValue: 2000000, IsWithdrawal: true}, State: intent.StateConfirmed, MintAccount: "mint", CreatedAt: time.Now()}, + {IntentId: "t9", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o1", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a0", Payments: []*intent.Payment{{DestinationOwnerAccount: "o9", DestinationTokenAccount: "a9", Quantity: 40000, NativeAmount: 80000, UsdMarketValue: 80000}, {DestinationOwnerAccount: "o10", DestinationTokenAccount: "a10", Quantity: 60000, NativeAmount: 120000, UsdMarketValue: 120000}}, Quantity: 100000, ExchangeCurrency: currency.USD, ExchangeRate: 2, NativeAmount: 200000, UsdMarketValue: 200000}, State: intent.StatePending, MintAccount: "mint", CreatedAt: time.Now().Add(-1 * time.Minute)}, + {IntentId: "t10", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o1", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a0", Payments: []*intent.Payment{{DestinationOwnerAccount: "o9", DestinationTokenAccount: "a9", Quantity: 1, NativeAmount: 2, UsdMarketValue: 2}, {DestinationOwnerAccount: "o10", DestinationTokenAccount: "a10", Quantity: 1, NativeAmount: 2, UsdMarketValue: 2}}, Quantity: 2, ExchangeCurrency: currency.USD, ExchangeRate: 2, NativeAmount: 4, UsdMarketValue: 4}, State: intent.StateRevoked, MintAccount: "mint", CreatedAt: time.Now()}, + {IntentId: "t11", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 1, NativeAmount: 2, UsdMarketValue: 2}, {DestinationOwnerAccount: "o10", DestinationTokenAccount: "a10", Quantity: 1, NativeAmount: 2, UsdMarketValue: 2}}, Quantity: 2, ExchangeCurrency: currency.USD, ExchangeRate: 2, NativeAmount: 4, UsdMarketValue: 4}, State: intent.StateConfirmed, MintAccount: "mint", CreatedAt: time.Now().Add(-48 * time.Hour)}, } for _, record := range records { @@ -511,16 +607,17 @@ func testGetTransactedAmountForAntiMoneyLaundering(t *testing.T, s intent.Store) // Capture all intents for the owner quarks, usdMarketValue, err = s.GetTransactedAmountForAntiMoneyLaundering(ctx, "o1", time.Now().Add(-24*time.Hour)) require.NoError(t, err) - assert.EqualValues(t, 1111, quarks) - assert.EqualValues(t, 2222, usdMarketValue) + assert.EqualValues(t, 101111, quarks) + assert.EqualValues(t, 202222, usdMarketValue) // Capture a subset of intents based on time quarks, usdMarketValue, err = s.GetTransactedAmountForAntiMoneyLaundering(ctx, "o1", time.Now().Add(-150*time.Second)) require.NoError(t, err) - assert.EqualValues(t, 11, quarks) - assert.EqualValues(t, 22, usdMarketValue) + assert.EqualValues(t, 100011, quarks) + assert.EqualValues(t, 200022, usdMarketValue) - // Capture no intents because the owner mismatches + // Capture no intents because the owner mismatches, or the owner's + // intents are outside the time window quarks, usdMarketValue, err = s.GetTransactedAmountForAntiMoneyLaundering(ctx, "o2", time.Now().Add(-24*time.Hour)) require.NoError(t, err) assert.EqualValues(t, 0, quarks) @@ -614,6 +711,53 @@ func testGetByOwner(t *testing.T, s intent.Store) { }) } +func testGetByOwnerWithSendPublicMultiPayment(t *testing.T, s intent.Store) { + t.Run("testGetByOwnerWithSendPublicMultiPayment", func(t *testing.T) { + ctx := context.Background() + + records := []*intent.Record{ + {IntentId: "t1", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o1", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a1", Payments: []*intent.Payment{{DestinationOwnerAccount: "o2", DestinationTokenAccount: "a2", Quantity: 1, NativeAmount: 1, UsdMarketValue: 1}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 2, NativeAmount: 2, UsdMarketValue: 2}}, Quantity: 3, ExchangeCurrency: currency.USD, ExchangeRate: 1, NativeAmount: 3, UsdMarketValue: 3}, MintAccount: "mint", State: intent.StatePending, CreatedAt: time.Now()}, + {IntentId: "t2", IntentType: intent.SendPublicPayment, InitiatorOwnerAccount: "o3", SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 1, ExchangeCurrency: currency.USD, ExchangeRate: 1, NativeAmount: 1, UsdMarketValue: 1}, MintAccount: "mint", State: intent.StateConfirmed, CreatedAt: time.Now()}, + {IntentId: "t3", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 1, NativeAmount: 1, UsdMarketValue: 1}, {DestinationOwnerAccount: "o4", DestinationTokenAccount: "a4", Quantity: 1, NativeAmount: 1, UsdMarketValue: 1}}, Quantity: 2, ExchangeCurrency: currency.USD, ExchangeRate: 1, NativeAmount: 2, UsdMarketValue: 2}, MintAccount: "mint", State: intent.StateConfirmed, CreatedAt: time.Now()}, + } + for _, record := range records { + require.NoError(t, s.Save(ctx, record)) + } + + for _, tc := range []struct { + owner string + expected []string + }{ + {"o1", []string{"t1", "t2"}}, + {"o2", []string{"t1", "t3"}}, + {"o3", []string{"t1", "t2", "t3"}}, + {"o4", []string{"t3"}}, + } { + actual, err := s.GetAllByOwner(ctx, tc.owner, query.EmptyCursor, 100, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, len(tc.expected), tc.owner) + for i, intentId := range tc.expected { + assert.Equal(t, intentId, actual[i].IntentId) + } + for _, record := range actual { + if record.IntentType == intent.SendPublicMultiPayment { + require.NotNil(t, record.SendPublicMultiPaymentMetadata) + assert.Len(t, record.SendPublicMultiPaymentMetadata.Payments, 2) + } + } + } + + actual, err := s.GetAllByOwner(ctx, "o3", query.ToCursor(records[0].Id), 100, query.Ascending) + require.NoError(t, err) + require.Len(t, actual, 2) + assert.Equal(t, "t2", actual[0].IntentId) + assert.Equal(t, "t3", actual[1].IntentId) + + _, err = s.GetAllByOwner(ctx, "o5", query.EmptyCursor, 100, query.Ascending) + assert.Equal(t, intent.ErrIntentNotFound, err) + }) +} + func testGetUsdCostBasis(t *testing.T, s intent.Store) { t.Run("testGetUsdCostBasis", func(t *testing.T) { ctx := context.Background() @@ -638,26 +782,37 @@ func testGetUsdCostBasis(t *testing.T, s intent.Store) { {IntentId: "t6", IntentType: intent.ExternalDeposit, InitiatorOwnerAccount: "o1", ExternalDepositMetadata: &intent.ExternalDepositMetadata{DestinationTokenAccount: "a1", Quantity: 5000, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 500, UsdMarketValue: 500}, MintAccount: "other_mint", State: intent.StateConfirmed}, // SendPublicPayment to self: o1 sends and receives, net 0 for this transaction {IntentId: "t7", IntentType: intent.SendPublicPayment, InitiatorOwnerAccount: "o1", SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 100, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 10, UsdMarketValue: 10}, MintAccount: "mint", State: intent.StateConfirmed}, + // SendPublicMultiPayment: o2 sends -20 USD, o1 receives +15 USD and o3 receives +5 USD + {IntentId: "t8", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 150, NativeAmount: 15, UsdMarketValue: 15}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 50, NativeAmount: 5, UsdMarketValue: 5}}, Quantity: 200, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 20, UsdMarketValue: 20}, MintAccount: "mint", State: intent.StateConfirmed}, + // Revoked SendPublicMultiPayment should be ignored + {IntentId: "t9", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}}, Quantity: 2000, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 200, UsdMarketValue: 200}, MintAccount: "mint", State: intent.StateRevoked}, + // SendPublicMultiPayment on a different mint should not be included + {IntentId: "t10", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}}, Quantity: 2000, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 200, UsdMarketValue: 200}, MintAccount: "other_mint", State: intent.StateConfirmed}, } for _, record := range records { require.NoError(t, s.Save(ctx, record)) } - // o1 cost basis: +100 (deposit) +50 (receive) -30 (send to o2) +20 (receive from o2) -10 (send to self) +10 (receive from self) = 140 + // o1 cost basis: +100 (deposit) +50 (receive) -30 (send to o2) +20 (receive from o2) -10 (send to self) +10 (receive from self) +15 (multi payment from o2) = 155 costBasis, err = s.GetUsdCostBasis(ctx, "o1", "mint") require.NoError(t, err) - assert.EqualValues(t, 140, costBasis) + assert.EqualValues(t, 155, costBasis) - // o2 cost basis: +30 (receive from o1) -20 (send to o1) = 10 + // o2 cost basis: +30 (receive from o1) -20 (send to o1) -20 (multi payment to o1 and o3) = -10 costBasis, err = s.GetUsdCostBasis(ctx, "o2", "mint") require.NoError(t, err) - assert.EqualValues(t, 10, costBasis) + assert.EqualValues(t, -10, costBasis) - // o1 cost basis for other_mint: +500 + // o3 cost basis: +5 (multi payment from o2) = 5 + costBasis, err = s.GetUsdCostBasis(ctx, "o3", "mint") + require.NoError(t, err) + assert.EqualValues(t, 5, costBasis) + + // o1 cost basis for other_mint: +500 (deposit) +100 (multi payment from o2) = 600 costBasis, err = s.GetUsdCostBasis(ctx, "o1", "other_mint") require.NoError(t, err) - assert.EqualValues(t, 500, costBasis) + assert.EqualValues(t, 600, costBasis) // Unknown owner has zero cost basis costBasis, err = s.GetUsdCostBasis(ctx, "unknown", "mint") @@ -688,22 +843,36 @@ func testGetUsdCostBasisBatch(t *testing.T, s intent.Store) { {IntentId: "t5", IntentType: intent.ExternalDeposit, InitiatorOwnerAccount: "o1", ExternalDepositMetadata: &intent.ExternalDepositMetadata{DestinationTokenAccount: "a1", Quantity: 10000, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 1000, UsdMarketValue: 1000}, MintAccount: "mint", State: intent.StateRevoked}, // SendPublicPayment to self: o1 sends and receives, net 0 for this transaction {IntentId: "t7", IntentType: intent.SendPublicPayment, InitiatorOwnerAccount: "o1", SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 100, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 10, UsdMarketValue: 10}, MintAccount: "mint", State: intent.StateConfirmed}, + // SendPublicMultiPayment: o2 sends -20 USD, o1 receives +15 USD and o3 receives +5 USD + {IntentId: "t8", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 150, NativeAmount: 15, UsdMarketValue: 15}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 50, NativeAmount: 5, UsdMarketValue: 5}}, Quantity: 200, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 20, UsdMarketValue: 20}, MintAccount: "mint", State: intent.StateConfirmed}, + // Revoked SendPublicMultiPayment should be ignored + {IntentId: "t9", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}}, Quantity: 2000, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 200, UsdMarketValue: 200}, MintAccount: "mint", State: intent.StateRevoked}, + // SendPublicMultiPayment on a different mint should not be included + {IntentId: "t10", IntentType: intent.SendPublicMultiPayment, InitiatorOwnerAccount: "o2", SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{Source: "a2", Payments: []*intent.Payment{{DestinationOwnerAccount: "o1", DestinationTokenAccount: "a1", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}, {DestinationOwnerAccount: "o3", DestinationTokenAccount: "a3", Quantity: 1000, NativeAmount: 100, UsdMarketValue: 100}}, Quantity: 2000, ExchangeCurrency: currency.USD, ExchangeRate: 0.1, NativeAmount: 200, UsdMarketValue: 200}, MintAccount: "other_mint", State: intent.StateConfirmed}, } for _, record := range records { require.NoError(t, s.Save(ctx, record)) } - // Batch query for o1, o2, and unknown owner - res, err = s.GetUsdCostBasisBatch(ctx, "mint", "o1", "o2", "unknown") + // Batch query for o1, o2, o3, and unknown owner + res, err = s.GetUsdCostBasisBatch(ctx, "mint", "o1", "o2", "o3", "unknown") require.NoError(t, err) - require.Len(t, res, 3) + require.Len(t, res, 4) + + // o1 cost basis: +100 (deposit) +50 (receive) -30 (send to o2) +20 (receive from o2) -10 (send to self) +10 (receive from self) +15 (multi payment from o2) = 155 + assert.EqualValues(t, 155, res["o1"]) - // o1 cost basis: +100 (deposit) +50 (receive) -30 (send to o2) +20 (receive from o2) -10 (send to self) +10 (receive from self) = 140 - assert.EqualValues(t, 140, res["o1"]) + // o2 cost basis: +30 (receive from o1) -20 (send to o1) -20 (multi payment to o1 and o3) = -10 + assert.EqualValues(t, -10, res["o2"]) - // o2 cost basis: +30 (receive from o1) -20 (send to o1) = 10 - assert.EqualValues(t, 10, res["o2"]) + // o3 cost basis: +5 (multi payment from o2) = 5 + assert.EqualValues(t, 5, res["o3"]) + + // o1 cost basis for other_mint: +100 (multi payment from o2) = 100 + res, err = s.GetUsdCostBasisBatch(ctx, "other_mint", "o1") + require.NoError(t, err) + assert.EqualValues(t, 100, res["o1"]) // Unknown owner has zero cost basis assert.EqualValues(t, 0, res["unknown"]) diff --git a/ocp/history/intent.go b/ocp/history/intent.go index c54b76c..ff7ab72 100644 --- a/ocp/history/intent.go +++ b/ocp/history/intent.go @@ -34,6 +34,8 @@ func BuildRecordsForIntent(ctx context.Context, data ocp_data.DatabaseData, inte return buildRecordsForSendPublicPaymentIntent(ctx, data, intentRecord, protoMetadata, actionRecords, withdrawalFeeQuarks) case intent.ReceivePaymentsPublicly: return buildRecordsForReceivePaymentsPubliclyIntent(intentRecord) + case intent.SendPublicMultiPayment: + return buildRecordsForSendPublicMultiPaymentIntent(intentRecord), nil default: // todo: Support remaining money movement intent types return nil, nil @@ -169,6 +171,48 @@ func buildRecordsForSendPublicPaymentIntent(ctx context.Context, data ocp_data.D return []*history.Record{sent, received}, nil } +// buildRecordsForSendPublicMultiPaymentIntent builds a single record for the +// sender with the cumulative total of all payments, and one record for each +// recipient with their individual payment. The sender's record has no +// counterparty, since there's more than one. Recipient owners are unique and +// never the sender, which SubmitIntent validates. +func buildRecordsForSendPublicMultiPaymentIntent(intentRecord *intent.Record) []*history.Record { + metadata := intentRecord.SendPublicMultiPaymentMetadata + + records := []*history.Record{{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.DirectlySent, + OwnerAccount: intentRecord.InitiatorOwnerAccount, + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: metadata.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: metadata.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + }} + + for _, payment := range metadata.Payments { + records = append(records, &history.Record{ + ReferenceId: intentRecord.IntentId, + ReferenceType: history.IntentReference, + Type: history.DirectlyReceived, + OwnerAccount: payment.DestinationOwnerAccount, + CounterpartyOwnerAccount: pointer.String(intentRecord.InitiatorOwnerAccount), + ExchangeCurrency: metadata.ExchangeCurrency, + NativeAmount: payment.NativeAmount, + MintAccount: intentRecord.MintAccount, + Quantity: payment.Quantity, + AppMetadata: intentRecord.AppMetadata, + State: history.StateCompleted, + CreatedAt: intentRecord.CreatedAt, + }) + } + + return records +} + func buildRecordsForWithdrawalIntent(ctx context.Context, data ocp_data.DatabaseData, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata, actionRecords []*action.Record, withdrawalFeeQuarks uint64) ([]*history.Record, error) { metadata := intentRecord.SendPublicPaymentMetadata diff --git a/ocp/history/intent_test.go b/ocp/history/intent_test.go index 04b77d4..90e0d9f 100644 --- a/ocp/history/intent_test.go +++ b/ocp/history/intent_test.go @@ -78,6 +78,75 @@ func TestBuildRecordsForIntent_SelfPayment(t *testing.T) { assert.NoError(t, records[0].Validate()) } +func TestBuildRecordsForIntent_MultiPayment(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := &intent.Record{ + IntentId: "send_public_multi_payment_intent", + IntentType: intent.SendPublicMultiPayment, + MintAccount: "mint", + InitiatorOwnerAccount: "sender_owner", + SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{ + Source: "sender_token_account", + Payments: []*intent.Payment{ + {DestinationOwnerAccount: "receiver_owner_1", DestinationTokenAccount: "receiver_token_account_1", Quantity: 100_000, NativeAmount: 12.5, UsdMarketValue: 10.0}, + {DestinationOwnerAccount: "receiver_owner_2", DestinationTokenAccount: "receiver_token_account_2", Quantity: 40_000, NativeAmount: 5.0, UsdMarketValue: 4.0}, + }, + Quantity: 140_000, + ExchangeCurrency: "cad", + ExchangeRate: 1.25, + NativeAmount: 17.5, + UsdMarketValue: 14.0, + }, + AppMetadata: []byte("app_metadata"), + State: intent.StatePending, + CreatedAt: time.Now(), + } + + records, err := BuildRecordsForIntent(ctx, data, intentRecord, nil, nil, testWithdrawalFeeQuarks) + require.NoError(t, err) + require.Len(t, records, 3) + + // The sender has a single record for the cumulative total, without a + // counterparty since there's more than one + sent := records[0] + assert.Equal(t, history.DirectlySent, sent.Type) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, sent.OwnerAccount) + assert.Nil(t, sent.CounterpartyOwnerAccount) + assert.EqualValues(t, 140_000, sent.Quantity) + assert.Equal(t, 17.5, sent.NativeAmount) + + // Each recipient has a record for their individual payment + for i, payment := range intentRecord.SendPublicMultiPaymentMetadata.Payments { + received := records[i+1] + assert.Equal(t, history.DirectlyReceived, received.Type) + assert.Equal(t, payment.DestinationOwnerAccount, received.OwnerAccount) + require.NotNil(t, received.CounterpartyOwnerAccount) + assert.Equal(t, intentRecord.InitiatorOwnerAccount, *received.CounterpartyOwnerAccount) + assert.Equal(t, payment.Quantity, received.Quantity) + assert.Equal(t, payment.NativeAmount, received.NativeAmount) + } + + for _, record := range records { + assert.Equal(t, intentRecord.IntentId, record.ReferenceId) + assert.Equal(t, history.IntentReference, record.ReferenceType) + assert.Equal(t, intentRecord.SendPublicMultiPaymentMetadata.ExchangeCurrency, record.ExchangeCurrency) + assert.Equal(t, intentRecord.MintAccount, record.MintAccount) + assert.Equal(t, intentRecord.AppMetadata, record.AppMetadata) + assert.Nil(t, record.GiftCardVault) + assert.Equal(t, history.StateCompleted, record.State) + assert.Equal(t, intentRecord.CreatedAt, record.CreatedAt) + assert.NoError(t, record.Validate()) + } + + // Records satisfy the one record per owner per reference constraint + saveRecordsForIntent(t, data, intentRecord) + saved, err := data.GetAllTransactionHistoryByReference(ctx, history.IntentReference, intentRecord.IntentId) + require.NoError(t, err) + assert.Len(t, saved, 3) +} + func TestBuildRecordsForIntent_GiftCardIssuance(t *testing.T) { ctx := context.Background() data := ocp_data.NewTestDataProvider() diff --git a/ocp/rpc/transaction/intent.go b/ocp/rpc/transaction/intent.go index e6f6668..84be1b8 100644 --- a/ocp/rpc/transaction/intent.go +++ b/ocp/rpc/transaction/intent.go @@ -118,6 +118,10 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm log = log.With(zap.String("intent_type", "public_distribution")) intentHandler = NewPublicDistributionIntentHandler(s.conf, s.log, s.data, s.antispamGuard, s.amlGuard) protoMint = typed.PublicDistribution.Mint + case *transactionpb.Metadata_SendPublicMultiPayment: + log = log.With(zap.String("intent_type", "send_public_multi_payment")) + intentHandler = NewSendPublicMultiPaymentIntentHandler(s.conf, s.log, s.data, s.antispamGuard, s.amlGuard) + protoMint = typed.SendPublicMultiPayment.Mint default: return handleSubmitIntentError(ctx, streamer, intentRecord, status.Error(codes.InvalidArgument, "SubmitIntentRequest.SubmitActions.Metadata is nil")) } @@ -855,14 +859,19 @@ func (s *transactionServer) GetIntentMetadata(ctx context.Context, req *transact log = log.With(zap.String("intent_type", intentRecord.IntentType.String())) - var destinationOwnerAccount string + destinationOwnerAccounts := make(map[string]any) switch intentRecord.IntentType { case intent.SendPublicPayment: - destinationOwnerAccount = intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount + destinationOwnerAccounts[intentRecord.SendPublicPaymentMetadata.DestinationOwnerAccount] = true + case intent.SendPublicMultiPayment: + for _, payment := range intentRecord.SendPublicMultiPaymentMetadata.Payments { + destinationOwnerAccounts[payment.DestinationOwnerAccount] = true + } } if req.Owner != nil { - if signer.PublicKey().ToBase58() != intentRecord.InitiatorOwnerAccount && signer.PublicKey().ToBase58() != destinationOwnerAccount { + _, isDestinationOwner := destinationOwnerAccounts[signer.PublicKey().ToBase58()] + if signer.PublicKey().ToBase58() != intentRecord.InitiatorOwnerAccount && !isDestinationOwner { return &transactionpb.GetIntentMetadataResponse{ Result: transactionpb.GetIntentMetadataResponse_DENIED, }, nil @@ -951,6 +960,12 @@ func (s *transactionServer) GetIntentMetadata(ctx context.Context, req *transact }, }, } + case intent.SendPublicMultiPayment: + metadata, err = toSendPublicMultiPaymentProtoMetadata(intentRecord) + if err != nil { + log.With(zap.Error(err)).Warn("failure converting intent metadata") + return nil, status.Error(codes.Internal, "") + } default: return &transactionpb.GetIntentMetadataResponse{ Result: transactionpb.GetIntentMetadataResponse_DENIED, diff --git a/ocp/rpc/transaction/intent_handler.go b/ocp/rpc/transaction/intent_handler.go index 4585d38..a442524 100644 --- a/ocp/rpc/transaction/intent_handler.go +++ b/ocp/rpc/transaction/intent_handler.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" "go.uber.org/zap" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" @@ -1603,6 +1604,564 @@ func (h *PublicDistributionIntentHandler) OnCommitToDB(ctx context.Context) erro return nil } +// multiPaymentCount is the exact number of payments a multi payment intent +// must make +const multiPaymentCount = 2 + +type SendPublicMultiPaymentIntentHandler struct { + conf *conf + log *zap.Logger + data ocp_data.Provider + antispamGuard *antispam.Guard + amlGuard *aml.Guard + + // Indexed by payment, in the order provided by the client + cachedDestinations []*multiPaymentDestination +} + +type multiPaymentDestination struct { + tokenAccount *common.Account + ownerAccount *common.Account + + // Exactly one is set, depending on whether the destination primary account + // already exists or will be auto-opened as a precursor synthetic + // OpenAccounts intent. + accountInfo *account.Record + autoOpen *autoOpenPrimaryAccount +} + +func NewSendPublicMultiPaymentIntentHandler( + conf *conf, + log *zap.Logger, + data ocp_data.Provider, + antispamGuard *antispam.Guard, + amlGuard *aml.Guard, +) CreateIntentHandler { + return &SendPublicMultiPaymentIntentHandler{ + conf: conf, + log: log, + data: data, + antispamGuard: antispamGuard, + amlGuard: amlGuard, + } +} + +func (h *SendPublicMultiPaymentIntentHandler) PopulateMetadata(ctx context.Context, intentRecord *intent.Record, protoMetadata *transactionpb.Metadata) error { + typedProtoMetadata := protoMetadata.GetSendPublicMultiPayment() + if typedProtoMetadata == nil { + return errors.New("unexpected metadata proto message") + } + + if len(typedProtoMetadata.Payments) != multiPaymentCount { + return NewIntentValidationErrorf("must send exactly %d payments", multiPaymentCount) + } + + mint, err := common.GetBackwardsCompatMint(typedProtoMetadata.Mint) + if err != nil { + return err + } + + source, err := common.NewAccountFromProto(typedProtoMetadata.Source) + if err != nil { + return err + } + + metadata := &intent.SendPublicMultiPaymentMetadata{ + Source: source.PublicKey().ToBase58(), + } + h.cachedDestinations = make([]*multiPaymentDestination, len(typedProtoMetadata.Payments)) + for i, protoPayment := range typedProtoMetadata.Payments { + clientExchangeData := protoPayment.GetClientExchangeData() + if clientExchangeData == nil { + return NewIntentDeniedError("client exchange data not provided") + } + coreMintFiatExchangeRate := clientExchangeData.GetCoreMintFiatExchangeRate().GetExchangeRate() + if coreMintFiatExchangeRate == nil { + return NewIntentValidationError("core mint fiat exchange rate not provided") + } + + destination, err := h.resolveDestination(ctx, protoPayment, mint) + if err != nil { + return err + } + h.cachedDestinations[i] = destination + + usdMarketValue, err := currency_util.CalculateUsdMarketValueFromFiatAmount(clientExchangeData.NativeAmount, coreMintFiatExchangeRate.ExchangeRate) + if err != nil { + return err + } + + // Validated to be consistent across payments in AllowCreation + metadata.ExchangeCurrency = currency_lib.Code(coreMintFiatExchangeRate.CurrencyCode) + if common.IsCoreMint(mint) { + metadata.ExchangeRate = coreMintFiatExchangeRate.ExchangeRate + } + + metadata.Payments = append(metadata.Payments, &intent.Payment{ + DestinationOwnerAccount: destination.ownerAccount.PublicKey().ToBase58(), + DestinationTokenAccount: destination.tokenAccount.PublicKey().ToBase58(), + Quantity: clientExchangeData.Quarks, + + NativeAmount: clientExchangeData.NativeAmount, + UsdMarketValue: usdMarketValue, + }) + metadata.Quantity += clientExchangeData.Quarks + metadata.NativeAmount += clientExchangeData.NativeAmount + metadata.UsdMarketValue += usdMarketValue + } + if !common.IsCoreMint(mint) { + metadata.ExchangeRate = currency_util.CalculateExchangeRate(mint, metadata.Quantity, metadata.NativeAmount) + } + + intentRecord.IntentType = intent.SendPublicMultiPayment + intentRecord.MintAccount = mint.PublicKey().ToBase58() + intentRecord.SendPublicMultiPaymentMetadata = metadata + + return nil +} + +// resolveDestination resolves a payment's destination to either an existing +// primary account, or one that will be auto-opened for an existing OCP user. +// Further validation on the destination happens in AllowCreation. +func (h *SendPublicMultiPaymentIntentHandler) resolveDestination(ctx context.Context, protoPayment *transactionpb.SendPublicMultiPaymentMetadata_Payment, mint *common.Account) (*multiPaymentDestination, error) { + destination, err := common.NewAccountFromProto(protoPayment.Destination) + if err != nil { + return nil, err + } + + var destinationOwner *common.Account + if protoPayment.DestinationOwner != nil { + destinationOwner, err = common.NewAccountFromProto(protoPayment.DestinationOwner) + if err != nil { + return nil, err + } + } + + accountInfoRecord, err := h.data.GetAccountInfoByTokenAddress(ctx, destination.PublicKey().ToBase58()) + switch err { + case nil: + owner, err := common.NewAccountFromPublicKeyString(accountInfoRecord.OwnerAccount) + if err != nil { + return nil, err + } + + if destinationOwner != nil && destinationOwner.PublicKey().ToBase58() != owner.PublicKey().ToBase58() { + return nil, NewIntentValidationErrorf("destination owner for %s must be %s", destination.PublicKey().ToBase58(), owner.PublicKey().ToBase58()) + } + + return &multiPaymentDestination{ + tokenAccount: destination, + ownerAccount: owner, + accountInfo: accountInfoRecord, + }, nil + case account.ErrAccountInfoNotFound: + if destinationOwner == nil { + return nil, NewIntentValidationErrorf("destination owner is required for %s, which isn't an existing ocp account", destination.PublicKey().ToBase58()) + } + + autoOpen, err := detectAutoOpenPrimaryAccount(ctx, h.data, destinationOwner, destination, mint) + if err != nil { + return nil, err + } else if autoOpen == nil { + return nil, NewIntentValidationErrorf("destination %s must be an ocp primary account", destination.PublicKey().ToBase58()) + } + + return &multiPaymentDestination{ + tokenAccount: destination, + ownerAccount: destinationOwner, + autoOpen: autoOpen, + }, nil + default: + return nil, err + } +} + +func (h *SendPublicMultiPaymentIntentHandler) CreatesNewUser(ctx context.Context, metadata *transactionpb.Metadata) (bool, error) { + return false, nil +} + +func (h *SendPublicMultiPaymentIntentHandler) IsNoop(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata, actions []*transactionpb.Action) (bool, error) { + return false, nil +} + +func (h *SendPublicMultiPaymentIntentHandler) GetAccountsToLock(ctx context.Context, intentRecord *intent.Record, metadata *transactionpb.Metadata) ([]*common.Account, error) { + return nil, nil +} + +func (h *SendPublicMultiPaymentIntentHandler) AllowCreation(ctx context.Context, intentRecord *intent.Record, untypedMetadata *transactionpb.Metadata, actions []*transactionpb.Action) error { + typedMetadata := untypedMetadata.GetSendPublicMultiPayment() + if typedMetadata == nil { + return errors.New("unexpected metadata proto message") + } + + initiatiorOwnerAccount, err := common.NewAccountFromPublicKeyString(intentRecord.InitiatorOwnerAccount) + if err != nil { + return err + } + + intentMintAccount, err := common.GetBackwardsCompatMint(typedMetadata.Mint) + if err != nil { + return err + } + + initiatorAccountsByMintAndType, err := common.GetLatestCodeTimelockAccountRecordsForOwner(ctx, h.data, initiatiorOwnerAccount) + if err != nil { + return err + } + initiatorAccountsByType, ok := initiatorAccountsByMintAndType[intentMintAccount.PublicKey().ToBase58()] + if !ok { + return errors.New("initiator mint accounts don't exist") + } + + initiatorAccounts := make([]*common.AccountRecords, 0) + initiatorAccountsByVault := make(map[string]*common.AccountRecords) + for _, batchRecords := range initiatorAccountsByType { + for _, records := range batchRecords { + initiatorAccounts = append(initiatorAccounts, records) + initiatorAccountsByVault[records.General.TokenAccount] = records + } + } + + // + // Part 1: Antispam guard checks against the owner + // + + if !h.conf.disableAntispamChecks.Get(ctx) { + for _, destination := range h.cachedDestinations { + allow, err := h.antispamGuard.AllowSendPayment(ctx, initiatiorOwnerAccount, destination.tokenAccount, true) + if err != nil { + return err + } else if !allow { + return ErrTooManyPayments + } + } + } + + // + // Part 2: AML checks against the owner + // + + if !h.conf.disableAmlChecks.Get(ctx) { + allow, err := h.amlGuard.AllowMoneyMovement(ctx, intentRecord) + if err != nil { + return err + } else if !allow { + return ErrTransactionLimitExceeded + } + } + + // + // Part 3: Account validation to determine if it's managed by Code + // + + err = validateAllUserAccountsManagedByCode(ctx, initiatorAccounts) + if err != nil { + return err + } + + // + // Part 4: Exchange data validation + // + + var firstClientExchangeData *transactionpb.VerifiedExchangeData + for _, payment := range typedMetadata.Payments { + clientExchangeData := payment.GetClientExchangeData() + if clientExchangeData == nil { + return NewIntentDeniedError("client exchange data not provided") + } + + if err := validateExchangeDataWithinIntent(typedMetadata.Mint, clientExchangeData); err != nil { + return err + } + + // Every payment must be valued at the same exchange rate, so the intent + // has a single, well-defined exchange rate and currency + if firstClientExchangeData == nil { + firstClientExchangeData = clientExchangeData + continue + } + if !proto.Equal(firstClientExchangeData.CoreMintFiatExchangeRate, clientExchangeData.CoreMintFiatExchangeRate) { + return NewIntentValidationError("all payments must use the same core mint fiat exchange rate") + } + if !proto.Equal(firstClientExchangeData.LaunchpadCurrencyReserveState, clientExchangeData.LaunchpadCurrencyReserveState) { + return NewIntentValidationError("all payments must use the same launchpad currency reserve state") + } + } + + // + // Part 5: Local simulation + // + + simResult, err := LocalSimulation(ctx, h.data, actions) + if err != nil { + return err + } + + // + // Part 6: Validate fee payments + // + + err = validateFeePayments(ctx, h.conf, intentRecord, simResult, nil) + if err != nil { + return err + } + + // + // Part 7: Validate reserved intent IDs + // + + _, err = validateSwapFunding(ctx, h.conf, h.data, intentRecord) + if err != nil { + return err + } + + // + // Part 8: Validate the individual actions + // + + return h.validateActions( + ctx, + initiatiorOwnerAccount, + initiatorAccountsByVault, + typedMetadata, + actions, + simResult, + ) +} + +func (h *SendPublicMultiPaymentIntentHandler) validateActions( + ctx context.Context, + initiatorOwnerAccount *common.Account, + initiatorAccountsByVault map[string]*common.AccountRecords, + metadata *transactionpb.SendPublicMultiPaymentMetadata, + actions []*transactionpb.Action, + simResult *LocalSimulationResult, +) error { + if len(actions) != len(metadata.Payments) { + return NewIntentValidationError("expected 1 action per payment") + } + if len(h.cachedDestinations) != len(metadata.Payments) { + return errors.New("destinations not resolved for every payment") + } + + // + // Part 1: Validate intent and action mints match + // + + intentMint, err := common.GetBackwardsCompatMint(metadata.Mint) + if err != nil { + return err + } + err = validateIntentAndActionMintsMatch(intentMint, actions) + if err != nil { + return err + } + + // + // Part 2: Check the source and destination accounts are valid + // + + source, err := common.NewAccountFromProto(metadata.Source) + if err != nil { + return err + } + + sourceAccountRecords, ok := initiatorAccountsByVault[source.PublicKey().ToBase58()] + if !ok || sourceAccountRecords.General.AccountType != commonpb.AccountType_PRIMARY { + return NewIntentValidationError("source account must be a PRIMARY account") + } + + destinationSet := make(map[string]any) + destinationOwnerSet := make(map[string]any) + for _, destination := range h.cachedDestinations { + destinationAddress := destination.tokenAccount.PublicKey().ToBase58() + destinationOwnerAddress := destination.ownerAccount.PublicKey().ToBase58() + + if destinationAddress == source.PublicKey().ToBase58() { + return NewIntentValidationError("payment is a no-op") + } + if destinationOwnerAddress == initiatorOwnerAccount.PublicKey().ToBase58() { + return NewIntentValidationError("cannot send a payment to an account owned by the source owner") + } + + if _, ok := destinationSet[destinationAddress]; ok { + return NewIntentValidationErrorf("duplicate destination account detected: %s", destinationAddress) + } + destinationSet[destinationAddress] = true + + if _, ok := destinationOwnerSet[destinationOwnerAddress]; ok { + return NewIntentValidationErrorf("duplicate destination owner account detected: %s", destinationOwnerAddress) + } + destinationOwnerSet[destinationOwnerAddress] = true + + // Auto-opened primary accounts are validated as part of detection + if destination.autoOpen != nil { + continue + } + + if destination.accountInfo.AccountType != commonpb.AccountType_PRIMARY { + return NewIntentValidationErrorf("destination account %s must be a PRIMARY account", destinationAddress) + } + + if destination.accountInfo.MintAccount != intentMint.PublicKey().ToBase58() { + return NewIntentValidationErrorf("destination account %s is not of %s mint", destinationAddress, intentMint.PublicKey().ToBase58()) + } + + timelockRecord, err := h.data.GetTimelockByVault(ctx, destinationAddress) + if err != nil { + return err + } + if !common.IsManagedByCode(ctx, timelockRecord) { + if timelockRecord.IsClosed() { + return NewStaleStateErrorf("destination account %s has been closed", destinationAddress) + } + return ErrDestinationNotManagedByCode + } + } + + // + // Part 3: Validate actions match intent metadata + // + + // + // Part 3.1: Check each destination account is paid the exact quark amount + // from the source account in one public transfer + // + + var totalQuarks uint64 + for i, payment := range metadata.Payments { + quarks := payment.GetClientExchangeData().Quarks + totalQuarks += quarks + + destinationAddress := h.cachedDestinations[i].tokenAccount.PublicKey().ToBase58() + destinationSimulation, ok := simResult.SimulationsByAccount[destinationAddress] + if !ok { + return NewIntentValidationErrorf("must send payment to destination account %s", destinationAddress) + } else if len(destinationSimulation.Transfers) != 1 { + return NewIntentValidationErrorf("must send payment to destination account %s in one action", destinationAddress) + } else if destinationSimulation.Transfers[0].IsPrivate || destinationSimulation.Transfers[0].IsWithdraw { + return NewActionValidationError(destinationSimulation.Transfers[0].Action, "payment sent to destination must be a public transfer") + } else if destinationSimulation.GetDeltaQuarks(false) != int64(quarks) { + return NewActionValidationErrorf(destinationSimulation.Transfers[0].Action, "must send %d quarks to destination account", quarks) + } + } + + // + // Part 3.2: Check that the user's primary account was used as the source + // of funds for every payment + // + + sourceSimulation, ok := simResult.SimulationsByAccount[source.PublicKey().ToBase58()] + if !ok { + return NewIntentValidationErrorf("must send payments from source account %s", source.PublicKey().ToBase58()) + } else if len(sourceSimulation.Transfers) != len(metadata.Payments) { + return NewIntentValidationErrorf("must send %d payments from source account", len(metadata.Payments)) + } else if sourceSimulation.GetDeltaQuarks(false) != -int64(totalQuarks) { + return NewIntentValidationErrorf("must send %d quarks from source account", totalQuarks) + } + for _, transfer := range sourceSimulation.Transfers { + if transfer.IsPrivate || transfer.IsWithdraw { + return NewActionValidationError(transfer.Action, "payment sent from source must be a public transfer") + } + } + + // Part 4: Generic validation of actions that move money + + err = validateMoneyMovementActionUserAccounts(ctx, h.data, intent.SendPublicMultiPayment, initiatorAccountsByVault, actions) + if err != nil { + return err + } + + // Part 5: Validate open and closed accounts + + if len(simResult.GetOpenedAccounts()) > 0 { + return NewIntentValidationError("cannot open any account") + } + + if len(simResult.GetClosedAccounts()) > 0 { + return NewIntentValidationError("cannot close any account") + } + + return nil +} + +func (h *SendPublicMultiPaymentIntentHandler) OnPreSaveToDB(ctx context.Context) error { + for _, destination := range h.cachedDestinations { + err := saveAutoOpenPrimaryAccountIntent(ctx, h.data, destination.autoOpen) + if err != nil { + return err + } + } + return nil +} + +func (h *SendPublicMultiPaymentIntentHandler) OnCommitToDB(ctx context.Context) error { + return nil +} + +// toSendPublicMultiPaymentProtoMetadata converts a submitted multi payment +// intent into its metadata proto with server exchange data for each payment. +// +// Each payment's exchange rate is its own. Core mint payments share the +// verified rate, but each launchpad currency payment is valued by its own sell +// against the bonding curve, so its rate is derived from its quarks and native +// amount. +func toSendPublicMultiPaymentProtoMetadata(intentRecord *intent.Record) (*transactionpb.Metadata, error) { + metadata := intentRecord.SendPublicMultiPaymentMetadata + + mintAccount, err := common.NewAccountFromPublicKeyString(intentRecord.MintAccount) + if err != nil { + return nil, errors.Wrap(err, "invalid mint account") + } + + sourceAccount, err := common.NewAccountFromPublicKeyString(metadata.Source) + if err != nil { + return nil, errors.Wrap(err, "invalid source account") + } + + var payments []*transactionpb.SendPublicMultiPaymentMetadata_Payment + for _, payment := range metadata.Payments { + destinationAccount, err := common.NewAccountFromPublicKeyString(payment.DestinationTokenAccount) + if err != nil { + return nil, errors.Wrap(err, "invalid destination account") + } + + destinationOwnerAccount, err := common.NewAccountFromPublicKeyString(payment.DestinationOwnerAccount) + if err != nil { + return nil, errors.Wrap(err, "invalid destination owner account") + } + + exchangeRate := metadata.ExchangeRate + if !common.IsCoreMint(mintAccount) { + exchangeRate = currency_util.CalculateExchangeRate(mintAccount, payment.Quantity, payment.NativeAmount) + } + + payments = append(payments, &transactionpb.SendPublicMultiPaymentMetadata_Payment{ + Destination: destinationAccount.ToProto(), + DestinationOwner: destinationOwnerAccount.ToProto(), + ExchangeData: &transactionpb.SendPublicMultiPaymentMetadata_Payment_ServerExchangeData{ + ServerExchangeData: &transactionpb.ExchangeData{ + Currency: strings.ToLower(string(metadata.ExchangeCurrency)), + ExchangeRate: exchangeRate, + NativeAmount: payment.NativeAmount, + Quarks: payment.Quantity, + Mint: mintAccount.ToProto(), + }, + }, + }) + } + + return &transactionpb.Metadata{ + Type: &transactionpb.Metadata_SendPublicMultiPayment{ + SendPublicMultiPayment: &transactionpb.SendPublicMultiPaymentMetadata{ + Source: sourceAccount.ToProto(), + Payments: payments, + Mint: mintAccount.ToProto(), + }, + }, + }, nil +} + func validateAllUserAccountsManagedByCode(ctx context.Context, initiatorAccounts []*common.AccountRecords) error { // Try to unlock *ANY* latest account, and you're done for _, accountRecords := range initiatorAccounts { diff --git a/ocp/worker/sequencer/intent_handler.go b/ocp/worker/sequencer/intent_handler.go index 84ca1ca..67d4b9d 100644 --- a/ocp/worker/sequencer/intent_handler.go +++ b/ocp/worker/sequencer/intent_handler.go @@ -156,6 +156,46 @@ func (h *PublicDistributionIntentHandler) OnActionUpdated(ctx context.Context, i return markIntentConfirmed(ctx, h.data, intentId) } +type SendPublicMultiPaymentIntentHandler struct { + data ocp_data.Provider +} + +func NewSendPublicMultiPaymentIntentHandler(data ocp_data.Provider) IntentHandler { + return &SendPublicMultiPaymentIntentHandler{ + data: data, + } +} + +func (h *SendPublicMultiPaymentIntentHandler) OnActionUpdated(ctx context.Context, intentId string) error { + actionRecords, err := h.data.GetAllActionsByIntent(ctx, intentId) + if err != nil { + return err + } + + // Every action is a payment, so all are checked to fail the intent as soon + // as any payment fails, regardless of the state of the others + allConfirmed := true + anyFailures := false + for _, actionRecord := range actionRecords { + switch actionRecord.State { + case action.StateConfirmed: + case action.StateFailed: + anyFailures = true + fallthrough + default: + allConfirmed = false + } + } + + if anyFailures { + return markIntentFailed(ctx, h.data, intentId) + } + if allConfirmed { + return markIntentConfirmed(ctx, h.data, intentId) + } + return nil +} + func validateIntentState(record *intent.Record, states ...intent.State) error { for _, validState := range states { if record.State == validState { @@ -208,5 +248,6 @@ func getIntentHandlers(data ocp_data.Provider) map[intent.Type]IntentHandler { handlersByType[intent.SendPublicPayment] = NewSendPublicPaymentIntentHandler(data) handlersByType[intent.ReceivePaymentsPublicly] = NewReceivePaymentsPubliclyIntentHandler(data) handlersByType[intent.PublicDistribution] = NewPublicDistributionIntentHandler(data) + handlersByType[intent.SendPublicMultiPayment] = NewSendPublicMultiPaymentIntentHandler(data) return handlersByType } diff --git a/ocp/worker/sequencer/intent_handler_test.go b/ocp/worker/sequencer/intent_handler_test.go index 69a2715..5d05f2a 100644 --- a/ocp/worker/sequencer/intent_handler_test.go +++ b/ocp/worker/sequencer/intent_handler_test.go @@ -1,3 +1,93 @@ package sequencer -// todo: implement me +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + currency_lib "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/ocp/common" + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/ocp/data/action" + "github.com/code-payments/ocp-server/ocp/data/intent" + "github.com/code-payments/ocp-server/pointer" +) + +func TestSendPublicMultiPaymentIntentHandler(t *testing.T) { + for _, tc := range []struct { + name string + states []action.State + expected intent.State + }{ + {"no payments confirmed", []action.State{action.StatePending, action.StatePending}, intent.StatePending}, + {"some payments confirmed", []action.State{action.StateConfirmed, action.StatePending}, intent.StatePending}, + {"all payments confirmed", []action.State{action.StateConfirmed, action.StateConfirmed}, intent.StateConfirmed}, + {"payment failed after another confirmed", []action.State{action.StateConfirmed, action.StateFailed}, intent.StateFailed}, + {"payment failed before another confirmed", []action.State{action.StateFailed, action.StatePending}, intent.StateFailed}, + {"later payment failed while earlier is pending", []action.State{action.StatePending, action.StateFailed}, intent.StateFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + data := ocp_data.NewTestDataProvider() + + intentRecord := newTestSendPublicMultiPaymentIntent(t) + require.NoError(t, data.SaveIntent(ctx, intentRecord)) + + var actionRecords []*action.Record + for i, state := range tc.states { + actionRecords = append(actionRecords, &action.Record{ + Intent: intentRecord.IntentId, + IntentType: intentRecord.IntentType, + ActionId: uint32(i), + ActionType: action.NoPrivacyTransfer, + Source: intentRecord.SendPublicMultiPaymentMetadata.Source, + Destination: pointer.String(intentRecord.SendPublicMultiPaymentMetadata.Payments[i].DestinationTokenAccount), + Quantity: pointer.Uint64(intentRecord.SendPublicMultiPaymentMetadata.Payments[i].Quantity), + State: state, + }) + } + require.NoError(t, data.PutAllActions(ctx, actionRecords...)) + + handler := getIntentHandlers(data)[intent.SendPublicMultiPayment] + require.NotNil(t, handler) + + // Repeated calls are idempotent, since the sequencer retries + for range 2 { + require.NoError(t, handler.OnActionUpdated(ctx, intentRecord.IntentId)) + + updated, err := data.GetIntent(ctx, intentRecord.IntentId) + require.NoError(t, err) + assert.Equal(t, tc.expected, updated.State) + } + }) + } +} + +func newTestSendPublicMultiPaymentIntent(t *testing.T) *intent.Record { + intentId, err := common.NewRandomAccount() + require.NoError(t, err) + + return &intent.Record{ + IntentId: intentId.PublicKey().ToBase58(), + IntentType: intent.SendPublicMultiPayment, + MintAccount: "mint", + InitiatorOwnerAccount: "owner", + SendPublicMultiPaymentMetadata: &intent.SendPublicMultiPaymentMetadata{ + Source: "source", + Payments: []*intent.Payment{ + {DestinationOwnerAccount: "destination_owner_1", DestinationTokenAccount: "destination_1", Quantity: 100, NativeAmount: 1, UsdMarketValue: 1}, + {DestinationOwnerAccount: "destination_owner_2", DestinationTokenAccount: "destination_2", Quantity: 50, NativeAmount: 0.5, UsdMarketValue: 0.5}, + }, + Quantity: 150, + ExchangeCurrency: currency_lib.USD, + ExchangeRate: 1, + NativeAmount: 1.5, + UsdMarketValue: 1.5, + }, + State: intent.StatePending, + CreatedAt: time.Now(), + } +}