Skip to content

Commit 607d456

Browse files
committed
Re-implement limits and AML guard
1 parent 2aa77c6 commit 607d456

File tree

21 files changed

+596
-131
lines changed

21 files changed

+596
-131
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.23.0
55
require (
66
github.com/aws/aws-sdk-go-v2 v0.17.0
77
github.com/bits-and-blooms/bloom/v3 v3.1.0
8-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250429171947-b8896029c856
8+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250514180326-b429b6cc7221
99
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba
1010
github.com/emirpasic/gods v1.12.0
1111
github.com/envoyproxy/protoc-gen-validate v1.2.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
7878
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
7979
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
8080
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
81-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250429171947-b8896029c856 h1:qHd5MHS7948r1DJgY9wRRuNIrISLmcG7SK3I8ui7ilc=
82-
github.com/code-payments/code-protobuf-api v1.19.1-0.20250429171947-b8896029c856/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs=
81+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250514180326-b429b6cc7221 h1:7NZ/JOCHYx1G784ekmBdsCrHWLKtIwjz3x6dH2lRp1o=
82+
github.com/code-payments/code-protobuf-api v1.19.1-0.20250514180326-b429b6cc7221/go.mod h1:ee6TzKbgMS42ZJgaFEMG3c4R3dGOiffHSu6MrY7WQvs=
8383
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba h1:Bkp+gmeb6Y2PWXfkSCTMBGWkb2P1BujRDSjWeI+0j5I=
8484
github.com/code-payments/code-vm-indexer v0.1.11-0.20241028132209-23031e814fba/go.mod h1:jSiifpiBpyBQ8q2R0MGEbkSgWC6sbdRTyDBntmW+j1E=
8585
github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw=

pkg/code/aml/guard.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package aml
2+
3+
import (
4+
"context"
5+
"errors"
6+
"time"
7+
8+
"github.com/sirupsen/logrus"
9+
10+
code_data "github.com/code-payments/code-server/pkg/code/data"
11+
"github.com/code-payments/code-server/pkg/code/data/intent"
12+
"github.com/code-payments/code-server/pkg/code/limit"
13+
currency_util "github.com/code-payments/code-server/pkg/currency"
14+
"github.com/code-payments/code-server/pkg/metrics"
15+
)
16+
17+
var (
18+
// These limits are intentionally higher than that enforced on clients,
19+
// so we can do better rounding on limits per currency.
20+
//
21+
// todo: configurable
22+
maxUsdTransactionValue = 2.0 * limit.SendLimits[currency_util.USD].PerTransaction
23+
maxDailyUsdLimit = 1.5 * limit.SendLimits[currency_util.USD].Daily
24+
)
25+
26+
// Guard gates money movement by applying rules on operations of interest to
27+
// discourage money laundering.
28+
type Guard struct {
29+
log *logrus.Entry
30+
data code_data.Provider
31+
}
32+
33+
func NewGuard(data code_data.Provider) *Guard {
34+
return &Guard{
35+
log: logrus.StandardLogger().WithField("type", "aml/guard"),
36+
data: data,
37+
}
38+
}
39+
40+
// AllowMoneyMovement determines whether an intent that moves funds is allowed
41+
// to be executed.
42+
func (g *Guard) AllowMoneyMovement(ctx context.Context, intentRecord *intent.Record) (bool, error) {
43+
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowMoneyMovement")
44+
defer tracer.End()
45+
46+
var usdMarketValue float64
47+
var consumptionCalculator func(ctx context.Context, owner string, since time.Time) (uint64, float64, error)
48+
switch intentRecord.IntentType {
49+
case intent.SendPublicPayment:
50+
// Public sends are subject to USD-based limits
51+
usdMarketValue = intentRecord.SendPublicPaymentMetadata.UsdMarketValue
52+
consumptionCalculator = g.data.GetTransactedAmountForAntiMoneyLaundering
53+
case intent.ReceivePaymentsPublicly:
54+
// Public receives are always allowed
55+
return true, nil
56+
default:
57+
err := errors.New("intent record must be a send or receive payment")
58+
tracer.OnError(err)
59+
return false, err
60+
}
61+
62+
log := g.log.WithFields(logrus.Fields{
63+
"method": "AllowMoneyMovement",
64+
"owner": intentRecord.InitiatorOwnerAccount,
65+
"usd_value": usdMarketValue,
66+
})
67+
68+
// Bound the maximum dollar value of a payment
69+
if usdMarketValue > maxUsdTransactionValue {
70+
log.Info("denying intent that exceeds per-transaction usd value")
71+
recordDenialEvent(ctx, "exceeds per-transaction usd value")
72+
return false, nil
73+
}
74+
75+
// Bound the maximum dollar value of payments in the last day
76+
_, usdInLastDay, err := consumptionCalculator(ctx, intentRecord.InitiatorOwnerAccount, time.Now().Add(-24*time.Hour))
77+
if err != nil {
78+
log.WithError(err).Warn("failure calculating previous day transaction amount")
79+
tracer.OnError(err)
80+
return false, err
81+
}
82+
83+
if usdInLastDay+usdMarketValue > maxDailyUsdLimit {
84+
log.Info("denying intent that exceeds daily usd limit")
85+
recordDenialEvent(ctx, "exceeds daily usd value")
86+
return false, nil
87+
}
88+
89+
return true, nil
90+
}

pkg/code/aml/guard_test.go

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package aml
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/code-payments/code-server/pkg/code/common"
12+
code_data "github.com/code-payments/code-server/pkg/code/data"
13+
"github.com/code-payments/code-server/pkg/code/data/currency"
14+
"github.com/code-payments/code-server/pkg/code/data/intent"
15+
currency_lib "github.com/code-payments/code-server/pkg/currency"
16+
"github.com/code-payments/code-server/pkg/testutil"
17+
)
18+
19+
func TestGuard_SendPublicPayment_TransactionValue(t *testing.T) {
20+
env := setupAmlTest(t)
21+
22+
owner := testutil.NewRandomAccount(t)
23+
24+
for _, acceptableValue := range []float64{
25+
1,
26+
maxUsdTransactionValue / 10,
27+
maxUsdTransactionValue - 1,
28+
maxUsdTransactionValue,
29+
} {
30+
intentRecord := makeSendPublicPaymentIntent(t, owner, acceptableValue, time.Now())
31+
32+
allow, err := env.guard.AllowMoneyMovement(env.ctx, intentRecord)
33+
require.NoError(t, err)
34+
assert.True(t, allow)
35+
}
36+
37+
for _, unacceptableValue := range []float64{
38+
maxUsdTransactionValue + 1,
39+
maxUsdTransactionValue * 10,
40+
} {
41+
intentRecord := makeSendPublicPaymentIntent(t, owner, unacceptableValue, time.Now())
42+
43+
allow, err := env.guard.AllowMoneyMovement(env.ctx, intentRecord)
44+
require.NoError(t, err)
45+
assert.False(t, allow)
46+
}
47+
}
48+
49+
func TestGuard_SendPublicPayment_DailyUsdLimit(t *testing.T) {
50+
env := setupAmlTest(t)
51+
52+
for _, tc := range []struct {
53+
consumedUsdValue float64
54+
at time.Time
55+
expected bool
56+
}{
57+
// Intent consumes some of the daily limit, but not all
58+
{
59+
consumedUsdValue: maxDailyUsdLimit / 2,
60+
at: time.Now().Add(-12 * time.Hour),
61+
expected: true,
62+
},
63+
// Intent consumes the remaining daily limit
64+
{
65+
consumedUsdValue: maxDailyUsdLimit - 1,
66+
at: time.Now().Add(-12 * time.Hour),
67+
expected: true,
68+
},
69+
// Daily limit was breached, but more than a day ago
70+
{
71+
consumedUsdValue: maxDailyUsdLimit + 1,
72+
at: time.Now().Add(-24*time.Hour - time.Minute),
73+
expected: true,
74+
},
75+
// Daily limit is breached, but is close to expiring
76+
{
77+
consumedUsdValue: maxDailyUsdLimit,
78+
at: time.Now().Add(-24*time.Hour + time.Minute),
79+
expected: false,
80+
},
81+
// Daily limit is breached well within the time window
82+
{
83+
consumedUsdValue: maxDailyUsdLimit + 1,
84+
at: time.Now().Add(-12 * time.Hour),
85+
expected: false,
86+
},
87+
} {
88+
owner := testutil.NewRandomAccount(t)
89+
intentRecord := makeSendPublicPaymentIntent(t, owner, 1, time.Now())
90+
91+
// Sanity check the intent for $1 USD is allowed
92+
allow, err := env.guard.AllowMoneyMovement(env.ctx, intentRecord)
93+
require.NoError(t, err)
94+
assert.True(t, allow)
95+
96+
// Save an intent to bring the user up to the desired consumed daily USD value
97+
require.NoError(t, env.data.SaveIntent(env.ctx, makeSendPublicPaymentIntent(t, owner, tc.consumedUsdValue, tc.at)))
98+
99+
// Check whether we allow the $1 USD intent
100+
allow, err = env.guard.AllowMoneyMovement(env.ctx, intentRecord)
101+
require.NoError(t, err)
102+
assert.Equal(t, tc.expected, allow)
103+
}
104+
}
105+
106+
func TestGuard_ReceivePaymentsPublicly(t *testing.T) {
107+
env := setupAmlTest(t)
108+
109+
owner := testutil.NewRandomAccount(t)
110+
111+
for _, usdMarketValue := range []float64{
112+
1,
113+
1_000_000_000_000,
114+
} {
115+
intentRecord := makeReceivePaymentsPubliclyIntent(t, owner, usdMarketValue, time.Now())
116+
117+
// We should always allow a public receive
118+
allow, err := env.guard.AllowMoneyMovement(env.ctx, intentRecord)
119+
require.NoError(t, err)
120+
assert.True(t, allow)
121+
}
122+
}
123+
124+
type amlTestEnv struct {
125+
ctx context.Context
126+
data code_data.Provider
127+
guard *Guard
128+
}
129+
130+
func setupAmlTest(t *testing.T) (env amlTestEnv) {
131+
env.ctx = context.Background()
132+
env.data = code_data.NewTestDataProvider()
133+
env.guard = NewGuard(env.data)
134+
135+
testutil.SetupRandomSubsidizer(t, env.data)
136+
137+
env.data.ImportExchangeRates(env.ctx, &currency.MultiRateRecord{
138+
Time: time.Now(),
139+
Rates: map[string]float64{
140+
string(currency_lib.USD): 0.1,
141+
},
142+
})
143+
144+
return env
145+
}
146+
147+
func makeSendPublicPaymentIntent(t *testing.T, owner *common.Account, usdMarketValue float64, at time.Time) *intent.Record {
148+
return &intent.Record{
149+
IntentId: testutil.NewRandomAccount(t).PublicKey().ToBase58(),
150+
IntentType: intent.SendPublicPayment,
151+
152+
SendPublicPaymentMetadata: &intent.SendPublicPaymentMetadata{
153+
DestinationOwnerAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(),
154+
DestinationTokenAccount: testutil.NewRandomAccount(t).PublicKey().ToBase58(),
155+
Quantity: uint64(usdMarketValue),
156+
157+
ExchangeRate: 1,
158+
ExchangeCurrency: currency_lib.USD,
159+
NativeAmount: usdMarketValue,
160+
UsdMarketValue: usdMarketValue,
161+
},
162+
163+
InitiatorOwnerAccount: owner.PublicKey().ToBase58(),
164+
165+
State: intent.StatePending,
166+
CreatedAt: at,
167+
}
168+
}
169+
170+
func makeReceivePaymentsPubliclyIntent(t *testing.T, owner *common.Account, usdMarketValue float64, at time.Time) *intent.Record {
171+
return &intent.Record{
172+
IntentId: testutil.NewRandomAccount(t).PublicKey().ToBase58(),
173+
IntentType: intent.ReceivePaymentsPublicly,
174+
175+
ReceivePaymentsPubliclyMetadata: &intent.ReceivePaymentsPubliclyMetadata{
176+
Source: testutil.NewRandomAccount(t).PublicKey().ToBase58(),
177+
Quantity: uint64(usdMarketValue),
178+
IsRemoteSend: true,
179+
180+
OriginalExchangeCurrency: currency_lib.USD,
181+
OriginalExchangeRate: 1.0,
182+
OriginalNativeAmount: usdMarketValue,
183+
184+
UsdMarketValue: usdMarketValue,
185+
},
186+
187+
InitiatorOwnerAccount: owner.PublicKey().ToBase58(),
188+
189+
State: intent.StatePending,
190+
CreatedAt: at,
191+
}
192+
}

