From a0bca0ba0182464644dfd06a191f346262840181 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 18 Dec 2025 12:32:12 -0500 Subject: [PATCH 1/2] Support external wallet swap fundings at the RPC layer --- go.mod | 2 +- go.sum | 4 +- ocp/antispam/guard.go | 5 ++- ocp/antispam/integration.go | 5 ++- ocp/data/swap/swap.go | 1 + ocp/rpc/transaction/swap.go | 89 +++++++++++++++++++++++++++++-------- 6 files changed, 80 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index 745032f..d5b9b7b 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 v0.5.0 + github.com/code-payments/ocp-protobuf-api v0.6.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 8917c50..13e0488 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 v0.5.0 h1:ns3pztvgukRubQlJcuRxAiQmpmCqZimtkCA1pTFJpF8= -github.com/code-payments/ocp-protobuf-api v0.5.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v0.6.0 h1:dv/QWox20Z8RRHEwPSlWMAJPDMIvkzw97FESZArS8WA= +github.com/code-payments/ocp-protobuf-api v0.6.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/antispam/guard.go b/ocp/antispam/guard.go index 202e260..a0985c4 100644 --- a/ocp/antispam/guard.go +++ b/ocp/antispam/guard.go @@ -7,6 +7,7 @@ import ( "github.com/code-payments/ocp-server/metrics" "github.com/code-payments/ocp-server/ocp/common" + "github.com/code-payments/ocp-server/ocp/data/swap" ) type Guard struct { @@ -87,11 +88,11 @@ func (g *Guard) AllowDistribution(ctx context.Context, owner *common.Account, is return allow, nil } -func (g *Guard) AllowSwap(ctx context.Context, owner, fromMint, toMint *common.Account) (bool, error) { +func (g *Guard) AllowSwap(ctx context.Context, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account) (bool, error) { tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowSwap") defer tracer.End() - allow, reason, err := g.integration.AllowSwap(ctx, owner, fromMint, toMint) + allow, reason, err := g.integration.AllowSwap(ctx, fundingSource, owner, fromMint, toMint) if err != nil { return false, err } diff --git a/ocp/antispam/integration.go b/ocp/antispam/integration.go index f42434c..9eaecfd 100644 --- a/ocp/antispam/integration.go +++ b/ocp/antispam/integration.go @@ -6,6 +6,7 @@ import ( transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" "github.com/code-payments/ocp-server/ocp/common" + "github.com/code-payments/ocp-server/ocp/data/swap" ) // Integration is an antispam guard integration that apps can implement to check @@ -21,7 +22,7 @@ type Integration interface { AllowDistribution(ctx context.Context, owner *common.Account, isPublic bool) (bool, string, error) - AllowSwap(ctx context.Context, owner, fromMint, toMint *common.Account) (bool, string, error) + AllowSwap(ctx context.Context, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account) (bool, string, error) } type allowEverythingIntegration struct { @@ -52,6 +53,6 @@ func (i *allowEverythingIntegration) AllowDistribution(ctx context.Context, owne return true, "", nil } -func (i *allowEverythingIntegration) AllowSwap(ctx context.Context, owner, fromMint, toMint *common.Account) (bool, string, error) { +func (i *allowEverythingIntegration) AllowSwap(ctx context.Context, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account) (bool, string, error) { return true, "", nil } diff --git a/ocp/data/swap/swap.go b/ocp/data/swap/swap.go index 6ca62ee..1e94395 100644 --- a/ocp/data/swap/swap.go +++ b/ocp/data/swap/swap.go @@ -26,6 +26,7 @@ type FundingSource uint8 const ( FundingSourceUnknown = iota FundingSourceSubmitIntent + FundingSourceExternalWallet ) type Record struct { diff --git a/ocp/rpc/transaction/swap.go b/ocp/rpc/transaction/swap.go index 6647865..7747d00 100644 --- a/ocp/rpc/transaction/swap.go +++ b/ocp/rpc/transaction/swap.go @@ -76,12 +76,19 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw log.With(zap.Error(err)).Warn("invalid source mint account") return handleStartSwapError(streamer, err) } + log = log.With(zap.String("from_mint", fromMint.PublicKey().ToBase58())) toMint, err := common.NewAccountFromProto(startCurrencyCreatorSwapReq.ToMint) if err != nil { log.With(zap.Error(err)).Warn("invalid destination mint account") return handleStartSwapError(streamer, err) } + log = log.With(zap.String("to_mint", toMint.PublicKey().ToBase58())) + + log = log.With( + zap.String("funding_source", startCurrencyCreatorSwapReq.FundingSource.String()), + zap.String("funding_id", startCurrencyCreatorSwapReq.FundingId), + ) // // Section: Antispam @@ -96,7 +103,7 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw return handleStartSwapError(streamer, NewSwapDeniedError("not an ocp account")) } - allow, err := s.antispamGuard.AllowSwap(ctx, owner, fromMint, toMint) + allow, err := s.antispamGuard.AllowSwap(ctx, swap.FundingSource(startCurrencyCreatorSwapReq.FundingSource), owner, fromMint, toMint) if err != nil { return handleStartSwapError(streamer, err) } else if !allow { @@ -111,7 +118,15 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw if err == nil { return handleStartSwapError(streamer, NewSwapDeniedError("attempt to reuse swap id")) } else if err != swap.ErrNotFound { - log.With(zap.Error(err)).Warn("failure checking for existing swap record") + log.With(zap.Error(err)).Warn("failure checking for existing swap record by id") + return handleStartSwapError(streamer, err) + } + + _, err = s.data.GetSwapByFundingId(ctx, startCurrencyCreatorSwapReq.FundingId) + if err == nil { + return handleStartSwapError(streamer, NewSwapDeniedError("attempt to reuse swap funding id")) + } else if err != swap.ErrNotFound { + log.With(zap.Error(err)).Warn("failure checking for existing swap record by funding id") return handleStartSwapError(streamer, err) } @@ -141,7 +156,7 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw ownerSourceTimelockVault, err := owner.ToTimelockVault(sourceVmConfig) if err != nil { - log.With(zap.Error(err)).Warn("failure getting owner destination timelock vault") + log.With(zap.Error(err)).Warn("failure getting owner source timelock vault") return handleStartSwapError(streamer, err) } @@ -153,15 +168,6 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw return handleStartSwapError(streamer, err) } - balance, err := balance.CalculateFromCache(ctx, s.data, ownerSourceTimelockVault) - if err != nil { - log.With(zap.Error(err)).Warn("failure getting owner source timelock vault balance") - return handleStartSwapError(streamer, err) - } - if balance < startCurrencyCreatorSwapReq.Amount { - return handleStartSwapError(streamer, NewSwapValidationError("insufficient balance")) - } - ownerDestinationTimelockVault, err := owner.ToTimelockVault(destinationVmConfig) if err != nil { log.With(zap.Error(err)).Warn("failure getting owner destination timelock vault") @@ -176,12 +182,47 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw return handleStartSwapError(streamer, err) } - _, err = s.data.GetIntent(ctx, startCurrencyCreatorSwapReq.FundingId) - if err == nil { - return handleStartSwapError(streamer, NewSwapValidationError("funding intent already exists")) - } else if err != intent.ErrIntentNotFound { - log.With(zap.Error(err)).Warn("failure getting funding intent record") - return handleStartSwapError(streamer, err) + switch startCurrencyCreatorSwapReq.FundingSource { + case transactionpb.FundingSource_FUNDING_SOURCE_SUBMIT_INTENT: + decodedFundingId, err := base58.Decode(startCurrencyCreatorSwapReq.FundingId) + if err != nil || len(decodedFundingId) != ed25519.PublicKeySize { + log.With(zap.Error(err)).Warn("invalid funding id") + return handleStartSwapError(streamer, NewSwapValidationError("funding id is not a public key")) + } + + _, err = s.data.GetIntent(ctx, startCurrencyCreatorSwapReq.FundingId) + if err == nil { + return handleStartSwapError(streamer, NewSwapValidationError("funding intent already exists")) + } else if err != intent.ErrIntentNotFound { + log.With(zap.Error(err)).Warn("failure getting funding intent record") + return handleStartSwapError(streamer, err) + } + + balance, err := balance.CalculateFromCache(ctx, s.data, ownerSourceTimelockVault) + if err != nil { + log.With(zap.Error(err)).Warn("failure getting owner source timelock vault balance") + return handleStartSwapError(streamer, err) + } + if balance < startCurrencyCreatorSwapReq.Amount { + return handleStartSwapError(streamer, NewSwapValidationError("insufficient balance")) + } + case transactionpb.FundingSource_FUNDING_SOURCE_EXTERNAL_WALLET: + decodedFundingId, err := base58.Decode(startCurrencyCreatorSwapReq.FundingId) + if err != nil || len(decodedFundingId) != ed25519.SignatureSize { + log.With(zap.Error(err)).Warn("invalid funding id") + return handleStartSwapError(streamer, NewSwapValidationError("funding id is not a signature")) + } + + _, err = s.data.GetTransaction(ctx, startCurrencyCreatorSwapReq.FundingId) + if err == nil { + // Current flows expect that client will call StartSwap before submitting the transaction + return handleStartSwapError(streamer, NewSwapValidationError("funding transaction already exists")) + } else if err != solana.ErrSignatureNotFound { + log.With(zap.Error(err)).Warn("failure getting funding transaction") + return handleStartSwapError(streamer, err) + } + default: + return handleStartSwapError(streamer, NewSwapDeniedErrorf("funding source %s is not supported", startCurrencyCreatorSwapReq.FundingSource)) } // @@ -256,6 +297,16 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw // Section: Swap state DB commit // + var initialState swap.State + switch startCurrencyCreatorSwapReq.FundingSource { + case transactionpb.FundingSource_FUNDING_SOURCE_SUBMIT_INTENT: + initialState = swap.StateCreated + case transactionpb.FundingSource_FUNDING_SOURCE_EXTERNAL_WALLET: + initialState = swap.StateFunding + default: + return handleStartSwapError(streamer, NewSwapDeniedErrorf("funding source %s is not supported", startCurrencyCreatorSwapReq.FundingSource)) + } + record := &swap.Record{ SwapId: swapId, Owner: owner.PublicKey().ToBase58(), @@ -269,7 +320,7 @@ func (s *transactionServer) StartSwap(streamer transactionpb.Transaction_StartSw ProofSignature: base58.Encode(submitSignatureReq.Signature.Value), TransactionSignature: nil, TransactionBlob: nil, - State: swap.StateCreated, + State: initialState, CreatedAt: time.Now(), } From 18e72deaa5ce520633a5d4be28728f0f52a8aa3d Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Mon, 5 Jan 2026 15:51:43 -0500 Subject: [PATCH 2/2] Support external wallet swap fundings at the worker layer --- ocp/worker/swap/config.go | 17 +++++--- ocp/worker/swap/util.go | 46 +++++++++++++++++++++- ocp/worker/swap/worker.go | 81 +++++++++++++++++++++++++++++++-------- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/ocp/worker/swap/config.go b/ocp/worker/swap/config.go index 712e300..c710e0c 100644 --- a/ocp/worker/swap/config.go +++ b/ocp/worker/swap/config.go @@ -18,12 +18,16 @@ const ( ClientTimeoutToSwapConfigEnvName = envConfigPrefix + "CLIENT_TIMEOUT_TO_SWAP" defaultClientTimeoutToSwap = 5 * time.Minute + + ExternalWalletFinalizationTimeoutConfigEnvName = envConfigPrefix + "EXTERNAL_WALLET_FINALIZATION_TIMEOUT" + defaultExternalWalletFinalizationTimeout = 90 * time.Second ) type conf struct { - batchSize config.Uint64 - clientTimeoutToFund config.Duration - clientTimeoutToSwap config.Duration + batchSize config.Uint64 + clientTimeoutToFund config.Duration + clientTimeoutToSwap config.Duration + externalWalletFinalizationTimeout config.Duration } // ConfigProvider defines how config values are pulled @@ -33,9 +37,10 @@ type ConfigProvider func() *conf func WithEnvConfigs() ConfigProvider { return func() *conf { return &conf{ - batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultFulfillmentBatchSize), - clientTimeoutToFund: env.NewDurationConfig(ClientTimeoutToFundConfigEnvName, defaultClientTimeoutToFund), - clientTimeoutToSwap: env.NewDurationConfig(ClientTimeoutToSwapConfigEnvName, defaultClientTimeoutToSwap), + batchSize: env.NewUint64Config(BatchSizeConfigEnvName, defaultFulfillmentBatchSize), + clientTimeoutToFund: env.NewDurationConfig(ClientTimeoutToFundConfigEnvName, defaultClientTimeoutToFund), + clientTimeoutToSwap: env.NewDurationConfig(ClientTimeoutToSwapConfigEnvName, defaultClientTimeoutToSwap), + externalWalletFinalizationTimeout: env.NewDurationConfig(ExternalWalletFinalizationTimeoutConfigEnvName, defaultExternalWalletFinalizationTimeout), } } } diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index 1cb6bd0..fed448b 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -103,13 +103,13 @@ func (p *runtime) markSwapCancelling(ctx context.Context, record *swap.Record, t func (p *runtime) markSwapCancelled(ctx context.Context, record *swap.Record) error { return p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error { - err := p.validateSwapState(record, swap.StateCreated, swap.StateCancelling) + err := p.validateSwapState(record, swap.StateCreated, swap.StateFunding, swap.StateCancelling) if err != nil { return err } switch record.State { - case swap.StateCreated: + case swap.StateCreated, swap.StateFunding: err = p.markNonceAvailableDueToCancelledSwap(ctx, record) if err != nil { return err @@ -482,6 +482,48 @@ func (p *runtime) markNonceAvailableDueToCancelledSwap(ctx context.Context, reco return p.data.SaveNonce(ctx, nonceRecord) } +func (p *runtime) validateExternalWalletFundingTransaction(ctx context.Context, record *swap.Record) (bool, error) { + if record.FundingSource != swap.FundingSourceExternalWallet { + 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") + } + + tokenBalances, err := p.data.GetBlockchainTransactionTokenBalances(ctx, record.FundingId) + 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.Amount) { + return false, nil + } + + return true, nil +} + func getSwapDepositIntentID(signature string, destination *common.Account) string { combined := fmt.Sprintf("%s-%s", signature, destination.PublicKey().ToBase58()) hashed := sha256.Sum256([]byte(combined)) diff --git a/ocp/worker/swap/worker.go b/ocp/worker/swap/worker.go index 2b76185..82ddea2 100644 --- a/ocp/worker/swap/worker.go +++ b/ocp/worker/swap/worker.go @@ -116,20 +116,57 @@ func (p *runtime) handleStateFunding(ctx context.Context, record *swap.Record) e return err } - // Wait for the funding intent to be confirmed before to transition the swap - // to a funded state - intentRecord, err := p.data.GetIntent(ctx, record.FundingId) - if err != nil { - return errors.Wrap(err, "error getting funding intent record") - } - switch intentRecord.State { - case intent.StateConfirmed: - return p.markSwapFunded(ctx, record) - case intent.StateFailed: - // todo: Should never happen, but maybe cancel the swap? - return errors.New("funding intent failed") - default: + switch record.FundingSource { + case swap.FundingSourceSubmitIntent: + // Wait for the funding intent to be confirmed before transitioning the swap + // to a funded state + intentRecord, err := p.data.GetIntent(ctx, record.FundingId) + if err != nil { + return errors.Wrap(err, "error getting funding intent record") + } + switch intentRecord.State { + case intent.StateConfirmed: + return p.markSwapFunded(ctx, record) + case intent.StateFailed: + // todo: Should never happen, but maybe cancel the swap? + return errors.New("funding intent failed") + default: + return nil + } + case swap.FundingSourceExternalWallet: + // Wait for the external wallet funding transaction to be finalized before + // transitioning the swap to a funded state + finalizedTxn, err := p.data.GetBlockchainTransaction(ctx, record.FundingId, solana.CommitmentFinalized) + if err != nil && err != solana.ErrSignatureNotFound { + return errors.Wrap(err, "error getting finalized funding transaction") + } + + if finalizedTxn != nil { + if finalizedTxn.Err != nil || finalizedTxn.Meta.Err != nil { + return p.markSwapCancelled(ctx, record) + } + + // Validate the funding transaction deposited exactly the expected amount + // into the user's swap ATA + valid, err := p.validateExternalWalletFundingTransaction(ctx, record) + if err != nil { + return errors.Wrap(err, "error validating external wallet funding transaction") + } else if !valid { + return p.markSwapCancelled(ctx, record) + } + + return p.markSwapFunded(ctx, record) + } + + // Cancel the swap if the external wallet funding transaction hasn't been + // finalized within a reasonable amount of time + if time.Since(record.CreatedAt) > p.conf.externalWalletFinalizationTimeout.Get(ctx) { + return p.markSwapCancelled(ctx, record) + } + return nil + default: + return errors.New("unknown funding source") } } @@ -138,15 +175,25 @@ func (p *runtime) handleStateFunded(ctx context.Context, record *swap.Record) er return err } - intentRecord, err := p.data.GetIntent(ctx, record.FundingId) - if err != nil { - return err + // Determine the starting point for the timeout based on the funding source + var fundedAt time.Time + switch record.FundingSource { + case swap.FundingSourceSubmitIntent: + intentRecord, err := p.data.GetIntent(ctx, record.FundingId) + if err != nil { + return err + } + fundedAt = intentRecord.CreatedAt + case swap.FundingSourceExternalWallet: + fundedAt = record.CreatedAt + default: + return errors.New("unknown funding source") } // Cancel the swap if the client hasn't signed the swap transaction within a // reasonable amount of time. The funds for the swap will be deposited back // into the source VM. - if time.Since(intentRecord.CreatedAt) > p.conf.clientTimeoutToSwap.Get(ctx) { + if time.Since(fundedAt) > p.conf.clientTimeoutToSwap.Get(ctx) { txn, err := p.makeCancellationTransaction(ctx, record) if err != nil { return err