From ca3673bb30eb43f4b00efb51294a8713f34343a3 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Thu, 9 Jul 2026 14:44:41 -0400 Subject: [PATCH 1/2] Support launching a currency with a purchase using another currency --- go.mod | 8 +- go.sum | 16 +- ocp/currency/validation.go | 39 ++++ ocp/data/currency/metadata/tests/tests.go | 2 +- ocp/data/currency/model.go | 6 +- ocp/integration/swap.go | 2 +- ocp/rpc/transaction/config.go | 40 +++- ocp/rpc/transaction/intent_handler.go | 39 +++- ocp/rpc/transaction/server.go | 10 + ocp/rpc/transaction/stateful_swap.go | 117 ++++++++--- ocp/rpc/transaction/swap_handler.go | 225 ++++++++++++++++++++++ ocp/rpc/transaction/withdraw.go | 2 +- ocp/worker/currency/launcher/metrics.go | 2 +- ocp/worker/currency/launcher/runtime.go | 2 +- ocp/worker/currency/launcher/util.go | 108 ++++++++++- ocp/worker/currency/launcher/worker.go | 32 ++- ocp/worker/geyser/external_deposit.go | 31 +-- ocp/worker/swap/metrics.go | 11 +- ocp/worker/swap/util.go | 32 ++- ocp/worker/swap/worker.go | 2 +- 20 files changed, 632 insertions(+), 94 deletions(-) diff --git a/go.mod b/go.mod index 493cad8..65ef1ae 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,12 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/anyproto/go-slip10 v1.0.1 github.com/aws/aws-sdk-go-v2 v1.42.0 - github.com/aws/aws-sdk-go-v2/config v1.32.25 github.com/aws/aws-sdk-go-v2/credentials v1.19.24 github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.25 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.59.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 github.com/code-payments/code-vm-indexer v1.2.0 - github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053 + github.com/code-payments/ocp-protobuf-api v1.14.0 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.3.3 github.com/golang/protobuf v1.5.4 @@ -47,7 +46,6 @@ require ( github.com/Microsoft/go-winio v0.4.14 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect @@ -56,10 +54,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.12.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect github.com/aws/smithy-go v1.27.1 // indirect github.com/cenkalti/backoff/v4 v4.1.0 // indirect github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 // indirect diff --git a/go.sum b/go.sum index ed5105d..ff32a5f 100644 --- a/go.sum +++ b/go.sum @@ -37,12 +37,8 @@ github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/by github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= -github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= -github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.25 h1:EeK30mZmhopHcNmKykyGF0LwmFB1ZwQNr+FeyRjcN0U= github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.25/go.mod h1:tudVnwAJyXgCh4N6ABYdzUM+i+PXsx8700FOyhMn+4k= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= @@ -65,14 +61,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDb github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM= github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298= github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= -github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -92,8 +80,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.13.2-0.20260610171241-de46af911053 h1:0B96K7/z7TpFbAJeyzP43A0eM58Ie/KRuu5U6PUrses= -github.com/code-payments/ocp-protobuf-api v1.13.2-0.20260610171241-de46af911053/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v1.14.0 h1:Q0IqyF4q31Mf+wvOILz+BM6P+xOnrPVAm/85/YolKDk= +github.com/code-payments/ocp-protobuf-api v1.14.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/currency/validation.go b/ocp/currency/validation.go index 097a490..1643029 100644 --- a/ocp/currency/validation.go +++ b/ocp/currency/validation.go @@ -6,6 +6,9 @@ import ( "math/big" "time" + "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" @@ -154,6 +157,42 @@ func ValidateClientExchangeData(proto *transactionpb.VerifiedExchangeData) (bool return validateLaunchpadCurrencyVerifiedExchangeData(proto) } +// ValidateClientUsdFeeValue validates that a quark amount of a mint is valued +// at the expected USD amount for a fee. +func ValidateClientUsdFeeValue( + mint *common.Account, + quarks uint64, + expectedUsdValue float64, + clientReserveState *currencypb.VerifiedLaunchpadCurrencyReserveState, +) (bool, string) { + // Values are always in USD, and we assume a USD stablecoin for simplicity + if !common.IsCoreMintUsdStableCoin() { + return false, "usd value validation requires a usd stable coin core mint" + } + + coreMintFiatExchangeRate := ¤cypb.CoreMintFiatExchangeRate{ + CurrencyCode: string(currency_lib.USD), + ExchangeRate: 1.0, + Timestamp: timestamppb.Now(), + } + + marshalled, err := auth.ForceConsistentMarshal(coreMintFiatExchangeRate) + if err != nil { + return false, "failed to marshal exchange rate" + } + + return ValidateClientExchangeData(&transactionpb.VerifiedExchangeData{ + Mint: mint.ToProto(), + Quarks: quarks, + NativeAmount: expectedUsdValue, + CoreMintFiatExchangeRate: ¤cypb.VerifiedCoreMintFiatExchangeRate{ + ExchangeRate: coreMintFiatExchangeRate, + Signature: &commonpb.Signature{Value: ed25519.Sign(common.GetSubsidizer().PrivateKey().ToBytes(), marshalled)}, + }, + LaunchpadCurrencyReserveState: clientReserveState, + }) +} + func validateCoreMintVerifiedExchangeData(proto *transactionpb.VerifiedExchangeData) (bool, string) { coreMintQuarksPerUnit := common.GetMintQuarksPerUnit(common.CoreMintAccount) diff --git a/ocp/data/currency/metadata/tests/tests.go b/ocp/data/currency/metadata/tests/tests.go index e9e0831..2284e29 100644 --- a/ocp/data/currency/metadata/tests/tests.go +++ b/ocp/data/currency/metadata/tests/tests.go @@ -598,7 +598,7 @@ func testCountMetadataByState(t *testing.T, s metadata.Store) { currency.MetadataStateUnknown, currency.MetadataStateAvailable, currency.MetadataStateWaitingForInitialPurchase, - currency.MetadataStateFundingAuthority, + currency.MetadataStatePrePurchaseSetup, currency.MetadataStateExecutingInitialPurchase, currency.MetadataStateCompletingInitialization, currency.MetadataStateFinalValidation, diff --git a/ocp/data/currency/model.go b/ocp/data/currency/model.go index ec7ccf6..c63907e 100644 --- a/ocp/data/currency/model.go +++ b/ocp/data/currency/model.go @@ -13,7 +13,7 @@ const ( MetadataStateUnknown MetadataState = iota MetadataStateAvailable MetadataStateWaitingForInitialPurchase - MetadataStateFundingAuthority + MetadataStatePrePurchaseSetup MetadataStateExecutingInitialPurchase MetadataStateCompletingInitialization MetadataStateFinalValidation @@ -344,8 +344,8 @@ func (s MetadataState) String() string { return "available" case MetadataStateWaitingForInitialPurchase: return "waiting_for_initial_purchase" - case MetadataStateFundingAuthority: - return "funding_authority" + case MetadataStatePrePurchaseSetup: + return "pre_purchase_setup" case MetadataStateExecutingInitialPurchase: return "executing_initial_purchase" case MetadataStateCompletingInitialization: diff --git a/ocp/integration/swap.go b/ocp/integration/swap.go index e78b967..8f0d731 100644 --- a/ocp/integration/swap.go +++ b/ocp/integration/swap.go @@ -13,5 +13,5 @@ type Swap interface { OnSwapSubmitted(ctx context.Context, owner *common.Account, fromMint, toMint *common.Account) error // OnSwapFinalized allows for notifications based on events processed by the swap worker - OnSwapFinalized(ctx context.Context, owner *common.Account, isBuy bool, mint *common.Account, currencyName string, region currency.Code, valueReceived float64, isMintInit bool) error + OnSwapFinalized(ctx context.Context, owner *common.Account, isBuy bool, mint *common.Account, currencyName string, region currency.Code, valueReceived float64) error } diff --git a/ocp/rpc/transaction/config.go b/ocp/rpc/transaction/config.go index f9e8a3c..225e253 100644 --- a/ocp/rpc/transaction/config.go +++ b/ocp/rpc/transaction/config.go @@ -7,6 +7,7 @@ import ( "github.com/code-payments/ocp-server/config/env" "github.com/code-payments/ocp-server/config/memory" "github.com/code-payments/ocp-server/config/wrapper" + "github.com/code-payments/ocp-server/ocp/common" ) const ( @@ -30,8 +31,21 @@ const ( FeeCollectorOwnerPublicKeyConfigEnvName = envConfigPrefix + "FEE_COLLECTOR_OWNER_PUBLIC_KEY" defaultFeeCollectorPublicKey = "invalid" // Ensure something valid is set - CreateOnSendWithdrawalUsdFeeConfigEnvName = envConfigPrefix + "CREATE_ON_SEND_WITHDRAWAL_USD_FEE" - defaultCreateOnSendWithdrawalUsdFee = 0.50 + CreateOnSendWithdrawalFeeQuarksConfigEnvName = envConfigPrefix + "CREATE_ON_SEND_WITHDRAWAL_FEE_QUARKS" + + SwapTreasuryOwnerPublicKeyConfigEnvName = envConfigPrefix + "SWAP_TREASURY_OWNER_PUBLIC_KEY" + defaultSwapTreasuryOwnerPublicKey = "invalid" // Ensure something valid is set + + NewCurrencyPurchaseQuarksConfigEnvName = envConfigPrefix + "NEW_CURRENCY_PURCHASE_QUARKS" + + NewCurrencyFeeQuarksConfigEnvName = envConfigPrefix + "NEW_CURRENCY_FEE_QUARKS" +) + +// Assumes a USD stable coin core mint +var ( + defaultCreateOnSendWithdrawalFeeQuarks = common.CoreMintQuarksPerUnit / 2 // $0.50 + defaultNewCurrencyPurchaseQuarks = 10 * common.CoreMintQuarksPerUnit + defaultNewCurrencyFeeQuarks = 10 * common.CoreMintQuarksPerUnit ) type conf struct { @@ -43,8 +57,11 @@ type conf struct { submitIntentTimeout config.Duration swapTimeout config.Duration clientReceiveTimeout config.Duration - feeCollectorOwnerPublicKey config.String - createOnSendWithdrawalUsdFee config.Float64 + feeCollectorOwnerPublicKey config.String + createOnSendWithdrawalFeeQuarks config.Uint64 + swapTreasuryOwnerPublicKey config.String + newCurrencyPurchaseQuarks config.Uint64 + newCurrencyFeeQuarks config.Uint64 } // ConfigProvider defines how config values are pulled @@ -62,8 +79,11 @@ func WithEnvConfigs() ConfigProvider { submitIntentTimeout: env.NewDurationConfig(SubmitIntentTimeoutConfigEnvName, defaultSubmitIntentTimeout), swapTimeout: env.NewDurationConfig(SwapTimeoutConfigEnvName, defaultSwapTimeout), clientReceiveTimeout: env.NewDurationConfig(ClientReceiveTimeoutConfigEnvName, defaultClientReceiveTimeout), - feeCollectorOwnerPublicKey: env.NewStringConfig(FeeCollectorOwnerPublicKeyConfigEnvName, defaultFeeCollectorPublicKey), - createOnSendWithdrawalUsdFee: env.NewFloat64Config(CreateOnSendWithdrawalUsdFeeConfigEnvName, defaultCreateOnSendWithdrawalUsdFee), + feeCollectorOwnerPublicKey: env.NewStringConfig(FeeCollectorOwnerPublicKeyConfigEnvName, defaultFeeCollectorPublicKey), + createOnSendWithdrawalFeeQuarks: env.NewUint64Config(CreateOnSendWithdrawalFeeQuarksConfigEnvName, defaultCreateOnSendWithdrawalFeeQuarks), + swapTreasuryOwnerPublicKey: env.NewStringConfig(SwapTreasuryOwnerPublicKeyConfigEnvName, defaultSwapTreasuryOwnerPublicKey), + newCurrencyPurchaseQuarks: env.NewUint64Config(NewCurrencyPurchaseQuarksConfigEnvName, defaultNewCurrencyPurchaseQuarks), + newCurrencyFeeQuarks: env.NewUint64Config(NewCurrencyFeeQuarksConfigEnvName, defaultNewCurrencyFeeQuarks), } } } @@ -74,6 +94,7 @@ type testOverrides struct { enableAmlChecks bool clientReceiveTimeout time.Duration feeCollectorOwnerPublicKey string + swapTreasuryOwnerPublicKey string } func withManualTestOverrides(overrides *testOverrides) ConfigProvider { @@ -86,8 +107,11 @@ func withManualTestOverrides(overrides *testOverrides) ConfigProvider { submitIntentTimeout: wrapper.NewDurationConfig(memory.NewConfig(defaultSubmitIntentTimeout), defaultSubmitIntentTimeout), swapTimeout: wrapper.NewDurationConfig(memory.NewConfig(defaultSwapTimeout), defaultSwapTimeout), clientReceiveTimeout: wrapper.NewDurationConfig(memory.NewConfig(overrides.clientReceiveTimeout), defaultClientReceiveTimeout), - feeCollectorOwnerPublicKey: wrapper.NewStringConfig(memory.NewConfig(overrides.feeCollectorOwnerPublicKey), defaultFeeCollectorPublicKey), - createOnSendWithdrawalUsdFee: wrapper.NewFloat64Config(memory.NewConfig(defaultCreateOnSendWithdrawalUsdFee), defaultCreateOnSendWithdrawalUsdFee), + feeCollectorOwnerPublicKey: wrapper.NewStringConfig(memory.NewConfig(overrides.feeCollectorOwnerPublicKey), defaultFeeCollectorPublicKey), + createOnSendWithdrawalFeeQuarks: wrapper.NewUint64Config(memory.NewConfig(defaultCreateOnSendWithdrawalFeeQuarks), defaultCreateOnSendWithdrawalFeeQuarks), + swapTreasuryOwnerPublicKey: wrapper.NewStringConfig(memory.NewConfig(overrides.swapTreasuryOwnerPublicKey), defaultSwapTreasuryOwnerPublicKey), + newCurrencyPurchaseQuarks: wrapper.NewUint64Config(memory.NewConfig(defaultNewCurrencyPurchaseQuarks), defaultNewCurrencyPurchaseQuarks), + newCurrencyFeeQuarks: wrapper.NewUint64Config(memory.NewConfig(defaultNewCurrencyFeeQuarks), defaultNewCurrencyFeeQuarks), } } } diff --git a/ocp/rpc/transaction/intent_handler.go b/ocp/rpc/transaction/intent_handler.go index de882d2..88dceb5 100644 --- a/ocp/rpc/transaction/intent_handler.go +++ b/ocp/rpc/transaction/intent_handler.go @@ -25,6 +25,7 @@ import ( ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/account" "github.com/code-payments/ocp-server/ocp/data/action" + "github.com/code-payments/ocp-server/ocp/data/currency" "github.com/code-payments/ocp-server/ocp/data/fulfillment" "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/swap" @@ -250,7 +251,7 @@ func (h *OpenAccountsIntentHandler) AllowCreation(ctx context.Context, intentRec // Part 5: Validate reserved intent IDs // - _, err = validateSwapFunding(ctx, h.data, intentRecord) + _, err = validateSwapFunding(ctx, h.conf, h.data, intentRecord) return err } @@ -670,7 +671,7 @@ func (h *SendPublicPaymentIntentHandler) AllowCreation(ctx context.Context, inte // Part 7: Validate reserved intent IDs // - h.cachedSwapRecord, err = validateSwapFunding(ctx, h.data, intentRecord) + h.cachedSwapRecord, err = validateSwapFunding(ctx, h.conf, h.data, intentRecord) if err != nil { return err } @@ -1218,7 +1219,7 @@ func (h *ReceivePaymentsPubliclyIntentHandler) AllowCreation(ctx context.Context // Part 7: Validate reserved intent IDs // - _, err = validateSwapFunding(ctx, h.data, intentRecord) + _, err = validateSwapFunding(ctx, h.conf, h.data, intentRecord) if err != nil { return err } @@ -1538,7 +1539,7 @@ func (h *PublicDistributionIntentHandler) AllowCreation(ctx context.Context, int // Part 5: Validate reserved intent IDs // - _, err = validateSwapFunding(ctx, h.data, intentRecord) + _, err = validateSwapFunding(ctx, h.conf, h.data, intentRecord) if err != nil { return err } @@ -2086,7 +2087,7 @@ func validateFeePayments( var expectedUsdValue float64 switch expectedFeeType { case transactionpb.FeePaymentAction_CREATE_ON_SEND_WITHDRAWAL: - expectedUsdValue = conf.createOnSendWithdrawalUsdFee.Get(ctx) + expectedUsdValue = float64(conf.createOnSendWithdrawalFeeQuarks.Get(ctx)) / float64(common.CoreMintQuarksPerUnit) default: return errors.New("unhandled fee type") } @@ -2257,7 +2258,7 @@ func validateDistributedPool(ctx context.Context, data ocp_data.Provider, poolVa return nil } -func validateSwapFunding(ctx context.Context, data ocp_data.Provider, intentRecord *intent.Record) (*swap.Record, error) { +func validateSwapFunding(ctx context.Context, conf *conf, data ocp_data.Provider, intentRecord *intent.Record) (*swap.Record, error) { swapRecord, err := data.GetSwapByFundingId(ctx, intentRecord.IntentId) if err != nil && err != swap.ErrNotFound { return nil, err @@ -2293,6 +2294,32 @@ func validateSwapFunding(ctx context.Context, data ocp_data.Provider, intentReco if intentRecord.SendPublicPaymentMetadata.Quantity != swapRecord.SwapAmount+swapRecord.FeeAmount { return nil, NewIntentValidationErrorf("must fund swap with %d quarks", swapRecord.SwapAmount+swapRecord.FeeAmount) } + + fromMintAccount, err := common.NewAccountFromPublicKeyString(swapRecord.FromMint) + if err != nil { + return nil, err + } + toMintAccount, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + return nil, err + } + if !common.IsCoreMint(fromMintAccount) && !common.IsCoreMint(toMintAccount) { + destinationCurrencyMetadataRecord, err := data.GetCurrencyMetadata(ctx, swapRecord.ToMint) + if err != nil { + return nil, err + } + + initializesMint := destinationCurrencyMetadataRecord.State != currency.MetadataStateAvailable + if initializesMint { + expectedUsdValue := float64(conf.newCurrencyPurchaseQuarks.Get(ctx)+conf.newCurrencyFeeQuarks.Get(ctx)) / float64(common.CoreMintQuarksPerUnit) + if intentRecord.SendPublicPaymentMetadata.ExchangeCurrency != currency_lib.USD { + return nil, NewIntentValidationError("swap funding must be valued in usd") + } + if intentRecord.SendPublicPaymentMetadata.NativeAmount != expectedUsdValue { + return nil, NewIntentValidationErrorf("swap funding must be valued at %.2f usd", expectedUsdValue) + } + } + } } if !isIntentReservedForSwap && intentRecord.IntentType != intent.SendPublicPayment { return nil, nil diff --git a/ocp/rpc/transaction/server.go b/ocp/rpc/transaction/server.go index 9bbca93..93515ae 100644 --- a/ocp/rpc/transaction/server.go +++ b/ocp/rpc/transaction/server.go @@ -49,6 +49,7 @@ type transactionServer struct { localAccountLocks map[string]*sync.Mutex feeCollector *common.Account + swapTreasury *common.Account transactionpb.UnimplementedTransactionServer } @@ -127,5 +128,14 @@ func NewTransactionServer( return nil, err } + swapTreasuryVaultRecord, err := data.GetKey(ctx, s.conf.swapTreasuryOwnerPublicKey.Get(ctx)) + if err != nil { + return nil, err + } + s.swapTreasury, err = common.NewAccountFromPrivateKeyString(swapTreasuryVaultRecord.PrivateKey) + if err != nil { + return nil, err + } + return s, nil } diff --git a/ocp/rpc/transaction/stateful_swap.go b/ocp/rpc/transaction/stateful_swap.go index 134e72c..359021d 100644 --- a/ocp/rpc/transaction/stateful_swap.go +++ b/ocp/rpc/transaction/stateful_swap.go @@ -20,9 +20,11 @@ import ( transactionpb "github.com/code-payments/ocp-protobuf-api/generated/go/transaction/v1" "github.com/code-payments/ocp-server/coinbase" + currency_lib "github.com/code-payments/ocp-server/currency" "github.com/code-payments/ocp-server/grpc/client" "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" "github.com/code-payments/ocp-server/ocp/data/currency" "github.com/code-payments/ocp-server/ocp/data/intent" "github.com/code-payments/ocp-server/ocp/data/nonce" @@ -297,9 +299,6 @@ func (s *transactionServer) handleReserveStatefulSwap( case currency.MetadataStateAvailable: initializesMint = false case currency.MetadataStateWaitingForInitialPurchase: - if !common.IsCoreMint(fromMint) { - return handleStatefulSwapError(streamer, NewSwapDeniedError("new currency can only be created from the core mint")) - } initializesMint = true default: return handleStatefulSwapError(streamer, NewSwapDeniedError("mint is being initialized")) @@ -333,6 +332,8 @@ func (s *transactionServer) handleReserveStatefulSwap( } var destinationVmAuthority *common.Account + var treasury *common.Account + var purchaseCoreQuarks uint64 if !initializesMint { if owner.PublicKey().ToBase58() == swapAuthority.PublicKey().ToBase58() { return handleStatefulSwapError(streamer, NewSwapValidationError("owner cannot be swap authority")) @@ -382,20 +383,73 @@ func (s *transactionServer) handleReserveStatefulSwap( return handleStatefulSwapError(streamer, NewSwapDeniedError("only the currency creator can buy initial tokens")) } - if !common.IsCoreMint(fromMint) { - return handleStatefulSwapError(streamer, NewSwapValidationError("source mint must be the core mint")) - } + expectedPurchaseQuarks := s.conf.newCurrencyPurchaseQuarks.Get(ctx) + expectedFeeQuarks := s.conf.newCurrencyFeeQuarks.Get(ctx) + purchaseCoreQuarks = expectedPurchaseQuarks - // The VM is not supported yet, so we need to work around GetVmConfigForMint - destinationVaultRecord, err := s.data.GetKey(ctx, destinationCurrencyMetadataRecord.Authority) - if err != nil { - log.With(zap.Error(err)).Warn("failure getting destination vm authority vault record") - return handleStatefulSwapError(streamer, err) - } - destinationVmAuthority, err = common.NewAccountFromPrivateKeyString(destinationVaultRecord.PrivateKey) - if err != nil { - log.With(zap.Error(err)).Warn("invalid destination vm authority private key") - return handleStatefulSwapError(streamer, err) + if common.IsCoreMint(fromMint) { + if initiateReserveSwapReq.SwapAmount != expectedPurchaseQuarks { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("swap amount must be %d quarks", expectedPurchaseQuarks)) + } + if initiateReserveSwapReq.FeeAmount != expectedFeeQuarks { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("fee amount must be %d quarks", expectedFeeQuarks)) + } + + // The VM is not supported yet, so we need to work around GetVmConfigForMint + destinationVaultRecord, err := s.data.GetKey(ctx, destinationCurrencyMetadataRecord.Authority) + if err != nil { + log.With(zap.Error(err)).Warn("failure getting destination vm authority vault record") + return handleStatefulSwapError(streamer, err) + } + destinationVmAuthority, err = common.NewAccountFromPrivateKeyString(destinationVaultRecord.PrivateKey) + if err != nil { + log.With(zap.Error(err)).Warn("invalid destination vm authority private key") + return handleStatefulSwapError(streamer, err) + } + } else { + if initiateReserveSwapReq.FundingSource != transactionpb.FundingSource_FUNDING_SOURCE_SUBMIT_INTENT { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("funding source %s is not supported for new currency purchases", initiateReserveSwapReq.FundingSource)) + } + + expectedFullUsdValue := float64(expectedPurchaseQuarks+expectedFeeQuarks) / float64(common.CoreMintQuarksPerUnit) + expectedFeeUsdValue := float64(expectedFeeQuarks) / float64(common.CoreMintQuarksPerUnit) + + fullAmountExchangeData := initiateReserveSwapReq.FullAmountExchangeData + if fullAmountExchangeData == nil { + return handleStatefulSwapError(streamer, NewSwapValidationError("full amount exchange data is required")) + } + if ok, message := currency_util.ValidateClientExchangeData(fullAmountExchangeData); !ok { + return handleStatefulSwapError(streamer, NewSwapValidationError(message)) + } + + exchangeDataMint, err := common.NewAccountFromProto(fullAmountExchangeData.Mint) + if err != nil { + log.With(zap.Error(err)).Warn("invalid full amount exchange data mint") + return handleStatefulSwapError(streamer, err) + } + if !bytes.Equal(exchangeDataMint.PublicKey().ToBytes(), fromMint.PublicKey().ToBytes()) { + return handleStatefulSwapError(streamer, NewSwapValidationError("full amount exchange data mint must be the source mint")) + } + if fullAmountExchangeData.Quarks != initiateReserveSwapReq.SwapAmount+initiateReserveSwapReq.FeeAmount { + return handleStatefulSwapError(streamer, NewSwapValidationError("full amount exchange data quarks must be the full swap and fee amount")) + } + if fullAmountExchangeData.CoreMintFiatExchangeRate.ExchangeRate.CurrencyCode != string(currency_lib.USD) { + return handleStatefulSwapError(streamer, NewSwapValidationError("full amount exchange data must be in usd")) + } + if fullAmountExchangeData.NativeAmount != expectedFullUsdValue { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("full amount must be valued at %.2f usd", expectedFullUsdValue)) + } + + if ok, _ := currency_util.ValidateClientUsdFeeValue( + fromMint, + initiateReserveSwapReq.FeeAmount, + expectedFeeUsdValue, + fullAmountExchangeData.LaunchpadCurrencyReserveState, + ); !ok { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("fee amount must be valued at %.2f usd", expectedFeeUsdValue)) + } + + treasury = s.swapTreasury } } @@ -448,7 +502,7 @@ func (s *transactionServer) handleReserveStatefulSwap( }() var swapHandler SwapHandler - if initializesMint { + if initializesMint && common.IsCoreMint(fromMint) { swapHandler, err = NewReserveCreateAndBuySwapHandler( ctx, s.data, @@ -458,6 +512,19 @@ func (s *transactionServer) handleReserveStatefulSwap( initiateReserveSwapReq.FeeAmount, selectedNonce, ) + } else if initializesMint { + swapHandler, err = NewReserveTreasuryFundedCreateAndBuySwapHandler( + ctx, + s.data, + owner, + fromMint, + toMint, + initiateReserveSwapReq.SwapAmount, + initiateReserveSwapReq.FeeAmount, + treasury, + purchaseCoreQuarks, + selectedNonce, + ) } else if isBuy && isSell { swapHandler = NewReserveBuySellSwapHandler( s.data, @@ -586,11 +653,17 @@ func (s *transactionServer) handleReserveStatefulSwap( copy(txn.Signatures[i][:], protoSignature.Value) } - err = txn.Sign( + serverSigners := []ed25519.PrivateKey{ common.GetSubsidizer().PrivateKey().ToBytes(), sourceVmConfig.Authority.PrivateKey().ToBytes(), - destinationVmAuthority.PrivateKey().ToBytes(), - ) + } + if destinationVmAuthority != nil { + serverSigners = append(serverSigners, destinationVmAuthority.PrivateKey().ToBytes()) + } + if treasury != nil { + serverSigners = append(serverSigners, treasury.PrivateKey().ToBytes()) + } + err = txn.Sign(serverSigners...) if err != nil { log.With(zap.Error(err)).Info("failure signing transaction") return handleStatefulSwapError(streamer, err) @@ -647,7 +720,7 @@ func (s *transactionServer) handleReserveStatefulSwap( } if initializesMint { - destinationCurrencyMetadataRecord.State = currency.MetadataStateFundingAuthority + destinationCurrencyMetadataRecord.State = currency.MetadataStatePrePurchaseSetup err = s.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) if err != nil { log.With(zap.Error(err)).Warn("failure saving currency metadata record") @@ -773,7 +846,7 @@ func (s *transactionServer) handleStablecoinStatefulSwap( return handleStatefulSwapError(streamer, NewSwapValidationError("swap amount must be positive")) } - expectedFeeQuarks := uint64(s.conf.createOnSendWithdrawalUsdFee.Get(ctx) * float64(common.CoreMintQuarksPerUnit)) + expectedFeeQuarks := s.conf.createOnSendWithdrawalFeeQuarks.Get(ctx) if initiateStablecoinSwapReq.FeeAmount != expectedFeeQuarks { return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("fee amount must be %d quarks", expectedFeeQuarks)) } diff --git a/ocp/rpc/transaction/swap_handler.go b/ocp/rpc/transaction/swap_handler.go index e9e3a12..caffa62 100644 --- a/ocp/rpc/transaction/swap_handler.go +++ b/ocp/rpc/transaction/swap_handler.go @@ -892,6 +892,231 @@ func (h *ReserveCreateAndBuySwapHandler) MakeInstructions(ctx context.Context) ( }, nil } +type ReserveTreasuryFundedCreateAndBuySwapHandler struct { + data ocp_data.Provider + + buyer *common.Account + fromMint *common.Account + swapAmount uint64 + feeAmount uint64 + treasury *common.Account + purchaseCoreQuarks uint64 + + alts []solana.AddressLookupTable + selectedNonce *transaction_util.Nonce + computeUnitLimit uint32 + computeUnitPrice uint64 + + sourceVmConfig *common.VmConfig + sourceCurrencyAccounts *common.LaunchpadCurrencyAccounts + destinationCurrencyMetadataRecord *currency.MetadataRecord + destinationCurrencyAccounts *common.LaunchpadCurrencyAccounts + destinationVmMetadataRecord *vm_metadata.Record + destinationVmConfig *common.VmConfig +} + +func NewReserveTreasuryFundedCreateAndBuySwapHandler( + ctx context.Context, + data ocp_data.Provider, + buyer *common.Account, + fromMint *common.Account, + toMint *common.Account, + swapAmount, feeAmount uint64, + treasury *common.Account, + purchaseCoreQuarks uint64, + selectedNonce *transaction_util.Nonce, +) (SwapHandler, error) { + var err error + + h := &ReserveTreasuryFundedCreateAndBuySwapHandler{ + data: data, + + buyer: buyer, + fromMint: fromMint, + swapAmount: swapAmount, + feeAmount: feeAmount, + treasury: treasury, + purchaseCoreQuarks: purchaseCoreQuarks, + + selectedNonce: selectedNonce, + computeUnitLimit: 250_000, // todo: optimize + computeUnitPrice: 10_000, + } + + h.sourceVmConfig, err = common.GetVmConfigForMint(ctx, data, fromMint) + if err != nil { + return nil, err + } + + sourceCurrencyMetadataRecord, err := data.GetCurrencyMetadata(ctx, fromMint.PublicKey().ToBase58()) + if err != nil { + return nil, err + } + + h.sourceCurrencyAccounts, err = common.GetLaunchpadCurrencyAccounts(sourceCurrencyMetadataRecord) + if err != nil { + return nil, err + } + + h.destinationCurrencyMetadataRecord, err = data.GetCurrencyMetadata(ctx, toMint.PublicKey().ToBase58()) + if err != nil { + return nil, err + } + + h.destinationCurrencyAccounts, err = common.GetLaunchpadCurrencyAccounts(h.destinationCurrencyMetadataRecord) + if err != nil { + return nil, err + } + + // The VM is not supported yet, so we need to work around GetVmConfigForMint + h.destinationVmMetadataRecord, err = data.GetVmMetadataByMint(ctx, toMint.PublicKey().ToBase58()) + if err != nil { + return nil, err + } + vmAccount, err := common.NewAccountFromPublicKeyString(h.destinationVmMetadataRecord.Vm) + if err != nil { + return nil, err + } + omnibusAccount, err := common.NewAccountFromPublicKeyString(h.destinationVmMetadataRecord.Omnibus) + if err != nil { + return nil, err + } + h.destinationVmConfig = &common.VmConfig{ + Authority: h.destinationCurrencyAccounts.Authority, + Vm: vmAccount, + Omnibus: omnibusAccount, + Mint: toMint, + } + + return h, nil +} + +func (h *ReserveTreasuryFundedCreateAndBuySwapHandler) GetAlts(ctx context.Context) ([]solana.AddressLookupTable, error) { + alt, err := transaction_util.GetAltForMint(ctx, h.data, h.fromMint) + if err != nil { + return nil, err + } + h.alts = []solana.AddressLookupTable{alt} + return h.alts, nil +} + +func (h *ReserveTreasuryFundedCreateAndBuySwapHandler) GetServerParameters() *transactionpb.StatefulSwapResponse_ServerParameters { + return &transactionpb.StatefulSwapResponse_ServerParameters{ + Kind: &transactionpb.StatefulSwapResponse_ServerParameters_ReserveNewCurrency{ + ReserveNewCurrency: &transactionpb.StatefulSwapResponse_ServerParameters_ReserveNewCurrencyServerParameter{ + Payer: common.GetSubsidizer().ToProto(), + Nonce: h.selectedNonce.Account.ToProto(), + Blockhash: &commonpb.Blockhash{Value: h.selectedNonce.Blockhash[:]}, + Alts: transaction_util.ToProtoAlts(h.alts), + ComputeUnitLimit: h.computeUnitLimit, + ComputeUnitPrice: h.computeUnitPrice, + MemoValue: "", + Authority: h.destinationCurrencyAccounts.Authority.ToProto(), + Name: h.destinationCurrencyMetadataRecord.Name, + Symbol: h.destinationCurrencyMetadataRecord.Symbol, + Seed: h.destinationCurrencyAccounts.Seed.ToProto(), + SellFeeBps: currencycreator.DefaultSellFeeBps, + VmLockDurationInDays: uint32(timelock_token.DefaultNumDaysLocked), + FeeDestination: common.CoreMintFeesAccount.ToProto(), + Treasury: h.treasury.ToProto(), + TreasuryPurchaseAmount: h.purchaseCoreQuarks, + }, + }, + } +} + +func (h *ReserveTreasuryFundedCreateAndBuySwapHandler) MakeInstructions(ctx context.Context) ([]solana.Instruction, error) { + buyerVmSwapAccounts, err := h.buyer.GetVmSwapAccounts(h.sourceVmConfig) + if err != nil { + return nil, err + } + + buyerVmDepositAccounts, err := h.buyer.GetVmDepositAccounts(h.destinationVmConfig) + if err != nil { + return nil, err + } + + treasuryCoreMintAtaAddress, err := token.GetAssociatedAccount(h.treasury.PublicKey().ToBytes(), common.CoreMintAccount.PublicKey().ToBytes()) + if err != nil { + return nil, err + } + + createTreasuryFromMintAtaIxn, treasuryFromMintAtaAddress, err := token.CreateAssociatedTokenAccountIdempotent( + common.GetSubsidizer().PublicKey().ToBytes(), + h.treasury.PublicKey().ToBytes(), + h.fromMint.PublicKey().ToBytes(), + ) + if err != nil { + return nil, err + } + + // The entire swap and fee amount is collected by the treasury, then converted + // into core mint sent to the fee destination + transferForSwapWithFeeIxn := vm.NewTransferForSwapWithFeeInstruction( + &vm.TransferForSwapWithFeeInstructionAccounts{ + VmAuthority: h.sourceVmConfig.Authority.PublicKey().ToBytes(), + Vm: h.sourceVmConfig.Vm.PublicKey().ToBytes(), + Swapper: h.buyer.PublicKey().ToBytes(), + SwapPda: buyerVmSwapAccounts.Pda.PublicKey().ToBytes(), + SwapAta: buyerVmSwapAccounts.Ata.PublicKey().ToBytes(), + SwapDestination: treasuryFromMintAtaAddress, + FeeDestination: treasuryFromMintAtaAddress, + }, + &vm.TransferForSwapWithFeeInstructionArgs{ + SwapAmount: h.swapAmount, + FeeAmount: h.feeAmount, + Bump: buyerVmSwapAccounts.PdaBump, + }, + ) + + sellIxn := currencycreator.NewSellTokensInstruction( + ¤cycreator.SellTokensInstructionAccounts{ + Seller: h.treasury.PublicKey().ToBytes(), + Pool: h.sourceCurrencyAccounts.LiquidityPool.PublicKey().ToBytes(), + TargetMint: h.fromMint.PublicKey().ToBytes(), + BaseMint: common.CoreMintAccount.PublicKey().ToBytes(), + VaultTarget: h.sourceCurrencyAccounts.VaultMint.PublicKey().ToBytes(), + VaultBase: h.sourceCurrencyAccounts.VaultBase.PublicKey().ToBytes(), + SellerTarget: treasuryFromMintAtaAddress, + SellerBase: common.CoreMintFeesAccount.PublicKey().ToBytes(), + }, + ¤cycreator.SellTokensInstructionArgs{ + InAmount: h.swapAmount + h.feeAmount, + MinAmountOut: 0, + }, + ) + + // The purchase is funded by the server-controlled treasury holding core mint + // tokens, which guarantees the full purchase value regardless of from mint + // price volatility + buyIxn := currencycreator.NewBuyTokensInstruction( + ¤cycreator.BuyTokensInstructionAccounts{ + Buyer: h.treasury.PublicKey().ToBytes(), + Pool: h.destinationCurrencyAccounts.LiquidityPool.PublicKey().ToBytes(), + TargetMint: h.destinationCurrencyAccounts.Mint.PublicKey().ToBytes(), + BaseMint: common.CoreMintAccount.PublicKey().ToBytes(), + VaultTarget: h.destinationCurrencyAccounts.VaultMint.PublicKey().ToBytes(), + VaultBase: h.destinationCurrencyAccounts.VaultBase.PublicKey().ToBytes(), + BuyerTarget: buyerVmDepositAccounts.Ata.PublicKey().ToBytes(), + BuyerBase: treasuryCoreMintAtaAddress, + }, + ¤cycreator.BuyTokensInstructionArgs{ + InAmount: h.purchaseCoreQuarks, + MinAmountOut: 0, + }, + ) + + return []solana.Instruction{ + system.AdvanceNonce(h.selectedNonce.Account.PublicKey().ToBytes(), common.GetSubsidizer().PublicKey().ToBytes()), + compute_budget.SetComputeUnitLimit(h.computeUnitLimit), + compute_budget.SetComputeUnitPrice(h.computeUnitPrice), + createTreasuryFromMintAtaIxn, + transferForSwapWithFeeIxn, + sellIxn, + buyIxn, + }, nil +} + type CoinbaseStableSwapperSwapHandler struct { data ocp_data.Provider diff --git a/ocp/rpc/transaction/withdraw.go b/ocp/rpc/transaction/withdraw.go index bc37349..f3521ab 100644 --- a/ocp/rpc/transaction/withdraw.go +++ b/ocp/rpc/transaction/withdraw.go @@ -158,7 +158,7 @@ func (s *transactionServer) CanWithdrawToAccount(ctx context.Context, req *trans if !isVmSwapPda { feeAmount = &transactionpb.ExchangeDataWithoutRate{ Currency: string(currency_lib.USD), - NativeAmount: s.conf.createOnSendWithdrawalUsdFee.Get(ctx), + NativeAmount: float64(s.conf.createOnSendWithdrawalFeeQuarks.Get(ctx)) / float64(common.CoreMintQuarksPerUnit), } } return &transactionpb.CanWithdrawToAccountResponse{ diff --git a/ocp/worker/currency/launcher/metrics.go b/ocp/worker/currency/launcher/metrics.go index a1c3db2..37fa36b 100644 --- a/ocp/worker/currency/launcher/metrics.go +++ b/ocp/worker/currency/launcher/metrics.go @@ -25,7 +25,7 @@ func (p *runtime) metricsGaugeWorker(ctx context.Context) error { for _, state := range []currency.MetadataState{ currency.MetadataStateUnknown, currency.MetadataStateWaitingForInitialPurchase, - currency.MetadataStateFundingAuthority, + currency.MetadataStatePrePurchaseSetup, currency.MetadataStateExecutingInitialPurchase, currency.MetadataStateCompletingInitialization, currency.MetadataStateFinalValidation, diff --git a/ocp/worker/currency/launcher/runtime.go b/ocp/worker/currency/launcher/runtime.go index 893d0ac..6afaa73 100644 --- a/ocp/worker/currency/launcher/runtime.go +++ b/ocp/worker/currency/launcher/runtime.go @@ -44,7 +44,7 @@ func New(log *zap.Logger, data ocp_data.Provider, reserveStore reserve.Store, ho func (p *runtime) Start(ctx context.Context, interval time.Duration) error { for _, state := range []currency.MetadataState{ currency.MetadataStateWaitingForInitialPurchase, - currency.MetadataStateFundingAuthority, + currency.MetadataStatePrePurchaseSetup, currency.MetadataStateCompletingInitialization, currency.MetadataStateFinalValidation, currency.MetadataStateAbandoning, diff --git a/ocp/worker/currency/launcher/util.go b/ocp/worker/currency/launcher/util.go index 84ec841..b6880da 100644 --- a/ocp/worker/currency/launcher/util.go +++ b/ocp/worker/currency/launcher/util.go @@ -74,7 +74,7 @@ func (p *runtime) validateCurrencyMetadataState(record *currency.MetadataRecord, } func (p *runtime) markCurrencyMetadataExecutingInitialPurchase(ctx context.Context, record *currency.MetadataRecord) error { - err := p.validateCurrencyMetadataState(record, currency.MetadataStateFundingAuthority) + err := p.validateCurrencyMetadataState(record, currency.MetadataStatePrePurchaseSetup) if err != nil { return err } @@ -483,6 +483,112 @@ func (p *runtime) deriveNewAlt(ctx context.Context, accounts *newCurrencyAccount return nil } +// Initializes the currency, liquidity pool, VM and creator's VM deposit ATA on +// the blockchain ahead of the initial purchase. This is only required for flows +// where the initial purchase transaction doesn't atomically perform initialization +// (ie. purchases funded outside the core mint). +func (p *runtime) initCurrencyOnBlockchain(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord, vmMetadataRecord *vm_metadata.Record, accounts *newCurrencyAccounts) error { + seedAccount, err := common.NewAccountFromPublicKeyString(currencyMetadataRecord.Seed) + if err != nil { + return errors.Wrap(err, "invalid seed") + } + + creatorAccount, err := common.NewAccountFromPublicKeyString(currencyMetadataRecord.CreatedBy) + if err != nil { + return errors.Wrap(err, "invalid creator") + } + + vmConfig := &common.VmConfig{ + Authority: accounts.Authority, + Vm: accounts.Vm, + Omnibus: accounts.Omnibus, + Mint: accounts.Mint, + } + creatorVmDepositAccounts, err := creatorAccount.GetVmDepositAccounts(vmConfig) + if err != nil { + return errors.Wrap(err, "error getting creator vm deposit accounts") + } + + initCurrencyIxn := currencycreator.NewInitializeCurrencyInstruction( + ¤cycreator.InitializeCurrencyInstructionAccounts{ + Authority: accounts.Authority.PublicKey().ToBytes(), + Mint: accounts.Mint.PublicKey().ToBytes(), + Currency: accounts.CurrencyConfig.PublicKey().ToBytes(), + }, + ¤cycreator.InitializeCurrencyInstructionArgs{ + Name: currencyMetadataRecord.Name, + Symbol: currencyMetadataRecord.Symbol, + Seed: seedAccount.PublicKey().ToBytes(), + Bump: accounts.CurrencyConfigBump, + MintBump: accounts.MintBump, + }, + ) + + initPoolIxn := currencycreator.NewInitializePoolInstruction( + ¤cycreator.InitializePoolInstructionAccounts{ + Authority: accounts.Authority.PublicKey().ToBytes(), + Currency: accounts.CurrencyConfig.PublicKey().ToBytes(), + TargetMint: accounts.Mint.PublicKey().ToBytes(), + BaseMint: common.CoreMintAccount.PublicKey().ToBytes(), + Pool: accounts.LiquidityPool.PublicKey().ToBytes(), + VaultTarget: accounts.VaultMint.PublicKey().ToBytes(), + VaultBase: accounts.VaultBase.PublicKey().ToBytes(), + }, + ¤cycreator.InitializePoolInstructionArgs{ + SellFee: currencyMetadataRecord.SellFeeBps, + Bump: accounts.LiquidityPoolBump, + VaultTargetBump: accounts.VaultMintBump, + VaultBaseBump: accounts.VaultBaseBump, + }, + ) + + initVmIxn := vm.NewInitVmInstruction( + &vm.InitVmInstructionAccounts{ + VmAuthority: accounts.Authority.PublicKey().ToBytes(), + Vm: accounts.Vm.PublicKey().ToBytes(), + VmOmnibus: accounts.Omnibus.PublicKey().ToBytes(), + Mint: accounts.Mint.PublicKey().ToBytes(), + }, + &vm.InitVmInstructionArgs{ + LockDuration: vmMetadataRecord.DaysLocked, + VmBump: accounts.VmBump, + VmOmnibusBump: accounts.OmnibusBump, + }, + ) + + createCreatorVmDepositAtaIxn, _, err := token.CreateAssociatedTokenAccountIdempotent( + accounts.Authority.PublicKey().ToBytes(), + creatorVmDepositAccounts.Pda.PublicKey().ToBytes(), + accounts.Mint.PublicKey().ToBytes(), + ) + if err != nil { + return errors.Wrap(err, "error creating creator vm deposit ata ixn") + } + + txn := solana.NewLegacyTransaction( + accounts.Authority.PublicKey().ToBytes(), + compute_budget.SetComputeUnitLimit(300_000), // todo: optimize + compute_budget.SetComputeUnitPrice(10_000), + initCurrencyIxn, + initPoolIxn, + initVmIxn, + createCreatorVmDepositAtaIxn, + ) + + bh, err := p.data.GetBlockchainLatestBlockhash(ctx) + if err != nil { + return errors.Wrap(err, "error getting latest blockhash") + } + txn.SetBlockhash(bh) + + err = txn.Sign(accounts.Authority.PrivateKey().ToBytes()) + if err != nil { + return errors.Wrap(err, "error signing transaction") + } + + return transaction_util.SubmitAndWaitForFinalization(ctx, p.data, &txn) +} + func (p *runtime) initRemainingBlockchainAccounts(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord, accounts *newCurrencyAccounts) error { initMetadataIxn := currencycreator.NewInitializeMetadataInstruction( ¤cycreator.InitializeMetadataInstructionAccounts{ diff --git a/ocp/worker/currency/launcher/worker.go b/ocp/worker/currency/launcher/worker.go index c407450..2e73841 100644 --- a/ocp/worker/currency/launcher/worker.go +++ b/ocp/worker/currency/launcher/worker.go @@ -96,8 +96,8 @@ func (p *runtime) handle(ctx context.Context, record *currency.MetadataRecord) e switch record.State { case currency.MetadataStateWaitingForInitialPurchase: err = p.handleStateWaitingForInitialPurchase(ctx, record) - case currency.MetadataStateFundingAuthority: - err = p.handleStateFundingAuthority(ctx, record) + case currency.MetadataStatePrePurchaseSetup: + err = p.handleStatePrePurchaseSetup(ctx, record) case currency.MetadataStateCompletingInitialization: err = p.handleStateCompletingInitialization(ctx, record) case currency.MetadataStateFinalValidation: @@ -127,8 +127,8 @@ func (p *runtime) handleStateWaitingForInitialPurchase(ctx context.Context, curr } // Note: Assumes unique authority per currency -func (p *runtime) handleStateFundingAuthority(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error { - err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStateFundingAuthority) +func (p *runtime) handleStatePrePurchaseSetup(ctx context.Context, currencyMetadataRecord *currency.MetadataRecord) error { + err := p.validateCurrencyMetadataState(currencyMetadataRecord, currency.MetadataStatePrePurchaseSetup) if err != nil { return err } @@ -173,6 +173,30 @@ func (p *runtime) handleStateFundingAuthority(ctx context.Context, currencyMetad return errors.Wrap(err, "error getting vm metadata record") } + // Initial purchases funded outside the core mint require the currency to + // be fully initialized on the blockchain before the purchase transaction + // can be executed. + fromMint, err := common.NewAccountFromPublicKeyString(swapRecords[0].FromMint) + if err != nil { + return errors.Wrap(err, "invalid swap source mint") + } + if !common.IsCoreMint(fromMint) { + accounts, err := p.getNewCurrencyAccounts(ctx, currencyMetadataRecord, vmMetadataRecord) + if err != nil { + return errors.Wrap(err, "error getting currency accounts") + } + + ok, err := validateMintExists(ctx, p.data, accounts.Mint) + if err != nil { + return errors.Wrap(err, "error checking if currency is initialized") + } else if !ok { + err = p.initCurrencyOnBlockchain(ctx, currencyMetadataRecord, vmMetadataRecord, accounts) + if err != nil { + return errors.Wrap(err, "error initializing currency on blockchain") + } + } + } + return p.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error { err := p.markVmMetadataInitializing(ctx, vmMetadataRecord) if err != nil { diff --git a/ocp/worker/geyser/external_deposit.go b/ocp/worker/geyser/external_deposit.go index 073c31c..1fd9dff 100644 --- a/ocp/worker/geyser/external_deposit.go +++ b/ocp/worker/geyser/external_deposit.go @@ -17,7 +17,6 @@ import ( currency_lib "github.com/code-payments/ocp-server/currency" "github.com/code-payments/ocp-server/database/query" "github.com/code-payments/ocp-server/ocp/common" - "github.com/code-payments/ocp-server/ocp/currency" currency_util "github.com/code-payments/ocp-server/ocp/currency" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/currency/exchange" @@ -321,7 +320,7 @@ func processPotentialExternalDepositIntoVm(ctx context.Context, data ocp_data.Pr return errors.Wrap(err, "invalid owner account") } - isInitialPurchase, usdMarketValue, err := isInitialCurrencyCreatorDeposit(ctx, data, exchangeRateStore, reserveStore, ownerAccount, mint, userVirtualTimelockVaultAccount, uint64(deltaQuarksIntoOmnibus)) + isInitialPurchase, usdMarketValue, err := isInitialCurrencyCreatorDeposit(ctx, data, ownerAccount, mint, userVirtualTimelockVaultAccount, uint64(deltaQuarksIntoOmnibus), tokenBalances.Slot) if err != nil { return errors.Wrap(err, "error checking for initial currency creator deposit") } @@ -483,12 +482,15 @@ func getSyncedVmDepositCacheKey(signature string, vmDepositAta *common.Account) } // isInitialCurrencyCreatorDeposit detects whether this external deposit is the -// initial purchase from a currency creator. It checks three conditions: +// initial purchase from a currency creator, and if so, calculates the USD market +// value of the deposit. It checks the following conditions: // 1. The mint is a launchpad currency (not the core mint) // 2. The depositor is the currency creator // 3. This is their first deposit for this currency -// 4. The deposit amount matches the expected output of the first swap within 1 quark error tolerance -func isInitialCurrencyCreatorDeposit(ctx context.Context, data ocp_data.Provider, exchangeRateStore exchange.Store, reserveStore reserve.Store, owner, mint, destination *common.Account, depositQuarks uint64) (bool, float64, error) { +// 4. A finalized reserve swap exists for the currency creator +// 5. The deposit occurred in a slot after the swap transaction, guaranteeing +// it contains the purchased funds +func isInitialCurrencyCreatorDeposit(ctx context.Context, data ocp_data.Provider, owner, mint, destination *common.Account, depositQuarks, depositSlot uint64) (bool, float64, error) { if common.IsCoreMint(mint) { return false, 0, nil } @@ -522,18 +524,21 @@ func isInitialCurrencyCreatorDeposit(ctx context.Context, data ocp_data.Provider initialSwap := swapRecords[0] - usdMarketValue, err := currency.CalculateUsdMarketValueFromTokenAmount(ctx, data, exchangeRateStore, reserveStore, common.CoreMintAccount, initialSwap.SwapAmount, initialSwap.CreatedAt) + swapTxn, err := data.GetBlockchainTransaction(ctx, initialSwap.TransactionSignature, solana.CommitmentFinalized) if err != nil { - return false, 0, errors.Wrap(err, "error calculating usd market value") + return false, 0, errors.Wrap(err, "error getting swap transaction") + } + if depositSlot <= swapTxn.Slot { + return false, 0, nil } - expectedQuarks := currencycreator.EstimateBuy(¤cycreator.EstimateBuyArgs{ - CurrentSupplyInQuarks: 0, - BuyAmountInQuarks: initialSwap.SwapAmount, + sellValueInQuarks, _ := currencycreator.EstimateSell(¤cycreator.EstimateSellArgs{ + CurrentSupplyInQuarks: depositQuarks, + SellAmountInQuarks: depositQuarks, ValueMintDecimals: uint8(common.CoreMintDecimals), + SellFeeBps: 0, }) + usdMarketValue := float64(sellValueInQuarks) / float64(common.CoreMintQuarksPerUnit) - minExpectedQuarks := expectedQuarks - 1 - maxExpectedQuarks := expectedQuarks + 1 - return depositQuarks >= minExpectedQuarks && depositQuarks <= maxExpectedQuarks, usdMarketValue, nil + return true, usdMarketValue, nil } diff --git a/ocp/worker/swap/metrics.go b/ocp/worker/swap/metrics.go index dec0dbd..035b0ee 100644 --- a/ocp/worker/swap/metrics.go +++ b/ocp/worker/swap/metrics.go @@ -5,6 +5,7 @@ import ( "time" "github.com/code-payments/ocp-server/metrics" + "github.com/code-payments/ocp-server/ocp/common" "github.com/code-payments/ocp-server/ocp/data/swap" ) @@ -50,12 +51,18 @@ func recordSwapCountEvent(ctx context.Context, state swap.State, count uint64) { }) } -func recordSwapFinalizedEvent(ctx context.Context, swapRecord *swap.Record, quarksBought uint64) { +func recordSwapFinalizedEvent(ctx context.Context, swapRecord *swap.Record, quarksBought uint64, isMintInit bool) { + quarksSold := swapRecord.SwapAmount + + if isMintInit && swapRecord.FromMint != common.CoreMintAccount.PublicKey().ToBase58() { + quarksSold += swapRecord.FeeAmount + } + metrics.RecordEvent(ctx, swapFinalizedEventName, map[string]interface{}{ "id": swapRecord.Id, "from_mint": swapRecord.FromMint, "to_mint": swapRecord.ToMint, - "quarks_sold": swapRecord.SwapAmount, + "quarks_sold": quarksSold, "quarks_bought": quarksBought, }) } diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index 31900ba..1b2ebdd 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -124,7 +124,7 @@ func (p *runtime) markSwapFailed(ctx context.Context, swapRecord *swap.Record) e } err = p.validateCurrencyMetadataState( destinationCurrencyMetadataRecord, - currency.MetadataStateFundingAuthority, + currency.MetadataStatePrePurchaseSetup, currency.MetadataStateExecutingInitialPurchase, currency.MetadataStateAvailable, ) @@ -146,7 +146,7 @@ func (p *runtime) markSwapFailed(ctx context.Context, swapRecord *swap.Record) e if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { switch destinationCurrencyMetadataRecord.State { - case currency.MetadataStateFundingAuthority, currency.MetadataStateExecutingInitialPurchase: + case currency.MetadataStatePrePurchaseSetup, currency.MetadataStateExecutingInitialPurchase: destinationCurrencyMetadataRecord.State = currency.MetadataStateAbandoning err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) if err != nil { @@ -316,7 +316,7 @@ func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record } err = p.validateCurrencyMetadataState( destinationCurrencyMetadataRecord, - currency.MetadataStateFundingAuthority, + currency.MetadataStatePrePurchaseSetup, currency.MetadataStateExecutingInitialPurchase, currency.MetadataStateAvailable, ) @@ -365,7 +365,7 @@ func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { switch destinationCurrencyMetadataRecord.State { - case currency.MetadataStateFundingAuthority, currency.MetadataStateExecutingInitialPurchase: + case currency.MetadataStatePrePurchaseSetup, currency.MetadataStateExecutingInitialPurchase: destinationCurrencyMetadataRecord.State = currency.MetadataStateAbandoning err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) if err != nil { @@ -550,14 +550,26 @@ func (p *runtime) maybeUpdateBalancesForFinalizedReserveSwap(ctx context.Context return 0, false, err } - if deltaQuarksIntoVault <= 0 { - return 0, false, errors.New("delta quarks into pool vault is not positives") + var quarksBought uint64 + if common.IsCoreMint(fromMint) { + // The currency was initialized within the swap transaction, so + // the delta includes seeding the vault with the full token supply + if deltaQuarksIntoVault <= 0 { + return 0, false, errors.New("delta quarks into pool vault is not positive") + } + quarksBought = currencycreator.DefaultMintMaxQuarkSupply - uint64(deltaQuarksIntoVault) + } else { + // The currency was initialized prior to the swap transaction + if deltaQuarksIntoVault >= 0 { + return 0, false, errors.New("delta quarks out of pool vault is not negative") + } + quarksBought = uint64(-deltaQuarksIntoVault) } // This swap is initializing the VM and the funds will be deposited // after memory accounts become available. Balances should only be // reflected after finalized deposit into a VTA. - return currencycreator.DefaultMintMaxQuarkSupply - uint64(deltaQuarksIntoVault), true, nil + return quarksBought, true, nil } } @@ -761,6 +773,10 @@ func (p *runtime) notifySwapFinalized(ctx context.Context, swapRecord *swap.Reco return nil } + if isMintInit { + return nil + } + owner, err := common.NewAccountFromPublicKeyString(swapRecord.Owner) if err != nil { return err @@ -826,7 +842,7 @@ func (p *runtime) notifySwapFinalized(ctx context.Context, swapRecord *swap.Reco ).Float64() } - return p.integration.OnSwapFinalized(ctx, owner, isBuy, targetMint, targetCurrencyMetadataRecord.Name, currencyCode, valueReceived, isMintInit) + return p.integration.OnSwapFinalized(ctx, owner, isBuy, targetMint, targetCurrencyMetadataRecord.Name, currencyCode, valueReceived) } func (p *runtime) markNonceReleasedDueToSubmittedTransaction(ctx context.Context, record *swap.Record) error { diff --git a/ocp/worker/swap/worker.go b/ocp/worker/swap/worker.go index bc694df..16d772b 100644 --- a/ocp/worker/swap/worker.go +++ b/ocp/worker/swap/worker.go @@ -299,7 +299,7 @@ func (p *runtime) handleStateSubmitting(ctx context.Context, record *swap.Record return errors.Wrap(err, "error marking swap as finalized") } - recordSwapFinalizedEvent(ctx, record, quarksBought) + recordSwapFinalizedEvent(ctx, record, quarksBought, isMintInit) go p.notifySwapFinalized(ctx, record, isMintInit) From f60d692f134068bfae570770b77e9f9851be14f7 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Fri, 10 Jul 2026 09:17:16 -0400 Subject: [PATCH 2/2] Prevent half-initialized currencies from being abandoned --- ocp/worker/currency/launcher/worker.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ocp/worker/currency/launcher/worker.go b/ocp/worker/currency/launcher/worker.go index 2e73841..fc56f45 100644 --- a/ocp/worker/currency/launcher/worker.go +++ b/ocp/worker/currency/launcher/worker.go @@ -424,6 +424,19 @@ func (p *runtime) handleStateAbandoning(ctx context.Context, currencyMetadataRec return err } + mintAccount, err := common.NewAccountFromPublicKeyString(currencyMetadataRecord.Mint) + if err != nil { + return errors.Wrap(err, "invalid mint") + } + + ok, err := validateMintExists(ctx, p.data, mintAccount) + if err != nil { + return errors.Wrap(err, "error checking if currency is initialized") + } else if ok { + // todo: requires manual intervention + return errors.New("currency exists") + } + authorityVaultRecord, err := p.data.GetKey(ctx, currencyMetadataRecord.Authority) if err != nil { return errors.Wrap(err, "error getting authority vault record")