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
4 changes: 4 additions & 0 deletions coinbase/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions ocp/data/swap/swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
FundingSourceUnknown = iota
FundingSourceSubmitIntent
FundingSourceExternalWallet
FundingSourceCoinbaseOnramp
)

type Kind uint8
Expand Down
6 changes: 6 additions & 0 deletions ocp/rpc/transaction/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -35,6 +36,8 @@ type transactionServer struct {
antispamGuard *antispam.Guard
amlGuard *aml.Guard

coinbaseClient *coinbase.Client

nodeID string
noncePools []*transaction.LocalNoncePool

Expand All @@ -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,
Expand Down Expand Up @@ -100,6 +104,8 @@ func NewTransactionServer(
antispamGuard: antispamGuard,
amlGuard: amlGuard,

coinbaseClient: coinbaseClient,

nodeID: nodeID,
noncePools: noncePools,

Expand Down
67 changes: 66 additions & 1 deletion ocp/rpc/transaction/swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions ocp/worker/swap/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
}
}
}
4 changes: 4 additions & 0 deletions ocp/worker/swap/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,6 +25,7 @@ type runtime struct {
vmIndexerClient indexerpb.IndexerClient
integration integration.Swap
solanaNoncePool *transaction.LocalNoncePool
coinbaseClient *coinbase.Client
}

func New(
Expand All @@ -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 {
Expand All @@ -45,6 +48,7 @@ func New(
vmIndexerClient: vmIndexerClient,
integration: integration,
solanaNoncePool: solanaNoncePool,
coinbaseClient: coinbaseClient,
}, nil
}

Expand Down
55 changes: 52 additions & 3 deletions ocp/worker/swap/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions ocp/worker/swap/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
Expand Down
Loading