diff --git a/go.mod b/go.mod index b3a97897..7da05848 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,10 @@ require ( firebase.google.com/go/v4 v4.8.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/bits-and-blooms/bloom/v3 v3.1.0 - github.com/code-payments/code-protobuf-api v1.8.2 + github.com/code-payments/code-protobuf-api v1.8.3 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v0.1.0 github.com/golang-jwt/jwt/v5 v5.0.0 - github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/golang/protobuf v1.5.3 github.com/google/uuid v1.3.0 github.com/grpc-ecosystem/go-grpc-middleware v1.2.2 @@ -97,7 +96,6 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect golang.org/x/sys v0.13.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect diff --git a/go.sum b/go.sum index a7514582..943992ec 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/code-payments/code-protobuf-api v1.8.2 h1:fl5BS54jI8kqFG9qOyvkwdmZNX9FyG0WvgUTpu/2/dg= -github.com/code-payments/code-protobuf-api v1.8.2/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU= +github.com/code-payments/code-protobuf-api v1.8.3 h1:BEGKUvHZu5TvfsX4zhzSppuvZGlHH/DbvVBS0/oB18M= +github.com/code-payments/code-protobuf-api v1.8.3/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= @@ -175,8 +175,6 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -573,8 +571,6 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/pkg/code/antispam/metrics.go b/pkg/code/antispam/metrics.go index d3e9d16b..d864010b 100644 --- a/pkg/code/antispam/metrics.go +++ b/pkg/code/antispam/metrics.go @@ -23,6 +23,8 @@ const ( actionWelcomeBonus = "WelcomeBonus" actionReferralBonus = "ReferralBonus" + + actionSwap = "Swap" ) func recordDenialEvent(ctx context.Context, action, reason string) { diff --git a/pkg/code/antispam/swap.go b/pkg/code/antispam/swap.go new file mode 100644 index 00000000..9ee626af --- /dev/null +++ b/pkg/code/antispam/swap.go @@ -0,0 +1,79 @@ +package antispam + +import ( + "context" + + "github.com/sirupsen/logrus" + + "github.com/code-payments/code-server/pkg/code/common" + "github.com/code-payments/code-server/pkg/code/data/phone" + "github.com/code-payments/code-server/pkg/code/data/user/identity" + "github.com/code-payments/code-server/pkg/grpc/client" + "github.com/code-payments/code-server/pkg/metrics" +) + +// AllowSwap determines whether a phone-verified owner account can perform a swap. +// The objective here is to limit attacks against our Swap Subsidizer's SOL balance. +// +// todo: needs tests +func (g *Guard) AllowSwap(ctx context.Context, owner *common.Account) (bool, error) { + tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowSwap") + defer tracer.End() + + log := g.log.WithFields(logrus.Fields{ + "method": "AllowSwap", + "owner": owner.PublicKey().ToBase58(), + }) + log = client.InjectLoggingMetadata(ctx, log) + + // Deny abusers from known IPs + if isIpBanned(ctx) { + log.Info("ip is banned") + recordDenialEvent(ctx, actionSwap, "ip banned") + return false, nil + } + + verification, err := g.data.GetLatestPhoneVerificationForAccount(ctx, owner.PublicKey().ToBase58()) + if err == phone.ErrVerificationNotFound { + // Owner account was never phone verified, so deny the action. + log.Info("owner account is not phone verified") + recordDenialEvent(ctx, actionSwap, "not phone verified") + return false, nil + } else if err != nil { + tracer.OnError(err) + log.WithError(err).Warn("failure getting phone verification record") + return false, err + } + + log = log.WithField("phone", verification.PhoneNumber) + + // Deny abusers from known phone ranges + if hasBannedPhoneNumberPrefix(verification.PhoneNumber) { + log.Info("denying phone prefix") + recordDenialEvent(ctx, actionSwap, "phone prefix banned") + return false, nil + } + + user, err := g.data.GetUserByPhoneView(ctx, verification.PhoneNumber) + switch err { + case nil: + // Deny banned users forever + if user.IsBanned { + log.Info("denying banned user") + recordDenialEvent(ctx, actionSwap, "user banned") + return false, nil + } + + // Staff users have unlimited access to enable testing and demoing. + if user.IsStaffUser { + return true, nil + } + case identity.ErrNotFound: + default: + tracer.OnError(err) + log.WithError(err).Warn("failure getting user identity by phone view") + return false, err + } + + return true, nil +} diff --git a/pkg/code/server/grpc/transaction/v2/config.go b/pkg/code/server/grpc/transaction/v2/config.go index 9f09278e..04bd54e2 100644 --- a/pkg/code/server/grpc/transaction/v2/config.go +++ b/pkg/code/server/grpc/transaction/v2/config.go @@ -21,8 +21,11 @@ const ( SubmitIntentTimeoutConfigEnvName = envConfigPrefix + "SUBMIT_INTENT_TIMEOUT" defaultSubmitIntentTimeout = 5 * time.Second - SubmitIntentReceiveTimeoutConfigEnvName = envConfigPrefix + "SUBMIT_INTENT_RECEIVE_TIMEOUT" - defaultSubmitIntentReceiveTimeout = time.Second + SwapTimeoutConfigEnvName = envConfigPrefix + "SWAP_TIMEOUT" + defaultSwapTimeout = 60 * time.Second + + ClientReceiveTimeoutConfigEnvName = envConfigPrefix + "CLIENT_RECEIVE_TIMEOUT" + defaultClientReceiveTimeout = time.Second FeeCollectorTokenPublicKeyConfigEnvName = envConfigPrefix + "FEE_COLLECTOR_TOKEN_PUBLIC_KEY" defaultFeeCollectorPublicKey = "invalid" // Ensure something valid is set @@ -33,6 +36,9 @@ const ( AirdropperOwnerPublicKeyEnvName = envConfigPrefix + "AIRDROPPER_OWNER_PUBLIC_KEY" defaultAirdropperOwnerPublicKey = "invalid" // Ensure something valid is set + SwapSubsidizerOwnerPublicKeyEnvName = envConfigPrefix + "SWAP_SUBSIDIZER_OWNER_PUBLIC_KEY" + defaultSwapSubsidizerOwnerPublicKey = "invalid" // Ensure something valid is set + TreasuryPoolOneKinBucketConfigEnvName = envConfigPrefix + "TREASURY_POOL_1_KIN_BUCKET" TreasuryPoolTenKinBucketConfigEnvName = envConfigPrefix + "TREASURY_POOL_10_KIN_BUCKET" TreasuryPoolHundredKinBucketConfigEnvName = envConfigPrefix + "TREASURY_POOL_100_KIN_BUCKET" @@ -55,11 +61,13 @@ type conf struct { disableAmlChecks config.Bool // To avoid limits during testing disableBlockchainChecks config.Bool submitIntentTimeout config.Duration - submitIntentReceiveTimeout config.Duration + swapTimeout config.Duration + clientReceiveTimeout config.Duration feeCollectorTokenPublicKey config.String enableAirdrops config.Bool enableAsyncAirdropProcessing config.Bool airdropperOwnerPublicKey config.String + swapSubsidizerOwnerPublicKey config.String treasuryPoolOneKinBucket config.String treasuryPoolTenKinBucket config.String treasuryPoolHundredKinBucket config.String @@ -84,11 +92,13 @@ func WithEnvConfigs() ConfigProvider { disableAmlChecks: wrapper.NewBoolConfig(memory.NewConfig(false), false), disableBlockchainChecks: env.NewBoolConfig(DisableBlockchainChecksConfigEnvName, defaultDisableBlockchainChecks), submitIntentTimeout: env.NewDurationConfig(SubmitIntentTimeoutConfigEnvName, defaultSubmitIntentTimeout), - submitIntentReceiveTimeout: env.NewDurationConfig(SubmitIntentReceiveTimeoutConfigEnvName, defaultSubmitIntentReceiveTimeout), + swapTimeout: env.NewDurationConfig(SwapTimeoutConfigEnvName, defaultSwapTimeout), + clientReceiveTimeout: env.NewDurationConfig(ClientReceiveTimeoutConfigEnvName, defaultClientReceiveTimeout), feeCollectorTokenPublicKey: env.NewStringConfig(FeeCollectorTokenPublicKeyConfigEnvName, defaultFeeCollectorPublicKey), enableAirdrops: env.NewBoolConfig(EnableAirdropsConfigEnvName, defaultEnableAirdrops), enableAsyncAirdropProcessing: wrapper.NewBoolConfig(memory.NewConfig(true), true), airdropperOwnerPublicKey: env.NewStringConfig(AirdropperOwnerPublicKeyEnvName, defaultAirdropperOwnerPublicKey), + swapSubsidizerOwnerPublicKey: env.NewStringConfig(SwapSubsidizerOwnerPublicKeyEnvName, defaultSwapSubsidizerOwnerPublicKey), treasuryPoolOneKinBucket: env.NewStringConfig(TreasuryPoolOneKinBucketConfigEnvName, defaultTreasuryPoolName), treasuryPoolTenKinBucket: env.NewStringConfig(TreasuryPoolTenKinBucketConfigEnvName, defaultTreasuryPoolName), treasuryPoolHundredKinBucket: env.NewStringConfig(TreasuryPoolHundredKinBucketConfigEnvName, defaultTreasuryPoolName), @@ -108,7 +118,7 @@ type testOverrides struct { enableAntispamChecks bool enableAmlChecks bool enableAirdrops bool - submitIntentReceiveTimeout time.Duration + clientReceiveTimeout time.Duration feeCollectorTokenPublicKey string treasuryPoolOneKinBucket string treasuryPoolTenKinBucket string @@ -127,11 +137,13 @@ func withManualTestOverrides(overrides *testOverrides) ConfigProvider { disableAmlChecks: wrapper.NewBoolConfig(memory.NewConfig(!overrides.enableAmlChecks), false), disableBlockchainChecks: wrapper.NewBoolConfig(memory.NewConfig(true), true), submitIntentTimeout: wrapper.NewDurationConfig(memory.NewConfig(defaultSubmitIntentTimeout), defaultSubmitIntentTimeout), - submitIntentReceiveTimeout: wrapper.NewDurationConfig(memory.NewConfig(overrides.submitIntentReceiveTimeout), defaultSubmitIntentReceiveTimeout), + swapTimeout: wrapper.NewDurationConfig(memory.NewConfig(defaultSwapTimeout), defaultSwapTimeout), + clientReceiveTimeout: wrapper.NewDurationConfig(memory.NewConfig(overrides.clientReceiveTimeout), defaultClientReceiveTimeout), feeCollectorTokenPublicKey: wrapper.NewStringConfig(memory.NewConfig(overrides.feeCollectorTokenPublicKey), defaultFeeCollectorPublicKey), enableAirdrops: wrapper.NewBoolConfig(memory.NewConfig(overrides.enableAirdrops), false), enableAsyncAirdropProcessing: wrapper.NewBoolConfig(memory.NewConfig(false), false), airdropperOwnerPublicKey: wrapper.NewStringConfig(memory.NewConfig(defaultAirdropperOwnerPublicKey), defaultAirdropperOwnerPublicKey), + swapSubsidizerOwnerPublicKey: wrapper.NewStringConfig(memory.NewConfig(defaultSwapSubsidizerOwnerPublicKey), defaultSwapSubsidizerOwnerPublicKey), treasuryPoolOneKinBucket: wrapper.NewStringConfig(memory.NewConfig(overrides.treasuryPoolOneKinBucket), defaultTreasuryPoolName), treasuryPoolTenKinBucket: wrapper.NewStringConfig(memory.NewConfig(overrides.treasuryPoolTenKinBucket), defaultTreasuryPoolName), treasuryPoolHundredKinBucket: wrapper.NewStringConfig(memory.NewConfig(overrides.treasuryPoolHundredKinBucket), defaultTreasuryPoolName), diff --git a/pkg/code/server/grpc/transaction/v2/errors.go b/pkg/code/server/grpc/transaction/v2/errors.go index 276ca863..f680c138 100644 --- a/pkg/code/server/grpc/transaction/v2/errors.go +++ b/pkg/code/server/grpc/transaction/v2/errors.go @@ -10,8 +10,8 @@ import ( commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1" transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" - "github.com/code-payments/code-server/pkg/solana" "github.com/code-payments/code-server/pkg/code/transaction" + "github.com/code-payments/code-server/pkg/solana" ) const ( @@ -84,6 +84,42 @@ func (e IntentDeniedError) Error() string { return e.message } +type SwapValidationError struct { + message string +} + +func newSwapValidationError(message string) SwapValidationError { + return SwapValidationError{ + message: message, + } +} + +func newSwapValidationErrorf(format string, args ...any) SwapValidationError { + return newSwapValidationError(fmt.Sprintf(format, args...)) +} + +func (e SwapValidationError) Error() string { + return e.message +} + +type SwapDeniedError struct { + message string +} + +func newSwapDeniedError(message string) SwapDeniedError { + return SwapDeniedError{ + message: message, + } +} + +func newSwapDeniedErrorf(format string, args ...any) SwapDeniedError { + return newSwapDeniedError(fmt.Sprintf(format, args...)) +} + +func (e SwapDeniedError) Error() string { + return e.message +} + type StaleStateError struct { message string } @@ -209,3 +245,56 @@ func handleSubmitIntentStructuredError(streamer transactionpb.Transaction_Submit } return streamer.Send(errResp) } + +func handleSwapError(streamer transactionpb.Transaction_SwapServer, err error) error { + // gRPC status errors are passed through as is + if _, ok := status.FromError(err); ok { + return err + } + + // Case 1: Errors that map to a Code error response + switch err.(type) { + case SwapValidationError: + return handleSwapStructuredError( + streamer, + transactionpb.SwapResponse_Error_INVALID_SWAP, + toReasonStringErrorDetails(err), + ) + case SwapDeniedError: + return handleSwapStructuredError( + streamer, + transactionpb.SwapResponse_Error_DENIED, + toReasonStringErrorDetails(err), + ) + } + + switch err { + case ErrInvalidSignature: + return handleSwapStructuredError( + streamer, + transactionpb.SwapResponse_Error_SIGNATURE_ERROR, + toReasonStringErrorDetails(err), + ) + case ErrNotImplemented: + return status.Error(codes.Unimplemented, err.Error()) + } + + // Case 2: Errors that map to gRPC status errors + switch err { + case ErrTimedOutReceivingRequest: + return status.Error(codes.DeadlineExceeded, err.Error()) + } + return status.Error(codes.Internal, "rpc server failure") +} + +func handleSwapStructuredError(streamer transactionpb.Transaction_SwapServer, code transactionpb.SwapResponse_Error_Code, errorDetails ...*transactionpb.ErrorDetails) error { + errResp := &transactionpb.SwapResponse{ + Response: &transactionpb.SwapResponse_Error_{ + Error: &transactionpb.SwapResponse_Error{ + Code: code, + ErrorDetails: errorDetails, + }, + }, + } + return streamer.Send(errResp) +} diff --git a/pkg/code/server/grpc/transaction/v2/intent.go b/pkg/code/server/grpc/transaction/v2/intent.go index 3b35ff2c..deb5c7ae 100644 --- a/pkg/code/server/grpc/transaction/v2/intent.go +++ b/pkg/code/server/grpc/transaction/v2/intent.go @@ -22,7 +22,6 @@ import ( messagingpb "github.com/code-payments/code-protobuf-api/generated/go/messaging/v1" transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" - "github.com/code-payments/code-server/pkg/code/chat" chat_util "github.com/code-payments/code-server/pkg/code/chat" "github.com/code-payments/code-server/pkg/code/common" code_data "github.com/code-payments/code-server/pkg/code/data" @@ -46,7 +45,8 @@ import ( ) const ( - // Assumes the client signature index is consistent across all transactions. + // Assumes the client signature index is consistent across all transactions, + // including those constructed in the SubmitIntent and Swap RPCs. clientSignatureIndex = 1 ) @@ -800,7 +800,7 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm } } - var chatMessagesToPush []*chat.MessageWithOwner + var chatMessagesToPush []*chat_util.MessageWithOwner // Save all of the required DB records in one transaction to complete the // intent operation. It's very bad if we end up failing halfway through. @@ -1025,7 +1025,7 @@ func (s *transactionServer) boundedSubmitIntentRecv(ctx context.Context, streame }() select { - case <-time.After(s.conf.submitIntentReceiveTimeout.Get(ctx)): + case <-time.After(s.conf.clientReceiveTimeout.Get(ctx)): return nil, ErrTimedOutReceivingRequest case <-done: return req, err diff --git a/pkg/code/server/grpc/transaction/v2/intent_test.go b/pkg/code/server/grpc/transaction/v2/intent_test.go index d131ac01..e6563a44 100644 --- a/pkg/code/server/grpc/transaction/v2/intent_test.go +++ b/pkg/code/server/grpc/transaction/v2/intent_test.go @@ -2696,7 +2696,7 @@ func TestSubmitIntent_InvalidNumberOfSignaturesSubmitted(t *testing.T) { func TestSubmitIntent_TimeBoundedRequestSend(t *testing.T) { server, phone, _, cleanup := setupTestEnv(t, &testOverrides{ - submitIntentReceiveTimeout: 100 * time.Millisecond, + clientReceiveTimeout: 100 * time.Millisecond, }) defer cleanup() diff --git a/pkg/code/server/grpc/transaction/v2/server.go b/pkg/code/server/grpc/transaction/v2/server.go index cb1660dd..3b852327 100644 --- a/pkg/code/server/grpc/transaction/v2/server.go +++ b/pkg/code/server/grpc/transaction/v2/server.go @@ -15,6 +15,7 @@ import ( code_data "github.com/code-payments/code-server/pkg/code/data" "github.com/code-payments/code-server/pkg/code/lawenforcement" "github.com/code-payments/code-server/pkg/code/server/grpc/messaging" + "github.com/code-payments/code-server/pkg/jupiter" "github.com/code-payments/code-server/pkg/kin" push_lib "github.com/code-payments/code-server/pkg/push" sync_util "github.com/code-payments/code-server/pkg/sync" @@ -30,10 +31,11 @@ type transactionServer struct { pusher push_lib.Provider - maxmind *maxminddb.Reader + jupiterClient *jupiter.Client messagingClient messaging.InternalMessageClient + maxmind *maxminddb.Reader antispamGuard *antispam.Guard amlGuard *lawenforcement.AntiMoneyLaunderingGuard @@ -53,6 +55,8 @@ type transactionServer struct { airdropperLock sync.Mutex airdropper *common.TimelockAccounts + swapSubsidizer *common.Account + feeCollector *common.Account transactionpb.UnimplementedTransactionServer @@ -61,9 +65,10 @@ type transactionServer struct { func NewTransactionServer( data code_data.Provider, pusher push_lib.Provider, - antispamGuard *antispam.Guard, - maxmind *maxminddb.Reader, + jupiterClient *jupiter.Client, messagingClient messaging.InternalMessageClient, + maxmind *maxminddb.Reader, + antispamGuard *antispam.Guard, configProvider ConfigProvider, ) transactionpb.TransactionServer { ctx := context.Background() @@ -82,10 +87,11 @@ func NewTransactionServer( pusher: pusher, - maxmind: maxmind, + jupiterClient: jupiterClient, messagingClient: messagingClient, + maxmind: maxmind, antispamGuard: antispamGuard, amlGuard: lawenforcement.NewAntiMoneyLaunderingGuard(data), @@ -123,6 +129,11 @@ func NewTransactionServer( s.mustLoadAirdropper(ctx) } + swapSubsidizer := s.conf.swapSubsidizerOwnerPublicKey.Get(ctx) + if len(swapSubsidizer) > 0 && swapSubsidizer != defaultSwapSubsidizerOwnerPublicKey { + s.mustLoadSwapSubsidizer(ctx) + } + feeCollector, err := common.NewAccountFromPublicKeyString(conf.feeCollectorTokenPublicKey.Get(ctx)) if err != nil { s.log.WithError(err).Fatal("failure loading fee collector account") diff --git a/pkg/code/server/grpc/transaction/v2/swap.go b/pkg/code/server/grpc/transaction/v2/swap.go new file mode 100644 index 00000000..0cd7624d --- /dev/null +++ b/pkg/code/server/grpc/transaction/v2/swap.go @@ -0,0 +1,497 @@ +package transaction_v2 + +import ( + "bytes" + "context" + "crypto/ed25519" + "time" + + "github.com/mr-tron/base58" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1" + transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2" + + "github.com/code-payments/code-server/pkg/code/common" + "github.com/code-payments/code-server/pkg/code/data/account" + currency_lib "github.com/code-payments/code-server/pkg/currency" + "github.com/code-payments/code-server/pkg/grpc/client" + "github.com/code-payments/code-server/pkg/jupiter" + "github.com/code-payments/code-server/pkg/kin" + "github.com/code-payments/code-server/pkg/solana" + compute_budget "github.com/code-payments/code-server/pkg/solana/computebudget" + swap_validator "github.com/code-payments/code-server/pkg/solana/swapvalidator" + "github.com/code-payments/code-server/pkg/solana/token" + "github.com/code-payments/code-server/pkg/usdc" +) + +func (s *transactionServer) Swap(streamer transactionpb.Transaction_SwapServer) error { + ctx, cancel := context.WithTimeout(streamer.Context(), s.conf.swapTimeout.Get(streamer.Context())) + defer cancel() + + log := s.log.WithField("method", "Swap") + log = log.WithContext(ctx) + log = client.InjectLoggingMetadata(ctx, log) + + if s.swapSubsidizer == nil { + log.Warn("swap subsidizer is not configured") + return handleSwapError(streamer, status.Error(codes.Unavailable, "")) + } + + req, err := s.boundedSwapRecv(ctx, streamer) + if err != nil { + log.WithError(err).Info("error receiving request from client") + return err + } + + // Client starts a swap by sending the initiation request + initiateReq := req.GetInitiate() + if initiateReq == nil { + return handleSwapError(streamer, status.Error(codes.InvalidArgument, "SwapRequest.Initiate is nil")) + } + + owner, err := common.NewAccountFromProto(initiateReq.Owner) + if err != nil { + log.WithError(err).Warn("invalid owner account") + return handleSwapError(streamer, err) + } + log = log.WithField("owner", owner.PublicKey().ToBase58()) + + signature := initiateReq.Signature + initiateReq.Signature = nil + if err := s.auth.Authenticate(ctx, owner, initiateReq, signature); err != nil { + return err + } + + // + // Section: Antispam + // + + allow, err := s.antispamGuard.AllowSwap(ctx, owner) + if err != nil { + return handleSwapError(streamer, err) + } else if !allow { + return handleSwapError(streamer, newSwapDeniedError("rate limited")) + } + + // + // Section: Swap parameter setup (accounts, balances, etc.) + // + + swapAuthority, err := common.NewAccountFromProto(initiateReq.SwapAuthority) + if err != nil { + log.WithError(err).Warn("invalid swap authority") + return handleSwapError(streamer, err) + } + + usdcAtaBytes, err := token.GetAssociatedAccount(swapAuthority.PublicKey().ToBytes(), usdc.TokenMint) + if err != nil { + log.WithError(err).Warn("failure deriving usdc ata") + return handleSwapError(streamer, err) + } + swapSource, err := common.NewAccountFromPublicKeyBytes(usdcAtaBytes) + if err != nil { + log.WithError(err).Warn("invalid usdc ata") + return handleSwapError(streamer, err) + } + log = log.WithField("swap_source", swapSource.PublicKey().ToBase58()) + + accountInfoRecord, err := s.data.GetAccountInfoByAuthorityAddress(ctx, owner.PublicKey().ToBase58()) + switch err { + case nil: + if accountInfoRecord.AccountType != commonpb.AccountType_PRIMARY { + // Should never happen if we're doing phone number validation against + // the owner account. + return handleSwapError(streamer, newSwapValidationError("owner must be authority to primary account")) + } + case account.ErrAccountInfoNotFound: + return handleSwapError(streamer, newSwapValidationError("must submit open accounts intent")) + default: + log.WithError(err).Warn("failure getting account info record") + return handleSwapError(streamer, err) + } + + swapDestination, err := common.NewAccountFromPublicKeyString(accountInfoRecord.TokenAccount) + if err != nil { + log.WithError(err).Warn("invalid kin primary account") + return handleSwapError(streamer, err) + } + log = log.WithField("swap_destination", swapDestination.PublicKey().ToBase58()) + + swapSourceBalance, err := s.data.GetBlockchainBalance(ctx, swapSource.PublicKey().ToBase58()) + if err != nil { + log.WithError(err).Warn("failure getting swap source account balance") + return handleSwapError(streamer, err) + } + + var amountToSwap uint64 + if initiateReq.Limit == 0 { + amountToSwap = swapSourceBalance + } else { + amountToSwap = initiateReq.Limit + } + if amountToSwap == 0 { + return handleSwapError(streamer, newSwapValidationError("usdc account balance is 0")) + } else if swapSourceBalance < amountToSwap { + return handleSwapError(streamer, newSwapValidationError("insufficient usdc balance")) + } + log = log.WithField("amount_to_swap", amountToSwap) + + // + // Section: Jupiter routing + // + + quote, err := s.jupiterClient.GetQuote( + ctx, + usdc.Mint, + kin.Mint, + amountToSwap, + 50, // todo: configurable slippage or something based on liquidity? + true, // Direct routes for now since we're using legacy instructions + 16, // Max accounts limited due to the use of legacy instructions + true, // Force legacy instructions + ) + if err != nil { + log.WithError(err).Warn("failure getting quote from jupiter") + return handleSwapError(streamer, err) + } + + jupiterSwapIxns, err := s.jupiterClient.GetSwapInstructions( + ctx, + quote, + swapAuthority.PublicKey().ToBase58(), + swapDestination.PublicKey().ToBase58(), + ) + if err != nil { + log.WithError(err).Warn("failure getting swap instructions from jupiter") + return handleSwapError(streamer, err) + } + + log = log.WithField("estimated_amount_to_receive", quote.GetEstimatedSwapAmount()) + + // + // Section: Validation + // + + if err := s.validateSwap(ctx, amountToSwap, quote, jupiterSwapIxns); err != nil { + switch err.(type) { + case SwapValidationError: + log.WithError(err).Warn("swap failed validation") + default: + log.WithError(err).Warn("failure performing swap validation") + } + return handleSwapError(streamer, err) + } + + // + // Section: Transaction construction + // + + swapNonce, err := common.NewRandomAccount() + if err != nil { + log.WithError(err).Warn("failure generating swap nonce") + return handleSwapError(streamer, err) + } + + preSwapState, preSwapStateBump, err := swap_validator.GetPreSwapStateAddress(&swap_validator.GetPreSwapStateAddressArgs{ + Source: swapSource.PublicKey().ToBytes(), + Destination: swapDestination.PublicKey().ToBytes(), + Nonce: swapNonce.PublicKey().ToBytes(), + }) + if err != nil { + log.WithError(err).Warn("failure deriving pre swap state account address") + return handleSwapError(streamer, err) + } + + var remainingAccountsToValidate []swap_validator.AccountMeta + for _, accountMeta := range jupiterSwapIxns.SwapInstruction.Accounts { + if accountMeta.IsWritable || accountMeta.IsSigner { + if bytes.Equal(accountMeta.PublicKey, swapAuthority.PublicKey().ToBytes()) || + bytes.Equal(accountMeta.PublicKey, swapSource.PublicKey().ToBytes()) || + bytes.Equal(accountMeta.PublicKey, swapDestination.PublicKey().ToBytes()) { + continue + } + + remainingAccountsToValidate = append(remainingAccountsToValidate, swap_validator.AccountMeta{ + PublicKey: accountMeta.PublicKey, + }) + } + } + + preSwapIxn := swap_validator.NewPreSwapInstruction( + &swap_validator.PreSwapInstructionAccounts{ + PreSwapState: preSwapState, + User: swapAuthority.PublicKey().ToBytes(), + Source: swapSource.PublicKey().ToBytes(), + Destination: swapDestination.PublicKey().ToBytes(), + Nonce: swapNonce.PublicKey().ToBytes(), + Payer: s.swapSubsidizer.PublicKey().ToBytes(), + RemainingAccounts: remainingAccountsToValidate, + }, + &swap_validator.PreSwapInstructionArgs{}, + ).ToLegacyInstruction() + + postSwapIxn := swap_validator.NewPostSwapInstruction( + &swap_validator.PostSwapInstructionAccounts{ + PreSwapState: preSwapState, + Source: swapSource.PublicKey().ToBytes(), + Destination: swapDestination.PublicKey().ToBytes(), + Payer: s.swapSubsidizer.PublicKey().ToBytes(), + }, + &swap_validator.PostSwapInstructionArgs{ + StateBump: preSwapStateBump, + MaxToSend: amountToSwap, + MinToReceive: quote.GetEstimatedSwapAmount(), + }, + ).ToLegacyInstruction() + + var ixns []solana.Instruction + ixns = append(ixns, jupiterSwapIxns.ComputeBudgetInstructions...) + ixns = append(ixns, preSwapIxn, jupiterSwapIxns.SwapInstruction, postSwapIxn) + + txn := solana.NewTransaction(s.swapSubsidizer.PublicKey().ToBytes(), ixns...) + + blockhash, err := s.data.GetBlockchainLatestBlockhash(ctx) + if err != nil { + log.WithError(err).Warn("failure getting latest blockhash") + return handleSwapError(streamer, err) + } + txn.SetBlockhash(blockhash) + + // + // Section: Server parameters + // + + computeUnitLimit, _ := compute_budget.DecompileSetComputeUnitLimitIxnData(jupiterSwapIxns.ComputeBudgetInstructions[0].Data) + computeUnitPrice, _ := compute_budget.DecompileSetComputeUnitPriceIxnData(jupiterSwapIxns.ComputeBudgetInstructions[1].Data) + + var protoSwapIxnAccounts []*commonpb.InstructionAccount + for _, ixnAccount := range jupiterSwapIxns.SwapInstruction.Accounts { + protoSwapIxnAccounts = append(protoSwapIxnAccounts, &commonpb.InstructionAccount{ + Account: &commonpb.SolanaAccountId{Value: ixnAccount.PublicKey}, + IsSigner: ixnAccount.IsSigner, + IsWritable: ixnAccount.IsWritable, + }) + } + + // Server responds back with parameters, so client can locally construct the + // transaction and validate it. + serverParameters := &transactionpb.SwapResponse{ + Response: &transactionpb.SwapResponse_ServerParamenters{ + ServerParamenters: &transactionpb.SwapResponse_ServerParameters{ + Payer: s.swapSubsidizer.ToProto(), + RecentBlockhash: &commonpb.Blockhash{Value: blockhash[:]}, + ComputeUnitLimit: computeUnitLimit, + ComputeUnitPrice: computeUnitPrice, + SwapProgram: &commonpb.SolanaAccountId{Value: jupiterSwapIxns.SwapInstruction.Program}, + SwapIxnAccounts: protoSwapIxnAccounts, + SwapIxnData: jupiterSwapIxns.SwapInstruction.Data, + MaxToSend: amountToSwap, + MinToReceive: quote.GetEstimatedSwapAmount(), + Nonce: swapNonce.ToProto(), + }, + }, + } + if err := streamer.Send(serverParameters); err != nil { + return handleSwapError(streamer, err) + } + + // + // Section: Transaction signing + // + + req, err = s.boundedSwapRecv(ctx, streamer) + if err != nil { + log.WithError(err).Info("error receiving request from client") + return err + } + + // Client responds back with a signatures to the swap transaction + submitSignatureReq := req.GetSubmitSignature() + if submitSignatureReq == nil { + return handleSwapError(streamer, status.Error(codes.InvalidArgument, "SwapRequest.SubmitSignature is nil")) + } + + if !ed25519.Verify( + swapAuthority.PublicKey().ToBytes(), + txn.Message.Marshal(), + submitSignatureReq.Signature.Value, + ) { + return handleSwapStructuredError( + streamer, + transactionpb.SwapResponse_Error_SIGNATURE_ERROR, + toInvalidSignatureErrorDetails(0, txn, submitSignatureReq.Signature), + ) + } + + copy(txn.Signatures[clientSignatureIndex][:], submitSignatureReq.Signature.Value) + txn.Sign(s.swapSubsidizer.PrivateKey().ToBytes()) + + log = log.WithField("transaction_id", base58.Encode(txn.Signature())) + + // + // Section: Transaction submission + // + + _, err = s.data.SubmitBlockchainTransaction(ctx, &txn) + if err != nil { + log.WithError(err).Warn("failure submitting transaction") + return handleSwapStructuredError( + streamer, + transactionpb.SwapResponse_Error_SWAP_FAILED, + toReasonStringErrorDetails(err), + ) + } + + log.Debug("submitted transaction") + + if !initiateReq.WaitForBlockchainStatus { + err = streamer.Send(&transactionpb.SwapResponse{ + Response: &transactionpb.SwapResponse_Success_{ + Success: &transactionpb.SwapResponse_Success{ + Code: transactionpb.SwapResponse_Success_SWAP_SUBMITTED, + }, + }, + }) + return handleSwapError(streamer, err) + } + + for { + time.Sleep(time.Second) + + statuses, err := s.data.GetBlockchainSignatureStatuses(ctx, []solana.Signature{solana.Signature(txn.Signature())}) + if err != nil { + continue + } + + if len(statuses) == 0 || statuses[0] == nil { + continue + } + + if statuses[0].ErrorResult != nil { + log.WithError(statuses[0].ErrorResult).Warn("transaction failed") + return handleSwapStructuredError(streamer, transactionpb.SwapResponse_Error_SWAP_FAILED) + } + + if statuses[0].Finalized() { + log.Debug("transaction succeeded and is finalized") + err = streamer.Send(&transactionpb.SwapResponse{ + Response: &transactionpb.SwapResponse_Success_{ + Success: &transactionpb.SwapResponse_Success{ + Code: transactionpb.SwapResponse_Success_SWAP_FINALIZED, + }, + }, + }) + return handleSwapError(streamer, err) + } + } +} + +func (s *transactionServer) validateSwap( + ctx context.Context, + amountToSwap uint64, + quote *jupiter.Quote, + ixns *jupiter.SwapInstructions, +) error { + // + // Part 1: Expected instructions sanity check + // + + if len(ixns.ComputeBudgetInstructions) != 2 { + return newSwapValidationError("expected two compute budget instructions") + } + + if len(ixns.SetupInstructions) != 0 || ixns.TokenLedgerInstruction != nil || ixns.CleanupInstruction != nil { + return newSwapValidationError("unexpected instruction") + } + + // + // Part 2: Compute budget instructions + // + + if !bytes.Equal(ixns.ComputeBudgetInstructions[0].Program, compute_budget.ProgramKey) || !bytes.Equal(ixns.ComputeBudgetInstructions[1].Program, compute_budget.ProgramKey) { + return newSwapValidationError("invalid ComputeBudget program key") + } + + if len(ixns.ComputeBudgetInstructions[0].Accounts) != 0 || len(ixns.ComputeBudgetInstructions[1].Accounts) != 0 { + return newSwapValidationError("invalid ComputeBudget instruction accounts") + } + + if _, err := compute_budget.DecompileSetComputeUnitLimitIxnData(ixns.ComputeBudgetInstructions[0].Data); err != nil { + return newSwapValidationErrorf("invalid ComputeBudget::SetComputeUnitLimit instruction data: %s", err.Error()) + } + + if _, err := compute_budget.DecompileSetComputeUnitPriceIxnData(ixns.ComputeBudgetInstructions[1].Data); err != nil { + return newSwapValidationErrorf("invalid ComputeBudget::SetComputeUnitPrice instruction data: %s", err.Error()) + } + + // + // Part 3: Swap instruction + // + + for _, ixnAccount := range ixns.SwapInstruction.Accounts { + if bytes.Equal(ixnAccount.PublicKey, s.swapSubsidizer.PublicKey().ToBytes()) { + return newSwapValidationError("swap subsidizer used in swap instruction") + } + } + + usdcAmount := float64(amountToSwap) / float64(usdc.QuarksPerUsdc) + kinAmount := float64(quote.GetEstimatedSwapAmount()) / float64(kin.QuarksPerKin) + swapRate := usdcAmount / kinAmount + + usdExchangeRateRateRecord, err := s.data.GetExchangeRate(ctx, currency_lib.USD, time.Now()) + if err != nil { + return errors.Wrap(err, "error getting usd exchange rate record") + } + + // todo: configurable + swapRateThreshold := 1.25 + if swapRate/usdExchangeRateRateRecord.Rate > swapRateThreshold { + return newSwapValidationErrorf("swap rate exceeds current exchange rate by %.2fx", swapRateThreshold) + } + + return nil +} + +func (s *transactionServer) mustLoadSwapSubsidizer(ctx context.Context) { + log := s.log.WithFields(logrus.Fields{ + "method": "mustLoadSwapSubsidizer", + "key": s.conf.swapSubsidizerOwnerPublicKey.Get(ctx), + }) + + err := func() error { + vaultRecord, err := s.data.GetKey(ctx, s.conf.swapSubsidizerOwnerPublicKey.Get(ctx)) + if err != nil { + return err + } + + ownerAccount, err := common.NewAccountFromPrivateKeyString(vaultRecord.PrivateKey) + if err != nil { + return err + } + + s.swapSubsidizer = ownerAccount + return nil + }() + if err != nil { + log.WithError(err).Fatal("failure loading account") + } +} + +func (s *transactionServer) boundedSwapRecv(ctx context.Context, streamer transactionpb.Transaction_SwapServer) (req *transactionpb.SwapRequest, err error) { + done := make(chan struct{}) + go func() { + req, err = streamer.Recv() + close(done) + }() + + select { + case <-time.After(s.conf.clientReceiveTimeout.Get(ctx)): + return nil, ErrTimedOutReceivingRequest + case <-done: + return req, err + } +} diff --git a/pkg/code/server/grpc/transaction/v2/testutil.go b/pkg/code/server/grpc/transaction/v2/testutil.go index c1bbab13..ec2e40be 100644 --- a/pkg/code/server/grpc/transaction/v2/testutil.go +++ b/pkg/code/server/grpc/transaction/v2/testutil.go @@ -75,8 +75,8 @@ import ( func setupTestEnv(t *testing.T, serverOverrides *testOverrides) (serverTestEnv, phoneTestEnv, phoneTestEnv, func()) { var err error - if serverOverrides.submitIntentReceiveTimeout == 0 { - serverOverrides.submitIntentReceiveTimeout = defaultSubmitIntentReceiveTimeout + if serverOverrides.clientReceiveTimeout == 0 { + serverOverrides.clientReceiveTimeout = defaultClientReceiveTimeout } db := code_data.NewTestDataProvider() @@ -178,9 +178,10 @@ func setupTestEnv(t *testing.T, serverOverrides *testOverrides) (serverTestEnv, testService := NewTransactionServer( db, memory_push.NewPushProvider(), - antispam.NewGuard(db, memory_device_verifier.NewMemoryDeviceVerifier(), nil), nil, messaging.NewMessagingClient(db), + nil, + antispam.NewGuard(db, memory_device_verifier.NewMemoryDeviceVerifier(), nil), withManualTestOverrides(serverOverrides), ) grpcTestServer.RegisterService(func(server *grpc.Server) { diff --git a/pkg/jupiter/client.go b/pkg/jupiter/client.go index 28b3d7a2..a4552e25 100644 --- a/pkg/jupiter/client.go +++ b/pkg/jupiter/client.go @@ -13,6 +13,7 @@ import ( "github.com/mr-tron/base58" "github.com/pkg/errors" + "github.com/code-payments/code-server/pkg/metrics" "github.com/code-payments/code-server/pkg/solana" ) @@ -23,6 +24,8 @@ const ( quoteEndpointName = "quote" swapInstructionsEndpointName = "swap-instructions" + + metricsStructName = "jupiter.client" ) type Client struct { @@ -59,6 +62,9 @@ func (c *Client) GetQuote( maxAccounts uint8, useLegacyInstruction bool, ) (*Quote, error) { + tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetQuote") + defer tracer.End() + url := fmt.Sprintf( "%s%s?inputMint=%s&outputMint=%s&amount=%d&slippageBps=%d&onlyDirectRoutes=%v&maxAccounts=%d&asLegacyTransaction=%v", c.baseUrl, @@ -120,19 +126,20 @@ func (c *Client) GetSwapInstructions( quote *Quote, owner string, destinationTokenAccount string, - pricePerComputeUnit uint64, ) (*SwapInstructions, error) { + tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetSwapInstructions") + defer tracer.End() + if !quote.useLegacyInstructions { return nil, errors.New("only legacy transactions are supported") } // todo: struct this reqBody := fmt.Sprintf( - `{"quoteResponse": %s, "userPublicKey": "%s", "destinationTokenAccount": "%s", "computeUnitPriceMicroLamports": %d, "asLegacyTransaction": %v}`, + `{"quoteResponse": %s, "userPublicKey": "%s", "destinationTokenAccount": "%s", "prioritizationFeeLamports": "auto", "asLegacyTransaction": %v}`, quote.jsonString, owner, destinationTokenAccount, - pricePerComputeUnit, quote.useLegacyInstructions, ) diff --git a/pkg/solana/computebudget/program.go b/pkg/solana/computebudget/program.go new file mode 100644 index 00000000..b93de284 --- /dev/null +++ b/pkg/solana/computebudget/program.go @@ -0,0 +1,65 @@ +package compute_budget + +import ( + "crypto/ed25519" + "encoding/binary" + "errors" + + "github.com/code-payments/code-server/pkg/solana" +) + +// ComputeBudget111111111111111111111111111111 +var ProgramKey = ed25519.PublicKey{3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, 231, 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0} + +const ( + commandRequestUnits uint8 = iota + commandRequestHeapFrame + commandSetComputeUnitLimit + commandSetComputeUnitPrice +) + +func SetComputeUnitLimit(computeUnitLimit uint32) solana.Instruction { + data := make([]byte, 1+4) + data[0] = commandSetComputeUnitLimit + binary.LittleEndian.PutUint32(data[1:], computeUnitLimit) + + return solana.NewInstruction( + ProgramKey[:], + data, + ) +} + +func SetComputeUnitPrice(computeUnitPrice uint64) solana.Instruction { + data := make([]byte, 1+8) + data[0] = commandSetComputeUnitPrice + binary.LittleEndian.PutUint64(data[1:], computeUnitPrice) + + return solana.NewInstruction( + ProgramKey[:], + data, + ) +} + +func DecompileSetComputeUnitLimitIxnData(data []byte) (uint32, error) { + if len(data) != 5 { + return 0, errors.New("invalid length") + } + + if data[0] != commandSetComputeUnitLimit { + return 0, errors.New("invalid instruction") + } + + return binary.LittleEndian.Uint32(data[1:]), nil +} + +func DecompileSetComputeUnitPriceIxnData(data []byte) (uint64, error) { + if len(data) != 9 { + return 0, errors.New("invalid length") + } + + if data[0] != commandSetComputeUnitPrice { + return 0, errors.New("invalid instruction") + } + + return binary.LittleEndian.Uint64(data[1:]), nil +} diff --git a/pkg/usdc/usdc.go b/pkg/usdc/usdc.go new file mode 100644 index 00000000..ddf6d06a --- /dev/null +++ b/pkg/usdc/usdc.go @@ -0,0 +1,15 @@ +package usdc + +import ( + "crypto/ed25519" +) + +const ( + Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + QuarksPerUsdc = 1000000 + Decimals = 6 +) + +var ( + TokenMint = ed25519.PublicKey{198, 250, 122, 243, 190, 219, 173, 58, 61, 101, 243, 106, 171, 201, 116, 49, 177, 187, 228, 194, 210, 246, 224, 228, 124, 166, 2, 3, 69, 47, 93, 97} +)