From 9cdc75091e983fab786c78660a0d4fa3104d967e Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 22 Jan 2026 09:03:11 -0500 Subject: [PATCH 1/5] Fix SubmitIntent using old exchange data proto --- go.mod | 2 +- go.sum | 4 ++-- ocp/rpc/transaction/intent.go | 14 ++++++++------ ocp/rpc/transaction/intent_handler.go | 16 ++++++++-------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 82857c6..10876e5 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.9.0 + github.com/code-payments/ocp-protobuf-api v0.9.1-0.20260122042634-3920d2e258e8 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 71a95c3..8541455 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.9.0 h1:BWRgd3smO8cz6MUm5DxNqXClp1D2pn2F4VUuna9jzxM= -github.com/code-payments/ocp-protobuf-api v0.9.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v0.9.1-0.20260122042634-3920d2e258e8 h1:rFLdsu1KO2l6EhUXcCEwcfEh57r1mDwE3374FG50FUo= +github.com/code-payments/ocp-protobuf-api v0.9.1-0.20260122042634-3920d2e258e8/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/rpc/transaction/intent.go b/ocp/rpc/transaction/intent.go index efba543..b4b2f8e 100644 --- a/ocp/rpc/transaction/intent.go +++ b/ocp/rpc/transaction/intent.go @@ -823,12 +823,14 @@ func (s *transactionServer) GetIntentMetadata(ctx context.Context, req *transact SendPublicPayment: &transactionpb.SendPublicPaymentMetadata{ Source: sourceAccount.ToProto(), Destination: destinationAccount.ToProto(), - ExchangeData: &transactionpb.ExchangeData{ - Currency: strings.ToLower(string(intentRecord.SendPublicPaymentMetadata.ExchangeCurrency)), - ExchangeRate: intentRecord.SendPublicPaymentMetadata.ExchangeRate, - NativeAmount: intentRecord.SendPublicPaymentMetadata.NativeAmount, - Quarks: intentRecord.SendPublicPaymentMetadata.Quantity, - Mint: mintAccount.ToProto(), + ExchangeData: &transactionpb.SendPublicPaymentMetadata_ServerExchangeData{ + ServerExchangeData: &transactionpb.ExchangeData{ + Currency: strings.ToLower(string(intentRecord.SendPublicPaymentMetadata.ExchangeCurrency)), + ExchangeRate: intentRecord.SendPublicPaymentMetadata.ExchangeRate, + NativeAmount: intentRecord.SendPublicPaymentMetadata.NativeAmount, + Quarks: intentRecord.SendPublicPaymentMetadata.Quantity, + Mint: mintAccount.ToProto(), + }, }, IsRemoteSend: intentRecord.SendPublicPaymentMetadata.IsRemoteSend, IsWithdrawal: intentRecord.SendPublicPaymentMetadata.IsWithdrawal, diff --git a/ocp/rpc/transaction/intent_handler.go b/ocp/rpc/transaction/intent_handler.go index c4461b9..07829ca 100644 --- a/ocp/rpc/transaction/intent_handler.go +++ b/ocp/rpc/transaction/intent_handler.go @@ -392,7 +392,7 @@ func (h *SendPublicPaymentIntentHandler) PopulateMetadata(ctx context.Context, i return err } - exchangeData := typedProtoMetadata.ExchangeData + exchangeData := typedProtoMetadata.GetServerExchangeData() usdMarketValue, _, err := currency_util.CalculateUsdMarketValue(ctx, h.data, mint, exchangeData.Quarks, currency_util.GetLatestExchangeRateTime()) if err != nil { @@ -418,7 +418,7 @@ func (h *SendPublicPaymentIntentHandler) PopulateMetadata(ctx context.Context, i ExchangeCurrency: currency_lib.Code(exchangeData.Currency), ExchangeRate: exchangeData.ExchangeRate, - NativeAmount: typedProtoMetadata.ExchangeData.NativeAmount, + NativeAmount: exchangeData.NativeAmount, UsdMarketValue: usdMarketValue, IsWithdrawal: typedProtoMetadata.IsWithdrawal, @@ -578,7 +578,7 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte // Part 4: Exchange data validation // - if err := validateExchangeDataWithinIntent(ctx, h.log, h.data, typedMetadata.Mint, typedMetadata.ExchangeData); err != nil { + if err := validateExchangeDataWithinIntent(ctx, h.log, h.data, typedMetadata.Mint, typedMetadata.GetServerExchangeData()); err != nil { return err } @@ -805,7 +805,7 @@ func (h *SendPublicPaymentIntentHandler) validateActions( // minus any fees // - expectedDestinationPayment := int64(metadata.ExchangeData.Quarks) + expectedDestinationPayment := int64(metadata.GetServerExchangeData().Quarks) // Minimal validation required here since validateFeePayments generically handles // most checks that isn't specific to an intent. @@ -834,8 +834,8 @@ func (h *SendPublicPaymentIntentHandler) validateActions( sourceSimulation, ok := simResult.SimulationsByAccount[source.PublicKey().ToBase58()] if !ok { return NewIntentValidationErrorf("must send payment from source account %s", source.PublicKey().ToBase58()) - } else if sourceSimulation.GetDeltaQuarks(false) != -int64(metadata.ExchangeData.Quarks) { - return NewActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must send %d quarks from source account", metadata.ExchangeData.Quarks) + } else if sourceSimulation.GetDeltaQuarks(false) != -int64(metadata.GetServerExchangeData().Quarks) { + return NewActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must send %d quarks from source account", metadata.GetServerExchangeData().Quarks) } // Part 5: Generic validation of actions that move money @@ -876,8 +876,8 @@ func (h *SendPublicPaymentIntentHandler) validateActions( return NewIntentValidationError("expected auto-return for the remote send gift card") } else if autoReturns[0].IsPrivate || !autoReturns[0].IsWithdraw { return NewActionValidationError(destinationSimulation.Transfers[0].Action, "auto-return must be a public withdraw") - } else if autoReturns[0].DeltaQuarks != -int64(metadata.ExchangeData.Quarks) { - return NewActionValidationErrorf(autoReturns[0].Action, "must auto-return %d quarks from remote send gift card", metadata.ExchangeData.Quarks) + } else if autoReturns[0].DeltaQuarks != -int64(metadata.GetServerExchangeData().Quarks) { + return NewActionValidationErrorf(autoReturns[0].Action, "must auto-return %d quarks from remote send gift card", metadata.GetServerExchangeData().Quarks) } autoReturns = sourceSimulation.GetAutoReturns() From e91db18ec4126273a34d22315fe9a2b111aa21f2 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 22 Jan 2026 09:04:43 -0500 Subject: [PATCH 2/5] Add ValidateVerifiedExchangeData, ValidateVerifiedExchangeRate and ValidateVerifiedReserveState utilities --- ocp/currency/validation.go | 217 +++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/ocp/currency/validation.go b/ocp/currency/validation.go index 1e0b1ba..73a58c8 100644 --- a/ocp/currency/validation.go +++ b/ocp/currency/validation.go @@ -2,14 +2,17 @@ package currency import ( "context" + "crypto/ed25519" "math" "math/big" "time" + currencypb "github.com/code-payments/ocp-protobuf-api/generated/go/currency/v1" transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" "go.uber.org/zap" currency_lib "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/ocp/auth" "github.com/code-payments/ocp-server/ocp/common" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/currency" @@ -18,8 +21,222 @@ import ( const ( defaultPrecision = 128 + + // maxVerifiedExchangeRateAge is the maximum age of a verified exchange rate + maxVerifiedExchangeRateAge = 15 * time.Minute + + // maxVerifiedReserveStateAge is the maximum age of a verified reserve state + maxVerifiedReserveStateAge = 2 * time.Minute ) +// ValidateVerifiedExchangeRate validates a server-signed exchange rate provided by a client. +// It verifies that the signature is valid and that the timestamp is not too far in the past. +func ValidateVerifiedExchangeRate(proto *currencypb.VerifiedCoreMintFiatExchangeRate) (bool, string) { + if proto == nil || proto.ExchangeRate == nil || proto.Signature == nil { + return false, "missing required fields" + } + + // Verify the timestamp is not too old + if proto.ExchangeRate.Timestamp == nil { + return false, "missing timestamp" + } + + age := time.Since(proto.ExchangeRate.Timestamp.AsTime()) + if age > maxVerifiedExchangeRateAge { + return false, "exchange rate is stale" + } + + // Verify the signature + messageBytes, err := auth.ForceConsistentMarshal(proto.ExchangeRate) + if err != nil { + return false, "failed to marshal exchange rate" + } + + if !ed25519.Verify(common.GetSubsidizer().PublicKey().ToBytes(), messageBytes, proto.Signature.Value) { + return false, "invalid signature" + } + + return true, "" +} + +// ValidateVerifiedReserveState validates a server-signed reserve state provided by a client. +// It verifies that the signature is valid and that the timestamp is not too far in the past. +func ValidateVerifiedReserveState(proto *currencypb.VerifiedLaunchpadCurrencyReserveState) (bool, string) { + if proto == nil || proto.ReserveState == nil || proto.Signature == nil { + return false, "missing required fields" + } + + // Verify the timestamp is not too old + if proto.ReserveState.Timestamp == nil { + return false, "missing timestamp" + } + + age := time.Since(proto.ReserveState.Timestamp.AsTime()) + if age > maxVerifiedReserveStateAge { + return false, "reserve state is stale" + } + + // Verify the signature + messageBytes, err := auth.ForceConsistentMarshal(proto.ReserveState) + if err != nil { + return false, "failed to marshal reserve state" + } + + if !ed25519.Verify(common.GetSubsidizer().PublicKey().ToBytes(), messageBytes, proto.Signature.Value) { + return false, "invalid signature" + } + + return true, "" +} + +// ValidateVerifiedExchangeData validates client-provided exchange data with +// provable exchange rates and reserve states that were provided by server. +func ValidateVerifiedExchangeData(proto *transactionpb.VerifiedExchangeData) (bool, string) { + if proto == nil { + return false, "verified exchange data is required" + } + + // Validate mint is present + if proto.Mint == nil { + return false, "mint is required" + } + + mintAccount, err := common.NewAccountFromProto(proto.Mint) + if err != nil { + return false, "invalid mint" + } + + // Validate quarks + if proto.Quarks == 0 { + return false, "quarks must be greater than zero" + } + + // Validate native amount + if proto.NativeAmount <= 0 { + return false, "native amount must be greater than zero" + } + + // Exchange rate is always required + if proto.CoreMintFiatExchangeRate == nil { + return false, "core mint fiat exchange rate is required" + } + + // Validate the exchange rate + if valid, msg := ValidateVerifiedExchangeRate(proto.CoreMintFiatExchangeRate); !valid { + return false, "exchange rate validation failed: " + msg + } + + // For core mint, only exchange rate validation is needed along with value checks + if common.IsCoreMint(mintAccount) { + if proto.LaunchpadCurrencyReserveState != nil { + return false, "launchpad currency reserve state is not required for core mint" + } + + return validateCoreMintVerifiedExchangeData(proto) + } + + // For launchpad currencies, reserve state is required + if proto.LaunchpadCurrencyReserveState == nil { + return false, "reserve state is required for launchpad currencies" + } + + // Validate the reserve state + if valid, msg := ValidateVerifiedReserveState(proto.LaunchpadCurrencyReserveState); !valid { + return false, "reserve state validation failed: " + msg + } + + // Verify the reserve state mint matches the expected mint + reserveMint, err := common.NewAccountFromProto(proto.LaunchpadCurrencyReserveState.ReserveState.Mint) + if err != nil { + return false, "invalid mint in reserve state" + } + + if reserveMint.PublicKey().ToBase58() != mintAccount.PublicKey().ToBase58() { + return false, "reserve state mint does not match expected mint" + } + + return validateLaunchpadCurrencyVerifiedExchangeData(proto) +} + +func validateCoreMintVerifiedExchangeData(proto *transactionpb.VerifiedExchangeData) (bool, string) { + coreMintQuarksPerUnit := common.GetMintQuarksPerUnit(common.CoreMintAccount) + + clientRate := big.NewFloat(proto.CoreMintFiatExchangeRate.ExchangeRate.ExchangeRate).SetPrec(defaultPrecision) + clientNativeAmount := big.NewFloat(proto.NativeAmount).SetPrec(defaultPrecision) + clientQuarks := big.NewFloat(float64(proto.Quarks)).SetPrec(defaultPrecision) + + currencyDecimals := currency_lib.GetDecimals(currency_lib.Code(proto.CoreMintFiatExchangeRate.ExchangeRate.CurrencyCode)) + one := big.NewFloat(1.0).SetPrec(defaultPrecision) + minTransferValue := new(big.Float).Quo(one, big.NewFloat(math.Pow10(currencyDecimals))) + + nativeAmountErrorThreshold := new(big.Float).Quo(minTransferValue, big.NewFloat(2.0)) + nativeAmountLowerBound := new(big.Float).Sub(clientNativeAmount, nativeAmountErrorThreshold) + nativeAmountUpperBound := new(big.Float).Add(clientNativeAmount, nativeAmountErrorThreshold) + if nativeAmountLowerBound.Cmp(nativeAmountErrorThreshold) < 0 { + nativeAmountLowerBound = nativeAmountErrorThreshold + } + quarksLowerBound := new(big.Float).Mul(new(big.Float).Quo(nativeAmountLowerBound, clientRate), big.NewFloat(float64(coreMintQuarksPerUnit))) + quarksUpperBound := new(big.Float).Mul(new(big.Float).Quo(nativeAmountUpperBound, clientRate), big.NewFloat(float64(coreMintQuarksPerUnit))) + + if clientNativeAmount.Cmp(nativeAmountErrorThreshold) < 0 { + return false, "native amount is less than minimum transfer value error threshold" + } + + // Validate that the native amount within half of the minimum transfer value + if clientQuarks.Cmp(quarksLowerBound) < 0 || clientQuarks.Cmp(quarksUpperBound) > 0 { + return false, "native amount and quark value mismatch" + } + + return true, "" +} + +func validateLaunchpadCurrencyVerifiedExchangeData(proto *transactionpb.VerifiedExchangeData) (bool, string) { + if !common.IsCoreMintUsdStableCoin() { + return false, "launchpad currencies only support a stable coin core mint" + } + + coreMintQuarksPerUnit := common.GetMintQuarksPerUnit(common.CoreMintAccount) + + clientFiatExchangeRate := big.NewFloat(proto.CoreMintFiatExchangeRate.ExchangeRate.ExchangeRate).SetPrec(defaultPrecision) + clientNativeAmount := big.NewFloat(proto.NativeAmount).SetPrec(defaultPrecision) + + currencyDecimals := currency_lib.GetDecimals(currency_lib.Code(proto.CoreMintFiatExchangeRate.ExchangeRate.CurrencyCode)) + one := big.NewFloat(1.0).SetPrec(defaultPrecision) + minTransferValue := new(big.Float).Quo(one, big.NewFloat(math.Pow10(currencyDecimals))) + + nativeAmountErrorThreshold := new(big.Float).Quo(minTransferValue, big.NewFloat(2.0)) + nativeAmountLowerBound := new(big.Float).Sub(clientNativeAmount, nativeAmountErrorThreshold) + nativeAmountUpperBound := new(big.Float).Add(clientNativeAmount, nativeAmountErrorThreshold) + if nativeAmountLowerBound.Cmp(nativeAmountErrorThreshold) < 0 { + nativeAmountLowerBound = nativeAmountErrorThreshold + } + + if clientNativeAmount.Cmp(nativeAmountErrorThreshold) < 0 { + return false, "native amount is less than minimum transfer value error threshold" + } + + // Calculate how much core mint would be received for a sell against the currency creator program + coreMintSellValueInQuarks, _ := currencycreator.EstimateSell(¤cycreator.EstimateSellArgs{ + CurrentSupplyInQuarks: proto.LaunchpadCurrencyReserveState.ReserveState.SupplyFromBonding, + SellAmountInQuarks: proto.Quarks, + ValueMintDecimals: uint8(common.CoreMintDecimals), + SellFeeBps: 0, + }) + + // Validate that the native amount within half of the minimum transfer value + coreMintSellValueInUnits := new(big.Float).Quo( + big.NewFloat(float64(coreMintSellValueInQuarks)).SetPrec(defaultPrecision), + big.NewFloat(float64(coreMintQuarksPerUnit)).SetPrec(defaultPrecision), + ) + potentialNativeAmount := new(big.Float).Mul(clientFiatExchangeRate, coreMintSellValueInUnits) + + if potentialNativeAmount.Cmp(nativeAmountLowerBound) < 0 || potentialNativeAmount.Cmp(nativeAmountUpperBound) > 0 { + return false, "native amount does not match expected sell value" + } + + return true, "" +} + // ValidateClientExchangeData validates proto exchange data provided by a client func ValidateClientExchangeData(ctx context.Context, log *zap.Logger, data ocp_data.Provider, proto *transactionpb.ExchangeData) (bool, string, error) { mint, err := common.GetBackwardsCompatMint(proto.Mint) From 5e9577232c53a11294fee211d6a7f3dc12bdfb2f Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 22 Jan 2026 09:08:12 -0500 Subject: [PATCH 3/5] Add checks to common utilities for a stable coin core mint when attempting to use launchpad currencies --- ocp/common/mint.go | 4 ++++ ocp/common/vm.go | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ocp/common/mint.go b/ocp/common/mint.go index 03375fe..6ef169c 100644 --- a/ocp/common/mint.go +++ b/ocp/common/mint.go @@ -62,6 +62,10 @@ func GetMintQuarksPerUnit(mint *Account) uint64 { } func IsSupportedMint(ctx context.Context, data ocp_data.Provider, mintAccount *Account) (bool, error) { + if !IsCoreMint(mintAccount) && !IsCoreMintUsdStableCoin() { + return false, nil + } + _, err := GetVmConfigForMint(ctx, data, mintAccount) if err == ErrUnsupportedMint { return false, nil diff --git a/ocp/common/vm.go b/ocp/common/vm.go index 30825dd..87cea64 100644 --- a/ocp/common/vm.go +++ b/ocp/common/vm.go @@ -24,8 +24,12 @@ type VmConfig struct { Mint *Account } -func GetVmConfigForMint(ctx context.Context, data ocp_data.Provider, mint *Account) (*VmConfig, error) { - switch mint.PublicKey().ToBase58() { +func GetVmConfigForMint(ctx context.Context, data ocp_data.Provider, mintAccount *Account) (*VmConfig, error) { + if !IsCoreMint(mintAccount) && !IsCoreMintUsdStableCoin() { + return nil, ErrUnsupportedMint + } + + switch mintAccount.PublicKey().ToBase58() { case CoreMintAccount.PublicKey().ToBase58(): return &VmConfig{ Authority: GetSubsidizer(), @@ -50,7 +54,7 @@ func GetVmConfigForMint(ctx context.Context, data ocp_data.Provider, mint *Accou Authority: jeffyAuthority, Vm: jeffyVmAccount, Omnibus: jeffyVmOmnibusAccount, - Mint: mint, + Mint: mintAccount, }, nil default: return nil, ErrUnsupportedMint From dc67bf3242f795c3913484aeebcd1f41e54867b9 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 22 Jan 2026 14:47:59 -0500 Subject: [PATCH 4/5] Integrate VerifiedExchangeData into SubmitIntent --- ocp/currency/exchange_rate.go | 22 ++++ ocp/currency/validation.go | 8 +- ocp/rpc/transaction/intent_handler.go | 147 ++++++++++++++++++++------ 3 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 ocp/currency/exchange_rate.go diff --git a/ocp/currency/exchange_rate.go b/ocp/currency/exchange_rate.go new file mode 100644 index 0000000..c69567b --- /dev/null +++ b/ocp/currency/exchange_rate.go @@ -0,0 +1,22 @@ +package currency + +import ( + "math/big" + + "github.com/code-payments/ocp-server/ocp/common" +) + +// CalculateExchangeRate calculates the exchange rate for a crypto value exchange. +func CalculateExchangeRate(mintAccount *common.Account, nativeAmount float64, quarks uint64) float64 { + quarksPerUnit := common.GetMintQuarksPerUnit(mintAccount) + tokenUnits := new(big.Float).Quo( + big.NewFloat(float64(quarks)).SetPrec(defaultPrecision), + big.NewFloat(float64(quarksPerUnit)).SetPrec(defaultPrecision), + ) + exchangeRate := new(big.Float).Quo( + big.NewFloat(float64(nativeAmount)).SetPrec(defaultPrecision), + tokenUnits, + ) + res, _ := exchangeRate.Float64() + return res +} diff --git a/ocp/currency/validation.go b/ocp/currency/validation.go index 73a58c8..4a29b5c 100644 --- a/ocp/currency/validation.go +++ b/ocp/currency/validation.go @@ -89,9 +89,9 @@ func ValidateVerifiedReserveState(proto *currencypb.VerifiedLaunchpadCurrencyRes return true, "" } -// ValidateVerifiedExchangeData validates client-provided exchange data with +// ValidateClientExchangeData validates client-provided exchange data with // provable exchange rates and reserve states that were provided by server. -func ValidateVerifiedExchangeData(proto *transactionpb.VerifiedExchangeData) (bool, string) { +func ValidateClientExchangeData(proto *transactionpb.VerifiedExchangeData) (bool, string) { if proto == nil { return false, "verified exchange data is required" } @@ -237,8 +237,8 @@ func validateLaunchpadCurrencyVerifiedExchangeData(proto *transactionpb.Verified return true, "" } -// ValidateClientExchangeData validates proto exchange data provided by a client -func ValidateClientExchangeData(ctx context.Context, log *zap.Logger, data ocp_data.Provider, proto *transactionpb.ExchangeData) (bool, string, error) { +// ValidateLegacyClientExchangeData validates legacy proto exchange data provided by a client +func ValidateLegacyClientExchangeData(ctx context.Context, log *zap.Logger, data ocp_data.Provider, proto *transactionpb.ExchangeData) (bool, string, error) { mint, err := common.GetBackwardsCompatMint(proto.Mint) if err != nil { return false, "", err diff --git a/ocp/rpc/transaction/intent_handler.go b/ocp/rpc/transaction/intent_handler.go index 07829ca..b651e80 100644 --- a/ocp/rpc/transaction/intent_handler.go +++ b/ocp/rpc/transaction/intent_handler.go @@ -3,18 +3,22 @@ package transaction import ( "bytes" "context" + "crypto/ed25519" "strings" "time" "github.com/pkg/errors" "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" commonpb "github.com/code-payments/ocp-protobuf-api/generated/go/common/v1" + currencypb "github.com/code-payments/ocp-protobuf-api/generated/go/currency/v1" transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" currency_lib "github.com/code-payments/ocp-server/currency" "github.com/code-payments/ocp-server/ocp/aml" "github.com/code-payments/ocp-server/ocp/antispam" + "github.com/code-payments/ocp-server/ocp/auth" "github.com/code-payments/ocp-server/ocp/balance" "github.com/code-payments/ocp-server/ocp/common" currency_util "github.com/code-payments/ocp-server/ocp/currency" @@ -230,7 +234,7 @@ func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRec // Part 4: Validate fee payments // - err = validateFeePayments(ctx, h.log, h.data, h.conf, intentRecord, simResult) + err = validateFeePayments(ctx, h.conf, intentRecord, simResult, nil) if err != nil { return err } @@ -392,9 +396,30 @@ func (h *SendPublicPaymentIntentHandler) PopulateMetadata(ctx context.Context, i return err } - exchangeData := typedProtoMetadata.GetServerExchangeData() + var currencyCode currency_lib.Code + var nativeAmount float64 + var exchangeRate float64 + var quarks uint64 + switch typed := typedProtoMetadata.ExchangeData.(type) { + case *transactionpb.SendPublicPaymentMetadata_ClientExchangeData: + currencyCode = currency_lib.Code(typed.ClientExchangeData.CoreMintFiatExchangeRate.ExchangeRate.CurrencyCode) + nativeAmount = typed.ClientExchangeData.NativeAmount + if common.IsCoreMint(mint) { + exchangeRate = typed.ClientExchangeData.CoreMintFiatExchangeRate.ExchangeRate.ExchangeRate + } else { + exchangeRate = currency_util.CalculateExchangeRate(mint, typed.ClientExchangeData.NativeAmount, typed.ClientExchangeData.Quarks) + } + quarks = typed.ClientExchangeData.Quarks + case *transactionpb.SendPublicPaymentMetadata_ServerExchangeData: // todo: deprecate this flow + currencyCode = currency_lib.Code(typed.ServerExchangeData.Currency) + nativeAmount = typed.ServerExchangeData.NativeAmount + exchangeRate = typed.ServerExchangeData.ExchangeRate + quarks = typed.ServerExchangeData.Quarks + default: + return NewIntentDeniedError("client exchange data not provided") + } - usdMarketValue, _, err := currency_util.CalculateUsdMarketValue(ctx, h.data, mint, exchangeData.Quarks, currency_util.GetLatestExchangeRateTime()) + usdMarketValue, _, err := currency_util.CalculateUsdMarketValue(ctx, h.data, mint, quarks, currency_util.GetLatestExchangeRateTime()) if err != nil { return err } @@ -414,11 +439,11 @@ func (h *SendPublicPaymentIntentHandler) PopulateMetadata(ctx context.Context, i intentRecord.MintAccount = mint.PublicKey().ToBase58() intentRecord.SendPublicPaymentMetadata = &intent.SendPublicPaymentMetadata{ DestinationTokenAccount: destination.PublicKey().ToBase58(), - Quantity: exchangeData.Quarks, + Quantity: quarks, - ExchangeCurrency: currency_lib.Code(exchangeData.Currency), - ExchangeRate: exchangeData.ExchangeRate, - NativeAmount: exchangeData.NativeAmount, + ExchangeCurrency: currencyCode, + ExchangeRate: exchangeRate, + NativeAmount: nativeAmount, UsdMarketValue: usdMarketValue, IsWithdrawal: typedProtoMetadata.IsWithdrawal, @@ -578,8 +603,15 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte // Part 4: Exchange data validation // - if err := validateExchangeDataWithinIntent(ctx, h.log, h.data, typedMetadata.Mint, typedMetadata.GetServerExchangeData()); err != nil { - return err + switch typed := typedMetadata.ExchangeData.(type) { + case *transactionpb.SendPublicPaymentMetadata_ClientExchangeData: + if err := validateExchangeDataWithinIntent(typedMetadata.Mint, typed.ClientExchangeData); err != nil { + return err + } + case *transactionpb.SendPublicPaymentMetadata_ServerExchangeData: + if err := validateLegacyExchangeDataWithinIntent(ctx, h.log, h.data, typedMetadata.Mint, typed.ServerExchangeData); err != nil { + return err + } } // @@ -595,7 +627,7 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte // Part 6: Validate fee payments // - err = validateFeePayments(ctx, h.log, h.data, h.conf, intentRecord, simResult) + err = validateFeePayments(ctx, h.conf, intentRecord, simResult, typedMetadata.GetClientExchangeData()) if err != nil { return err } @@ -641,6 +673,14 @@ func (h *SendPublicPaymentIntentHandler) validateActions( return err } + var quarks uint64 + switch typed := metadata.ExchangeData.(type) { + case *transactionpb.SendPublicPaymentMetadata_ClientExchangeData: + quarks = typed.ClientExchangeData.Quarks + case *transactionpb.SendPublicPaymentMetadata_ServerExchangeData: + quarks = typed.ServerExchangeData.Quarks + } + // // Part 1: High-level action validation based on intent metadata // @@ -805,7 +845,7 @@ func (h *SendPublicPaymentIntentHandler) validateActions( // minus any fees // - expectedDestinationPayment := int64(metadata.GetServerExchangeData().Quarks) + expectedDestinationPayment := int64(quarks) // Minimal validation required here since validateFeePayments generically handles // most checks that isn't specific to an intent. @@ -834,8 +874,8 @@ func (h *SendPublicPaymentIntentHandler) validateActions( sourceSimulation, ok := simResult.SimulationsByAccount[source.PublicKey().ToBase58()] if !ok { return NewIntentValidationErrorf("must send payment from source account %s", source.PublicKey().ToBase58()) - } else if sourceSimulation.GetDeltaQuarks(false) != -int64(metadata.GetServerExchangeData().Quarks) { - return NewActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must send %d quarks from source account", metadata.GetServerExchangeData().Quarks) + } else if sourceSimulation.GetDeltaQuarks(false) != -int64(quarks) { + return NewActionValidationErrorf(sourceSimulation.Transfers[0].Action, "must send %d quarks from source account", quarks) } // Part 5: Generic validation of actions that move money @@ -876,8 +916,8 @@ func (h *SendPublicPaymentIntentHandler) validateActions( return NewIntentValidationError("expected auto-return for the remote send gift card") } else if autoReturns[0].IsPrivate || !autoReturns[0].IsWithdraw { return NewActionValidationError(destinationSimulation.Transfers[0].Action, "auto-return must be a public withdraw") - } else if autoReturns[0].DeltaQuarks != -int64(metadata.GetServerExchangeData().Quarks) { - return NewActionValidationErrorf(autoReturns[0].Action, "must auto-return %d quarks from remote send gift card", metadata.GetServerExchangeData().Quarks) + } else if autoReturns[0].DeltaQuarks != -int64(quarks) { + return NewActionValidationErrorf(autoReturns[0].Action, "must auto-return %d quarks from remote send gift card", quarks) } autoReturns = sourceSimulation.GetAutoReturns() @@ -1114,7 +1154,7 @@ func (h *ReceivePaymentsPubliclyIntentHandler) AllowCreation(ctx context.Context // Part 6: Validate fee payments // - err = validateFeePayments(ctx, h.log, h.data, h.conf, intentRecord, simResult) + err = validateFeePayments(ctx, h.conf, intentRecord, simResult, nil) if err != nil { return err } @@ -1435,7 +1475,7 @@ func (h *PublicDistributionIntentHandler) AllowCreation(ctx context.Context, int // Part 4: Validate fee payments // - err = validateFeePayments(ctx, h.log, h.data, h.conf, intentRecord, simResult) + err = validateFeePayments(ctx, h.conf, intentRecord, simResult, nil) if err != nil { return err } @@ -1771,7 +1811,7 @@ func validateExternalTokenAccountWithinIntent(ctx context.Context, data ocp_data return nil } -func validateExchangeDataWithinIntent(ctx context.Context, log *zap.Logger, data ocp_data.Provider, intentMint *commonpb.SolanaAccountId, proto *transactionpb.ExchangeData) error { +func validateExchangeDataWithinIntent(intentMint *commonpb.SolanaAccountId, proto *transactionpb.VerifiedExchangeData) error { intentMintAccount, err := common.GetBackwardsCompatMint(intentMint) if err != nil { return err @@ -1786,7 +1826,32 @@ func validateExchangeDataWithinIntent(ctx context.Context, log *zap.Logger, data return NewIntentValidationErrorf("expected exchange data mint to be %s", intentMintAccount.PublicKey().ToBase58()) } - isValid, message, err := currency_util.ValidateClientExchangeData(ctx, log, data, proto) + isValid, message := currency_util.ValidateClientExchangeData(proto) + if !isValid { + if strings.Contains(message, "stale") { + return NewStaleStateError(message) + } + return NewIntentValidationError(message) + } + return nil +} + +func validateLegacyExchangeDataWithinIntent(ctx context.Context, log *zap.Logger, data ocp_data.Provider, intentMint *commonpb.SolanaAccountId, proto *transactionpb.ExchangeData) error { + intentMintAccount, err := common.GetBackwardsCompatMint(intentMint) + if err != nil { + return err + } + + exchangeMintAccount, err := common.GetBackwardsCompatMint(proto.Mint) + if err != nil { + return err + } + + if !bytes.Equal(intentMintAccount.PublicKey().ToBytes(), exchangeMintAccount.PublicKey().ToBytes()) { + return NewIntentValidationErrorf("expected exchange data mint to be %s", intentMintAccount.PublicKey().ToBase58()) + } + + isValid, message, err := currency_util.ValidateLegacyClientExchangeData(ctx, log, data, proto) if err != nil { return err } else if !isValid { @@ -1800,11 +1865,10 @@ func validateExchangeDataWithinIntent(ctx context.Context, log *zap.Logger, data func validateFeePayments( ctx context.Context, - log *zap.Logger, - data ocp_data.Provider, conf *conf, intentRecord *intent.Record, simResult *LocalSimulationResult, + clientExchangeData *transactionpb.VerifiedExchangeData, ) error { var isFeeOptional bool var expectedFeeType transactionpb.FeePaymentAction_FeeType @@ -1830,6 +1894,10 @@ func validateFeePayments( return nil } + if clientExchangeData == nil { + return errors.New("expected client exchange data") + } + feePayments := simResult.GetFeePayments() if len(feePayments) > 1 { return NewIntentValidationError("expected at most 1 fee payment") @@ -1862,19 +1930,36 @@ func validateFeePayments( } feeAmount = -feeAmount // Because it's coming out of a user account in this simulation - mintQuarksPerUnit := common.GetMintQuarksPerUnit(mintAccount) - unitsOfMint := float64(feeAmount) / float64(mintQuarksPerUnit) + // todo: Fix for non USD stable coins + if !common.IsCoreMintUsdStableCoin() { + return errors.New("fee validation doesn't support non-stable coin core mint") + } - isValid, _, err := currency_util.ValidateClientExchangeData(ctx, log, data, &transactionpb.ExchangeData{ - Currency: string(currency_lib.USD), - NativeAmount: expectedUsdValue, - ExchangeRate: expectedUsdValue / unitsOfMint, - Quarks: uint64(feeAmount), - Mint: mintAccount.ToProto(), - }) + // Fees are always in USD, and we assume a USD stablecoin for simplicity + // To support other types of core mint tokens, we'd need to pass additional + // USD exchange data from client. + coreMintFiatExchangeRate := ¤cypb.CoreMintFiatExchangeRate{ + CurrencyCode: string(currency_lib.USD), + ExchangeRate: 1.0, + Timestamp: timestamppb.Now(), + } + + marshalled, err := auth.ForceConsistentMarshal(coreMintFiatExchangeRate) if err != nil { return err - } else if !isValid { + } + + isValid, _ := currency_util.ValidateClientExchangeData(&transactionpb.VerifiedExchangeData{ + Mint: mintAccount.ToProto(), + Quarks: uint64(feeAmount), + NativeAmount: expectedUsdValue, + CoreMintFiatExchangeRate: ¤cypb.VerifiedCoreMintFiatExchangeRate{ + ExchangeRate: coreMintFiatExchangeRate, + Signature: &commonpb.Signature{Value: ed25519.Sign(common.GetSubsidizer().PrivateKey().ToBytes(), marshalled)}, + }, + LaunchpadCurrencyReserveState: clientExchangeData.LaunchpadCurrencyReserveState, + }) + if !isValid { return NewActionValidationErrorf(feePayment.Action, "%s fee payment amount must be %.2f USD", expectedFeeType.String(), expectedUsdValue) } From a2ddc91dc2236b5855c1d1089b974e7fd90bb803 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 22 Jan 2026 14:50:04 -0500 Subject: [PATCH 5/5] Pull ocp-protobuf-api v0.10.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10876e5..7a902b0 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.9.1-0.20260122042634-3920d2e258e8 + github.com/code-payments/ocp-protobuf-api v0.10.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 8541455..3834e1b 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.9.1-0.20260122042634-3920d2e258e8 h1:rFLdsu1KO2l6EhUXcCEwcfEh57r1mDwE3374FG50FUo= -github.com/code-payments/ocp-protobuf-api v0.9.1-0.20260122042634-3920d2e258e8/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v0.10.0 h1:l9Yh3eXdhvgBQS/evg1HYMERXlr7ymASHekAhW/RUmA= +github.com/code-payments/ocp-protobuf-api v0.10.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=