diff --git a/go.mod b/go.mod index e3a1d62..d7eff6d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/code-payments/code-vm-indexer v1.2.0 - github.com/code-payments/ocp-protobuf-api v1.9.0 + github.com/code-payments/ocp-protobuf-api v1.9.3-0.20260428163429-9d7dee8bd6ca 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 6592b0f..add2b3c 100644 --- a/go.sum +++ b/go.sum @@ -78,10 +78,8 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/ocp-protobuf-api v1.8.1 h1:IaCVADbbTUtZwf0Rk8Pf8PygsancuOXc+A3CcTG/74w= -github.com/code-payments/ocp-protobuf-api v1.8.1/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= -github.com/code-payments/ocp-protobuf-api v1.9.0 h1:VpcOENVTmebpTENhpVaDbFfPPliK1zuMtjHzdhBQY2U= -github.com/code-payments/ocp-protobuf-api v1.9.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v1.9.3-0.20260428163429-9d7dee8bd6ca h1:I7k/Rq3OKn+E94zc2GvsUA76e7RVkAqXWT4inUwbtuQ= +github.com/code-payments/ocp-protobuf-api v1.9.3-0.20260428163429-9d7dee8bd6ca/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= diff --git a/ocp/antispam/guard.go b/ocp/antispam/guard.go index b5b8a5d..ea1f078 100644 --- a/ocp/antispam/guard.go +++ b/ocp/antispam/guard.go @@ -75,11 +75,11 @@ func (g *Guard) AllowDistribution(ctx context.Context, owner *common.Account, is return allow, nil } -func (g *Guard) AllowSwap(ctx context.Context, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account, swapAmount, feeAmount uint64, initializesMint bool) (bool, error) { +func (g *Guard) AllowSwap(ctx context.Context, kind swap.Kind, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account, swapAmount, feeAmount uint64, initializesMint bool) (bool, error) { tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowSwap") defer tracer.End() - allow, reason, err := g.integration.AllowSwap(ctx, fundingSource, owner, fromMint, toMint, swapAmount, feeAmount, initializesMint) + allow, reason, err := g.integration.AllowSwap(ctx, kind, fundingSource, owner, fromMint, toMint, swapAmount, feeAmount, initializesMint) if err != nil { return false, err } diff --git a/ocp/data/swap/postgres/model.go b/ocp/data/swap/postgres/model.go index 92f8edb..b4dc127 100644 --- a/ocp/data/swap/postgres/model.go +++ b/ocp/data/swap/postgres/model.go @@ -19,7 +19,9 @@ const ( type model struct { Id sql.NullInt64 `db:"id"` SwapId string `db:"swap_id"` + Kind uint8 `db:"kind"` Owner string `db:"owner"` + DestinationOwner string `db:"destination_owner"` FromMint string `db:"from_mint"` ToMint string `db:"to_mint"` SwapAmount uint64 `db:"amount"` @@ -48,7 +50,9 @@ func toModel(obj *swap.Record) (*model, error) { return &model{ Id: sql.NullInt64{Int64: int64(obj.Id), Valid: true}, SwapId: obj.SwapId, + Kind: uint8(obj.Kind), Owner: obj.Owner, + DestinationOwner: obj.DestinationOwner, FromMint: obj.FromMint, ToMint: obj.ToMint, SwapAmount: obj.SwapAmount, @@ -70,7 +74,9 @@ func fromModel(m *model) *swap.Record { return &swap.Record{ Id: uint64(m.Id.Int64), SwapId: m.SwapId, + Kind: swap.Kind(m.Kind), Owner: m.Owner, + DestinationOwner: m.DestinationOwner, FromMint: m.FromMint, ToMint: m.ToMint, SwapAmount: m.SwapAmount, @@ -91,22 +97,24 @@ func fromModel(m *model) *swap.Record { func (m *model) dbSave(ctx context.Context, db *sqlx.DB) error { return pgutil.ExecuteInTx(ctx, db, sql.LevelDefault, func(tx *sqlx.Tx) error { query := `INSERT INTO ` + tableName + ` - (swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + 1, $16) + (swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 + 1, $18) ON CONFLICT (swap_id) DO UPDATE - SET transaction_blob = $13, state = $14, version = ` + tableName + `.version + 1 - WHERE ` + tableName + `.swap_id = $1 AND ` + tableName + `.version = $15 + SET transaction_blob = $15, state = $16, version = ` + tableName + `.version + 1 + WHERE ` + tableName + `.swap_id = $1 AND ` + tableName + `.version = $17 RETURNING - id, swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at` + id, swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at` err := tx.QueryRowxContext( ctx, query, m.SwapId, + m.Kind, m.Owner, + m.DestinationOwner, m.FromMint, m.ToMint, m.SwapAmount, @@ -132,7 +140,7 @@ func (m *model) dbSave(ctx context.Context, db *sqlx.DB) error { func dbGetById(ctx context.Context, db *sqlx.DB, id string) (*model, error) { res := &model{} - query := `SELECT id, swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at + query := `SELECT id, swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at FROM ` + tableName + ` WHERE swap_id = $1 LIMIT 1` @@ -147,7 +155,7 @@ func dbGetById(ctx context.Context, db *sqlx.DB, id string) (*model, error) { func dbGetByFundingId(ctx context.Context, db *sqlx.DB, fundingId string) (*model, error) { res := &model{} - query := `SELECT id, swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at + query := `SELECT id, swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at FROM ` + tableName + ` WHERE funding_id = $1 LIMIT 1` @@ -162,7 +170,7 @@ func dbGetByFundingId(ctx context.Context, db *sqlx.DB, fundingId string) (*mode func dbGetAllByOwnerAndState(ctx context.Context, db *sqlx.DB, owner string, state swap.State) ([]*model, error) { res := []*model{} - query := `SELECT id, swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at + query := `SELECT id, swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at FROM ` + tableName + ` WHERE owner = $1 AND state = $2 ORDER BY id ASC` @@ -182,7 +190,7 @@ func dbGetAllByOwnerAndState(ctx context.Context, db *sqlx.DB, owner string, sta func dbGetAllByOwnerMintAndState(ctx context.Context, db *sqlx.DB, owner string, mint string, state swap.State) ([]*model, error) { res := []*model{} - query := `SELECT id, swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at + query := `SELECT id, swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at FROM ` + tableName + ` WHERE owner = $1 AND state = $2 AND (from_mint = $3 OR to_mint = $3) ORDER BY id ASC` @@ -203,7 +211,7 @@ func dbGetAllByState(ctx context.Context, db *sqlx.DB, state swap.State, cursor res := []*model{} query := `SELECT - id, swap_id, owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at + id, swap_id, kind, owner, destination_owner, from_mint, to_mint, amount, fee_amount, funding_id, funding_source, nonce, blockhash, proof_signature, transaction_signature, transaction_blob, state, version, created_at FROM ` + tableName + ` WHERE state = $1` diff --git a/ocp/data/swap/postgres/store_test.go b/ocp/data/swap/postgres/store_test.go index 8ef2084..c3611b7 100644 --- a/ocp/data/swap/postgres/store_test.go +++ b/ocp/data/swap/postgres/store_test.go @@ -24,7 +24,10 @@ const ( swap_id TEXT NOT NULL UNIQUE, + kind INTEGER NOT NULL DEFAULT 1, + owner TEXT NOT NULL, + destination_owner TEXT NOT NULL DEFAULT '', from_mint TEXT NOT NULL, to_mint TEXT NOT NULL, diff --git a/ocp/data/swap/swap.go b/ocp/data/swap/swap.go index ca5584a..3b1adc8 100644 --- a/ocp/data/swap/swap.go +++ b/ocp/data/swap/swap.go @@ -27,12 +27,23 @@ const ( FundingSourceExternalWallet ) +type Kind uint8 + +const ( + KindUnknown Kind = iota + KindReserve + KindStablecoin +) + type Record struct { Id uint64 SwapId string - Owner string + Kind Kind + + Owner string + DestinationOwner string FromMint string ToMint string @@ -63,7 +74,10 @@ func (r *Record) Clone() Record { SwapId: r.SwapId, - Owner: r.Owner, + Kind: r.Kind, + + Owner: r.Owner, + DestinationOwner: r.DestinationOwner, FromMint: r.FromMint, ToMint: r.ToMint, @@ -94,7 +108,10 @@ func (r *Record) CopyTo(dst *Record) { dst.SwapId = r.SwapId + dst.Kind = r.Kind + dst.Owner = r.Owner + dst.DestinationOwner = r.DestinationOwner dst.FromMint = r.FromMint dst.ToMint = r.ToMint @@ -124,10 +141,18 @@ func (r *Record) Validate() error { return errors.New("swap id is requried") } + if r.Kind == KindUnknown { + return errors.New("kind is required") + } + if len(r.Owner) == 0 { return errors.New("owner is required") } + if r.Kind == KindStablecoin && len(r.DestinationOwner) == 0 { + return errors.New("destination owner is required for stablecoin swaps") + } + if len(r.FromMint) == 0 { return errors.New("source mint is required") } @@ -188,3 +213,13 @@ func (s State) String() string { } return "unknown" } + +func (k Kind) String() string { + switch k { + case KindReserve: + return "reserve" + case KindStablecoin: + return "stablecoin" + } + return "unknown" +} diff --git a/ocp/data/swap/tests/tests.go b/ocp/data/swap/tests/tests.go index e0d6cfc..c97c4b6 100644 --- a/ocp/data/swap/tests/tests.go +++ b/ocp/data/swap/tests/tests.go @@ -15,7 +15,8 @@ import ( func RunTests(t *testing.T, s swap.Store, teardown func()) { for _, tf := range []func(t *testing.T, s swap.Store){ - testRoundTrip, + testReserveRoundTrip, + testStablecoinRoundTrip, testUpdateHappyPath, testUpdateStaleRecord, testGetAllByOwnerAndState, @@ -27,8 +28,8 @@ func RunTests(t *testing.T, s swap.Store, teardown func()) { } } -func testRoundTrip(t *testing.T, s swap.Store) { - t.Run("testRoundTrip", func(t *testing.T) { +func testReserveRoundTrip(t *testing.T, s swap.Store) { + t.Run("testReserveRoundTrip", func(t *testing.T) { ctx := context.Background() actual, err := s.GetById(ctx, "test_swap_id") @@ -44,11 +45,85 @@ func testRoundTrip(t *testing.T, s swap.Store) { expected := &swap.Record{ SwapId: "test_swap_id", + Kind: swap.KindReserve, + Owner: "test_owner", - FromMint: "test_from_mint", - ToMint: "test_to_mint", - SwapAmount: 12345, + FromMint: "test_from_mint", + ToMint: "test_to_mint", + SwapAmount: 12345, + + FundingId: "test_funding_id", + FundingSource: swap.FundingSourceSubmitIntent, + + Nonce: "test_nonce", + Blockhash: "test_blockhash", + + ProofSignature: "test_proof_signature", + + TransactionSignature: "test_transaction_signature", + TransactionBlob: []byte("test_transaction_blob"), + + State: swap.StateFinalized, + + CreatedAt: time.Now(), + } + cloned := expected.Clone() + err = s.Save(ctx, expected) + require.NoError(t, err) + assert.EqualValues(t, 1, expected.Id) + assert.EqualValues(t, 1, expected.Version) + + actual, err = s.GetById(ctx, "test_swap_id") + require.NoError(t, err) + assertEquivalentRecords(t, &cloned, actual) + + actual, err = s.GetByFundingId(ctx, "test_funding_id") + require.NoError(t, err) + assertEquivalentRecords(t, &cloned, actual) + }) +} + +func testStablecoinRoundTrip(t *testing.T, s swap.Store) { + t.Run("testStablecoinRoundTrip", func(t *testing.T) { + ctx := context.Background() + + actual, err := s.GetById(ctx, "test_swap_id") + require.Error(t, err) + assert.Equal(t, swap.ErrNotFound, err) + assert.Nil(t, actual) + + // DestinationOwner is required for stablecoin swaps + invalid := &swap.Record{ + SwapId: "test_swap_id", + Kind: swap.KindStablecoin, + Owner: "test_owner", + FromMint: "test_from_mint", + ToMint: "test_to_mint", + SwapAmount: 12345, + FundingId: "test_funding_id", + FundingSource: swap.FundingSourceSubmitIntent, + Nonce: "test_nonce", + Blockhash: "test_blockhash", + ProofSignature: "test_proof_signature", + TransactionSignature: "test_transaction_signature", + State: swap.StateCreated, + CreatedAt: time.Now(), + } + require.Error(t, s.Save(ctx, invalid)) + + expected := &swap.Record{ + SwapId: "test_swap_id", + + Kind: swap.KindStablecoin, + + Owner: "test_owner", + DestinationOwner: "test_destination_owner", + + FromMint: "test_from_mint", + ToMint: "test_to_mint", + SwapAmount: 12345, + FeeAmount: 100, FundingId: "test_funding_id", FundingSource: swap.FundingSourceSubmitIntent, @@ -93,11 +168,13 @@ func testUpdateHappyPath(t *testing.T, s swap.Store) { expected := &swap.Record{ SwapId: "test_swap_id", + Kind: swap.KindReserve, + Owner: "test_owner", - FromMint: "test_from_mint", - ToMint: "test_to_mint", - SwapAmount: 12345, + FromMint: "test_from_mint", + ToMint: "test_to_mint", + SwapAmount: 12345, FundingId: "test_funding_id", FundingSource: swap.FundingSourceSubmitIntent, @@ -140,11 +217,13 @@ func testUpdateStaleRecord(t *testing.T, s swap.Store) { expected := &swap.Record{ SwapId: "test_swap_id", + Kind: swap.KindReserve, + Owner: "test_owner", - FromMint: "test_from_mint", - ToMint: "test_to_mint", - SwapAmount: 12345, + FromMint: "test_from_mint", + ToMint: "test_to_mint", + SwapAmount: 12345, FundingId: "test_funding_id", FundingSource: swap.FundingSourceSubmitIntent, @@ -198,11 +277,13 @@ func testGetAllByOwnerAndState(t *testing.T, s swap.Store) { record := &swap.Record{ SwapId: fmt.Sprintf("test_swap_id_%d", i), + Kind: swap.KindReserve, + Owner: fmt.Sprintf("test_owner_%d", i%2), - FromMint: fmt.Sprintf("test_from_mint_%d", i), - ToMint: fmt.Sprintf("test_to_mint_%d", i), - SwapAmount: uint64(i + 1), + FromMint: fmt.Sprintf("test_from_mint_%d", i), + ToMint: fmt.Sprintf("test_to_mint_%d", i), + SwapAmount: uint64(i + 1), FundingId: fmt.Sprintf("test_funding_id_%d", i), FundingSource: swap.FundingSourceSubmitIntent, @@ -258,35 +339,35 @@ func testGetAllByOwnerMintAndState(t *testing.T, s swap.Store) { // Create swaps with different owners, mints, and states records := []*swap.Record{ { // owner_a buying mint_x - SwapId: "swap_0", Owner: "owner_a", + SwapId: "swap_0", Kind: swap.KindReserve, Owner: "owner_a", FromMint: "core_mint", ToMint: "mint_x", SwapAmount: 100, FundingId: "fund_0", FundingSource: swap.FundingSourceSubmitIntent, Nonce: "nonce_0", Blockhash: "bh_0", ProofSignature: "proof_0", TransactionSignature: "sig_0", State: swap.StateFinalized, CreatedAt: time.Now(), }, { // owner_a selling mint_x - SwapId: "swap_1", Owner: "owner_a", + SwapId: "swap_1", Kind: swap.KindReserve, Owner: "owner_a", FromMint: "mint_x", ToMint: "core_mint", SwapAmount: 50, FundingId: "fund_1", FundingSource: swap.FundingSourceSubmitIntent, Nonce: "nonce_1", Blockhash: "bh_1", ProofSignature: "proof_1", TransactionSignature: "sig_1", State: swap.StateFinalized, CreatedAt: time.Now(), }, { // owner_a buying mint_y (different mint) - SwapId: "swap_2", Owner: "owner_a", + SwapId: "swap_2", Kind: swap.KindReserve, Owner: "owner_a", FromMint: "core_mint", ToMint: "mint_y", SwapAmount: 200, FundingId: "fund_2", FundingSource: swap.FundingSourceSubmitIntent, Nonce: "nonce_2", Blockhash: "bh_2", ProofSignature: "proof_2", TransactionSignature: "sig_2", State: swap.StateFinalized, CreatedAt: time.Now(), }, { // owner_b buying mint_x (different owner) - SwapId: "swap_3", Owner: "owner_b", + SwapId: "swap_3", Kind: swap.KindReserve, Owner: "owner_b", FromMint: "core_mint", ToMint: "mint_x", SwapAmount: 300, FundingId: "fund_3", FundingSource: swap.FundingSourceSubmitIntent, Nonce: "nonce_3", Blockhash: "bh_3", ProofSignature: "proof_3", TransactionSignature: "sig_3", State: swap.StateFinalized, CreatedAt: time.Now(), }, { // owner_a buying mint_x but not finalized - SwapId: "swap_4", Owner: "owner_a", + SwapId: "swap_4", Kind: swap.KindReserve, Owner: "owner_a", FromMint: "core_mint", ToMint: "mint_x", SwapAmount: 400, FundingId: "fund_4", FundingSource: swap.FundingSourceSubmitIntent, Nonce: "nonce_4", Blockhash: "bh_4", ProofSignature: "proof_4", @@ -346,11 +427,13 @@ func testGetAllByState(t *testing.T, s swap.Store) { record := &swap.Record{ SwapId: fmt.Sprintf("test_swap_id_%d", i), + Kind: swap.KindReserve, + Owner: fmt.Sprintf("test_owner_%d", i%3), - FromMint: "test_from_mint", - ToMint: "test_to_mint", - SwapAmount: uint64(i + 1), + FromMint: "test_from_mint", + ToMint: "test_to_mint", + SwapAmount: uint64(i + 1), FundingId: fmt.Sprintf("test_funding_id_%d", i), FundingSource: swap.FundingSourceSubmitIntent, @@ -415,7 +498,10 @@ func testGetAllByState(t *testing.T, s swap.Store) { func assertEquivalentRecords(t *testing.T, obj1, obj2 *swap.Record) { assert.Equal(t, obj1.SwapId, obj2.SwapId) + assert.Equal(t, obj1.Kind, obj2.Kind) + assert.Equal(t, obj1.Owner, obj2.Owner) + assert.Equal(t, obj1.DestinationOwner, obj2.DestinationOwner) assert.Equal(t, obj1.FromMint, obj2.FromMint) assert.Equal(t, obj1.ToMint, obj2.ToMint) diff --git a/ocp/integration/antispam.go b/ocp/integration/antispam.go index f25ca7c..94c7a6f 100644 --- a/ocp/integration/antispam.go +++ b/ocp/integration/antispam.go @@ -20,7 +20,7 @@ type Antispam interface { AllowDistribution(ctx context.Context, owner *common.Account, isPublic bool) (bool, string, error) - AllowSwap(ctx context.Context, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account, swapAmount, feeAmount uint64, initializesMint bool) (bool, string, error) + AllowSwap(ctx context.Context, kind swap.Kind, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account, swapAmount, feeAmount uint64, initializesMint bool) (bool, string, error) AllowCurrencyLaunch(ctx context.Context, owner *common.Account, name, symbol string) (bool, string, error) } @@ -49,7 +49,7 @@ func (i *allowEverythingAntispamIntegration) AllowDistribution(ctx context.Conte return true, "", nil } -func (i *allowEverythingAntispamIntegration) AllowSwap(ctx context.Context, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account, swapAmount, feeAmount uint64, initializesMint bool) (bool, string, error) { +func (i *allowEverythingAntispamIntegration) AllowSwap(ctx context.Context, kind swap.Kind, fundingSource swap.FundingSource, owner, fromMint, toMint *common.Account, swapAmount, feeAmount uint64, initializesMint bool) (bool, string, error) { return true, "", nil } diff --git a/ocp/rpc/transaction/swap.go b/ocp/rpc/transaction/swap.go index 386b406..617de37 100644 --- a/ocp/rpc/transaction/swap.go +++ b/ocp/rpc/transaction/swap.go @@ -30,6 +30,7 @@ import ( "github.com/code-payments/ocp-server/protoutil" "github.com/code-payments/ocp-server/solana" "github.com/code-payments/ocp-server/solana/currencycreator" + "github.com/code-payments/ocp-server/usdc" ) func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_StatefulSwapServer) error { @@ -63,19 +64,32 @@ func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_Stat } log = log.With(zap.String("owner", owner.PublicKey().ToBase58())) - swapAuthority, err := common.NewAccountFromProto(initiateReq.SwapAuthority) - if err != nil { - log.With(zap.Error(err)).Warn("invalid swap authority") - return handleStatefulSwapError(streamer, err) - } - log = log.With(zap.String("swap_authority", swapAuthority.PublicKey().ToBase58())) - reqSignature := initiateReq.Signature initiateReq.Signature = nil if err := s.auth.Authenticate(ctx, owner, initiateReq, reqSignature); err != nil { return handleStatefulSwapError(streamer, err) } + // todo: Refactor needed for duplication of code, but this isolates fundamentally different swap flows + switch initiateReq.GetKind().(type) { + case *transactionpb.StatefulSwapRequest_Initiate_Reserve: + log = log.With(zap.String("kind", "reserve")) + return s.handleReserveStatefulSwap(ctx, log, streamer, initiateReq, owner) + case *transactionpb.StatefulSwapRequest_Initiate_Stablecoin: + log = log.With(zap.String("kind", "stablecoin")) + return s.handleStablecoinStatefulSwap(ctx, log, streamer, initiateReq, owner) + default: + return handleStatefulSwapError(streamer, status.Error(codes.InvalidArgument, "StatefulSwapRequest.Initiate.Kind is nil")) + } +} + +func (s *transactionServer) handleReserveStatefulSwap( + ctx context.Context, + log *zap.Logger, + streamer transactionpb.Transaction_StatefulSwapServer, + initiateReq *transactionpb.StatefulSwapRequest_Initiate, + owner *common.Account, +) error { initiateReserveSwapReq := initiateReq.GetReserve() if initiateReserveSwapReq == nil { return handleStatefulSwapError(streamer, status.Error(codes.InvalidArgument, "StatefulSwapRequest.Initiate.Reserve is nil")) @@ -84,6 +98,13 @@ func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_Stat swapId := base58.Encode(initiateReserveSwapReq.Id.Value) log = log.With(zap.String("swap_id", swapId)) + swapAuthority, err := common.NewAccountFromProto(initiateReq.SwapAuthority) + if err != nil { + log.With(zap.Error(err)).Warn("invalid swap authority") + return handleStatefulSwapError(streamer, err) + } + log = log.With(zap.String("swap_authority", swapAuthority.PublicKey().ToBase58())) + fromMint, err := common.NewAccountFromProto(initiateReserveSwapReq.FromMint) if err != nil { log.With(zap.Error(err)).Warn("invalid source mint account") @@ -342,7 +363,7 @@ func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_Stat return handleStatefulSwapError(streamer, NewSwapDeniedError("not a user ocp account")) } - allow, err := s.antispamGuard.AllowSwap(ctx, swap.FundingSource(initiateReserveSwapReq.FundingSource), owner, fromMint, toMint, initiateReserveSwapReq.SwapAmount, initiateReserveSwapReq.FeeAmount, initializesMint) + allow, err := s.antispamGuard.AllowSwap(ctx, swap.KindReserve, swap.FundingSource(initiateReserveSwapReq.FundingSource), owner, fromMint, toMint, initiateReserveSwapReq.SwapAmount, initiateReserveSwapReq.FeeAmount, initializesMint) if err != nil { return handleStatefulSwapError(streamer, err) } else if !allow { @@ -455,7 +476,7 @@ func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_Stat // Section: Transaction signing // - req, err = protoutil.BoundedReceive[transactionpb.StatefulSwapRequest](ctx, streamer, s.conf.clientReceiveTimeout.Get(ctx)) + req, err := protoutil.BoundedReceive[transactionpb.StatefulSwapRequest](ctx, streamer, s.conf.clientReceiveTimeout.Get(ctx)) if err != nil { log.With(zap.Error(err)).Info("error receiving request from client") return err @@ -541,6 +562,7 @@ func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_Stat record := &swap.Record{ SwapId: swapId, + Kind: swap.KindReserve, Owner: owner.PublicKey().ToBase58(), FromMint: fromMint.PublicKey().ToBase58(), ToMint: toMint.PublicKey().ToBase58(), @@ -599,6 +621,385 @@ func (s *transactionServer) StatefulSwap(streamer transactionpb.Transaction_Stat return handleStatefulSwapError(streamer, err) } +func (s *transactionServer) handleStablecoinStatefulSwap( + ctx context.Context, + log *zap.Logger, + streamer transactionpb.Transaction_StatefulSwapServer, + initiateReq *transactionpb.StatefulSwapRequest_Initiate, + owner *common.Account, +) error { + initiateStablecoinSwapReq := initiateReq.GetStablecoin() + if initiateStablecoinSwapReq == nil { + return handleStatefulSwapError(streamer, status.Error(codes.InvalidArgument, "StatefulSwapRequest.Initiate.Stablecoin is nil")) + } + + swapId := base58.Encode(initiateStablecoinSwapReq.Id.Value) + log = log.With(zap.String("swap_id", swapId)) + + swapAuthority, err := common.NewAccountFromProto(initiateReq.SwapAuthority) + if err != nil { + log.With(zap.Error(err)).Warn("invalid swap authority") + return handleStatefulSwapError(streamer, err) + } + log = log.With(zap.String("swap_authority", swapAuthority.PublicKey().ToBase58())) + + fromMint, err := common.NewAccountFromProto(initiateStablecoinSwapReq.FromMint) + if err != nil { + log.With(zap.Error(err)).Warn("invalid source mint account") + return handleStatefulSwapError(streamer, err) + } + log = log.With(zap.String("from_mint", fromMint.PublicKey().ToBase58())) + + toMint, err := common.NewAccountFromProto(initiateStablecoinSwapReq.ToMint) + if err != nil { + log.With(zap.Error(err)).Warn("invalid destination mint account") + return handleStatefulSwapError(streamer, err) + } + log = log.With(zap.String("to_mint", toMint.PublicKey().ToBase58())) + + destinationOwner, err := common.NewAccountFromProto(initiateStablecoinSwapReq.DestinationOwner) + if err != nil { + log.With(zap.Error(err)).Warn("invalid destination owner account") + return handleStatefulSwapError(streamer, err) + } + log = log.With(zap.String("destination_owner", destinationOwner.PublicKey().ToBase58())) + + log = log.With( + zap.Uint64("swap_amount", initiateStablecoinSwapReq.SwapAmount), + zap.Uint64("fee_amount", initiateStablecoinSwapReq.FeeAmount), + zap.String("funding_source", initiateStablecoinSwapReq.FundingSource.String()), + zap.String("funding_id", initiateStablecoinSwapReq.FundingId), + ) + + // + // Section: Verified metadata signature verification + // + + verifiedMetadata := &transactionpb.VerifiedSwapMetadata{ + Kind: &transactionpb.VerifiedSwapMetadata_Stablecoin{ + Stablecoin: &transactionpb.VerifiedCoinbaseStableSwapperSwapMetadata{ + ClientParameters: initiateStablecoinSwapReq, + }, + }, + } + + metadataSignature := initiateReq.ProofSignature + if err := s.auth.Authenticate(ctx, owner, verifiedMetadata, metadataSignature); err != nil { + return handleStatefulSwapStructuredError(streamer, transactionpb.StatefulSwapResponse_Error_SIGNATURE_ERROR) + } + + // + // Section: Validation + // + + _, err = s.data.GetSwapById(ctx, swapId) + if err == nil { + return handleStatefulSwapError(streamer, NewSwapDeniedError("attempt to reuse swap id")) + } else if err != swap.ErrNotFound { + log.With(zap.Error(err)).Warn("failure checking for existing swap record by id") + return handleStatefulSwapError(streamer, err) + } + + _, err = s.data.GetSwapByFundingId(ctx, initiateStablecoinSwapReq.FundingId) + if err == nil { + return handleStatefulSwapError(streamer, NewSwapDeniedError("attempt to reuse swap funding id")) + } else if err != swap.ErrNotFound { + log.With(zap.Error(err)).Warn("failure checking for existing swap record by funding id") + return handleStatefulSwapError(streamer, err) + } + + if !common.IsCoreMint(fromMint) { + return handleStatefulSwapError(streamer, NewSwapValidationError("source mint must be the core mint")) + } + if toMint.PublicKey().ToBase58() != usdc.Mint { + return handleStatefulSwapError(streamer, NewSwapValidationError("destination mint must be usdc")) + } + + if initiateStablecoinSwapReq.SwapAmount == 0 { + return handleStatefulSwapError(streamer, NewSwapValidationError("swap amount must be positive")) + } + + expectedFeeQuarks := uint64(s.conf.createOnSendWithdrawalUsdFee.Get(ctx) * float64(common.CoreMintQuarksPerUnit)) + if initiateStablecoinSwapReq.FeeAmount != expectedFeeQuarks { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("fee amount must be %d quarks", expectedFeeQuarks)) + } + + if initiateStablecoinSwapReq.FundingSource != transactionpb.FundingSource_FUNDING_SOURCE_SUBMIT_INTENT { + return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("funding source %s is not supported", initiateStablecoinSwapReq.FundingSource)) + } + + decodedFundingId, err := base58.Decode(initiateStablecoinSwapReq.FundingId) + if err != nil || len(decodedFundingId) != ed25519.PublicKeySize { + log.With(zap.Error(err)).Warn("invalid funding id") + return handleStatefulSwapError(streamer, NewSwapValidationError("funding id is not a public key")) + } + + _, err = s.data.GetIntent(ctx, initiateStablecoinSwapReq.FundingId) + if err == nil { + return handleStatefulSwapError(streamer, NewSwapValidationError("funding intent already exists")) + } else if err != intent.ErrIntentNotFound { + log.With(zap.Error(err)).Warn("failure getting funding intent record") + return handleStatefulSwapError(streamer, err) + } + + sourceVmConfig, err := common.GetVmConfigForMint(ctx, s.data, fromMint) + if err == common.ErrUnsupportedMint { + return handleStatefulSwapError(streamer, NewSwapValidationError("invalid source mint")) + } else if err != nil { + log.With(zap.Error(err)).Warn("failure getting source vm config") + return handleStatefulSwapError(streamer, err) + } + + ownerSourceTimelockVault, err := owner.ToTimelockVault(sourceVmConfig) + if err != nil { + log.With(zap.Error(err)).Warn("failure getting owner source timelock vault") + return handleStatefulSwapError(streamer, err) + } + + sourceTimelockAccountRecord, err := s.data.GetTimelockByVault(ctx, ownerSourceTimelockVault.PublicKey().ToBase58()) + if err == timelock.ErrTimelockNotFound { + return handleStatefulSwapError(streamer, NewSwapValidationError("source timelock vault account not opened")) + } else if err != nil { + log.With(zap.Error(err)).Warn("failure getting source timelock record") + return handleStatefulSwapError(streamer, err) + } + if !sourceTimelockAccountRecord.IsLocked() { + return handleStatefulSwapError(streamer, NewSwapDeniedError("source timelock account isn't locked")) + } + + ownerBalance, err := balance.CalculateFromCache(ctx, s.data, ownerSourceTimelockVault) + if err != nil { + log.With(zap.Error(err)).Warn("failure getting owner source timelock vault balance") + return handleStatefulSwapError(streamer, err) + } + if ownerBalance < initiateStablecoinSwapReq.SwapAmount+initiateStablecoinSwapReq.FeeAmount { + return handleStatefulSwapError(streamer, NewSwapValidationError("insufficient balance")) + } + + if owner.PublicKey().ToBase58() == swapAuthority.PublicKey().ToBase58() { + return handleStatefulSwapError(streamer, NewSwapValidationError("owner cannot be swap authority")) + } + + // + // Section: Antispam + // + + ownerMetadata, err := common.GetOwnerMetadata(ctx, s.data, owner) + if err == common.ErrOwnerNotFound { + return handleStatefulSwapError(streamer, NewSwapDeniedError("not an ocp account")) + } else if err != nil { + log.With(zap.Error(err)).Warn("failure getting owner metadata") + return handleStatefulSwapError(streamer, err) + } + if ownerMetadata.State != common.OwnerManagementStateOcpAccount { + return handleStatefulSwapError(streamer, NewSwapDeniedError("not an ocp account")) + } + if ownerMetadata.Type != common.OwnerTypeUser12Words { + return handleStatefulSwapError(streamer, NewSwapDeniedError("not a user ocp account")) + } + + allow, err := s.antispamGuard.AllowSwap(ctx, swap.KindStablecoin, swap.FundingSource(initiateStablecoinSwapReq.FundingSource), owner, fromMint, toMint, initiateStablecoinSwapReq.SwapAmount, initiateStablecoinSwapReq.FeeAmount, false) + if err != nil { + return handleStatefulSwapError(streamer, err) + } else if !allow { + return handleStatefulSwapError(streamer, NewSwapDeniedError("rate limited")) + } + + // + // Section: Transaction construction + // + + noncePool, err := transaction_util.SelectNoncePool( + nonce.EnvironmentSolana, + nonce.EnvironmentInstanceSolanaMainnet, + nonce.PurposeClientSwap, + s.noncePools..., + ) + if err != nil { + log.With(zap.Error(err)).Warn("failure selecting nonce pool") + return handleStatefulSwapError(streamer, err) + } + selectedNonce, err := noncePool.GetNonce(ctx) + if err != nil { + log.With(zap.Error(err)).Warn("failure selecting available nonce") + return handleStatefulSwapError(streamer, err) + } + defer func() { + selectedNonce.ReleaseIfNotReserved(ctx) + }() + + swapHandler := NewCoinbaseStableSwapperSwapHandler( + s.data, + owner, + swapAuthority, + destinationOwner, + fromMint, + toMint, + initiateStablecoinSwapReq.SwapAmount, + initiateStablecoinSwapReq.FeeAmount, + selectedNonce, + ) + + alts, err := swapHandler.GetAlts(ctx) + if err != nil { + log.With(zap.Error(err)).Warn("failure getting alt") + return handleStatefulSwapError(streamer, err) + } + + ixns, err := swapHandler.MakeInstructions(ctx) + if err != nil { + log.With(zap.Error(err)).Warn("failure making instructions") + return handleStatefulSwapError(streamer, err) + } + + txn := solana.NewV0Transaction( + common.GetSubsidizer().PublicKey().ToBytes(), + alts, + ixns, + ) + + txn.SetBlockhash(selectedNonce.Blockhash) + + marshalledTxnMessage := txn.Message.Marshal() + + // + // Section: Server parameters + // + + if err := streamer.Send(&transactionpb.StatefulSwapResponse{ + Response: &transactionpb.StatefulSwapResponse_ServerParameters_{ + ServerParameters: swapHandler.GetServerParameters(), + }, + }); err != nil { + return handleStatefulSwapError(streamer, err) + } + + // + // Section: Transaction signing + // + + req, err := protoutil.BoundedReceive[transactionpb.StatefulSwapRequest](ctx, streamer, s.conf.clientReceiveTimeout.Get(ctx)) + if err != nil { + log.With(zap.Error(err)).Info("error receiving request from client") + return err + } + + submitSignaturesReq := req.GetSubmitSignatures() + if submitSignaturesReq == nil { + return handleStatefulSwapError(streamer, status.Error(codes.InvalidArgument, "StatefulSwapRequest.SubmitSignatures is nil")) + } + + if len(submitSignaturesReq.TransactionSignatures) != 2 { + return handleStatefulSwapStructuredError( + streamer, + transactionpb.StatefulSwapResponse_Error_SIGNATURE_ERROR, + toReasonStringErrorDetails(errors.New("expected 2 signatures")), + ) + } + + for i := range txn.Message.Header.NumSignatures { + account := txn.Message.Accounts[i] + + var isClientSignature bool + var protoSignature *commonpb.Signature + + if bytes.Equal(account, owner.PublicKey().ToBytes()) { + isClientSignature = true + protoSignature = submitSignaturesReq.TransactionSignatures[0] + } else if bytes.Equal(account, swapAuthority.PublicKey().ToBytes()) { + isClientSignature = true + protoSignature = submitSignaturesReq.TransactionSignatures[1] + } + + if !isClientSignature { + continue + } + + if !ed25519.Verify( + account, + marshalledTxnMessage, + protoSignature.Value, + ) { + return handleStatefulSwapStructuredError( + streamer, + transactionpb.StatefulSwapResponse_Error_SIGNATURE_ERROR, + toInvalidTxnSignatureErrorDetails(0, txn, protoSignature), + ) + } + + copy(txn.Signatures[i][:], protoSignature.Value) + } + + err = txn.Sign( + common.GetSubsidizer().PrivateKey().ToBytes(), + sourceVmConfig.Authority.PrivateKey().ToBytes(), + ) + if err != nil { + log.With(zap.Error(err)).Info("failure signing transaction") + return handleStatefulSwapError(streamer, err) + } + + marshalledTxn := txn.Marshal() + + txnSignature := base58.Encode(txn.Signature()) + + // + // Section: Swap state DB commit + // + + record := &swap.Record{ + SwapId: swapId, + Kind: swap.KindStablecoin, + Owner: owner.PublicKey().ToBase58(), + DestinationOwner: destinationOwner.PublicKey().ToBase58(), + FromMint: fromMint.PublicKey().ToBase58(), + ToMint: toMint.PublicKey().ToBase58(), + SwapAmount: initiateStablecoinSwapReq.SwapAmount, + FeeAmount: initiateStablecoinSwapReq.FeeAmount, + FundingSource: swap.FundingSource(initiateStablecoinSwapReq.FundingSource), + FundingId: initiateStablecoinSwapReq.FundingId, + Nonce: selectedNonce.Account.PublicKey().ToBase58(), + Blockhash: base58.Encode(selectedNonce.Blockhash[:]), + ProofSignature: base58.Encode(initiateReq.ProofSignature.Value), + TransactionSignature: txnSignature, + TransactionBlob: marshalledTxn, + State: swap.StateCreated, + CreatedAt: time.Now(), + } + + err = s.data.ExecuteInTx(ctx, sql.LevelDefault, func(ctx context.Context) error { + err := selectedNonce.MarkReservedWithSignature(ctx, txnSignature) + if err != nil { + log.With(zap.Error(err)).Warn("failure reserving nonce") + return err + } + + err = s.data.SaveSwap(ctx, record) + if err != nil { + log.With(zap.Error(err)).Warn("failure saving swap record") + return err + } + + return nil + }) + if err != nil { + return handleStatefulSwapError(streamer, err) + } + + // + // Section: Final RPC response + // + + err = streamer.Send(&transactionpb.StatefulSwapResponse{ + Response: &transactionpb.StatefulSwapResponse_Success_{ + Success: &transactionpb.StatefulSwapResponse_Success{ + Code: transactionpb.StatefulSwapResponse_Success_OK, + }, + }, + }) + return handleStatefulSwapError(streamer, err) +} + func (s *transactionServer) GetSwap(ctx context.Context, req *transactionpb.GetSwapRequest) (*transactionpb.GetSwapResponse, error) { log := s.log.With(zap.String("method", "GetSwap")) log = client.InjectLoggingMetadata(ctx, log, rpc.UserAgentName) @@ -723,8 +1124,10 @@ func toProtoSwap(record *swap.Record) (*transactionpb.SwapMetadata, error) { return nil, err } - return &transactionpb.SwapMetadata{ - VerifiedMetadata: &transactionpb.VerifiedSwapMetadata{ + var verifiedMetadata *transactionpb.VerifiedSwapMetadata + switch record.Kind { + case swap.KindReserve: + verifiedMetadata = &transactionpb.VerifiedSwapMetadata{ Kind: &transactionpb.VerifiedSwapMetadata_Reserve{ Reserve: &transactionpb.VerifiedReserveSwapMetadata{ ClientParameters: &transactionpb.StatefulSwapRequest_Initiate_ReserveSwapClientParameters{ @@ -738,8 +1141,35 @@ func toProtoSwap(record *swap.Record) (*transactionpb.SwapMetadata, error) { }, }, }, - }, - State: transactionpb.SwapMetadata_State(record.State), - Signature: &commonpb.Signature{Value: decodedSignature}, + } + case swap.KindStablecoin: + destinationOwner, err := common.NewAccountFromPublicKeyString(record.DestinationOwner) + if err != nil { + return nil, err + } + verifiedMetadata = &transactionpb.VerifiedSwapMetadata{ + Kind: &transactionpb.VerifiedSwapMetadata_Stablecoin{ + Stablecoin: &transactionpb.VerifiedCoinbaseStableSwapperSwapMetadata{ + ClientParameters: &transactionpb.StatefulSwapRequest_Initiate_CoinbaseStableSwapperClientParameters{ + Id: &commonpb.SwapId{Value: decodedSwapId}, + FromMint: fromMint.ToProto(), + ToMint: toMint.ToProto(), + SwapAmount: record.SwapAmount, + FeeAmount: record.FeeAmount, + FundingSource: transactionpb.FundingSource(record.FundingSource), + FundingId: record.FundingId, + DestinationOwner: destinationOwner.ToProto(), + }, + }, + }, + } + default: + return nil, errors.Errorf("unsupported swap kind: %s", record.Kind) + } + + return &transactionpb.SwapMetadata{ + VerifiedMetadata: verifiedMetadata, + State: transactionpb.SwapMetadata_State(record.State), + Signature: &commonpb.Signature{Value: decodedSignature}, }, nil } diff --git a/ocp/rpc/transaction/swap_handler.go b/ocp/rpc/transaction/swap_handler.go index ce560cd..4aee8c9 100644 --- a/ocp/rpc/transaction/swap_handler.go +++ b/ocp/rpc/transaction/swap_handler.go @@ -891,3 +891,165 @@ func (h *ReserveCreateAndBuySwapHandler) MakeInstructions(ctx context.Context) ( closeTemporaryCoreMintAta, }, nil } + +type CoinbaseStableSwapperSwapHandler struct { + data ocp_data.Provider + + owner *common.Account + swapAuthority *common.Account + destinationOwner *common.Account + fromMint *common.Account + toMint *common.Account + swapAmount uint64 + feeAmount uint64 + + alts []solana.AddressLookupTable + selectedNonce *transaction_util.Nonce + computeUnitLimit uint32 + computeUnitPrice uint64 + memoValue string + + feeDestination *common.Account + coinbaseAccounts *transaction_util.CoinbaseSwapAccounts +} + +func NewCoinbaseStableSwapperSwapHandler( + data ocp_data.Provider, + owner *common.Account, + swapAuthority *common.Account, + destinationOwner *common.Account, + fromMint *common.Account, + toMint *common.Account, + swapAmount uint64, + feeAmount uint64, + selectedNonce *transaction_util.Nonce, +) SwapHandler { + return &CoinbaseStableSwapperSwapHandler{ + data: data, + + owner: owner, + swapAuthority: swapAuthority, + destinationOwner: destinationOwner, + fromMint: fromMint, + toMint: toMint, + swapAmount: swapAmount, + feeAmount: feeAmount, + + selectedNonce: selectedNonce, + computeUnitLimit: 150_000, + computeUnitPrice: 10_000, + memoValue: "coinbase_stable_swapper_v0", + feeDestination: common.CoreMintFeesAccount, + } +} + +func (h *CoinbaseStableSwapperSwapHandler) GetAlts(ctx context.Context) ([]solana.AddressLookupTable, error) { + h.alts = []solana.AddressLookupTable{transaction_util.GetAltForCoreMint()} + return h.alts, nil +} + +func (h *CoinbaseStableSwapperSwapHandler) GetServerParameters() *transactionpb.StatefulSwapResponse_ServerParameters { + feeRecipient, _ := common.NewAccountFromPublicKeyBytes(h.coinbaseAccounts.FeeRecipient) + return &transactionpb.StatefulSwapResponse_ServerParameters{ + Kind: &transactionpb.StatefulSwapResponse_ServerParameters_Stablecoin{ + Stablecoin: &transactionpb.StatefulSwapResponse_ServerParameters_CoinbaseStableSwapperServerParameter{ + 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: h.memoValue, + FeeDestination: h.feeDestination.ToProto(), + PoolFeeRecipient: feeRecipient.ToProto(), + }, + }, + } +} + +func (h *CoinbaseStableSwapperSwapHandler) MakeInstructions(ctx context.Context) ([]solana.Instruction, error) { + sourceVmConfig, err := common.GetVmConfigForMint(ctx, h.data, h.fromMint) + if err != nil { + return nil, err + } + + sourceTimelockAccounts, err := h.owner.GetTimelockAccounts(sourceVmConfig) + if err != nil { + return nil, err + } + + coinbaseAccounts, err := transaction_util.GetCoinbaseSwapAccounts( + ctx, + h.data, + h.fromMint.PublicKey().ToBytes(), + h.toMint.PublicKey().ToBytes(), + ) + if err != nil { + return nil, err + } + h.coinbaseAccounts = coinbaseAccounts + + createSwapAuthorityFromMintAtaIxn, swapAuthorityFromMintAta, err := token.CreateAssociatedTokenAccountIdempotent( + common.GetSubsidizer().PublicKey().ToBytes(), + h.swapAuthority.PublicKey().ToBytes(), + h.fromMint.PublicKey().ToBytes(), + ) + if err != nil { + return nil, err + } + + createDestinationOwnerToMintAtaIxn, destinationOwnerToMintAta, err := token.CreateAssociatedTokenAccountIdempotent( + common.GetSubsidizer().PublicKey().ToBytes(), + h.destinationOwner.PublicKey().ToBytes(), + h.toMint.PublicKey().ToBytes(), + ) + if err != nil { + return nil, err + } + + transferForSwapWithFeeIxn := vm.NewTransferForSwapWithFeeInstruction( + &vm.TransferForSwapWithFeeInstructionAccounts{ + VmAuthority: common.GetSubsidizer().PublicKey().ToBytes(), + Vm: sourceVmConfig.Vm.PublicKey().ToBytes(), + Swapper: h.owner.PublicKey().ToBytes(), + SwapPda: sourceTimelockAccounts.VmSwapAccounts.Pda.PublicKey().ToBytes(), + SwapAta: sourceTimelockAccounts.VmSwapAccounts.Ata.PublicKey().ToBytes(), + SwapDestination: swapAuthorityFromMintAta, + FeeDestination: h.feeDestination.PublicKey().ToBytes(), + }, + &vm.TransferForSwapWithFeeInstructionArgs{ + SwapAmount: h.swapAmount, + FeeAmount: h.feeAmount, + Bump: sourceTimelockAccounts.VmSwapAccounts.PdaBump, + }, + ) + + coinbaseSwapIxn := transaction_util.MakeCoinbaseSwapInstruction( + coinbaseAccounts, + h.swapAuthority.PublicKey().ToBytes(), + h.fromMint.PublicKey().ToBytes(), + h.toMint.PublicKey().ToBytes(), + swapAuthorityFromMintAta, + destinationOwnerToMintAta, + h.swapAmount, + h.swapAmount, + ) + + closeSwapAuthorityFromMintAtaIxn := token.CloseAccount( + swapAuthorityFromMintAta, + common.GetSubsidizer().PublicKey().ToBytes(), + h.swapAuthority.PublicKey().ToBytes(), + ) + + return []solana.Instruction{ + system.AdvanceNonce(h.selectedNonce.Account.PublicKey().ToBytes(), common.GetSubsidizer().PublicKey().ToBytes()), + compute_budget.SetComputeUnitLimit(h.computeUnitLimit), + compute_budget.SetComputeUnitPrice(h.computeUnitPrice), + memo.Instruction(h.memoValue), + createSwapAuthorityFromMintAtaIxn, + createDestinationOwnerToMintAtaIxn, + transferForSwapWithFeeIxn, + coinbaseSwapIxn, + closeSwapAuthorityFromMintAtaIxn, + }, nil +} diff --git a/ocp/transaction/coinbase_swap.go b/ocp/transaction/coinbase_swap.go new file mode 100644 index 0000000..52519c0 --- /dev/null +++ b/ocp/transaction/coinbase_swap.go @@ -0,0 +1,129 @@ +package transaction + +import ( + "context" + "crypto/ed25519" + + "github.com/mr-tron/base58" + "github.com/pkg/errors" + + ocp_data "github.com/code-payments/ocp-server/ocp/data" + "github.com/code-payments/ocp-server/solana" + coinbase_stable_swapper "github.com/code-payments/ocp-server/solana/coinbasestableswapper" + "github.com/code-payments/ocp-server/solana/token" +) + +// CoinbaseSwapAccounts captures all derived PDAs and on-chain values needed +// to build a CoinbaseStableSwapper::Swap instruction for a (fromMint, toMint) +// pair. FeeRecipient is sourced from the on-chain LiquidityPool account. +type CoinbaseSwapAccounts struct { + Pool ed25519.PublicKey + InVault ed25519.PublicKey + OutVault ed25519.PublicKey + InVaultTokenAccount ed25519.PublicKey + OutVaultTokenAccount ed25519.PublicKey + Whitelist ed25519.PublicKey + FeeRecipient ed25519.PublicKey + FeeRecipientTokenAccount ed25519.PublicKey +} + +// GetCoinbaseSwapAccounts derives all PDAs and reads the on-chain pool account +// to resolve the fee recipient and its from-mint ATA. +func GetCoinbaseSwapAccounts(ctx context.Context, data ocp_data.Provider, fromMint, toMint ed25519.PublicKey) (*CoinbaseSwapAccounts, error) { + pool, _, err := coinbase_stable_swapper.GetPoolAddress() + if err != nil { + return nil, errors.Wrap(err, "error getting coinbase pool address") + } + + inVault, _, err := coinbase_stable_swapper.GetTokenVaultAddress(&coinbase_stable_swapper.GetTokenVaultAddressArgs{ + Pool: pool, + Mint: fromMint, + }) + if err != nil { + return nil, errors.Wrap(err, "error getting in vault address") + } + + outVault, _, err := coinbase_stable_swapper.GetTokenVaultAddress(&coinbase_stable_swapper.GetTokenVaultAddressArgs{ + Pool: pool, + Mint: toMint, + }) + if err != nil { + return nil, errors.Wrap(err, "error getting out vault address") + } + + inVaultTokenAccount, _, err := coinbase_stable_swapper.GetVaultTokenAccountAddress(&coinbase_stable_swapper.GetVaultTokenAccountAddressArgs{ + Vault: inVault, + }) + if err != nil { + return nil, errors.Wrap(err, "error getting in vault token account address") + } + + outVaultTokenAccount, _, err := coinbase_stable_swapper.GetVaultTokenAccountAddress(&coinbase_stable_swapper.GetVaultTokenAccountAddressArgs{ + Vault: outVault, + }) + if err != nil { + return nil, errors.Wrap(err, "error getting out vault token account address") + } + + whitelist, _, err := coinbase_stable_swapper.GetWhitelistAddress() + if err != nil { + return nil, errors.Wrap(err, "error getting whitelist address") + } + + poolAccountInfo, _, err := data.GetBlockchainAccountInfo(ctx, base58.Encode(pool), solana.CommitmentProcessed) + if err != nil { + return nil, errors.Wrap(err, "error getting coinbase liquidity pool account info") + } + + var poolAccount coinbase_stable_swapper.LiquidityPoolAccount + if err := poolAccount.Unmarshal(poolAccountInfo.Data); err != nil { + return nil, errors.Wrap(err, "error unmarshalling coinbase liquidity pool account") + } + + feeRecipientTokenAccount, err := token.GetAssociatedAccount(poolAccount.FeeRecipient, fromMint) + if err != nil { + return nil, errors.Wrap(err, "error getting fee recipient token account") + } + + return &CoinbaseSwapAccounts{ + Pool: pool, + InVault: inVault, + OutVault: outVault, + InVaultTokenAccount: inVaultTokenAccount, + OutVaultTokenAccount: outVaultTokenAccount, + Whitelist: whitelist, + FeeRecipient: poolAccount.FeeRecipient, + FeeRecipientTokenAccount: feeRecipientTokenAccount, + }, nil +} + +// MakeCoinbaseSwapInstruction builds a CoinbaseStableSwapper::Swap instruction. +func MakeCoinbaseSwapInstruction( + accounts *CoinbaseSwapAccounts, + user ed25519.PublicKey, + fromMint, toMint ed25519.PublicKey, + userFromTokenAccount, userToTokenAccount ed25519.PublicKey, + amountIn, minAmountOut uint64, +) solana.Instruction { + return coinbase_stable_swapper.NewSwapInstruction( + &coinbase_stable_swapper.SwapInstructionAccounts{ + Pool: accounts.Pool, + InVault: accounts.InVault, + OutVault: accounts.OutVault, + InVaultTokenAccount: accounts.InVaultTokenAccount, + OutVaultTokenAccount: accounts.OutVaultTokenAccount, + UserFromTokenAccount: userFromTokenAccount, + ToTokenAccount: userToTokenAccount, + FeeRecipientTokenAccount: accounts.FeeRecipientTokenAccount, + FeeRecipient: accounts.FeeRecipient, + FromMint: fromMint, + ToMint: toMint, + User: user, + Whitelist: accounts.Whitelist, + }, + &coinbase_stable_swapper.SwapInstructionArgs{ + AmountIn: amountIn, + MinAmountOut: minAmountOut, + }, + ) +} diff --git a/ocp/worker/swap/util.go b/ocp/worker/swap/util.go index 568bd55..fcb7e13 100644 --- a/ocp/worker/swap/util.go +++ b/ocp/worker/swap/util.go @@ -25,6 +25,7 @@ import ( vm_util "github.com/code-payments/ocp-server/ocp/vm" "github.com/code-payments/ocp-server/solana" "github.com/code-payments/ocp-server/solana/currencycreator" + "github.com/code-payments/ocp-server/solana/token" ) func (p *runtime) validateSwapState(record *swap.Record, states ...swap.State) error { @@ -68,7 +69,7 @@ func (p *runtime) markSwapFinalized(ctx context.Context, swapRecord *swap.Record } var destinationCurrencyMetadataRecord *currency.MetadataRecord - if !common.IsCoreMint(toMint) { + if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { destinationCurrencyMetadataRecord, err = p.data.GetCurrencyMetadata(ctx, swapRecord.ToMint) if err != nil { return err @@ -90,7 +91,7 @@ func (p *runtime) markSwapFinalized(ctx context.Context, swapRecord *swap.Record return err } - if !common.IsCoreMint(toMint) { + if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { if destinationCurrencyMetadataRecord.State == currency.MetadataStateExecutingInitialPurchase { destinationCurrencyMetadataRecord.State = currency.MetadataStateCompletingInitialization err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) @@ -113,7 +114,7 @@ func (p *runtime) markSwapFailed(ctx context.Context, swapRecord *swap.Record) e } var destinationCurrencyMetadataRecord *currency.MetadataRecord - if !common.IsCoreMint(toMint) { + if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { destinationCurrencyMetadataRecord, err = p.data.GetCurrencyMetadata(ctx, swapRecord.ToMint) if err != nil { return err @@ -135,7 +136,7 @@ func (p *runtime) markSwapFailed(ctx context.Context, swapRecord *swap.Record) e return err } - if !common.IsCoreMint(toMint) { + if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { if destinationCurrencyMetadataRecord.State == currency.MetadataStateExecutingInitialPurchase { destinationCurrencyMetadataRecord.State = currency.MetadataStateAbandoning err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) @@ -158,7 +159,7 @@ func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record } var destinationCurrencyMetadataRecord *currency.MetadataRecord - if !common.IsCoreMint(toMint) { + if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { destinationCurrencyMetadataRecord, err = p.data.GetCurrencyMetadata(ctx, swapRecord.ToMint) if err != nil { return err @@ -183,7 +184,7 @@ func (p *runtime) markSwapCancelled(ctx context.Context, swapRecord *swap.Record } } - if !common.IsCoreMint(toMint) { + if swapRecord.Kind == swap.KindReserve && !common.IsCoreMint(toMint) { if destinationCurrencyMetadataRecord.State == currency.MetadataStateFundingAuthority { destinationCurrencyMetadataRecord.State = currency.MetadataStateAbandoning err = p.data.SaveCurrencyMetadata(ctx, destinationCurrencyMetadataRecord) @@ -223,6 +224,21 @@ func (p *runtime) submitTransaction(ctx context.Context, record *swap.Record) er } func (p *runtime) maybeUpdateBalancesForFinalizedSwap(ctx context.Context, swapRecord *swap.Record, tokenBalances *solana.TransactionTokenBalances) (uint64, bool, error) { + switch swapRecord.Kind { + case swap.KindReserve: + return p.maybeUpdateBalancesForFinalizedReserveSwap(ctx, swapRecord, tokenBalances) + case swap.KindStablecoin: + return p.getStablecoinSwapQuarksBought(swapRecord, tokenBalances) + default: + return 0, false, errors.New("unsupported swap kind") + } +} + +func (p *runtime) maybeUpdateBalancesForFinalizedReserveSwap(ctx context.Context, swapRecord *swap.Record, tokenBalances *solana.TransactionTokenBalances) (uint64, bool, error) { + if swapRecord.Kind != swap.KindReserve { + return 0, false, errors.New("swap is not a reserve swap") + } + owner, err := common.NewAccountFromPublicKeyString(swapRecord.Owner) if err != nil { return 0, false, err @@ -407,7 +423,47 @@ func (p *runtime) maybeUpdateBalancesForFinalizedSwap(ctx context.Context, swapR return uint64(deltaQuarksIntoOmnibus), false, nil } +func (p *runtime) getStablecoinSwapQuarksBought(swapRecord *swap.Record, tokenBalances *solana.TransactionTokenBalances) (uint64, bool, error) { + if swapRecord.Kind != swap.KindStablecoin { + return 0, false, errors.New("swap is not a stablecoin swap") + } + + destinationOwner, err := common.NewAccountFromPublicKeyString(swapRecord.DestinationOwner) + if err != nil { + return 0, false, errors.Wrap(err, "error parsing destination owner") + } + + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) + if err != nil { + return 0, false, errors.Wrap(err, "error parsing destination mint") + } + + destinationAtaPubkey, err := token.GetAssociatedAccount(destinationOwner.PublicKey().ToBytes(), toMint.PublicKey().ToBytes()) + if err != nil { + return 0, false, errors.Wrap(err, "error deriving destination ata") + } + + destinationAta, err := common.NewAccountFromPublicKeyBytes(destinationAtaPubkey) + if err != nil { + return 0, false, err + } + + deltaQuarks, err := transaction_util.GetDeltaQuarksFromTokenBalances(destinationAta, tokenBalances) + if err != nil { + return 0, false, errors.Wrap(err, "error getting delta quarks into destination ata") + } + if deltaQuarks <= 0 { + return 0, false, errors.New("delta quarks into destination ata is not positive") + } + + return uint64(deltaQuarks), false, nil +} + func (p *runtime) notifySwapFinalized(ctx context.Context, swapRecord *swap.Record, isMintInit bool) error { + if swapRecord.Kind != swap.KindReserve { + return nil + } + owner, err := common.NewAccountFromPublicKeyString(swapRecord.Owner) if err != nil { return err @@ -615,6 +671,10 @@ func (p *runtime) validateExternalWalletFunding(ctx context.Context, record *swa } func (p *runtime) ensureSwapDestinationIsInitialized(ctx context.Context, record *swap.Record) error { + if record.Kind != swap.KindReserve { + return nil + } + toMint, err := common.NewAccountFromPublicKeyString(record.ToMint) if err != nil { return err @@ -651,6 +711,10 @@ func (p *runtime) ensureSwapDestinationIsInitialized(ctx context.Context, record } func (p *runtime) validateDestinationCurrencyReadyForSwap(ctx context.Context, swapRecord *swap.Record) (bool, error) { + if swapRecord.Kind != swap.KindReserve { + return true, nil + } + toMint, err := common.NewAccountFromPublicKeyString(swapRecord.ToMint) if err != nil { return false, err @@ -673,6 +737,10 @@ func (p *runtime) validateDestinationCurrencyReadyForSwap(ctx context.Context, s } func (p *runtime) updateLiveReserveStateForFinalizedSwap(ctx context.Context, swapRecord *swap.Record, tokenBalances *solana.TransactionTokenBalances) error { + if swapRecord.Kind != swap.KindReserve { + return nil + } + fromMint, err := common.NewAccountFromPublicKeyString(swapRecord.FromMint) if err != nil { return err