pkg/code/lawenforcement/metrics.go renamed to pkg/code/aml/metrics.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package lawenforcement
1+
package aml
22

33
import (
44
"context"
@@ -7,7 +7,7 @@ import (
77
)
88

99
const (
10-
metricsStructName = "lawenforcement.anti_money_laundering_guard"
10+
metricsStructName = "aml.guard"
1111

1212
eventName = "AntiMoneyLaunderingGuardDenial"
1313
)

pkg/code/data/intent/memory/store.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,20 @@ func (s *store) findByInitiatorAndType(intentType intent.Type, owner string) []*
126126
return res
127127
}
128128

129+
func (s *store) findByOwnerSinceTimestamp(owner string, since time.Time) []*intent.Record {
130+
res := make([]*intent.Record, 0)
131+
for _, item := range s.records {
132+
if item.CreatedAt.Before(since) {
133+
continue
134+
}
135+
136+
if item.InitiatorOwnerAccount == owner {
137+
res = append(res, item)
138+
}
139+
}
140+
return res
141+
}
142+
129143
func (s *store) filter(items []*intent.Record, cursor query.Cursor, limit uint64, direction query.Ordering) []*intent.Record {
130144
var start uint64
131145

@@ -203,6 +217,32 @@ func (s *store) filterByRemoteSendFlag(items []*intent.Record, want bool) []*int
203217
return res
204218
}
205219

