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
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,18 @@ message StatefulSwapRequest {


// The fee amount to pay for this swap
//
// Note: Amounts are coordinated outside this RPC for user verifications
// and validated in this RPC.
uint64 fee_amount = 7;

// Verified exchange data for flows that require a specific fiat value
// over the full amount (swap + fee). For intent fundings, it's expected
// that this exchange data will be used.
//
// Required for the following flows:
// - Initializing a new reserve currency outside of the core mint
VerifiedExchangeData full_amount_exchange_data = 8;
}

// Client parameters for starting swaps against the Coinbase Stable Swaper program
Expand Down Expand Up @@ -530,7 +541,7 @@ message StatefulSwapResponse {
// 4. [Optional] Memo::Memo
// 5. AssociatedTokenAccount::CreateIdempotent (open Core Mint temporary account)
// 6. VM::TransferForSwap (Core Mint VM swap ATA -> Core Mint temporary account)
// 6. Reserve::BuyAndDepositIntoVm (bounded buy depositing to_mint tokens into the to_mint VM)
// 7. Reserve::BuyAndDepositIntoVm (bounded buy depositing to_mint tokens into the to_mint VM)
// 8. Token::CloseAccount (closes Core Mint temporary account)
// 9. VM::CloseSwapAccountIfEmpty (closes Core Mint VM swap ATA if empty)
//
Expand Down Expand Up @@ -606,7 +617,7 @@ message StatefulSwapResponse {
//
// Supported Solana transaction version: v0
//
// Instruction format:
// Instruction format (paying with Core Mint):
// 1. System::AdvanceNonce
// 2. [Optional] ComputeBudget::SetComputeUnitLimit
// 3. [Optional] ComputeBudget::SetComputeUnitPrice
Expand All @@ -620,6 +631,20 @@ message StatefulSwapResponse {
// 11. Reserve::BuyTokens (limited buy transferring to_mint tokens into the to_mint VM Deposit ATA)
// 12. Token::CloseAccount (closes owner's Core Mint ATA)
//
// Instruction format (all other cases):
// 1. System::AdvanceNonce
// 2. [Optional] ComputeBudget::SetComputeUnitLimit
// 3. [Optional] ComputeBudget::SetComputeUnitPrice
// 4. [Optional] Memo::Memo
// 5. AssociatedTokenAccount::CreateIdempotent (open treasury's from_mint ATA)
// 6. VM::TransferForSwapWithFee (from_mint VM swap ATA -> treasury's from_mint ATA (swap + fee amount))
// 7. Reserve::SellTokens (limited sell of the full swap + fee amount, transferring the Core Mint value into the fee destination)
// 8. Reserve::BuyTokens (limited buy funded by the treasury's Core Mint ATA, transferring to_mint tokens into the to_mint VM Deposit ATA)
//
// Note: The currency, VM and to_mint VM Deposit ATA will be initialized prior to
// executing this transaction. Atomicity cannot be guaranteed due to transaction
// size limits, which will be revisited when the v1 transaction format is released.
//
// Note: Client should verify that the new currency's mint address matches that derived
// from using these server parameters.
message ReserveNewCurrencyServerParameter {
Expand Down Expand Up @@ -695,6 +720,14 @@ message StatefulSwapResponse {
common.v1.SolanaAccountId fee_destination = 14 [(validate.rules).message.required = true];



// Server-controlled treasury for flows that require it
common.v1.SolanaAccountId treasury = 15;

// The amount of core mint tokens used for purchase. Client should
// validate this is as expected based on a pre-coordinated amount
// accepted by the user.
uint64 treasury_purchase_amount = 16;
}

// Server parameters when executing stateful swap flows against the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,40 @@ class SwapInstructionsTest {
createdAt = Clock.System.now(),
)

private val destinationVmMetadata = VmMetadata(
authority = generateRandomPublicKeyForTest(),
vm = generateRandomPublicKeyForTest(),
lockDurationInDays = 21,
)

private val destinationLaunchpadMetadata = LaunchpadMetadata(
currencyConfig = generateRandomPublicKeyForTest(),
liquidityPool = generateRandomPublicKeyForTest(),
seed = generateRandomPublicKeyForTest(),
authority = generateRandomPublicKeyForTest(),
mintVault = generateRandomPublicKeyForTest(),
coreMintVault = generateRandomPublicKeyForTest(),
currentCirculatingSupplyQuarks = 0,
sellFeeBps = 0,
price = Fiat.MIN_VALUE,
marketCap = Fiat.MIN_VALUE,
)

private val destinationMint = MintMetadata(
address = Mint(PublicKey.generate().bytes),
vmMetadata = destinationVmMetadata,
launchpadMetadata = destinationLaunchpadMetadata,
decimals = 6,
name = "Dest Token",
symbol = "DTOK",
description = "",
imageUrl = "",
billCustomizations = null,
socialLinks = emptyList(),
holderMetrics = HolderMetrics.None,
createdAt = Clock.System.now(),
)

// Mock Server Response (Stateless for simplicity)
private val mockServerParams = StatefulSwapResponseServerParameters.ExistingCurrency(
payer = mockPayer,
Expand Down Expand Up @@ -351,7 +385,16 @@ class SwapInstructionsTest {
sellFeeBps = 100,
vmLockDurationInDays = 21,
alts = emptyList(),
feeDestination = mockFeeDestination
feeDestination = mockFeeDestination,
treasury = null,
treasuryPurchaseAmount = 0,
)

private val mockTreasury = generateRandomPublicKeyForTest()

private val mockTreasuryNewCurrencyServerParams = mockNewCurrencyServerParams.copy(
treasury = mockTreasury,
treasuryPurchaseAmount = 250_000L,
)

@Test
Expand Down Expand Up @@ -537,6 +580,190 @@ class SwapInstructionsTest {
assertEquals(mockNewCurrencyAuthority, instructions[10].accounts[2].publicKey)
}

@Test
fun testBuildTreasuryFundedNewCurrencyBuyInstructionsCount() {
val instructions = buildTreasuryFundedNewCurrencyBuyInstructions(
serverParameters = mockTreasuryNewCurrencyServerParams,
nonce = mockNonce,
authority = mockNewCurrencyAuthority,
sourceMintMetadata = targetMint,
coreMintMetadata = coreMint,
swapAmount = 95_000L,
feeAmount = 5_000L,
)

// Expected sequence (7 instructions):
// 1. System::AdvanceNonce
// 2. ComputeBudget::SetComputeUnitLimit
// 3. ComputeBudget::SetComputeUnitPrice
// 4. AssociatedTokenAccount::CreateIdempotent (treasury from_mint ATA)
// 5. VM::TransferForSwapWithFee
// 6. Reserve::SellTokens
// 7. Reserve::BuyTokens
assertEquals("Should generate 7 instructions", 7, instructions.size)
}

@Test
fun testBuildTreasuryFundedNewCurrencyBuyInstructionPrograms() {
val instructions = buildTreasuryFundedNewCurrencyBuyInstructions(
serverParameters = mockTreasuryNewCurrencyServerParams,
nonce = mockNonce,
authority = mockNewCurrencyAuthority,
sourceMintMetadata = targetMint,
coreMintMetadata = coreMint,
swapAmount = 95_000L,
feeAmount = 5_000L,
)

assertEquals(SystemProgram.address, instructions[0].program)
assertEquals(ComputeBudgetProgram.address, instructions[1].program)
assertEquals(ComputeBudgetProgram.address, instructions[2].program)
assertEquals(AssociatedTokenProgram.address, instructions[3].program)
assertEquals(VirtualMachineProgram.address, instructions[4].program)
assertEquals(CurrencyCreatorProgram.address, instructions[5].program)
assertEquals(CurrencyCreatorProgram.address, instructions[6].program)
}

@Test
fun testBuildTreasuryFundedNewCurrencyBuyInstructionAccounts() {
val instructions = buildTreasuryFundedNewCurrencyBuyInstructions(
serverParameters = mockTreasuryNewCurrencyServerParams,
nonce = mockNonce,
authority = mockNewCurrencyAuthority,
sourceMintMetadata = targetMint,
coreMintMetadata = coreMint,
swapAmount = 95_000L,
feeAmount = 5_000L,
)

val treasuryFromMintAta = PublicKey.deriveAssociatedAccount(
mockTreasury, targetMint.address
).publicKey
val treasuryCoreMintAta = PublicKey.deriveAssociatedAccount(
mockTreasury, coreMint.address
).publicKey

// 4. CreateIdempotent for the treasury's from_mint ATA: owner=treasury(1), mint=from(3)
assertEquals(mockTreasury, instructions[3].accounts[1].publicKey)
assertEquals(targetMint.address, instructions[3].accounts[3].publicKey)

// 5. TransferForSwapWithFee: source VM authority(0)/vm(1); both destination(5) and
// feeDestination(6) route the full swap + fee into the treasury's from_mint ATA
assertEquals(targetMint.vmMetadata.authority, instructions[4].accounts[0].publicKey)
assertEquals(targetMint.vmMetadata.vm, instructions[4].accounts[1].publicKey)
assertEquals(treasuryFromMintAta, instructions[4].accounts[5].publicKey)
assertEquals(treasuryFromMintAta, instructions[4].accounts[6].publicKey)

// 6. SellTokens: the treasury is the seller/signer; core mint proceeds route to the fee
// destination (seller=0, sellerTarget=6, sellerBase=7)
assertEquals(mockTreasury, instructions[5].accounts[0].publicKey)
assertTrue(instructions[5].accounts[0].isSigner)
assertEquals(treasuryFromMintAta, instructions[5].accounts[6].publicKey)
assertEquals(mockFeeDestination, instructions[5].accounts[7].publicKey)

// 7. BuyTokens: the treasury funds the buy from its own core mint ATA (buyer=0, buyerBase=7)
assertEquals(mockTreasury, instructions[6].accounts[0].publicKey)
assertTrue(instructions[6].accounts[0].isSigner)
assertEquals(treasuryCoreMintAta, instructions[6].accounts[7].publicKey)
}

@Test
fun testBuildCrossCurrencyExistingSwapInstructionsCount() {
val instructions = buildCrossCurrencyExistingSwapInstructions(
serverParameters = mockServerParams,
nonce = mockNonce,
authority = mockAuthority,
swapAuthority = mockSwapAuthority,
fromMintMetadata = targetMint,
toMintMetadata = destinationMint,
coreMintMetadata = coreMint,
amount = 100_000L,
)

// Expected sequence (12 instructions):
// 1. System::AdvanceNonce
// 2. ComputeBudget::SetComputeUnitLimit
// 3. ComputeBudget::SetComputeUnitPrice
// 4. Memo::Memo
// 5. AssociatedTokenAccount::CreateIdempotent (temp core mint ATA)
// 6. AssociatedTokenAccount::CreateIdempotent (temp source mint ATA)
// 7. VM::TransferForSwap
// 8. Reserve::SellTokens
// 9. Reserve::BuyAndDepositIntoVm
// 10. Token::CloseAccount (temp core)
// 11. Token::CloseAccount (temp source)
// 12. VM::CloseSwapAccountIfEmpty
assertEquals("Should generate 12 instructions", 12, instructions.size)
}

@Test
fun testBuildCrossCurrencyExistingSwapInstructionPrograms() {
val instructions = buildCrossCurrencyExistingSwapInstructions(
serverParameters = mockServerParams,
nonce = mockNonce,
authority = mockAuthority,
swapAuthority = mockSwapAuthority,
fromMintMetadata = targetMint,
toMintMetadata = destinationMint,
coreMintMetadata = coreMint,
amount = 100_000L,
)

assertEquals(SystemProgram.address, instructions[0].program)
assertEquals(ComputeBudgetProgram.address, instructions[1].program)
assertEquals(ComputeBudgetProgram.address, instructions[2].program)
assertEquals(MemoProgram.address, instructions[3].program)
assertEquals(AssociatedTokenProgram.address, instructions[4].program)
assertEquals(AssociatedTokenProgram.address, instructions[5].program)
assertEquals(VirtualMachineProgram.address, instructions[6].program)
assertEquals(CurrencyCreatorProgram.address, instructions[7].program)
assertEquals(CurrencyCreatorProgram.address, instructions[8].program)
assertEquals(TokenProgram.address, instructions[9].program)
assertEquals(TokenProgram.address, instructions[10].program)
assertEquals(VirtualMachineProgram.address, instructions[11].program)
}

@Test
fun testBuildCrossCurrencyExistingSwapInstructionAccounts() {
val instructions = buildCrossCurrencyExistingSwapInstructions(
serverParameters = mockServerParams,
nonce = mockNonce,
authority = mockAuthority,
swapAuthority = mockSwapAuthority,
fromMintMetadata = targetMint,
toMintMetadata = destinationMint,
coreMintMetadata = coreMint,
amount = 100_000L,
)

val tempCoreMintAta = PublicKey.deriveAssociatedAccount(
mockSwapAuthority, coreMint.address
).publicKey
val tempSourceMintAta = PublicKey.deriveAssociatedAccount(
mockSwapAuthority, targetMint.address
).publicKey

// 7. TransferForSwap: source VM authority(0)/vm(1); swapper(2)=owner; destination(5)=temp source ATA
assertEquals(targetMint.vmMetadata.authority, instructions[6].accounts[0].publicKey)
assertEquals(targetMint.vmMetadata.vm, instructions[6].accounts[1].publicKey)
assertEquals(mockAuthority, instructions[6].accounts[2].publicKey)
assertEquals(tempSourceMintAta, instructions[6].accounts[5].publicKey)

// 8. SellTokens: the swap authority is the seller/signer; core proceeds land in the temp core ATA
// (seller=0, sellerTarget=6, sellerBase=7)
assertEquals(mockSwapAuthority, instructions[7].accounts[0].publicKey)
assertTrue(instructions[7].accounts[0].isSigner)
assertEquals(targetMint.launchpadMetadata!!.liquidityPool, instructions[7].accounts[1].publicKey)
assertEquals(tempSourceMintAta, instructions[7].accounts[6].publicKey)
assertEquals(tempCoreMintAta, instructions[7].accounts[7].publicKey)

// 9. BuyAndDepositIntoVm: buyer(0)=swap authority, buyerBase(6)=temp core ATA (no buyerTarget,
// since it deposits straight into the destination VM); vtaOwner is the owner
assertEquals(mockSwapAuthority, instructions[8].accounts[0].publicKey)
assertEquals(destinationMint.launchpadMetadata!!.liquidityPool, instructions[8].accounts[1].publicKey)
assertEquals(tempCoreMintAta, instructions[8].accounts[6].publicKey)
}

@Test
fun testBuildNewCurrencyBuyInstructionInitializeCurrencyData() {
val totalAmount = 100_000L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,63 @@ class TransactionController @Inject constructor(
}
}

override suspend fun crossCurrencySwap(
owner: AccountCluster,
amount: VerifiedFiat,
feeAmount: LocalFiat?,
from: Token,
to: Token,
fullAmountExchangeData: ExchangeData.Verified?,
swapId: SwapId?,
source: SwapFundingSource,
fund: (suspend (StatefulSwapRequest) -> Result<Unit>)?,
): Result<SwapId> {
trace("Starting cross-currency swap of ${from.symbol} -> ${to.symbol}")

// A freshly-launched stub destination is created by the swap itself, so no destination
// account needs provisioning; existing destinations need a VM account to receive the buy.
val initializesDestination = to.launchpadMetadata == null && to.address != Mint.usdf
val tokenizedOwner = owner.withTimelockForToken(to)
val accountResult = when {
initializesDestination -> Result.success(Unit)
accountController.hasAccountFor(to.address) -> Result.success(Unit)
else -> accountController.createUserAccount(
ownerForMint = tokenizedOwner,
mint = to.address,
)
}

val verifiedState = amount.verifiedState
?: return Result.failure(SwapError.Other(IllegalStateException("No verified state found")))

return accountResult.fold(
onSuccess = {
repository.crossCurrencySwap(
scope = scope,
owner = owner,
amount = amount.localFiat,
feeAmount = feeAmount,
from = from,
to = to,
verifiedState = verifiedState,
fullAmountExchangeData = fullAmountExchangeData,
swapId = swapId,
source = source,
fund = fund,
).onSuccess { refreshAccountState() }
},
onFailure = { error ->
trace(
tag = "TransactionController",
message = error.message.orEmpty(),
type = TraceType.Error,
error = error
)
Result.failure(error)
}
)
}

override suspend fun pollSwapForState(
swapId: SwapId,
owner: AccountCluster,
Expand Down
Loading
Loading