diff --git a/go.mod b/go.mod
index b3a97897..7da05848 100644
--- a/go.mod
+++ b/go.mod
@@ -6,11 +6,10 @@ require (
 	firebase.google.com/go/v4 v4.8.0
 	github.com/aws/aws-sdk-go-v2 v0.17.0
 	github.com/bits-and-blooms/bloom/v3 v3.1.0
-	github.com/code-payments/code-protobuf-api v1.8.2
+	github.com/code-payments/code-protobuf-api v1.8.3
 	github.com/emirpasic/gods v1.12.0
 	github.com/envoyproxy/protoc-gen-validate v0.1.0
 	github.com/golang-jwt/jwt/v5 v5.0.0
-	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
 	github.com/golang/protobuf v1.5.3
 	github.com/google/uuid v1.3.0
 	github.com/grpc-ecosystem/go-grpc-middleware v1.2.2
@@ -97,7 +96,6 @@ require (
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
 	golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
 	golang.org/x/sys v0.13.0 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
diff --git a/go.sum b/go.sum
index a7514582..943992ec 100644
--- a/go.sum
+++ b/go.sum
@@ -108,8 +108,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
 github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 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-protobuf-api v1.8.2 h1:fl5BS54jI8kqFG9qOyvkwdmZNX9FyG0WvgUTpu/2/dg=
-github.com/code-payments/code-protobuf-api v1.8.2/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU=
+github.com/code-payments/code-protobuf-api v1.8.3 h1:BEGKUvHZu5TvfsX4zhzSppuvZGlHH/DbvVBS0/oB18M=
+github.com/code-payments/code-protobuf-api v1.8.3/go.mod h1:pHQm75vydD6Cm2qHAzlimW6drysm489Z4tVxC2zHSsU=
 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=
@@ -175,8 +175,6 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
 github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
-github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
-github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -573,8 +571,6 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
 golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
-golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
diff --git a/pkg/code/antispam/metrics.go b/pkg/code/antispam/metrics.go
index d3e9d16b..d864010b 100644
--- a/pkg/code/antispam/metrics.go
+++ b/pkg/code/antispam/metrics.go
@@ -23,6 +23,8 @@ const (
 
 	actionWelcomeBonus  = "WelcomeBonus"
 	actionReferralBonus = "ReferralBonus"
+
+	actionSwap = "Swap"
 )
 
 func recordDenialEvent(ctx context.Context, action, reason string) {
diff --git a/pkg/code/antispam/swap.go b/pkg/code/antispam/swap.go
new file mode 100644
index 00000000..9ee626af
--- /dev/null
+++ b/pkg/code/antispam/swap.go
@@ -0,0 +1,79 @@
+package antispam
+
+import (
+	"context"
+
+	"github.com/sirupsen/logrus"
+
+	"github.com/code-payments/code-server/pkg/code/common"
+	"github.com/code-payments/code-server/pkg/code/data/phone"
+	"github.com/code-payments/code-server/pkg/code/data/user/identity"
+	"github.com/code-payments/code-server/pkg/grpc/client"
+	"github.com/code-payments/code-server/pkg/metrics"
+)
+
+// AllowSwap determines whether a phone-verified owner account can perform a swap.
+// The objective here is to limit attacks against our Swap Subsidizer's SOL balance.
+//
+// todo: needs tests
+func (g *Guard) AllowSwap(ctx context.Context, owner *common.Account) (bool, error) {
+	tracer := metrics.TraceMethodCall(ctx, metricsStructName, "AllowSwap")
+	defer tracer.End()
+
+	log := g.log.WithFields(logrus.Fields{
+		"method": "AllowSwap",
+		"owner":  owner.PublicKey().ToBase58(),
+	})
+	log = client.InjectLoggingMetadata(ctx, log)
+
+	// Deny abusers from known IPs
+	if isIpBanned(ctx) {
+		log.Info("ip is banned")
+		recordDenialEvent(ctx, actionSwap, "ip banned")
+		return false, nil
+	}
+
+	verification, err := g.data.GetLatestPhoneVerificationForAccount(ctx, owner.PublicKey().ToBase58())
+	if err == phone.ErrVerificationNotFound {
+		// Owner account was never phone verified, so deny the action.
+		log.Info("owner account is not phone verified")
+		recordDenialEvent(ctx, actionSwap, "not phone verified")
+		return false, nil
+	} else if err != nil {
+		tracer.OnError(err)
+		log.WithError(err).Warn("failure getting phone verification record")
+		return false, err
+	}
+
+	log = log.WithField("phone", verification.PhoneNumber)
+
+	// Deny abusers from known phone ranges
+	if hasBannedPhoneNumberPrefix(verification.PhoneNumber) {
+		log.Info("denying phone prefix")
+		recordDenialEvent(ctx, actionSwap, "phone prefix banned")
+		return false, nil
+	}
+
+	user, err := g.data.GetUserByPhoneView(ctx, verification.PhoneNumber)
+	switch err {
+	case nil:
+		// Deny banned users forever
+		if user.IsBanned {
+			log.Info("denying banned user")
+			recordDenialEvent(ctx, actionSwap, "user banned")
+			return false, nil
+		}
+
+		// Staff users have unlimited access to enable testing and demoing.
+		if user.IsStaffUser {
+			return true, nil
+		}
+	case identity.ErrNotFound:
+	default:
+		tracer.OnError(err)
+		log.WithError(err).Warn("failure getting user identity by phone view")
+		return false, err
+	}
+
+	return true, nil
+}
diff --git a/pkg/code/server/grpc/transaction/v2/config.go b/pkg/code/server/grpc/transaction/v2/config.go
index 9f09278e..04bd54e2 100644
--- a/pkg/code/server/grpc/transaction/v2/config.go
+++ b/pkg/code/server/grpc/transaction/v2/config.go
@@ -21,8 +21,11 @@ const (
 	SubmitIntentTimeoutConfigEnvName = envConfigPrefix + "SUBMIT_INTENT_TIMEOUT"
 	defaultSubmitIntentTimeout       = 5 * time.Second
 
-	SubmitIntentReceiveTimeoutConfigEnvName = envConfigPrefix + "SUBMIT_INTENT_RECEIVE_TIMEOUT"
-	defaultSubmitIntentReceiveTimeout       = time.Second
+	SwapTimeoutConfigEnvName = envConfigPrefix + "SWAP_TIMEOUT"
+	defaultSwapTimeout       = 60 * time.Second
+
+	ClientReceiveTimeoutConfigEnvName = envConfigPrefix + "CLIENT_RECEIVE_TIMEOUT"
+	defaultClientReceiveTimeout       = time.Second
 
 	FeeCollectorTokenPublicKeyConfigEnvName = envConfigPrefix + "FEE_COLLECTOR_TOKEN_PUBLIC_KEY"
 	defaultFeeCollectorPublicKey            = "invalid" // Ensure something valid is set
@@ -33,6 +36,9 @@ const (
 	AirdropperOwnerPublicKeyEnvName = envConfigPrefix + "AIRDROPPER_OWNER_PUBLIC_KEY"
 	defaultAirdropperOwnerPublicKey = "invalid" // Ensure something valid is set
 
+	SwapSubsidizerOwnerPublicKeyEnvName = envConfigPrefix + "SWAP_SUBSIDIZER_OWNER_PUBLIC_KEY"
+	defaultSwapSubsidizerOwnerPublicKey = "invalid" // Ensure something valid is set
+
 	TreasuryPoolOneKinBucketConfigEnvName             = envConfigPrefix + "TREASURY_POOL_1_KIN_BUCKET"
 	TreasuryPoolTenKinBucketConfigEnvName             = envConfigPrefix + "TREASURY_POOL_10_KIN_BUCKET"
 	TreasuryPoolHundredKinBucketConfigEnvName         = envConfigPrefix + "TREASURY_POOL_100_KIN_BUCKET"
@@ -55,11 +61,13 @@ type conf struct {
 	disableAmlChecks                     config.Bool // To avoid limits during testing
 	disableBlockchainChecks              config.Bool
 	submitIntentTimeout                  config.Duration
-	submitIntentReceiveTimeout           config.Duration
+	swapTimeout                          config.Duration
+	clientReceiveTimeout                 config.Duration
 	feeCollectorTokenPublicKey           config.String
 	enableAirdrops                       config.Bool
 	enableAsyncAirdropProcessing         config.Bool
 	airdropperOwnerPublicKey             config.String
+	swapSubsidizerOwnerPublicKey         config.String
 	treasuryPoolOneKinBucket             config.String
 	treasuryPoolTenKinBucket             config.String
 	treasuryPoolHundredKinBucket         config.String
@@ -84,11 +92,13 @@ func WithEnvConfigs() ConfigProvider {
 			disableAmlChecks:                     wrapper.NewBoolConfig(memory.NewConfig(false), false),
 			disableBlockchainChecks:              env.NewBoolConfig(DisableBlockchainChecksConfigEnvName, defaultDisableBlockchainChecks),
 			submitIntentTimeout:                  env.NewDurationConfig(SubmitIntentTimeoutConfigEnvName, defaultSubmitIntentTimeout),
-			submitIntentReceiveTimeout:           env.NewDurationConfig(SubmitIntentReceiveTimeoutConfigEnvName, defaultSubmitIntentReceiveTimeout),
+			swapTimeout:                          env.NewDurationConfig(SwapTimeoutConfigEnvName, defaultSwapTimeout),
+			clientReceiveTimeout:                 env.NewDurationConfig(ClientReceiveTimeoutConfigEnvName, defaultClientReceiveTimeout),
 			feeCollectorTokenPublicKey:           env.NewStringConfig(FeeCollectorTokenPublicKeyConfigEnvName, defaultFeeCollectorPublicKey),
 			enableAirdrops:                       env.NewBoolConfig(EnableAirdropsConfigEnvName, defaultEnableAirdrops),
 			enableAsyncAirdropProcessing:         wrapper.NewBoolConfig(memory.NewConfig(true), true),
 			airdropperOwnerPublicKey:             env.NewStringConfig(AirdropperOwnerPublicKeyEnvName, defaultAirdropperOwnerPublicKey),
+			swapSubsidizerOwnerPublicKey:         env.NewStringConfig(SwapSubsidizerOwnerPublicKeyEnvName, defaultSwapSubsidizerOwnerPublicKey),
 			treasuryPoolOneKinBucket:             env.NewStringConfig(TreasuryPoolOneKinBucketConfigEnvName, defaultTreasuryPoolName),
 			treasuryPoolTenKinBucket:             env.NewStringConfig(TreasuryPoolTenKinBucketConfigEnvName, defaultTreasuryPoolName),
 			treasuryPoolHundredKinBucket:         env.NewStringConfig(TreasuryPoolHundredKinBucketConfigEnvName, defaultTreasuryPoolName),
@@ -108,7 +118,7 @@ type testOverrides struct {
 	enableAntispamChecks                 bool
 	enableAmlChecks                      bool
 	enableAirdrops                       bool
-	submitIntentReceiveTimeout           time.Duration
+	clientReceiveTimeout                 time.Duration
 	feeCollectorTokenPublicKey           string
 	treasuryPoolOneKinBucket             string
 	treasuryPoolTenKinBucket             string
@@ -127,11 +137,13 @@ func withManualTestOverrides(overrides *testOverrides) ConfigProvider {
 			disableAmlChecks:                     wrapper.NewBoolConfig(memory.NewConfig(!overrides.enableAmlChecks), false),
 			disableBlockchainChecks:              wrapper.NewBoolConfig(memory.NewConfig(true), true),
 			submitIntentTimeout:                  wrapper.NewDurationConfig(memory.NewConfig(defaultSubmitIntentTimeout), defaultSubmitIntentTimeout),
-			submitIntentReceiveTimeout:           wrapper.NewDurationConfig(memory.NewConfig(overrides.submitIntentReceiveTimeout), defaultSubmitIntentReceiveTimeout),
+			swapTimeout:                          wrapper.NewDurationConfig(memory.NewConfig(defaultSwapTimeout), defaultSwapTimeout),
+			clientReceiveTimeout:                 wrapper.NewDurationConfig(memory.NewConfig(overrides.clientReceiveTimeout), defaultClientReceiveTimeout),
 			feeCollectorTokenPublicKey:           wrapper.NewStringConfig(memory.NewConfig(overrides.feeCollectorTokenPublicKey), defaultFeeCollectorPublicKey),
 			enableAirdrops:                       wrapper.NewBoolConfig(memory.NewConfig(overrides.enableAirdrops), false),
 			enableAsyncAirdropProcessing:         wrapper.NewBoolConfig(memory.NewConfig(false), false),
 			airdropperOwnerPublicKey:             wrapper.NewStringConfig(memory.NewConfig(defaultAirdropperOwnerPublicKey), defaultAirdropperOwnerPublicKey),
+			swapSubsidizerOwnerPublicKey:         wrapper.NewStringConfig(memory.NewConfig(defaultSwapSubsidizerOwnerPublicKey), defaultSwapSubsidizerOwnerPublicKey),
 			treasuryPoolOneKinBucket:             wrapper.NewStringConfig(memory.NewConfig(overrides.treasuryPoolOneKinBucket), defaultTreasuryPoolName),
 			treasuryPoolTenKinBucket:             wrapper.NewStringConfig(memory.NewConfig(overrides.treasuryPoolTenKinBucket), defaultTreasuryPoolName),
 			treasuryPoolHundredKinBucket:         wrapper.NewStringConfig(memory.NewConfig(overrides.treasuryPoolHundredKinBucket), defaultTreasuryPoolName),
diff --git a/pkg/code/server/grpc/transaction/v2/errors.go b/pkg/code/server/grpc/transaction/v2/errors.go
index 276ca863..f680c138 100644
--- a/pkg/code/server/grpc/transaction/v2/errors.go
+++ b/pkg/code/server/grpc/transaction/v2/errors.go
@@ -10,8 +10,8 @@ import (
 	commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
 	transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
 
-	"github.com/code-payments/code-server/pkg/solana"
 	"github.com/code-payments/code-server/pkg/code/transaction"
+	"github.com/code-payments/code-server/pkg/solana"
 )
 
 const (
@@ -84,6 +84,42 @@ func (e IntentDeniedError) Error() string {
 	return e.message
 }
 
+type SwapValidationError struct {
+	message string
+}
+
+func newSwapValidationError(message string) SwapValidationError {
+	return SwapValidationError{
+		message: message,
+	}
+}
+
+func newSwapValidationErrorf(format string, args ...any) SwapValidationError {
+	return newSwapValidationError(fmt.Sprintf(format, args...))
+}
+
+func (e SwapValidationError) Error() string {
+	return e.message
+}
+
+type SwapDeniedError struct {
+	message string
+}
+
+func newSwapDeniedError(message string) SwapDeniedError {
+	return SwapDeniedError{
+		message: message,
+	}
+}
+
+func newSwapDeniedErrorf(format string, args ...any) SwapDeniedError {
+	return newSwapDeniedError(fmt.Sprintf(format, args...))
+}
+
+func (e SwapDeniedError) Error() string {
+	return e.message
+}
+
 type StaleStateError struct {
 	message string
 }
@@ -209,3 +245,56 @@ func handleSubmitIntentStructuredError(streamer transactionpb.Transaction_Submit
 	}
 	return streamer.Send(errResp)
 }
+
+func handleSwapError(streamer transactionpb.Transaction_SwapServer, err error) error {
+	// gRPC status errors are passed through as is
+	if _, ok := status.FromError(err); ok {
+		return err
+	}
+
+	// Case 1: Errors that map to a Code error response
+	switch err.(type) {
+	case SwapValidationError:
+		return handleSwapStructuredError(
+			streamer,
+			transactionpb.SwapResponse_Error_INVALID_SWAP,
+			toReasonStringErrorDetails(err),
+		)
+	case SwapDeniedError:
+		return handleSwapStructuredError(
+			streamer,
+			transactionpb.SwapResponse_Error_DENIED,
+			toReasonStringErrorDetails(err),
+		)
+	}
+
+	switch err {
+	case ErrInvalidSignature:
+		return handleSwapStructuredError(
+			streamer,
+			transactionpb.SwapResponse_Error_SIGNATURE_ERROR,
+			toReasonStringErrorDetails(err),
+		)
+	case ErrNotImplemented:
+		return status.Error(codes.Unimplemented, err.Error())
+	}
+
+	// Case 2: Errors that map to gRPC status errors
+	switch err {
+	case ErrTimedOutReceivingRequest:
+		return status.Error(codes.DeadlineExceeded, err.Error())
+	}
+	return status.Error(codes.Internal, "rpc server failure")
+}
+
+func handleSwapStructuredError(streamer transactionpb.Transaction_SwapServer, code transactionpb.SwapResponse_Error_Code, errorDetails ...*transactionpb.ErrorDetails) error {
+	errResp := &transactionpb.SwapResponse{
+		Response: &transactionpb.SwapResponse_Error_{
+			Error: &transactionpb.SwapResponse_Error{
+				Code:         code,
+				ErrorDetails: errorDetails,
+			},
+		},
+	}
+	return streamer.Send(errResp)
+}
diff --git a/pkg/code/server/grpc/transaction/v2/intent.go b/pkg/code/server/grpc/transaction/v2/intent.go
index 3b35ff2c..deb5c7ae 100644
--- a/pkg/code/server/grpc/transaction/v2/intent.go
+++ b/pkg/code/server/grpc/transaction/v2/intent.go
@@ -22,7 +22,6 @@ import (
 	messagingpb "github.com/code-payments/code-protobuf-api/generated/go/messaging/v1"
 	transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
 
-	"github.com/code-payments/code-server/pkg/code/chat"
 	chat_util "github.com/code-payments/code-server/pkg/code/chat"
 	"github.com/code-payments/code-server/pkg/code/common"
 	code_data "github.com/code-payments/code-server/pkg/code/data"
@@ -46,7 +45,8 @@ import (
 )
 
 const (
-	// Assumes the client signature index is consistent across all transactions.
+	// Assumes the client signature index is consistent across all transactions,
+	// including those constructed in the SubmitIntent and Swap RPCs.
 	clientSignatureIndex = 1
 )
 
@@ -800,7 +800,7 @@ func (s *transactionServer) SubmitIntent(streamer transactionpb.Transaction_Subm
 		}
 	}
 
-	var chatMessagesToPush []*chat.MessageWithOwner
+	var chatMessagesToPush []*chat_util.MessageWithOwner
 
 	// Save all of the required DB records in one transaction to complete the
 	// intent operation. It's very bad if we end up failing halfway through.
@@ -1025,7 +1025,7 @@ func (s *transactionServer) boundedSubmitIntentRecv(ctx context.Context, streame
 	}()
 
 	select {
-	case <-time.After(s.conf.submitIntentReceiveTimeout.Get(ctx)):
+	case <-time.After(s.conf.clientReceiveTimeout.Get(ctx)):
 		return nil, ErrTimedOutReceivingRequest
 	case <-done:
 		return req, err
diff --git a/pkg/code/server/grpc/transaction/v2/intent_test.go b/pkg/code/server/grpc/transaction/v2/intent_test.go
index d131ac01..e6563a44 100644
--- a/pkg/code/server/grpc/transaction/v2/intent_test.go
+++ b/pkg/code/server/grpc/transaction/v2/intent_test.go
@@ -2696,7 +2696,7 @@ func TestSubmitIntent_InvalidNumberOfSignaturesSubmitted(t *testing.T) {
 
 func TestSubmitIntent_TimeBoundedRequestSend(t *testing.T) {
 	server, phone, _, cleanup := setupTestEnv(t, &testOverrides{
-		submitIntentReceiveTimeout: 100 * time.Millisecond,
+		clientReceiveTimeout: 100 * time.Millisecond,
 	})
 	defer cleanup()
 
diff --git a/pkg/code/server/grpc/transaction/v2/server.go b/pkg/code/server/grpc/transaction/v2/server.go
index cb1660dd..3b852327 100644
--- a/pkg/code/server/grpc/transaction/v2/server.go
+++ b/pkg/code/server/grpc/transaction/v2/server.go
@@ -15,6 +15,7 @@ import (
 	code_data "github.com/code-payments/code-server/pkg/code/data"
 	"github.com/code-payments/code-server/pkg/code/lawenforcement"
 	"github.com/code-payments/code-server/pkg/code/server/grpc/messaging"
+	"github.com/code-payments/code-server/pkg/jupiter"
 	"github.com/code-payments/code-server/pkg/kin"
 	push_lib "github.com/code-payments/code-server/pkg/push"
 	sync_util "github.com/code-payments/code-server/pkg/sync"
@@ -30,10 +31,11 @@ type transactionServer struct {
 
 	pusher push_lib.Provider
 
-	maxmind *maxminddb.Reader
+	jupiterClient *jupiter.Client
 
 	messagingClient messaging.InternalMessageClient
 
+	maxmind       *maxminddb.Reader
 	antispamGuard *antispam.Guard
 	amlGuard      *lawenforcement.AntiMoneyLaunderingGuard
 
@@ -53,6 +55,8 @@ type transactionServer struct {
 	airdropperLock sync.Mutex
 	airdropper     *common.TimelockAccounts
 
+	swapSubsidizer *common.Account
+
 	feeCollector *common.Account
 
 	transactionpb.UnimplementedTransactionServer
@@ -61,9 +65,10 @@ type transactionServer struct {
 func NewTransactionServer(
 	data code_data.Provider,
 	pusher push_lib.Provider,
-	antispamGuard *antispam.Guard,
-	maxmind *maxminddb.Reader,
+	jupiterClient *jupiter.Client,
 	messagingClient messaging.InternalMessageClient,
+	maxmind *maxminddb.Reader,
+	antispamGuard *antispam.Guard,
 	configProvider ConfigProvider,
 ) transactionpb.TransactionServer {
 	ctx := context.Background()
@@ -82,10 +87,11 @@ func NewTransactionServer(
 
 		pusher: pusher,
 
-		maxmind: maxmind,
+		jupiterClient: jupiterClient,
 
 		messagingClient: messagingClient,
 
+		maxmind:       maxmind,
 		antispamGuard: antispamGuard,
 		amlGuard:      lawenforcement.NewAntiMoneyLaunderingGuard(data),
 
@@ -123,6 +129,11 @@ func NewTransactionServer(
 		s.mustLoadAirdropper(ctx)
 	}
 
+	swapSubsidizer := s.conf.swapSubsidizerOwnerPublicKey.Get(ctx)
+	if len(swapSubsidizer) > 0 && swapSubsidizer != defaultSwapSubsidizerOwnerPublicKey {
+		s.mustLoadSwapSubsidizer(ctx)
+	}
+
 	feeCollector, err := common.NewAccountFromPublicKeyString(conf.feeCollectorTokenPublicKey.Get(ctx))
 	if err != nil {
 		s.log.WithError(err).Fatal("failure loading fee collector account")
diff --git a/pkg/code/server/grpc/transaction/v2/swap.go b/pkg/code/server/grpc/transaction/v2/swap.go
new file mode 100644
index 00000000..0cd7624d
--- /dev/null
+++ b/pkg/code/server/grpc/transaction/v2/swap.go
@@ -0,0 +1,497 @@
+package transaction_v2
+
+import (
+	"bytes"
+	"context"
+	"crypto/ed25519"
+	"time"
+
+	"github.com/mr-tron/base58"
+	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
+	transactionpb "github.com/code-payments/code-protobuf-api/generated/go/transaction/v2"
+
+	"github.com/code-payments/code-server/pkg/code/common"
+	"github.com/code-payments/code-server/pkg/code/data/account"
+	currency_lib "github.com/code-payments/code-server/pkg/currency"
+	"github.com/code-payments/code-server/pkg/grpc/client"
+	"github.com/code-payments/code-server/pkg/jupiter"
+	"github.com/code-payments/code-server/pkg/kin"
+	"github.com/code-payments/code-server/pkg/solana"
+	compute_budget "github.com/code-payments/code-server/pkg/solana/computebudget"
+	swap_validator "github.com/code-payments/code-server/pkg/solana/swapvalidator"
+	"github.com/code-payments/code-server/pkg/solana/token"
+	"github.com/code-payments/code-server/pkg/usdc"
+)
+
+func (s *transactionServer) Swap(streamer transactionpb.Transaction_SwapServer) error {
+	ctx, cancel := context.WithTimeout(streamer.Context(), s.conf.swapTimeout.Get(streamer.Context()))
+	defer cancel()
+
+	log := s.log.WithField("method", "Swap")
+	log = log.WithContext(ctx)
+	log = client.InjectLoggingMetadata(ctx, log)
+
+	if s.swapSubsidizer == nil {
+		log.Warn("swap subsidizer is not configured")
+		return handleSwapError(streamer, status.Error(codes.Unavailable, ""))
+	}
+
+	req, err := s.boundedSwapRecv(ctx, streamer)
+	if err != nil {
+		log.WithError(err).Info("error receiving request from client")
+		return err
+	}
+
+	// Client starts a swap by sending the initiation request
+	initiateReq := req.GetInitiate()
+	if initiateReq == nil {
+		return handleSwapError(streamer, status.Error(codes.InvalidArgument, "SwapRequest.Initiate is nil"))
+	}
+
+	owner, err := common.NewAccountFromProto(initiateReq.Owner)
+	if err != nil {
+		log.WithError(err).Warn("invalid owner account")
+		return handleSwapError(streamer, err)
+	}
+	log = log.WithField("owner", owner.PublicKey().ToBase58())
+
+	signature := initiateReq.Signature
+	initiateReq.Signature = nil
+	if err := s.auth.Authenticate(ctx, owner, initiateReq, signature); err != nil {
+		return err
+	}
+
+	//
+	// Section: Antispam
+	//
+
+	allow, err := s.antispamGuard.AllowSwap(ctx, owner)
+	if err != nil {
+		return handleSwapError(streamer, err)
+	} else if !allow {
+		return handleSwapError(streamer, newSwapDeniedError("rate limited"))
+	}
+
+	//
+	// Section: Swap parameter setup (accounts, balances, etc.)
+	//
+
+	swapAuthority, err := common.NewAccountFromProto(initiateReq.SwapAuthority)
+	if err != nil {
+		log.WithError(err).Warn("invalid swap authority")
+		return handleSwapError(streamer, err)
+	}
+
+	usdcAtaBytes, err := token.GetAssociatedAccount(swapAuthority.PublicKey().ToBytes(), usdc.TokenMint)
+	if err != nil {
+		log.WithError(err).Warn("failure deriving usdc ata")
+		return handleSwapError(streamer, err)
+	}
+	swapSource, err := common.NewAccountFromPublicKeyBytes(usdcAtaBytes)
+	if err != nil {
+		log.WithError(err).Warn("invalid usdc ata")
+		return handleSwapError(streamer, err)
+	}
+	log = log.WithField("swap_source", swapSource.PublicKey().ToBase58())
+
+	accountInfoRecord, err := s.data.GetAccountInfoByAuthorityAddress(ctx, owner.PublicKey().ToBase58())
+	switch err {
+	case nil:
+		if accountInfoRecord.AccountType != commonpb.AccountType_PRIMARY {
+			// Should never happen if we're doing phone number validation against
+			// the owner account.
+			return handleSwapError(streamer, newSwapValidationError("owner must be authority to primary account"))
+		}
+	case account.ErrAccountInfoNotFound:
+		return handleSwapError(streamer, newSwapValidationError("must submit open accounts intent"))
+	default:
+		log.WithError(err).Warn("failure getting account info record")
+		return handleSwapError(streamer, err)
+	}
+
+	swapDestination, err := common.NewAccountFromPublicKeyString(accountInfoRecord.TokenAccount)
+	if err != nil {
+		log.WithError(err).Warn("invalid kin primary account")
+		return handleSwapError(streamer, err)
+	}
+	log = log.WithField("swap_destination", swapDestination.PublicKey().ToBase58())
+
+	swapSourceBalance, err := s.data.GetBlockchainBalance(ctx, swapSource.PublicKey().ToBase58())
+	if err != nil {
+		log.WithError(err).Warn("failure getting swap source account balance")
+		return handleSwapError(streamer, err)
+	}
+
+	var amountToSwap uint64
+	if initiateReq.Limit == 0 {
+		amountToSwap = swapSourceBalance
+	} else {
+		amountToSwap = initiateReq.Limit
+	}
+	if amountToSwap == 0 {
+		return handleSwapError(streamer, newSwapValidationError("usdc account balance is 0"))
+	} else if swapSourceBalance < amountToSwap {
+		return handleSwapError(streamer, newSwapValidationError("insufficient usdc balance"))
+	}
+	log = log.WithField("amount_to_swap", amountToSwap)
+
+	//
+	// Section: Jupiter routing
+	//
+
+	quote, err := s.jupiterClient.GetQuote(
+		ctx,
+		usdc.Mint,
+		kin.Mint,
+		amountToSwap,
+		50,   // todo: configurable slippage or something based on liquidity?
+		true, // Direct routes for now since we're using legacy instructions
+		16,   // Max accounts limited due to the use of legacy instructions
+		true, // Force legacy instructions
+	)
+	if err != nil {
+		log.WithError(err).Warn("failure getting quote from jupiter")
+		return handleSwapError(streamer, err)
+	}
+
+	jupiterSwapIxns, err := s.jupiterClient.GetSwapInstructions(
+		ctx,
+		quote,
+		swapAuthority.PublicKey().ToBase58(),
+		swapDestination.PublicKey().ToBase58(),
+	)
+	if err != nil {
+		log.WithError(err).Warn("failure getting swap instructions from jupiter")
+		return handleSwapError(streamer, err)
+	}
+
+	log = log.WithField("estimated_amount_to_receive", quote.GetEstimatedSwapAmount())
+
+	//
+	// Section: Validation
+	//
+
+	if err := s.validateSwap(ctx, amountToSwap, quote, jupiterSwapIxns); err != nil {
+		switch err.(type) {
+		case SwapValidationError:
+			log.WithError(err).Warn("swap failed validation")
+		default:
+			log.WithError(err).Warn("failure performing swap validation")
+		}
+		return handleSwapError(streamer, err)
+	}
+
+	//
+	// Section: Transaction construction
+	//
+
+	swapNonce, err := common.NewRandomAccount()
+	if err != nil {
+		log.WithError(err).Warn("failure generating swap nonce")
+		return handleSwapError(streamer, err)
+	}
+
+	preSwapState, preSwapStateBump, err := swap_validator.GetPreSwapStateAddress(&swap_validator.GetPreSwapStateAddressArgs{
+		Source:      swapSource.PublicKey().ToBytes(),
+		Destination: swapDestination.PublicKey().ToBytes(),
+		Nonce:       swapNonce.PublicKey().ToBytes(),
+	})
+	if err != nil {
+		log.WithError(err).Warn("failure deriving pre swap state account address")
+		return handleSwapError(streamer, err)
+	}
+
+	var remainingAccountsToValidate []swap_validator.AccountMeta
+	for _, accountMeta := range jupiterSwapIxns.SwapInstruction.Accounts {
+		if accountMeta.IsWritable || accountMeta.IsSigner {
+			if bytes.Equal(accountMeta.PublicKey, swapAuthority.PublicKey().ToBytes()) ||
+				bytes.Equal(accountMeta.PublicKey, swapSource.PublicKey().ToBytes()) ||
+				bytes.Equal(accountMeta.PublicKey, swapDestination.PublicKey().ToBytes()) {
+				continue
+			}
+
+			remainingAccountsToValidate = append(remainingAccountsToValidate, swap_validator.AccountMeta{
+				PublicKey: accountMeta.PublicKey,
+			})
+		}
+	}
+
+	preSwapIxn := swap_validator.NewPreSwapInstruction(
+		&swap_validator.PreSwapInstructionAccounts{
+			PreSwapState:      preSwapState,
+			User:              swapAuthority.PublicKey().ToBytes(),
+			Source:            swapSource.PublicKey().ToBytes(),
+			Destination:       swapDestination.PublicKey().ToBytes(),
+			Nonce:             swapNonce.PublicKey().ToBytes(),
+			Payer:             s.swapSubsidizer.PublicKey().ToBytes(),
+			RemainingAccounts: remainingAccountsToValidate,
+		},
+		&swap_validator.PreSwapInstructionArgs{},
+	).ToLegacyInstruction()
+
+	postSwapIxn := swap_validator.NewPostSwapInstruction(
+		&swap_validator.PostSwapInstructionAccounts{
+			PreSwapState: preSwapState,
+			Source:       swapSource.PublicKey().ToBytes(),
+			Destination:  swapDestination.PublicKey().ToBytes(),
+			Payer:        s.swapSubsidizer.PublicKey().ToBytes(),
+		},
+		&swap_validator.PostSwapInstructionArgs{
+			StateBump:    preSwapStateBump,
+			MaxToSend:    amountToSwap,
+			MinToReceive: quote.GetEstimatedSwapAmount(),
+		},
+	).ToLegacyInstruction()
+
+	var ixns []solana.Instruction
+	ixns = append(ixns, jupiterSwapIxns.ComputeBudgetInstructions...)
+	ixns = append(ixns, preSwapIxn, jupiterSwapIxns.SwapInstruction, postSwapIxn)
+
+	txn := solana.NewTransaction(s.swapSubsidizer.PublicKey().ToBytes(), ixns...)
+
+	blockhash, err := s.data.GetBlockchainLatestBlockhash(ctx)
+	if err != nil {
+		log.WithError(err).Warn("failure getting latest blockhash")
+		return handleSwapError(streamer, err)
+	}
+	txn.SetBlockhash(blockhash)
+
+	//
+	// Section: Server parameters
+	//
+
+	computeUnitLimit, _ := compute_budget.DecompileSetComputeUnitLimitIxnData(jupiterSwapIxns.ComputeBudgetInstructions[0].Data)
+	computeUnitPrice, _ := compute_budget.DecompileSetComputeUnitPriceIxnData(jupiterSwapIxns.ComputeBudgetInstructions[1].Data)
+
+	var protoSwapIxnAccounts []*commonpb.InstructionAccount
+	for _, ixnAccount := range jupiterSwapIxns.SwapInstruction.Accounts {
+		protoSwapIxnAccounts = append(protoSwapIxnAccounts, &commonpb.InstructionAccount{
+			Account:    &commonpb.SolanaAccountId{Value: ixnAccount.PublicKey},
+			IsSigner:   ixnAccount.IsSigner,
+			IsWritable: ixnAccount.IsWritable,
+		})
+	}
+
+	// Server responds back with parameters, so client can locally construct the
+	// transaction and validate it.
+	serverParameters := &transactionpb.SwapResponse{
+		Response: &transactionpb.SwapResponse_ServerParamenters{
+			ServerParamenters: &transactionpb.SwapResponse_ServerParameters{
+				Payer:            s.swapSubsidizer.ToProto(),
+				RecentBlockhash:  &commonpb.Blockhash{Value: blockhash[:]},
+				ComputeUnitLimit: computeUnitLimit,
+				ComputeUnitPrice: computeUnitPrice,
+				SwapProgram:      &commonpb.SolanaAccountId{Value: jupiterSwapIxns.SwapInstruction.Program},
+				SwapIxnAccounts:  protoSwapIxnAccounts,
+				SwapIxnData:      jupiterSwapIxns.SwapInstruction.Data,
+				MaxToSend:        amountToSwap,
+				MinToReceive:     quote.GetEstimatedSwapAmount(),
+				Nonce:            swapNonce.ToProto(),
+			},
+		},
+	}
+	if err := streamer.Send(serverParameters); err != nil {
+		return handleSwapError(streamer, err)
+	}
+
+	//
+	// Section: Transaction signing
+	//
+
+	req, err = s.boundedSwapRecv(ctx, streamer)
+	if err != nil {
+		log.WithError(err).Info("error receiving request from client")
+		return err
+	}
+
+	// Client responds back with a signatures to the swap transaction
+	submitSignatureReq := req.GetSubmitSignature()
+	if submitSignatureReq == nil {
+		return handleSwapError(streamer, status.Error(codes.InvalidArgument, "SwapRequest.SubmitSignature is nil"))
+	}
+
+	if !ed25519.Verify(
+		swapAuthority.PublicKey().ToBytes(),
+		txn.Message.Marshal(),
+		submitSignatureReq.Signature.Value,
+	) {
+		return handleSwapStructuredError(
+			streamer,
+			transactionpb.SwapResponse_Error_SIGNATURE_ERROR,
+			toInvalidSignatureErrorDetails(0, txn, submitSignatureReq.Signature),
+		)
+	}
+
+	copy(txn.Signatures[clientSignatureIndex][:], submitSignatureReq.Signature.Value)
+	txn.Sign(s.swapSubsidizer.PrivateKey().ToBytes())
+
+	log = log.WithField("transaction_id", base58.Encode(txn.Signature()))
+
+	//
+	// Section: Transaction submission
+	//
+
+	_, err = s.data.SubmitBlockchainTransaction(ctx, &txn)
+	if err != nil {
+		log.WithError(err).Warn("failure submitting transaction")
+		return handleSwapStructuredError(
+			streamer,
+			transactionpb.SwapResponse_Error_SWAP_FAILED,
+			toReasonStringErrorDetails(err),
+		)
+	}
+
+	log.Debug("submitted transaction")
+
+	if !initiateReq.WaitForBlockchainStatus {
+		err = streamer.Send(&transactionpb.SwapResponse{
+			Response: &transactionpb.SwapResponse_Success_{
+				Success: &transactionpb.SwapResponse_Success{
+					Code: transactionpb.SwapResponse_Success_SWAP_SUBMITTED,
+				},
+			},
+		})
+		return handleSwapError(streamer, err)
+	}
+
+	for {
+		time.Sleep(time.Second)
+
+		statuses, err := s.data.GetBlockchainSignatureStatuses(ctx, []solana.Signature{solana.Signature(txn.Signature())})
+		if err != nil {
+			continue
+		}
+
+		if len(statuses) == 0 || statuses[0] == nil {
+			continue
+		}
+
+		if statuses[0].ErrorResult != nil {
+			log.WithError(statuses[0].ErrorResult).Warn("transaction failed")
+			return handleSwapStructuredError(streamer, transactionpb.SwapResponse_Error_SWAP_FAILED)
+		}
+
+		if statuses[0].Finalized() {
+			log.Debug("transaction succeeded and is finalized")
+			err = streamer.Send(&transactionpb.SwapResponse{
+				Response: &transactionpb.SwapResponse_Success_{
+					Success: &transactionpb.SwapResponse_Success{
+						Code: transactionpb.SwapResponse_Success_SWAP_FINALIZED,
+					},
+				},
+			})
+			return handleSwapError(streamer, err)
+		}
+	}
+}
+
+func (s *transactionServer) validateSwap(
+	ctx context.Context,
+	amountToSwap uint64,
+	quote *jupiter.Quote,
+	ixns *jupiter.SwapInstructions,
+) error {
+	//
+	// Part 1: Expected instructions sanity check
+	//
+
+	if len(ixns.ComputeBudgetInstructions) != 2 {
+		return newSwapValidationError("expected two compute budget instructions")
+	}
+
+	if len(ixns.SetupInstructions) != 0 || ixns.TokenLedgerInstruction != nil || ixns.CleanupInstruction != nil {
+		return newSwapValidationError("unexpected instruction")
+	}
+
+	//
+	// Part 2: Compute budget instructions
+	//
+
+	if !bytes.Equal(ixns.ComputeBudgetInstructions[0].Program, compute_budget.ProgramKey) || !bytes.Equal(ixns.ComputeBudgetInstructions[1].Program, compute_budget.ProgramKey) {
+		return newSwapValidationError("invalid ComputeBudget program key")
+	}
+
+	if len(ixns.ComputeBudgetInstructions[0].Accounts) != 0 || len(ixns.ComputeBudgetInstructions[1].Accounts) != 0 {
+		return newSwapValidationError("invalid ComputeBudget instruction accounts")
+	}
+
+	if _, err := compute_budget.DecompileSetComputeUnitLimitIxnData(ixns.ComputeBudgetInstructions[0].Data); err != nil {
+		return newSwapValidationErrorf("invalid ComputeBudget::SetComputeUnitLimit instruction data: %s", err.Error())
+	}
+
+	if _, err := compute_budget.DecompileSetComputeUnitPriceIxnData(ixns.ComputeBudgetInstructions[1].Data); err != nil {
+		return newSwapValidationErrorf("invalid ComputeBudget::SetComputeUnitPrice instruction data: %s", err.Error())
+	}
+
+	//
+	// Part 3: Swap instruction
+	//
+
+	for _, ixnAccount := range ixns.SwapInstruction.Accounts {
+		if bytes.Equal(ixnAccount.PublicKey, s.swapSubsidizer.PublicKey().ToBytes()) {
+			return newSwapValidationError("swap subsidizer used in swap instruction")
+		}
+	}
+
+	usdcAmount := float64(amountToSwap) / float64(usdc.QuarksPerUsdc)
+	kinAmount := float64(quote.GetEstimatedSwapAmount()) / float64(kin.QuarksPerKin)
+	swapRate := usdcAmount / kinAmount
+
+	usdExchangeRateRateRecord, err := s.data.GetExchangeRate(ctx, currency_lib.USD, time.Now())
+	if err != nil {
+		return errors.Wrap(err, "error getting usd exchange rate record")
+	}
+
+	// todo: configurable
+	swapRateThreshold := 1.25
+	if swapRate/usdExchangeRateRateRecord.Rate > swapRateThreshold {
+		return newSwapValidationErrorf("swap rate exceeds current exchange rate by %.2fx", swapRateThreshold)
+	}
+
+	return nil
+}
+
+func (s *transactionServer) mustLoadSwapSubsidizer(ctx context.Context) {
+	log := s.log.WithFields(logrus.Fields{
+		"method": "mustLoadSwapSubsidizer",
+		"key":    s.conf.swapSubsidizerOwnerPublicKey.Get(ctx),
+	})
+
+	err := func() error {
+		vaultRecord, err := s.data.GetKey(ctx, s.conf.swapSubsidizerOwnerPublicKey.Get(ctx))
+		if err != nil {
+			return err
+		}
+
+		ownerAccount, err := common.NewAccountFromPrivateKeyString(vaultRecord.PrivateKey)
+		if err != nil {
+			return err
+		}
+
+		s.swapSubsidizer = ownerAccount
+		return nil
+	}()
+	if err != nil {
+		log.WithError(err).Fatal("failure loading account")
+	}
+}
+
+func (s *transactionServer) boundedSwapRecv(ctx context.Context, streamer transactionpb.Transaction_SwapServer) (req *transactionpb.SwapRequest, err error) {
+	done := make(chan struct{})
+	go func() {
+		req, err = streamer.Recv()
+		close(done)
+	}()
+
+	select {
+	case <-time.After(s.conf.clientReceiveTimeout.Get(ctx)):
+		return nil, ErrTimedOutReceivingRequest
+	case <-done:
+		return req, err
+	}
+}
diff --git a/pkg/code/server/grpc/transaction/v2/testutil.go b/pkg/code/server/grpc/transaction/v2/testutil.go
index c1bbab13..ec2e40be 100644
--- a/pkg/code/server/grpc/transaction/v2/testutil.go
+++ b/pkg/code/server/grpc/transaction/v2/testutil.go
@@ -75,8 +75,8 @@ import (
 func setupTestEnv(t *testing.T, serverOverrides *testOverrides) (serverTestEnv, phoneTestEnv, phoneTestEnv, func()) {
 	var err error
 
-	if serverOverrides.submitIntentReceiveTimeout == 0 {
-		serverOverrides.submitIntentReceiveTimeout = defaultSubmitIntentReceiveTimeout
+	if serverOverrides.clientReceiveTimeout == 0 {
+		serverOverrides.clientReceiveTimeout = defaultClientReceiveTimeout
 	}
 
 	db := code_data.NewTestDataProvider()
@@ -178,9 +178,10 @@ func setupTestEnv(t *testing.T, serverOverrides *testOverrides) (serverTestEnv,
 	testService := NewTransactionServer(
 		db,
 		memory_push.NewPushProvider(),
-		antispam.NewGuard(db, memory_device_verifier.NewMemoryDeviceVerifier(), nil),
 		nil,
 		messaging.NewMessagingClient(db),
+		nil,
+		antispam.NewGuard(db, memory_device_verifier.NewMemoryDeviceVerifier(), nil),
 		withManualTestOverrides(serverOverrides),
 	)
 	grpcTestServer.RegisterService(func(server *grpc.Server) {
diff --git a/pkg/jupiter/client.go b/pkg/jupiter/client.go
index 28b3d7a2..a4552e25 100644
--- a/pkg/jupiter/client.go
+++ b/pkg/jupiter/client.go
@@ -13,6 +13,7 @@ import (
 	"github.com/mr-tron/base58"
 	"github.com/pkg/errors"
 
+	"github.com/code-payments/code-server/pkg/metrics"
 	"github.com/code-payments/code-server/pkg/solana"
 )
 
@@ -23,6 +24,8 @@ const (
 
 	quoteEndpointName            = "quote"
 	swapInstructionsEndpointName = "swap-instructions"
+
+	metricsStructName = "jupiter.client"
 )
 
 type Client struct {
@@ -59,6 +62,9 @@ func (c *Client) GetQuote(
 	maxAccounts uint8,
 	useLegacyInstruction bool,
 ) (*Quote, error) {
+	tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetQuote")
+	defer tracer.End()
+
 	url := fmt.Sprintf(
 		"%s%s?inputMint=%s&outputMint=%s&amount=%d&slippageBps=%d&onlyDirectRoutes=%v&maxAccounts=%d&asLegacyTransaction=%v",
 		c.baseUrl,
@@ -120,19 +126,20 @@ func (c *Client) GetSwapInstructions(
 	quote *Quote,
 	owner string,
 	destinationTokenAccount string,
-	pricePerComputeUnit uint64,
 ) (*SwapInstructions, error) {
+	tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetSwapInstructions")
+	defer tracer.End()
+
 	if !quote.useLegacyInstructions {
 		return nil, errors.New("only legacy transactions are supported")
 	}
 
 	// todo: struct this
 	reqBody := fmt.Sprintf(
-		`{"quoteResponse": %s, "userPublicKey": "%s", "destinationTokenAccount": "%s", "computeUnitPriceMicroLamports": %d, "asLegacyTransaction": %v}`,
+		`{"quoteResponse": %s, "userPublicKey": "%s", "destinationTokenAccount": "%s", "prioritizationFeeLamports": "auto", "asLegacyTransaction": %v}`,
 		quote.jsonString,
 		owner,
 		destinationTokenAccount,
-		pricePerComputeUnit,
 		quote.useLegacyInstructions,
 	)
 
diff --git a/pkg/solana/computebudget/program.go b/pkg/solana/computebudget/program.go
new file mode 100644
index 00000000..b93de284
--- /dev/null
+++ b/pkg/solana/computebudget/program.go
@@ -0,0 +1,65 @@
+package compute_budget
+
+import (
+	"crypto/ed25519"
+	"encoding/binary"
+	"errors"
+
+	"github.com/code-payments/code-server/pkg/solana"
+)
+
+// ComputeBudget111111111111111111111111111111
+var ProgramKey = ed25519.PublicKey{3, 6, 70, 111, 229, 33, 23, 50, 255, 236, 173, 186, 114, 195, 155, 231, 188, 140, 229, 187, 197, 247, 18, 107, 44, 67, 155, 58, 64, 0, 0, 0}
+
+const (
+	commandRequestUnits uint8 = iota
+	commandRequestHeapFrame
+	commandSetComputeUnitLimit
+	commandSetComputeUnitPrice
+)
+
+func SetComputeUnitLimit(computeUnitLimit uint32) solana.Instruction {
+	data := make([]byte, 1+4)
+	data[0] = commandSetComputeUnitLimit
+	binary.LittleEndian.PutUint32(data[1:], computeUnitLimit)
+
+	return solana.NewInstruction(
+		ProgramKey[:],
+		data,
+	)
+}
+
+func SetComputeUnitPrice(computeUnitPrice uint64) solana.Instruction {
+	data := make([]byte, 1+8)
+	data[0] = commandSetComputeUnitPrice
+	binary.LittleEndian.PutUint64(data[1:], computeUnitPrice)
+
+	return solana.NewInstruction(
+		ProgramKey[:],
+		data,
+	)
+}
+
+func DecompileSetComputeUnitLimitIxnData(data []byte) (uint32, error) {
+	if len(data) != 5 {
+		return 0, errors.New("invalid length")
+	}
+
+	if data[0] != commandSetComputeUnitLimit {
+		return 0, errors.New("invalid instruction")
+	}
+
+	return binary.LittleEndian.Uint32(data[1:]), nil
+}
+
+func DecompileSetComputeUnitPriceIxnData(data []byte) (uint64, error) {
+	if len(data) != 9 {
+		return 0, errors.New("invalid length")
+	}
+
+	if data[0] != commandSetComputeUnitPrice {
+		return 0, errors.New("invalid instruction")
+	}
+
+	return binary.LittleEndian.Uint64(data[1:]), nil
+}
diff --git a/pkg/usdc/usdc.go b/pkg/usdc/usdc.go
new file mode 100644
index 00000000..ddf6d06a
--- /dev/null
+++ b/pkg/usdc/usdc.go
@@ -0,0 +1,15 @@
+package usdc
+
+import (
+	"crypto/ed25519"
+)
+
+const (
+	Mint          = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+	QuarksPerUsdc = 1000000
+	Decimals      = 6
+)
+
+var (
+	TokenMint = ed25519.PublicKey{198, 250, 122, 243, 190, 219, 173, 58, 61, 101, 243, 106, 171, 201, 116, 49, 177, 187, 228, 194, 210, 246, 224, 228, 124, 166, 2, 3, 69, 47, 93, 97}
+)