220+
func sumQuarkAmount(items []*intent.Record) uint64 {
221+
var value uint64
222+
for _, item := range items {
223+
if item.SendPublicPaymentMetadata != nil {
224+
value += item.SendPublicPaymentMetadata.Quantity
225+
}
226+
if item.ReceivePaymentsPubliclyMetadata != nil {
227+
value += item.ReceivePaymentsPubliclyMetadata.Quantity
228+
}
229+
}
230+
return value
231+
}
232+
233+
func sumUsdMarketValue(items []*intent.Record) float64 {
234+
var value float64
235+
for _, item := range items {
236+
if item.SendPublicPaymentMetadata != nil {
237+
value += item.SendPublicPaymentMetadata.UsdMarketValue
238+
}
239+
if item.ReceivePaymentsPubliclyMetadata != nil {
240+
value += item.ReceivePaymentsPubliclyMetadata.UsdMarketValue
241+
}
242+
}
243+
return value
244+
}
245+
206246
func (s *store) Save(ctx context.Context, data *intent.Record) error {
207247
if err := data.Validate(); err != nil {
208248
return err
@@ -317,3 +357,13 @@ func (s *store) GetGiftCardClaimedIntent(ctx context.Context, giftCardVault stri
317357
cloned := items[0].Clone()
318358
return &cloned, nil
319359
}
360+
361+
func (s *store) GetTransactedAmountForAntiMoneyLaundering(ctx context.Context, owner string, since time.Time) (uint64, float64, error) {
362+
s.mu.Lock()
363+
defer s.mu.Unlock()
364+
365+
items := s.findByOwnerSinceTimestamp(owner, since)
366+
items = s.filterByState(items, false, intent.StateRevoked)
367+
items = s.filterByType(items, intent.SendPublicPayment)
368+
return sumQuarkAmount(items), sumUsdMarketValue(items), nil
369+
}

0 commit comments

Comments
 (0)