Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 7 additions & 0 deletions ocp/aml/guard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion ocp/balance/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
150 changes: 150 additions & 0 deletions ocp/balance/delta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading