Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.59.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
github.com/code-payments/code-vm-indexer v1.2.0
github.com/code-payments/ocp-protobuf-api v1.14.0
github.com/code-payments/ocp-protobuf-api v1.14.1-0.20260814155826-8088d9d58830
github.com/emirpasic/gods v1.12.0
github.com/envoyproxy/protoc-gen-validate v1.3.3
github.com/golang/protobuf v1.5.4
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0=
github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8=
github.com/code-payments/ocp-protobuf-api v1.14.0 h1:Q0IqyF4q31Mf+wvOILz+BM6P+xOnrPVAm/85/YolKDk=
github.com/code-payments/ocp-protobuf-api v1.14.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8=
github.com/code-payments/ocp-protobuf-api v1.14.1-0.20260814155826-8088d9d58830 h1:PVX61XNEbm8iBKAUw6i+NN04qOX9bbenkpRWs0R6CCc=
github.com/code-payments/ocp-protobuf-api v1.14.1-0.20260814155826-8088d9d58830/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=
Expand Down
83 changes: 83 additions & 0 deletions ocp/currency/fee.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package currency

import (
"math/big"

"github.com/code-payments/ocp-server/solana/currencycreator"
)

const (
// BuyFeeBps is the fee charged when buying a launchpad currency with the
// core mint, which mirrors currencycreator.DefaultSellFeeBps.
BuyFeeBps = currencycreator.DefaultSellFeeBps

// FeeQuarkTolerance is the maximum difference allowed between a client
// provided fee amount and the server computed value. It absorbs rounding
// differences between client and server.
FeeQuarkTolerance = 1

bpsPerUnit = 10_000

// sellFeeRemainder is the fraction of a swap's value that remains after
// the launchpad sell fee is taken by the liquidity pool.
sellFeeRemainder = 1 - currencycreator.DefaultSellFeeBps/float64(bpsPerUnit)
)

// ExpectedBuyFeeQuarks returns the fee in quarks charged on a buy of the
// provided quark amount.
func ExpectedBuyFeeQuarks(swapAmount uint64) uint64 {
fee := new(big.Int).Mul(
new(big.Int).SetUint64(swapAmount),
big.NewInt(BuyFeeBps),
)
return fee.Div(fee, big.NewInt(bpsPerUnit)).Uint64()
}

// IsExpectedFeeQuarks returns whether a client provided fee amount matches the
// server computed value within FeeQuarkTolerance.
func IsExpectedFeeQuarks(actual, expected uint64) bool {
if actual > expected {
return actual-expected <= FeeQuarkTolerance
}
return expected-actual <= FeeQuarkTolerance
}

// DiscountValueForBuyFee scales a value quoted over the full amount funding a
// swap (swap plus fee) down to the portion that was actually swapped.
func DiscountValueForBuyFee(value float64, swapAmount, feeAmount uint64) float64 {
if feeAmount == 0 {
return value
}

swapAmountBig := new(big.Float).SetPrec(defaultPrecision).SetUint64(swapAmount)
feeAmountBig := new(big.Float).SetPrec(defaultPrecision).SetUint64(feeAmount)
fundedAmountBig := new(big.Float).Add(swapAmountBig, feeAmountBig)
if fundedAmountBig.Sign() == 0 {
return value
}

discounted, _ := new(big.Float).Mul(
big.NewFloat(value).SetPrec(defaultPrecision),
new(big.Float).Quo(swapAmountBig, fundedAmountBig),
).Float64()
return discounted
}

// ApplySellFee returns the value remaining after the launchpad sell fee.
func ApplySellFee(value float64) float64 {
discounted, _ := new(big.Float).Mul(
big.NewFloat(sellFeeRemainder).SetPrec(defaultPrecision),
big.NewFloat(value).SetPrec(defaultPrecision),
).Float64()
return discounted
}

// GrossUpSellFee returns the value prior to the launchpad sell fee being taken,
// which is the inverse of ApplySellFee.
func GrossUpSellFee(value float64) float64 {
grossedUp, _ := new(big.Float).Quo(
big.NewFloat(value).SetPrec(defaultPrecision),
big.NewFloat(sellFeeRemainder).SetPrec(defaultPrecision),
).Float64()
return grossedUp
}
81 changes: 81 additions & 0 deletions ocp/currency/fee_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package currency

import (
"math"
"math/big"
"testing"

"github.com/stretchr/testify/assert"

"github.com/code-payments/ocp-server/ocp/common"
)

func TestExpectedBuyFeeQuarks(t *testing.T) {
for _, tc := range []struct {
swapAmount uint64
expected uint64
}{
{swapAmount: 0, expected: 0},
{swapAmount: 99, expected: 0},
{swapAmount: 100, expected: 1},
{swapAmount: 199, expected: 1},
{swapAmount: 10 * common.CoreMintQuarksPerUnit, expected: common.CoreMintQuarksPerUnit / 10},
{swapAmount: 12_345_678, expected: 123_456},

// The maximum swap amount doesn't overflow
{swapAmount: math.MaxUint64, expected: math.MaxUint64 / 100},
} {
assert.Equal(t, tc.expected, ExpectedBuyFeeQuarks(tc.swapAmount))
}
}

func TestIsExpectedFeeQuarks(t *testing.T) {
assert.True(t, IsExpectedFeeQuarks(100, 100))
assert.True(t, IsExpectedFeeQuarks(99, 100))
assert.True(t, IsExpectedFeeQuarks(101, 100))

assert.False(t, IsExpectedFeeQuarks(98, 100))
assert.False(t, IsExpectedFeeQuarks(102, 100))

assert.True(t, IsExpectedFeeQuarks(0, 0))
assert.True(t, IsExpectedFeeQuarks(1, 0))
assert.False(t, IsExpectedFeeQuarks(2, 0))
}

func TestDiscountValueForBuyFee(t *testing.T) {
// Without a fee, the value is untouched
assert.Equal(t, 10.0, DiscountValueForBuyFee(10.0, 10*common.CoreMintQuarksPerUnit, 0))

// A $10.10 payment buying $10.00 of a currency is valued at the swap amount
swapAmount := 10 * common.CoreMintQuarksPerUnit
feeAmount := ExpectedBuyFeeQuarks(swapAmount)
fundedValue := float64(swapAmount+feeAmount) / float64(common.CoreMintQuarksPerUnit)
assert.InDelta(t, 10.0, DiscountValueForBuyFee(fundedValue, swapAmount, feeAmount), 1e-9)

// The discount is proportional regardless of the exchange currency
assert.InDelta(t, 100.0, DiscountValueForBuyFee(101.0, 10_000, 100), 1e-9)
}

func TestSellFeeRoundTrip(t *testing.T) {
for _, value := range []float64{0.01, 1.0, 12.34, 1_000_000.0} {
assert.InDelta(t, value, GrossUpSellFee(ApplySellFee(value)), 1e-9)
}
}

// The sell fee was previously applied with hardcoded 0.99 literals. The helpers
// must remain bit-for-bit identical so reported values don't shift.
func TestSellFeeMatchesLegacyLiterals(t *testing.T) {
for _, value := range []float64{0.01, 1.0, 12.34, 99.99, 1_000_000.0} {
legacyApplied, _ := new(big.Float).Mul(
big.NewFloat(0.99).SetPrec(128),
big.NewFloat(value).SetPrec(128),
).Float64()
assert.Equal(t, legacyApplied, ApplySellFee(value))

legacyGrossedUp, _ := new(big.Float).Quo(
big.NewFloat(value).SetPrec(128),
big.NewFloat(0.99).SetPrec(128),
).Float64()
assert.Equal(t, legacyGrossedUp, GrossUpSellFee(value))
}
}
16 changes: 15 additions & 1 deletion ocp/rpc/transaction/stateful_swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,21 @@ func (s *transactionServer) handleReserveStatefulSwap(
return handleStatefulSwapError(streamer, NewSwapValidationError("owner cannot be swap authority"))
}

// A buy fee is only charged when buying a currency with the core mint.
// Legacy clients don't provide a fee amount, and get the flow that
// doesn't collect one.
if initiateReserveSwapReq.FeeAmount != 0 {
return handleStatefulSwapError(streamer, NewSwapValidationError("fee amount must be 0"))
if !isBuy || isSell {
return handleStatefulSwapError(streamer, NewSwapValidationError("fee amount must be 0"))
}

expectedFeeAmount := currency_util.ExpectedBuyFeeQuarks(initiateReserveSwapReq.SwapAmount)
if expectedFeeAmount == 0 {
return handleStatefulSwapError(streamer, NewSwapValidationError("swap amount is too small to charge a fee"))
}
if !currency_util.IsExpectedFeeQuarks(initiateReserveSwapReq.FeeAmount, expectedFeeAmount) {
return handleStatefulSwapError(streamer, NewSwapDeniedErrorf("fee amount must be %d quarks", expectedFeeAmount))
}
}

destinationVmConfig, err := common.GetVmConfigForMint(ctx, s.data, toMint)
Expand Down Expand Up @@ -542,6 +555,7 @@ func (s *transactionServer) handleReserveStatefulSwap(
swapAuthority,
toMint,
initiateReserveSwapReq.SwapAmount,
initiateReserveSwapReq.FeeAmount,
selectedNonce,
)
} else {
Expand Down
111 changes: 78 additions & 33 deletions ocp/rpc/transaction/swap_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,63 +41,85 @@ type ReserveBuySwapHandler struct {
buyer *common.Account
temporaryHolder *common.Account
mint *common.Account
amount uint64
swapAmount uint64
feeAmount uint64

alts []solana.AddressLookupTable
selectedNonce *transaction_util.Nonce
computeUnitLimit uint32
computeUnitPrice uint64
memoValue string

memoryAccount *common.Account
memoryIndex uint16
feeDestination *common.Account
memoryAccount *common.Account
memoryIndex uint16
}

func NewReserveBuySwapHandler(
data ocp_data.Provider,
buyer *common.Account,
temporaryHolder *common.Account,
mint *common.Account,
amount uint64,
swapAmount, feeAmount uint64,
selectedNonce *transaction_util.Nonce,
) SwapHandler {
return &ReserveBuySwapHandler{
h := &ReserveBuySwapHandler{
data: data,

buyer: buyer,
temporaryHolder: temporaryHolder,
mint: mint,
amount: amount,
swapAmount: swapAmount,
feeAmount: feeAmount,

selectedNonce: selectedNonce,
computeUnitPrice: 10_000,
memoValue: "buy_v0",
}

if feeAmount > 0 {
h.feeDestination = common.CoreMintFeesAccount
}

return h
}

func (h *ReserveBuySwapHandler) GetAlts(ctx context.Context) ([]solana.AddressLookupTable, error) {
alt, err := transaction_util.GetAltForMint(ctx, h.data, h.mint)
if err != nil {
return nil, err
}

h.alts = []solana.AddressLookupTable{alt}

// The fee destination is only available in the core mint's ALT
if h.feeDestination != nil {
h.alts = append(h.alts, transaction_util.GetAltForCoreMint())
}

return h.alts, nil
}

func (h *ReserveBuySwapHandler) GetServerParameters() *transactionpb.StatefulSwapResponse_ServerParameters {
serverParameters := &transactionpb.StatefulSwapResponse_ServerParameters_ReserveExistingCurrencyServerParameters{
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,
MemoryAccount: h.memoryAccount.ToProto(),
MemoryIndex: uint32(h.memoryIndex),
}

if h.feeDestination != nil {
serverParameters.FeeDestination = h.feeDestination.ToProto()
}

return &transactionpb.StatefulSwapResponse_ServerParameters{
Kind: &transactionpb.StatefulSwapResponse_ServerParameters_ReserveExistingCurrency{
ReserveExistingCurrency: &transactionpb.StatefulSwapResponse_ServerParameters_ReserveExistingCurrencyServerParameters{
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,
MemoryAccount: h.memoryAccount.ToProto(),
MemoryIndex: uint32(h.memoryIndex),
},
ReserveExistingCurrency: serverParameters,
},
}
}
Expand Down Expand Up @@ -158,22 +180,45 @@ func (h *ReserveBuySwapHandler) MakeInstructions(ctx context.Context) ([]solana.
if err != nil {
return nil, err
}
h.computeUnitLimit = transaction_util.ReserveBuySwapComputeUnitLimit(temporaryCoreMintAtaBump)

transferFromSourceVmSwapAtaIxn := vm.NewTransferForSwapInstruction(
&vm.TransferForSwapInstructionAccounts{
VmAuthority: sourceVmConfig.Authority.PublicKey().ToBytes(),
Vm: sourceVmConfig.Vm.PublicKey().ToBytes(),
Swapper: h.buyer.PublicKey().ToBytes(),
SwapPda: sourceTimelockAccounts.VmSwapAccounts.Pda.PublicKey().ToBytes(),
SwapAta: sourceTimelockAccounts.VmSwapAccounts.Ata.PublicKey().ToBytes(),
Destination: temporaryCoreMintAta.PublicKey().ToBytes(),
},
&vm.TransferForSwapInstructionArgs{
Amount: h.amount,
Bump: sourceTimelockAccounts.VmSwapAccounts.PdaBump,
},
)
var transferFromSourceVmSwapAtaIxn solana.Instruction
if h.feeDestination != nil {
h.computeUnitLimit = transaction_util.ReserveBuyWithFeeSwapComputeUnitLimit(temporaryCoreMintAtaBump)

transferFromSourceVmSwapAtaIxn = vm.NewTransferForSwapWithFeeInstruction(
&vm.TransferForSwapWithFeeInstructionAccounts{
VmAuthority: sourceVmConfig.Authority.PublicKey().ToBytes(),
Vm: sourceVmConfig.Vm.PublicKey().ToBytes(),
Swapper: h.buyer.PublicKey().ToBytes(),
SwapPda: sourceTimelockAccounts.VmSwapAccounts.Pda.PublicKey().ToBytes(),
SwapAta: sourceTimelockAccounts.VmSwapAccounts.Ata.PublicKey().ToBytes(),
SwapDestination: temporaryCoreMintAta.PublicKey().ToBytes(),
FeeDestination: h.feeDestination.PublicKey().ToBytes(),
},
&vm.TransferForSwapWithFeeInstructionArgs{
SwapAmount: h.swapAmount,
FeeAmount: h.feeAmount,
Bump: sourceTimelockAccounts.VmSwapAccounts.PdaBump,
},
)
} else {
h.computeUnitLimit = transaction_util.ReserveBuySwapComputeUnitLimit(temporaryCoreMintAtaBump)

transferFromSourceVmSwapAtaIxn = vm.NewTransferForSwapInstruction(
&vm.TransferForSwapInstructionAccounts{
VmAuthority: sourceVmConfig.Authority.PublicKey().ToBytes(),
Vm: sourceVmConfig.Vm.PublicKey().ToBytes(),
Swapper: h.buyer.PublicKey().ToBytes(),
SwapPda: sourceTimelockAccounts.VmSwapAccounts.Pda.PublicKey().ToBytes(),
SwapAta: sourceTimelockAccounts.VmSwapAccounts.Ata.PublicKey().ToBytes(),
Destination: temporaryCoreMintAta.PublicKey().ToBytes(),
},
&vm.TransferForSwapInstructionArgs{
Amount: h.swapAmount,
Bump: sourceTimelockAccounts.VmSwapAccounts.PdaBump,
},
)
}

buyAndDepositIntoDestinationVmIxn := currencycreator.NewBuyAndDepositIntoVmInstruction(
&currencycreator.BuyAndDepositIntoVmInstructionAccounts{
Expand All @@ -192,7 +237,7 @@ func (h *ReserveBuySwapHandler) MakeInstructions(ctx context.Context) ([]solana.
VtaOwner: h.buyer.PublicKey().ToBytes(),
},
&currencycreator.BuyAndDepositIntoVmInstructionArgs{
InAmount: h.amount,
InAmount: h.swapAmount,
MinOutAmount: 0,
VmMemoryIndex: h.memoryIndex,
},
Expand Down
Loading
Loading