diff --git a/coinbase/order.go b/coinbase/order.go index f2e5a03..57ac294 100644 --- a/coinbase/order.go +++ b/coinbase/order.go @@ -34,6 +34,10 @@ const ( FeeTypeExchange FeeType = "FEE_TYPE_EXCHANGE" ) +const ( + NetworkSolana = "solana" +) + // Order is a Coinbase Onramp order as returned by the v2 API. type Order struct { OrderID string // UUID assigned by Coinbase diff --git a/go.mod b/go.mod index eed0527..21b9269 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/code-payments/code-vm-indexer v1.2.0 - github.com/code-payments/ocp-protobuf-api v1.10.0 + github.com/code-payments/ocp-protobuf-api v1.11.0 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.2.1 github.com/golang/protobuf v1.5.4 diff --git a/go.sum b/go.sum index 9f926e7..0d64aeb 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,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.10.0 h1:8GEDLh3NShOYz6J7a9VOCqu+xJSd7xR42pewaPfkiE4= -github.com/code-payments/ocp-protobuf-api v1.10.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v1.11.0 h1:bvAtcOC3llKWckLKcuK2/i1aY6LorVZebWUybNG43PM= +github.com/code-payments/ocp-protobuf-api v1.11.0/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/data/swap/swap.go b/ocp/data/swap/swap.go index 3b1adc8..517e6c9 100644 --- a/ocp/data/swap/swap.go +++ b/ocp/data/swap/swap.go @@ -25,6 +25,7 @@ const ( FundingSourceUnknown = iota FundingSourceSubmitIntent FundingSourceExternalWallet + FundingSourceCoinbaseOnramp ) type Kind uint8 diff --git a/ocp/rpc/transaction/server.go b/ocp/rpc/transaction/server.go index e5ba1fa..3add3c8 100644 --- a/ocp/rpc/transaction/server.go +++ b/ocp/rpc/transaction/server.go @@ -9,6 +9,7 @@ import ( transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" + "github.com/code-payments/ocp-server/coinbase" "github.com/code-payments/ocp-server/ocp/aml" "github.com/code-payments/ocp-server/ocp/antispam" auth_util "github.com/code-payments/ocp-server/ocp/auth" @@ -35,6 +36,8 @@ type transactionServer struct { antispamGuard *antispam.Guard amlGuard *aml.Guard + coinbaseClient *coinbase.Client + nodeID string noncePools []*transaction.LocalNoncePool @@ -53,6 +56,7 @@ func NewTransactionServer( submitIntentIntegration integration.SubmitIntent, antispamGuard *antispam.Guard, amlGuard *aml.Guard, + coinbaseClient *coinbase.Client, nodeID string, noncePools []*transaction.LocalNoncePool, configProvider ConfigProvider, @@ -100,6 +104,8 @@ func NewTransactionServer( antispamGuard: antispamGuard, amlGuard: amlGuard, + coinbaseClient: coinbaseClient, + nodeID: nodeID, noncePools: noncePools, diff --git a/ocp/rpc/transaction/swap.go b/ocp/rpc/transaction/swap.go index 7643ed6..6f107f2 100644 --- a/ocp/rpc/transaction/swap.go +++ b/ocp/rpc/transaction/swap.go @@ -5,8 +5,11 @@ import ( "context" "crypto/ed25519" "database/sql" + "math/big" + "strings" "time" + "github.com/google/uuid" "github.com/mr-tron/base58/base58" "github.com/pkg/errors" "go.uber.org/zap" @@ -16,6 +19,7 @@ import ( commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" + "github.com/code-payments/ocp-server/coinbase" "github.com/code-payments/ocp-server/grpc/client" "github.com/code-payments/ocp-server/ocp/balance" "github.com/code-payments/ocp-server/ocp/common" @@ -235,6 +239,47 @@ func (s *transactionServer) handleReserveStatefulSwap( if !common.IsCoreMint(fromMint) { return handleStatefulSwapError(streamer, NewSwapDeniedError("source mint must be core mint")) } + case transactionpb.FundingSource_FUNDING_SOURCE_COINBASE_ONRAMP: + if !common.IsCoreMint(fromMint) { + return handleStatefulSwapError(streamer, NewSwapDeniedError("source mint must be core mint")) + } + + if _, err := uuid.Parse(initiateReserveSwapReq.FundingId); err != nil { + return handleStatefulSwapError(streamer, NewSwapValidationError("funding id is not a uuid")) + } + + order, err := s.coinbaseClient.GetOrder(ctx, initiateReserveSwapReq.FundingId) + if err == coinbase.ErrOrderNotFound { + return handleStatefulSwapError(streamer, NewSwapValidationError("coinbase order not found")) + } else if err != nil { + log.With(zap.Error(err)).Warn("failure getting coinbase order") + return handleStatefulSwapError(streamer, err) + } + if order.Status == coinbase.OrderStatusFailed { + return handleStatefulSwapError(streamer, NewSwapValidationError("coinbase order is in a failed state")) + } + + if !strings.EqualFold(order.DestinationNetwork, coinbase.NetworkSolana) { + return handleStatefulSwapError(streamer, NewSwapValidationError("coinbase order destination network is not solana")) + } + if !strings.EqualFold(order.PurchaseAmount.Currency, common.CoreMintSymbol) { + return handleStatefulSwapError(streamer, NewSwapValidationError("coinbase order is not for the core mint")) + } + if order.PartnerUserRef != owner.PublicKey().ToBase58() { + return handleStatefulSwapError(streamer, NewSwapDeniedError("coinbase order partner user ref does not match owner")) + } + if order.DestinationAddress != sourceTimelockAccountRecord.SwapPdaAddress { + return handleStatefulSwapError(streamer, NewSwapValidationError("coinbase order destination address is not the owner's swap pda")) + } + + orderQuarks, err := decimalToQuarks(order.PurchaseAmount.Value, common.CoreMintDecimals) + if err != nil { + log.With(zap.Error(err)).Warn("invalid coinbase order purchase amount") + return handleStatefulSwapError(streamer, NewSwapValidationError("coinbase order purchase amount is invalid")) + } + if orderQuarks != initiateReserveSwapReq.SwapAmount+initiateReserveSwapReq.FeeAmount { + return handleStatefulSwapError(streamer, NewSwapDeniedError("coinbase order purchase amount does not match swap amount")) + } default: return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("funding source %s is not supported", initiateReserveSwapReq.FundingSource)) } @@ -555,7 +600,7 @@ func (s *transactionServer) handleReserveStatefulSwap( switch initiateReserveSwapReq.FundingSource { case transactionpb.FundingSource_FUNDING_SOURCE_SUBMIT_INTENT: initialState = swap.StateCreated - case transactionpb.FundingSource_FUNDING_SOURCE_EXTERNAL_WALLET: + case transactionpb.FundingSource_FUNDING_SOURCE_EXTERNAL_WALLET, transactionpb.FundingSource_FUNDING_SOURCE_COINBASE_ONRAMP: initialState = swap.StateFunding default: return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("funding source %s is not supported", initiateReserveSwapReq.FundingSource)) @@ -1195,3 +1240,23 @@ func toProtoSwap(record *swap.Record) (*transactionpb.SwapMetadata, error) { Signature: &commonpb.Signature{Value: decodedSignature}, }, nil } + +func decimalToQuarks(value string, decimals int) (uint64, error) { + rat, ok := new(big.Rat).SetString(value) + if !ok { + return 0, errors.Errorf("invalid decimal value: %s", value) + } + if rat.Sign() < 0 { + return 0, errors.New("amount is negative") + } + multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + scaled := new(big.Rat).Mul(rat, new(big.Rat).SetInt(multiplier)) + if !scaled.IsInt() { + return 0, errors.New("amount has more precision than mint decimals") + } + quarks := scaled.Num() + if !quarks.IsUint64() { + return 0, errors.New("amount overflows uint64") + } + return quarks.Uint64(), nil +} diff --git a/ocp/worker/swap/config.go b/ocp/worker/swap/config.go index 9dcf2fd..22a82bd 100644 --- a/ocp/worker/swap/config.go +++ b/ocp/worker/swap/config.go @@ -18,12 +18,16 @@ const ( ExternalWalletFinalizationTimeoutConfigEnvName = envConfigPrefix + "EXTERNAL_WALLET_FINALIZATION_TIMEOUT" defaultExternalWalletFinalizationTimeout = 30 * time.Second + + CoinbaseOnrampOrderTimeoutConfigEnvName = envConfigPrefix + "COINBASE_ONRAMP_ORDER_TIMEOUT" + defaultCoinbaseOnrampOrderTimeout = 5 * time.Minute ) type conf struct { batchSize config.Uint64 clientTimeoutToFund config.Duration externalWalletFinalizationTimeout config.Duration + coinbaseOnrampOrderTimeout config.Duration } // ConfigProvider defines how config values are pulled @@ -36,6 +40,7 @@ func WithEnvConfigs() ConfigProvider { batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultBatchSize), clientTimeoutToFund: env.NewDurationConfig(ClientTimeoutToFundConfigEnvName, defaultClientTimeoutToFund), externalWalletFinalizationTimeout: env.NewDurationConfig(ExternalWalletFinalizationTimeoutConfigEnvName, defaultExternalWalletFinalizationTimeout), + coinbaseOnrampOrderTimeout: env.NewDurationConfig(CoinbaseOnrampOrderTimeoutConfigEnvName, defaultCoinbaseOnrampOrderTimeout), } } } diff --git a/ocp/worker/swap/runtime.go b/ocp/worker/swap/runtime.go index b19462c..3e2fd91 100644 --- a/ocp/worker/swap/runtime.go +++ b/ocp/worker/swap/runtime.go @@ -9,6 +9,7 @@ import ( indexerpb "github.com/code-payments/code-vm-indexer/generated/indexer/v1" + "github.com/code-payments/ocp-server/coinbase" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/nonce" "github.com/code-payments/ocp-server/ocp/data/swap" @@ -24,6 +25,7 @@ type runtime struct { vmIndexerClient indexerpb.IndexerClient integration integration.Swap solanaNoncePool *transaction.LocalNoncePool + coinbaseClient *coinbase.Client } func New( @@ -32,6 +34,7 @@ func New( vmIndexerClient indexerpb.IndexerClient, integration integration.Swap, solanaNoncePool *transaction.LocalNoncePool, + coinbaseClient *coinbase.Client, configProvider ConfigProvider, ) (worker.Runtime, error) { if err := solanaNoncePool.Validate(nonce.EnvironmentSolana, nonce.EnvironmentInstanceSolanaMainnet, nonce.PurposeOnDemandTransaction); err != nil { @@ -45,6 +48,7 @@ func New( vmIndexerClient: vmIndexerClient, integration: integration, solanaNoncePool: solanaNoncePool, + coinbaseClient: coinbaseClient, }, nil } diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index e32f452..af7dd40 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -423,7 +423,7 @@ func (p *runtime) buildRefundRecordsForCancelledSwap(ctx context.Context, swapRe nativeAmount = fundingIntentRecord.SendPublicPaymentMetadata.NativeAmount usdMarketValue = fundingIntentRecord.SendPublicPaymentMetadata.UsdMarketValue isReturned = true - case swap.FundingSourceExternalWallet: + case swap.FundingSourceExternalWallet, swap.FundingSourceCoinbaseOnramp: if !common.IsCoreMint(fromMint) { return nil, nil, errors.New("unexpected source mint") } @@ -621,7 +621,7 @@ func (p *runtime) maybeUpdateBalancesForFinalizedReserveSwap(ctx context.Context return 0, false, err } } - case swap.FundingSourceExternalWallet: + case swap.FundingSourceExternalWallet, swap.FundingSourceCoinbaseOnramp: if !common.IsCoreMint(fromMint) { return 0, false, errors.New("unexpected source mint") } @@ -781,7 +781,7 @@ func (p *runtime) notifySwapFinalized(ctx context.Context, swapRecord *swap.Reco currencyCode = fundingIntentRecord.SendPublicPaymentMetadata.ExchangeCurrency nativeAmount = fundingIntentRecord.SendPublicPaymentMetadata.NativeAmount - case swap.FundingSourceExternalWallet: + case swap.FundingSourceExternalWallet, swap.FundingSourceCoinbaseOnramp: if !common.IsCoreMint(fromMint) { return errors.New("unexpected source mint") } @@ -945,6 +945,55 @@ func (p *runtime) validateExternalWalletFunding(ctx context.Context, record *swa return true, nil } +func (p *runtime) validateCoinbaseOnrampFunding(ctx context.Context, record *swap.Record) (bool, error) { + if record.FundingSource != swap.FundingSourceCoinbaseOnramp { + return false, errors.New("invalid funding source") + } + + owner, err := common.NewAccountFromPublicKeyString(record.Owner) + if err != nil { + return false, errors.Wrap(err, "error parsing owner") + } + + fromMint, err := common.NewAccountFromPublicKeyString(record.FromMint) + if err != nil { + return false, errors.Wrap(err, "error parsing from mint") + } + + sourceVmConfig, err := common.GetVmConfigForMint(ctx, p.data, fromMint) + if err != nil { + return false, errors.Wrap(err, "error getting vm config for source mint") + } + + swapAta, err := owner.ToVmSwapAta(sourceVmConfig) + if err != nil { + return false, errors.Wrap(err, "error getting swap ata") + } + + order, err := p.coinbaseClient.GetOrder(ctx, record.FundingId) + if err != nil { + return false, errors.Wrap(err, "error getting coinbase order") + } + if order.TxHash == "" { + return false, errors.New("coinbase order has no on-chain transaction") + } + + tokenBalances, err := p.data.GetBlockchainTransactionTokenBalances(ctx, order.TxHash) + if err != nil { + return false, errors.Wrap(err, "error getting token balances") + } + + deltaQuarks, err := transaction_util.GetDeltaQuarksFromTokenBalances(swapAta, tokenBalances) + if err != nil { + return false, errors.Wrap(err, "error getting delta quarks from token balances") + } + + if deltaQuarks < int64(record.SwapAmount+record.FeeAmount) { + return false, nil + } + return true, nil +} + func (p *runtime) ensureSwapDestinationIsInitialized(ctx context.Context, record *swap.Record) error { if record.Kind != swap.KindReserve { return nil diff --git a/ocp/worker/swap/worker.go b/ocp/worker/swap/worker.go index ca47758..bc694df 100644 --- a/ocp/worker/swap/worker.go +++ b/ocp/worker/swap/worker.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "go.uber.org/zap" + "github.com/code-payments/ocp-server/coinbase" "github.com/code-payments/ocp-server/database/query" "github.com/code-payments/ocp-server/metrics" "github.com/code-payments/ocp-server/ocp/data/intent" @@ -115,6 +116,13 @@ func (p *runtime) handleStateCreated(ctx context.Context, record *swap.Record) e } func (p *runtime) handleStateFunding(ctx context.Context, record *swap.Record) error { + log := p.log.With( + zap.String("method", "handleStateFunding"), + zap.String("swap_id", record.SwapId), + zap.String("funding_id", record.FundingId), + zap.String("owner", record.Owner), + ) + if err := p.validateSwapState(record, swap.StateFunding); err != nil { return err } @@ -156,6 +164,49 @@ func (p *runtime) handleStateFunding(ctx context.Context, record *swap.Record) e return p.markSwapCancelled(ctx, record, nil) } + return nil + case swap.FundingSourceCoinbaseOnramp: + // Look up the Coinbase order. The funding ID is the Coinbase order ID, + // and the order's TxHash holds the on-chain settlement signature once + // Coinbase has broadcast the transaction. + order, err := p.coinbaseClient.GetOrder(ctx, record.FundingId) + if err != nil { + return errors.Wrap(err, "error getting coinbase order") + } + + switch order.Status { + case coinbase.OrderStatusProcessing, coinbase.OrderStatusCompleted: + if order.TxHash != "" { + finalizedTxn, err := p.data.GetBlockchainTransaction(ctx, order.TxHash, solana.CommitmentFinalized) + if err != nil && err != solana.ErrSignatureNotFound { + return errors.Wrap(err, "error getting finalized coinbase funding transaction") + } + + if finalizedTxn != nil { + if finalizedTxn.Err != nil || finalizedTxn.Meta.Err != nil { + return p.markSwapCancelled(ctx, record, nil) + } + return p.markSwapFunded(ctx, record) + } + } + + if time.Since(record.CreatedAt) > 2*p.conf.coinbaseOnrampOrderTimeout.Get(ctx) { + log.With( + zap.String("txn", order.TxHash), + zap.String("order_status", string(order.Status)), + ).Info("funding transaction for coinbase order is not finalizing") + } + case coinbase.OrderStatusFailed: + return p.markSwapCancelled(ctx, record, nil) + default: + // Cancel the swap if the Coinbase onramp order hasn't been completed + // within a reasonable amount of time. Timeout should be greater than + // that enforced on client to avoid lost funds. + if time.Since(record.CreatedAt) > p.conf.coinbaseOnrampOrderTimeout.Get(ctx) { + return p.markSwapCancelled(ctx, record, nil) + } + } + return nil default: return errors.New("unsupported funding source") @@ -180,6 +231,11 @@ func (p *runtime) handleStateFunded(ctx context.Context, record *swap.Record) er if err != nil { return errors.Wrap(err, "error validating external wallet funding") } + case swap.FundingSourceCoinbaseOnramp: + isValid, err = p.validateCoinbaseOnrampFunding(ctx, record) + if err != nil { + return errors.Wrap(err, "error validating coinbase onramp funding") + } default: return errors.New("unsupported funding source") }