diff --git a/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto b/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto index 67d7ac47c..b0851e46a 100644 --- a/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto +++ b/definitions/opencode/protos/src/main/proto/transaction/v1/ocp_transaction_service.proto @@ -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 @@ -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) // @@ -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 @@ -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 { @@ -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 diff --git a/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt b/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt index a2ee984ea..75512ccbf 100644 --- a/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt +++ b/services/opencode/src/androidTest/kotlin/com/getcode/opencode/solana/swap/SwapTests.kt @@ -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, @@ -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 @@ -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 diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt index 291154fa1..7124693fc 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionController.kt @@ -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)?, + ): Result { + 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, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt index 2fb1eed8f..13de27c73 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/controllers/TransactionOperations.kt @@ -7,6 +7,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.transactions.SwapMetadata import com.getcode.opencode.model.transactions.StatefulSwapRequest @@ -44,6 +45,24 @@ interface TransactionOperations { of: Token, ): Result + /** + * Swaps one launchpad currency directly for another (neither side the core mint). When [to] is + * a freshly-launched stub the server creates it during the swap using a treasury-funded flow, + * which requires [fullAmountExchangeData] — a USD valuation over the full swap + fee amount keyed + * to [from]. For existing destinations [fullAmountExchangeData] is not needed. + */ + suspend fun crossCurrencySwap( + owner: AccountCluster, + amount: VerifiedFiat, + feeAmount: LocalFiat? = null, + from: Token, + to: Token, + fullAmountExchangeData: ExchangeData.Verified? = null, + swapId: SwapId? = null, + source: SwapFundingSource = SwapFundingSource.SubmitIntent(), + fund: (suspend (StatefulSwapRequest) -> Result)? = null, + ): Result + suspend fun pollSwapForState( swapId: SwapId, owner: AccountCluster, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt index aead054cf..5a689cc61 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/domain/repositories/InternalTransactionRepository.kt @@ -8,6 +8,7 @@ import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.model.transactions.StatelessSwapRequest import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.transactions.StatefulSwapRequest @@ -101,6 +102,32 @@ internal class InternalTransactionRepository @Inject constructor( verifiedState = verifiedState ).onFailure { ErrorUtils.handleError(it) } + override suspend fun crossCurrencySwap( + scope: CoroutineScope, + owner: AccountCluster, + amount: LocalFiat, + feeAmount: LocalFiat?, + from: Token, + to: Token, + verifiedState: VerifiedState, + fullAmountExchangeData: ExchangeData.Verified?, + swapId: SwapId?, + source: SwapFundingSource, + fund: (suspend (StatefulSwapRequest) -> Result)?, + ): Result = service.crossCurrencySwap( + scope = scope, + amount = amount, + feeAmount = feeAmount, + from = from, + to = to, + owner = owner, + verifiedState = verifiedState, + fullAmountExchangeData = fullAmountExchangeData, + swapId = swapId, + source = source, + fund = fund, + ).onFailure { ErrorUtils.handleError(it) } + override suspend fun withdrawUsdf( scope: CoroutineScope, amount: LocalFiat, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt index 99a461e46..1ff05e2dc 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/api/intents/IntentStatefulSwap.kt @@ -12,7 +12,7 @@ import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf import com.getcode.opencode.model.transactions.StatefulSwapRequest import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters -import com.getcode.opencode.model.transactions.SwapStartKind +import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.VerifiedSwapMetadata import com.getcode.opencode.solana.SolanaTransaction import com.getcode.opencode.solana.TransactionBuilder @@ -47,12 +47,13 @@ internal class IntentStatefulSwap( response = parameters, authority = request.owner.authorityPublicKey, swapAuthority = request.swapAuthority.toPublicKey(), - direction = request.direction, + route = request.route, amount = request.swapAmount.underlyingTokenAmount.quarks, ) is StatefulSwapResponseServerParameters.NewCurrency -> TransactionBuilder.buyNewCurrency( response = parameters, authority = request.owner.authorityPublicKey, + sourceMintMetadata = request.route.sourceMint, coreMintMetadata = Token.usdf, amount = request.swapAmount.underlyingTokenAmount.quarks, feeAmount = request.feeAmount?.underlyingTokenAmount?.quarks, @@ -62,8 +63,8 @@ internal class IntentStatefulSwap( response = parameters, authority = request.owner.authorityPublicKey, swapAuthority = request.swapAuthority.toPublicKey(), - destinationOwner = (request.kind as SwapStartKind.Stablecoin).destinationOwner, - direction = request.direction, + destinationOwner = (request.program as SwapProgram.Stablecoin).destinationOwner, + route = request.route, amount = request.swapAmount.underlyingTokenAmount.quarks, feeAmount = request.feeAmount?.underlyingTokenAmount?.quarks ?: 0, // server expects 1:1 for stablecoins @@ -80,9 +81,9 @@ internal class IntentStatefulSwap( .setOwner(request.owner.authorityPublicKey.asSolanaAccountId()) .setSwapAuthority(request.swapAuthority.toPublicKey().asSolanaAccountId()) .apply { - when (request.kind) { - is SwapStartKind.Reserve -> setReserve(request.currencyCreatorParams()) - is SwapStartKind.Stablecoin -> setStablecoin(request.stablecoinParams()) + when (request.program) { + is SwapProgram.Reserve -> setReserve(request.currencyCreatorParams()) + is SwapProgram.Stablecoin -> setStablecoin(request.stablecoinParams()) } val proofSignature = request.verifiedMetadata().sign(signer) setProofSignature(proofSignature) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt index 6ae20171f..3781ce530 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/executors/StatefulSwapExecutor.kt @@ -11,7 +11,7 @@ import com.getcode.opencode.model.core.errors.SubmitIntentError import com.getcode.opencode.model.core.errors.SwapError import com.getcode.opencode.model.transactions.StatefulSwapRequest import com.getcode.opencode.model.transactions.SwapResult -import com.getcode.opencode.model.transactions.SwapStartKind +import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.VerifiedSwapMetadata import com.getcode.opencode.solana.SolanaTransaction import com.getcode.opencode.solana.diff @@ -60,24 +60,24 @@ internal class SwapExecutor( streamReference.retain() - val metadata = when (request.kind) { - is SwapStartKind.Reserve -> VerifiedSwapMetadata.Reserve( + val metadata = when (request.program) { + is SwapProgram.Reserve -> VerifiedSwapMetadata.Reserve( id = request.swapId, - fromMint = request.direction.sourceMint.address, - toMint = request.direction.destinationMint.address, + fromMint = request.route.sourceMint.address, + toMint = request.route.destinationMint.address, swapAmount = request.swapAmount.underlyingTokenAmount, feeAmount = request.feeAmount?.underlyingTokenAmount, - fundingSource = request.kind.fundingSource + fundingSource = request.program.fundingSource ) - is SwapStartKind.Stablecoin -> VerifiedSwapMetadata.StableCoin( + is SwapProgram.Stablecoin -> VerifiedSwapMetadata.StableCoin( id = request.swapId, - fromMint = request.direction.sourceMint.address, - toMint = request.direction.destinationMint.address, + fromMint = request.route.sourceMint.address, + toMint = request.route.destinationMint.address, swapAmount = request.swapAmount.underlyingTokenAmount, feeAmount = request.feeAmount?.underlyingTokenAmount, - fundingSource = request.kind.fundingSource, - destinationOwner = request.kind.destinationOwner + fundingSource = request.program.fundingSource, + destinationOwner = request.program.destinationOwner ) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt index e02185ad0..186e7ee45 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/LocalToProtobuf.kt @@ -21,7 +21,7 @@ import com.getcode.opencode.model.transactions.GiveRequest import com.getcode.opencode.model.transactions.GrabRequest import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.transactions.StatefulSwapRequest -import com.getcode.opencode.model.transactions.SwapStartKind +import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.TransactionMetadata import com.getcode.opencode.model.transactions.TransferRequest import com.getcode.opencode.model.ui.BillBackground @@ -282,14 +282,22 @@ internal fun LocalFiat.asExchangeData(): OcpTransactionService.ExchangeData { } internal fun StatefulSwapRequest.currencyCreatorParams(): OcpTransactionService.StatefulSwapRequest.Initiate.ReserveSwapClientParameters.Builder { - return when (val details = kind) { - is SwapStartKind.Reserve -> { + return when (val details = program) { + is SwapProgram.Reserve -> { OcpTransactionService.StatefulSwapRequest.Initiate.ReserveSwapClientParameters.newBuilder() .setId(swapId.asSwapId()) .setFromMint(details.fromMint.asSolanaAccountId()) .setToMint(details.toMint.asSolanaAccountId()) .setSwapAmount(this@currencyCreatorParams.swapAmount.underlyingTokenAmount.quarks) .setFeeAmount(this@currencyCreatorParams.feeAmount?.underlyingTokenAmount?.quarks ?: 0) + .apply { + // Required by the server when creating a new reserve currency from a non-core + // source mint (treasury-funded flow); omitted otherwise. + val fullAmountExchangeData = this@currencyCreatorParams.fullAmountExchangeData + if (fullAmountExchangeData != null) { + setFullAmountExchangeData(fullAmountExchangeData.asProtobufExchangeData()) + } + } .apply { when (val source = details.fundingSource) { is SwapFundingSource.ExternalWallet -> { @@ -312,25 +320,25 @@ internal fun StatefulSwapRequest.currencyCreatorParams(): OcpTransactionService. } } - is SwapStartKind.Stablecoin -> { + is SwapProgram.Stablecoin -> { throw IllegalStateException("Stablecoin should not be used for currency creator params") } } } internal fun StatefulSwapRequest.stablecoinParams(): OcpTransactionService.StatefulSwapRequest.Initiate.CoinbaseStableSwapperClientParameters.Builder { - return when (val details = kind) { - is SwapStartKind.Reserve -> { + return when (val details = program) { + is SwapProgram.Reserve -> { throw IllegalStateException("Reserve should not be used for stable swapper params") } - is SwapStartKind.Stablecoin -> { + is SwapProgram.Stablecoin -> { OcpTransactionService.StatefulSwapRequest.Initiate.CoinbaseStableSwapperClientParameters.newBuilder() .setId(swapId.asSwapId()) .setFromMint(details.fromMint.asSolanaAccountId()) .setToMint(details.toMint.asSolanaAccountId()) .setSwapAmount(this@stablecoinParams.swapAmount.underlyingTokenAmount.quarks) - .setDestinationOwner(this@stablecoinParams.kind.destinationOwner.asSolanaAccountId()) + .setDestinationOwner(this@stablecoinParams.program.destinationOwner.asSolanaAccountId()) .setFeeAmount(this@stablecoinParams.feeAmount?.underlyingTokenAmount?.quarks ?: 0) .setFundingSource(OcpTransactionService.FundingSource.FUNDING_SOURCE_SUBMIT_INTENT) .setFundingId(details.fundingSource.id.base58) @@ -341,12 +349,12 @@ internal fun StatefulSwapRequest.stablecoinParams(): OcpTransactionService.State internal fun StatefulSwapRequest.verifiedMetadata(): OcpTransactionService.VerifiedSwapMetadata.Builder { return OcpTransactionService.VerifiedSwapMetadata.newBuilder() .apply { - when (kind) { - is SwapStartKind.Reserve -> setReserve( + when (program) { + is SwapProgram.Reserve -> setReserve( OcpTransactionService.VerifiedReserveSwapMetadata.newBuilder() .setClientParameters(currencyCreatorParams()) ) - is SwapStartKind.Stablecoin -> setStablecoin( + is SwapProgram.Stablecoin -> setStablecoin( OcpTransactionService.VerifiedCoinbaseStableSwapperSwapMetadata.newBuilder() .setClientParameters(stablecoinParams()) ) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt index 1f25f1a55..f531972b6 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/extensions/ProtobufToLocal.kt @@ -170,6 +170,8 @@ internal fun OcpTransactionService.StatefulSwapResponse.ServerParameters.Reserve sellFeeBps = sellFeeBps, vmLockDurationInDays = vmLockDurationInDays, feeDestination = feeDestination.toPublicKey(), + treasury = if (hasTreasury()) treasury.toPublicKey() else null, + treasuryPurchaseAmount = treasuryPurchaseAmount, ) } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt index 4fb11533e..9a8f70bf5 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/funding/SwapFunding.kt @@ -24,7 +24,7 @@ internal class SwapFunding @Inject constructor( intentId = PublicKey(request.fundingIntentId), sourceCluster = request.owner, amount = request.totalTransferAmount, - fromMint = request.direction.sourceMint, + fromMint = request.route.sourceMint, verifiedState = request.verifiedState, ) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt index 1fe087f37..332e90afc 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/network/services/TransactionService.kt @@ -23,11 +23,12 @@ import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.minus +import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.model.transactions.StatefulSwapRequest import com.getcode.opencode.model.transactions.StatelessSwapRequest -import com.getcode.opencode.model.transactions.SwapDirection +import com.getcode.opencode.model.transactions.SwapRoute import com.getcode.opencode.model.transactions.SwapFundingSource -import com.getcode.opencode.model.transactions.SwapStartKind +import com.getcode.opencode.model.transactions.SwapProgram import com.getcode.opencode.model.transactions.TransactionMetadata import com.getcode.opencode.model.transactions.WithdrawalAvailability import com.getcode.opencode.solana.intents.IntentType @@ -196,12 +197,12 @@ internal class TransactionService @Inject constructor( val request = StatefulSwapRequest( owner = owner, swapAuthority = swapAuthority, - kind = SwapStartKind.Reserve( + program = SwapProgram.Reserve( fromMint = Mint.usdf, toMint = of.address, fundingSource = source, ), - direction = SwapDirection.Buy(of), + route = SwapRoute.Buy(of), swapAmount = netAmount, feeAmount = feeAmount, swapId = swapId ?: SwapId.generate(), @@ -226,12 +227,12 @@ internal class TransactionService @Inject constructor( owner = tokenCluster, swapId = SwapId.generate(), swapAuthority = Ed25519.createKeyPair(), - kind = SwapStartKind.Reserve( + program = SwapProgram.Reserve( fromMint = of.address, toMint = Mint.usdf, fundingSource = source, ), - direction = SwapDirection.Sell(of), + route = SwapRoute.Sell(of), swapAmount = amount, feeAmount = null, verifiedState = verifiedState, @@ -240,6 +241,56 @@ internal class TransactionService @Inject constructor( return statefulSwap(scope, request, tokenCluster) } + /** + * Swaps one launchpad currency directly for another (neither side the core mint). + * + * When [to] is an existing currency the server returns RESERVE_EXISTING_CURRENCY parameters and + * the swap runs through a one-time [swapAuthority]. When [to] is a freshly-launched stub + * (launchpadMetadata == null && address != USDF), the server creates the currency during the + * swap and returns RESERVE_NEW_CURRENCY parameters funded by a server-controlled treasury; that + * path requires owner == swapAuthority and a [fullAmountExchangeData] valuation over the full + * swap + fee amount, keyed to [from] and denominated in USD. + */ + suspend fun crossCurrencySwap( + scope: CoroutineScope, + amount: LocalFiat, + feeAmount: LocalFiat?, + from: Token, + to: Token, + owner: AccountCluster, + verifiedState: VerifiedState, + fullAmountExchangeData: ExchangeData.Verified? = null, + swapId: SwapId? = null, + source: SwapFundingSource = SwapFundingSource.SubmitIntent(), + fund: (suspend (StatefulSwapRequest) -> Result)? = null, + ): Result { + val initializesDestination = to.launchpadMetadata == null && to.address != Mint.usdf + // New currency requires owner == swapAuthority; existing swaps use a one-time authority. + val swapAuthority = + if (initializesDestination) owner.authority.keyPair else Ed25519.createKeyPair() + + val netAmount = amount - (feeAmount ?: LocalFiat.Zero) + val tokenCluster = owner.withTimelockForToken(from) + val request = StatefulSwapRequest( + owner = tokenCluster, + swapAuthority = swapAuthority, + swapId = swapId ?: SwapId.generate(), + program = SwapProgram.Reserve( + fromMint = from.address, + toMint = to.address, + fundingSource = source, + ), + route = SwapRoute.CrossCurrency(from = from, to = to), + swapAmount = netAmount, + feeAmount = feeAmount, + verifiedState = verifiedState, + fullAmountExchangeData = fullAmountExchangeData, + ) + + val fundedWith = fund ?: { swapFunding.fund(scope, tokenCluster, it).map { Unit } } + return statefulSwap(scope, request, tokenCluster, fundedWith) + } + suspend fun withdrawUsdf( scope: CoroutineScope, amount: LocalFiat, @@ -254,13 +305,13 @@ internal class TransactionService @Inject constructor( owner = owner, swapId = SwapId.generate(), swapAuthority = Ed25519.createKeyPair(), - kind = SwapStartKind.Stablecoin( + program = SwapProgram.Stablecoin( fromMint = Mint.usdf, toMint = Mint.usdc, fundingSource = SwapFundingSource.SubmitIntent(), destinationOwner = destinationOwner ), - direction = SwapDirection.WithdrawUsdc, + route = SwapRoute.WithdrawUsdc, swapAmount = netAmount, feeAmount = fee, verifiedState = verifiedState, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt index fee865494..902c80873 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/solana/extensions/ExtractServerParameters.kt @@ -34,6 +34,8 @@ sealed interface ExtractedServerParams { val sellFeeBps: Int, val vmLockDurationInDays: Int, val feeDestination: PublicKey, + val treasury: PublicKey?, + val treasuryPurchaseAmount: Long, ): ExtractedServerParams } @@ -62,6 +64,8 @@ internal fun extractServerParameters(serverParameters: StatefulSwapResponseServe seed = serverParameters.seed, sellFeeBps = serverParameters.sellFeeBps, vmLockDurationInDays = serverParameters.vmLockDurationInDays, - feeDestination = serverParameters.feeDestination + feeDestination = serverParameters.feeDestination, + treasury = serverParameters.treasury, + treasuryPurchaseAmount = serverParameters.treasuryPurchaseAmount, ) } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt index a5c123dcf..7b6cde3db 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt @@ -264,6 +264,10 @@ operator fun Fiat.div(rhs: Int): Fiat { return Fiat(quarks = this.quarks / rhs, currencyCode = currencyCode) } +operator fun Fiat.div(rhs: Double): Fiat { + return Fiat(quarks = (this.quarks / rhs).roundToLong(), currencyCode = currencyCode) +} + fun Fiat?.orZero() = this ?: Fiat.Zero fun Iterable.sum(): Fiat { return this.fold(Fiat.Zero) { acc, fiat -> acc + fiat } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt new file mode 100644 index 000000000..fbc304cf4 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt @@ -0,0 +1,40 @@ +package com.getcode.opencode.model.financial + +/** + * Launchpad pool sell-fee math, the fiat-domain analog of iOS's + * `ExchangedFiat+LaunchpadSellFee`. + * + * Unlike iOS — which computes the fee in token quarks and needs an + * overflow-safe split multiply — Android never multiplies token quarks by bps + * on the client. The pool deducts the fee on-chain during the swap; the client + * only works in the fiat domain (a [Fiat]'s `Long` micro-unit quarks), where no + * realistic amount overflows. Both helpers cap bps at 10_000 (100%) to match + * the on-chain clamp. + */ + +private const val MAX_FEE_BPS = 10_000 + +/** + * The launchpad pool's sell fee taken from this amount when it is sold: + * `amount × bps / 10_000`. + */ +fun Fiat.launchpadSellFee(bps: Int): Fiat { + val cappedBps = bps.coerceIn(0, MAX_FEE_BPS) + return this * (cappedBps / MAX_FEE_BPS.toDouble()) +} + +/** + * Grosses this net amount up so that, after the launchpad pool's sell fee is + * deducted on-chain, the proceeds net back to this amount: + * `net / (1 − bps/10_000)`. + * + * A 100% (or greater) fee can't be grossed up — the guard returns the amount + * unchanged instead of dividing by zero (or going negative). Server-sourced bps + * drives this, and the flow gates insufficient funds downstream regardless. + */ +fun Fiat.grossingUpLaunchpadSellFee(bps: Int): Fiat { + val cappedBps = bps.coerceIn(0, MAX_FEE_BPS) + if (cappedBps >= MAX_FEE_BPS) return this + val feeFraction = cappedBps / MAX_FEE_BPS.toDouble() + return this / (1.0 - feeFraction) +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt index d087ae282..abb86f2be 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LocalFiat.kt @@ -148,3 +148,10 @@ operator fun LocalFiat.plus(other: LocalFiat): LocalFiat { nativeAmount = nativeAmount + other.nativeAmount ) } + +/** + * Compares two [LocalFiat] values by their [underlyingTokenAmount], which is always + * USD-denominated and therefore the common denominator regardless of native currency. + */ +operator fun LocalFiat.compareTo(other: LocalFiat): Int = + underlyingTokenAmount.compareTo(other.underlyingTokenAmount) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt index 9d1474115..f959512ab 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapRequest.kt @@ -11,17 +11,23 @@ import com.getcode.solana.keys.PublicKey data class StatefulSwapRequest( val owner: AccountCluster, val swapAuthority: KeyPair, - val kind: SwapStartKind, - val direction: SwapDirection, + val program: SwapProgram, + val route: SwapRoute, val swapAmount: LocalFiat, val feeAmount: LocalFiat?, val swapId: SwapId, val verifiedState: VerifiedState, + /** + * Verified exchange data over the full amount (swap + fee), keyed to the source mint. Required + * by the server when initializing a new reserve currency from a source mint other than the core + * mint (the treasury-funded flow); null otherwise. + */ + val fullAmountExchangeData: ExchangeData.Verified? = null, ) { val fundingIntentId: List - get() = when (kind) { - is SwapStartKind.Reserve -> kind.fundingIntentId - is SwapStartKind.Stablecoin -> kind.fundingIntentId + get() = when (program) { + is SwapProgram.Reserve -> program.fundingIntentId + is SwapProgram.Stablecoin -> program.fundingIntentId } val totalTransferAmount: LocalFiat @@ -31,7 +37,7 @@ data class StatefulSwapRequest( } } -sealed interface SwapStartKind { +sealed interface SwapProgram { /** * Server parameters for starting swaps against the Reserve program */ @@ -48,7 +54,7 @@ sealed interface SwapStartKind { * Where "amount" of "from_mint" will be sent from to the VM swap PDA */ val fundingSource: SwapFundingSource, - ) : SwapStartKind { + ) : SwapProgram { val fundingIntentId: List get() = when (fundingSource) { is SwapFundingSource.ExternalWallet -> fundingSource.transactionSignature @@ -78,7 +84,7 @@ sealed interface SwapStartKind { * Where "amount" of "from_mint" will be sent from to the VM swap PDA */ val fundingSource: SwapFundingSource.SubmitIntent, - ): SwapStartKind { + ): SwapProgram { val fundingIntentId: List = fundingSource.id } } \ No newline at end of file diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt index 5bb818d8d..6fd02c5b1 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/StatefulSwapResponseServerParameters.kt @@ -204,6 +204,18 @@ sealed interface StatefulSwapResponseServerParameters { * Destination account where fee should be paid */ val feeDestination: PublicKey, + /** + * Server-controlled treasury account for flows that require it (e.g. + * initializing a new reserve currency outside of the core mint). Null + * when the swap is funded directly with the core mint. + */ + val treasury: PublicKey?, + /** + * The amount of core mint tokens used for the purchase when funding + * through the [treasury]. Client should validate this matches a + * pre-coordinated amount accepted by the user. Zero when unused. + */ + val treasuryPurchaseAmount: Long, ): StatefulSwapResponseServerParameters /** diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapDirection.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapRoute.kt similarity index 62% rename from services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapDirection.kt rename to services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapRoute.kt index b28601313..c2d0f442d 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapDirection.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/transactions/SwapRoute.kt @@ -4,19 +4,22 @@ import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.financial.usdc import com.getcode.opencode.model.financial.usdf -sealed interface SwapDirection { +sealed interface SwapRoute { // USDC -> Bonded Token - data class Buy(val mint: MintMetadata): SwapDirection + data class Buy(val mint: MintMetadata): SwapRoute // Bonded Token -> USDC - data class Sell(val mint: MintMetadata): SwapDirection + data class Sell(val mint: MintMetadata): SwapRoute // USDF -> USDC - data object WithdrawUsdc: SwapDirection + data object WithdrawUsdc: SwapRoute + // Arbitrary currency -> currency (neither side the core mint) + data class CrossCurrency(val from: MintMetadata, val to: MintMetadata): SwapRoute val sourceMint: MintMetadata get() = when (this) { is Buy -> MintMetadata.usdf is Sell -> mint is WithdrawUsdc -> MintMetadata.usdf + is CrossCurrency -> from } val destinationMint: MintMetadata @@ -24,5 +27,6 @@ sealed interface SwapDirection { is Buy -> mint is Sell -> MintMetadata.usdf is WithdrawUsdc -> MintMetadata.usdc + is CrossCurrency -> to } -} \ No newline at end of file +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt index 9b2b74605..05f893705 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/repositories/TransactionRepository.kt @@ -7,6 +7,7 @@ import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.Limits import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token +import com.getcode.opencode.model.transactions.ExchangeData import com.getcode.opencode.model.transactions.SwapFundingSource import com.getcode.opencode.model.transactions.StatefulSwapRequest import com.getcode.opencode.model.transactions.TransactionMetadata @@ -65,6 +66,20 @@ interface TransactionRepository { verifiedState: VerifiedState, ): Result + suspend fun crossCurrencySwap( + scope: CoroutineScope, + owner: AccountCluster, + amount: LocalFiat, + feeAmount: LocalFiat? = null, + from: Token, + to: Token, + verifiedState: VerifiedState, + fullAmountExchangeData: ExchangeData.Verified? = null, + swapId: SwapId? = null, + source: SwapFundingSource = SwapFundingSource.SubmitIntent(), + fund: (suspend (StatefulSwapRequest) -> Result)? = null, + ): Result + suspend fun withdrawUsdf( scope: CoroutineScope, amount: LocalFiat, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt index b289c69dd..299cd2fe8 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/SolanaTransaction.kt @@ -229,8 +229,6 @@ data class SolanaTransaction(val message: Message, val signatures: List() } val staticAccountKeys = mutableListOf() - val dynamicWritableAccountKeys = mutableListOf() - val dynamicReadOnlyAccountKeys = mutableListOf() var requiredSigners = 0 var readOnlySigners = 0 @@ -252,10 +250,8 @@ data class SolanaTransaction(val message: Message, val signatures: List + writableLUTIndexes[lutIndex].map { lut.addresses[it.toInt() and 0xFF] } + } + val loadedReadonlyAccounts = sortedLuts.withIndex().flatMap { (lutIndex, lut) -> + readonlyLUTIndexes[lutIndex].map { lut.addresses[it.toInt() and 0xFF] } + } + val allAccounts = staticAccountKeys + loadedWritableAccounts + loadedReadonlyAccounts // Build address table lookups (only include LUTs that are actually used) val addressTableLookups = mutableListOf() diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt index e8ca08e5f..64dc824d5 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/TransactionBuilder.kt @@ -3,13 +3,15 @@ package com.getcode.opencode.solana import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf -import com.getcode.opencode.model.transactions.SwapDirection +import com.getcode.opencode.model.transactions.SwapRoute import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters import com.getcode.opencode.model.financial.MintMetadata import com.getcode.opencode.model.transactions.FundSwapPool +import com.getcode.opencode.solana.swap.buildCrossCurrencyExistingSwapInstructions import com.getcode.opencode.solana.swap.buildExistingCurrencyBuyInstructions import com.getcode.opencode.solana.swap.buildNewCurrencyBuyInstructions import com.getcode.opencode.solana.swap.buildSellInstructions +import com.getcode.opencode.solana.swap.buildTreasuryFundedNewCurrencyBuyInstructions import com.getcode.opencode.solana.swap.buildStablecoinSwapperInstructions import com.getcode.opencode.solana.swap.buildStatelessSwapInstructions import com.getcode.opencode.solana.swap.buildUsdcDepositInstructions @@ -25,13 +27,13 @@ object TransactionBuilder { * * This function generates a V0 transaction that executes a swap between USDC and another * supported token. It handles both "Buy" (USDC -> Token) and "Sell" (Token -> USDC) directions - * by generating the appropriate instruction sets based on the [direction] parameter. + * by generating the appropriate instruction sets based on the [route] parameter. * * @param response The server parameters required for the swap, containing payer information, * lookup tables (ALTs), nonce, and a blockhash. * @param authority The public key of the user authorizing the swap (the wallet owner). * @param swapAuthority The public key of the temporary swap authority derived from the nonce. - * @param direction The direction of the swap (Buy or Sell) and the target/source mint involved. + * @param route The route of the swap (Buy or Sell) and the target/source mint involved. * @param amount The amount of tokens to swap (in the source currency's smallest unit). * @param minOutput The minimum acceptable amount of output tokens to receive (slippage protection). * Defaults to 0. @@ -41,36 +43,49 @@ object TransactionBuilder { response: StatefulSwapResponseServerParameters.ExistingCurrency, authority: PublicKey, swapAuthority: PublicKey, - direction: SwapDirection, + route: SwapRoute, amount: Long, minOutput: Long = 0, ): SolanaTransaction { val coreMint = Token.usdf - val instructions = when (direction) { - is SwapDirection.Buy -> buildExistingCurrencyBuyInstructions( + val instructions = when (route) { + is SwapRoute.Buy -> buildExistingCurrencyBuyInstructions( serverParameters = response, nonce = response.nonce, authority = authority, swapAuthority = swapAuthority, coreMintMetadata = coreMint, - targetMintMetadata = direction.mint, + targetMintMetadata = route.mint, amount = amount, minOutput = minOutput, ) - is SwapDirection.Sell -> buildSellInstructions( + is SwapRoute.Sell -> buildSellInstructions( serverParameters = response, nonce = response.nonce, authority = authority, swapAuthority = swapAuthority, - sourceMintMetadata = direction.mint, + sourceMintMetadata = route.mint, coreMintMetadata = coreMint, amount = amount, minOutput = minOutput, ) - SwapDirection.WithdrawUsdc -> throw IllegalArgumentException("Withdraw USDC should not be used with ExistingCurrency params") + SwapRoute.WithdrawUsdc -> throw IllegalArgumentException("Withdraw USDC should not be used with ExistingCurrency params") + + // Cross-currency (currency -> currency) swaps between two existing currencies map to the + // server's buy+sell handler: sell the source into the core mint, then buy the destination. + is SwapRoute.CrossCurrency -> buildCrossCurrencyExistingSwapInstructions( + serverParameters = response, + nonce = response.nonce, + authority = authority, + swapAuthority = swapAuthority, + fromMintMetadata = route.from, + toMintMetadata = route.to, + coreMintMetadata = coreMint, + amount = amount, + ) } return SolanaTransaction.newV0Instance( @@ -94,7 +109,7 @@ object TransactionBuilder { * blockhash, ALTs, fee destination, and the pool fee recipient. * @param authority The public key of the user authorizing the swap (the wallet owner). * @param swapAuthority The public key of a random one-time use account that signs the swap. - * @param direction The direction of the swap (Buy or Sell) and the target/source mint involved. + * @param route The route of the swap (Buy or Sell) and the target/source mint involved. * @param amount The amount of tokens to swap from the source mint (in quarks). * @param minOutput The minimum acceptable amount of output tokens to receive (slippage protection). * Defaults to 0. @@ -105,7 +120,7 @@ object TransactionBuilder { authority: PublicKey, swapAuthority: PublicKey, destinationOwner: PublicKey, - direction: SwapDirection, + route: SwapRoute, amount: Long, feeAmount: Long = 0, minOutput: Long = 0, @@ -116,8 +131,8 @@ object TransactionBuilder { authority = authority, swapAuthority = swapAuthority, destinationOwner = destinationOwner, - fromMintMetadata = direction.sourceMint, - toMintMetadata = direction.destinationMint, + fromMintMetadata = route.sourceMint, + toMintMetadata = route.destinationMint, amount = amount, feeAmount = feeAmount, minOutput = minOutput, @@ -150,18 +165,34 @@ object TransactionBuilder { fun buyNewCurrency( response: StatefulSwapResponseServerParameters.NewCurrency, authority: PublicKey, + sourceMintMetadata: MintMetadata, coreMintMetadata: MintMetadata, amount: Long, feeAmount: Long?, ): SolanaTransaction { - val instructions = buildNewCurrencyBuyInstructions( - serverParameters = response, - nonce = response.nonce, - authority = authority, - coreMintMetadata = coreMintMetadata, - amount = amount, - feeAmount = feeAmount ?: 0, - ) + // When the server provides a treasury the initial buy is funded from a non-core source mint + // (cross-currency), which uses a distinct instruction layout. Otherwise the buy is paid for + // directly with the core mint. + val instructions = if (response.treasury != null) { + buildTreasuryFundedNewCurrencyBuyInstructions( + serverParameters = response, + nonce = response.nonce, + authority = authority, + sourceMintMetadata = sourceMintMetadata, + coreMintMetadata = coreMintMetadata, + swapAmount = amount, + feeAmount = feeAmount ?: 0, + ) + } else { + buildNewCurrencyBuyInstructions( + serverParameters = response, + nonce = response.nonce, + authority = authority, + coreMintMetadata = coreMintMetadata, + amount = amount, + feeAmount = feeAmount ?: 0, + ) + } return SolanaTransaction.newV0Instance( payer = response.payer, diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt new file mode 100644 index 000000000..5e62880c0 --- /dev/null +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/CrossCurrencyExistingSwapInstructions.kt @@ -0,0 +1,191 @@ +package com.getcode.opencode.solana.swap + +import com.getcode.opencode.internal.solana.extensions.extractServerParameters +import com.getcode.opencode.internal.solana.extensions.timelockSwapAccounts +import com.getcode.opencode.internal.solana.programs.AssociatedTokenProgram_CreateIdempotent +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitLimit +import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetComputeUnitPrice +import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_BuyAndDepositIntoVm +import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_SellTokens +import com.getcode.opencode.internal.solana.programs.MemoProgram_Memo +import com.getcode.opencode.internal.solana.programs.SystemProgram_AdvanceNonce +import com.getcode.opencode.internal.solana.programs.TokenProgram_CloseAccount +import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_CloseSwapAccountIfEmpty +import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_TransferForSwap +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.transactions.StatefulSwapResponseServerParameters +import com.getcode.opencode.solana.Instruction +import com.getcode.solana.keys.PublicKey + +/** + * Builds the list of instructions for a cross-currency swap between two existing launchpad + * currencies (neither side is the core mint). This mirrors the server's + * `ReserveBuySellSwapHandler`. + * + * The source currency is transferred out of the user's VM swap account into a temporary account + * held by the [swapAuthority], sold for the core mint, and the resulting core mint is immediately + * spent buying the destination currency, which is deposited into the user's destination VM. The + * swap authority is the seller/buyer and owns the temporary accounts (it signs client-side); the + * VM authorities are signed server-side. + * + * Instruction format (matches the server's buy+sell existing currency layout): + * 1. System::AdvanceNonce + * 2. ComputeBudget::SetComputeUnitLimit + * 3. ComputeBudget::SetComputeUnitPrice + * 4. Memo::Memo + * 5. AssociatedTokenAccount::CreateIdempotent (temporary core mint ATA) + * 6. AssociatedTokenAccount::CreateIdempotent (temporary source mint ATA) + * 7. VM::TransferForSwap (source VM swap ATA -> temporary source mint ATA) + * 8. Reserve::SellTokens (sell source mint -> core mint into the temporary core mint ATA) + * 9. Reserve::BuyAndDepositIntoVm (buy destination mint funded by the temporary core mint ATA, + * depositing into the destination VM) + * 10. Token::CloseAccount (close temporary core mint ATA) + * 11. Token::CloseAccount (close temporary source mint ATA) + * 12. VM::CloseSwapAccountIfEmpty (close source VM swap ATA if empty) + * + * @param serverParameters Existing currency server parameters (payer, limits, memory account, etc.). + * @param nonce The nonce account to use for the transaction. + * @param authority The owner of the user's accounts (the VTA owner and source swapper). + * @param swapAuthority The temporary holder that executes the sell/buy and owns the temporary ATAs. + * @param fromMintMetadata Metadata for the source currency being sold. + * @param toMintMetadata Metadata for the destination currency being bought. + * @param coreMintMetadata Metadata for the core mint (the reserve base currency). + * @param amount The amount of the source currency to swap. + * @return A list of [Instruction]s to execute the cross-currency swap. + */ +internal fun buildCrossCurrencyExistingSwapInstructions( + serverParameters: StatefulSwapResponseServerParameters.ExistingCurrency, + nonce: PublicKey, + authority: PublicKey, + swapAuthority: PublicKey, + fromMintMetadata: MintMetadata, + toMintMetadata: MintMetadata, + coreMintMetadata: MintMetadata, + amount: Long, +): List { + val sourceVm = fromMintMetadata.vmMetadata + val destinationVm = toMintMetadata.vmMetadata + + val sourceLaunchpad = fromMintMetadata.launchpadMetadata + ?: throw IllegalStateException("source mint has no launchpad metadata") + val destinationLaunchpad = toMintMetadata.launchpadMetadata + ?: throw IllegalStateException("destination mint has no launchpad metadata") + + val serverParams = extractServerParameters(serverParameters) + + val sourceTimelockAccounts = fromMintMetadata.timelockSwapAccounts(authority) + + val createTemporaryCoreMintAta = AssociatedTokenProgram_CreateIdempotent( + subsidizer = serverParams.payer, + owner = swapAuthority, + mint = coreMintMetadata.address, + ) + + val createTemporarySourceCurrencyAta = AssociatedTokenProgram_CreateIdempotent( + subsidizer = serverParams.payer, + owner = swapAuthority, + mint = fromMintMetadata.address, + ) + + return buildList { + // 1. System::AdvanceNonce + add(SystemProgram_AdvanceNonce(nonce, serverParams.payer).instruction()) + + // 2. ComputeBudget::SetComputeUnitLimit + add(ComputeBudgetProgram_SetComputeUnitLimit(units = serverParams.computeUnitLimit).instruction()) + + // 3. ComputeBudget::SetComputeUnitPrice + add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = serverParams.computeUnitPrice).instruction()) + + // 4. Memo::Memo + add(MemoProgram_Memo(message = serverParams.memo).instruction()) + + // 5. AssociatedTokenAccount::CreateIdempotent (temporary core mint ATA) + add(createTemporaryCoreMintAta.instruction()) + + // 6. AssociatedTokenAccount::CreateIdempotent (temporary source mint ATA) + add(createTemporarySourceCurrencyAta.instruction()) + + // 7. VM::TransferForSwap (source VM swap ATA -> temporary source mint ATA) + add( + VirtualMachineProgram_TransferForSwap( + vmAuthority = sourceVm.authority, + vm = sourceVm.vm, + swapper = authority, + swapPda = sourceTimelockAccounts.pda.publicKey, + swapAta = sourceTimelockAccounts.ata.publicKey, + destination = createTemporarySourceCurrencyAta.address, + amount = amount, + bump = sourceTimelockAccounts.pda.bump, + ).instruction() + ) + + // 8. Reserve::SellTokens (sell source mint -> core mint into the temporary core mint ATA) + add( + CurrencyCreatorProgram_SellTokens( + inAmount = amount, + minOutAmount = 0, + seller = swapAuthority, + pool = sourceLaunchpad.liquidityPool, + targetMint = fromMintMetadata.address, + baseMint = coreMintMetadata.address, + vaultTarget = sourceLaunchpad.mintVault, + vaultBase = sourceLaunchpad.coreMintVault, + sellerTarget = createTemporarySourceCurrencyAta.address, + sellerBase = createTemporaryCoreMintAta.address, + ).instruction() + ) + + // 9. Reserve::BuyAndDepositIntoVm (buy destination mint funded by the temporary core mint ATA) + add( + CurrencyCreatorProgram_BuyAndDepositIntoVm( + inAmount = 0, + minOutAmount = 0, + vmMemoryIndex = serverParams.memoryIndex, + buyer = swapAuthority, + pool = destinationLaunchpad.liquidityPool, + targetMint = toMintMetadata.address, + baseMint = coreMintMetadata.address, + vaultTarget = destinationLaunchpad.mintVault, + vaultBase = destinationLaunchpad.coreMintVault, + buyerBase = createTemporaryCoreMintAta.address, + vmAuthority = destinationVm.authority, + vm = destinationVm.vm, + vmMemory = serverParams.memoryAccount, + vmOmnibus = destinationVm.omnibus, + vtaOwner = authority, + ).instruction() + ) + + // 10. Token::CloseAccount (close temporary core mint ATA) + add( + TokenProgram_CloseAccount( + account = createTemporaryCoreMintAta.address, + destination = serverParams.payer, + owner = swapAuthority, + ).instruction() + ) + + // 11. Token::CloseAccount (close temporary source mint ATA) + add( + TokenProgram_CloseAccount( + account = createTemporarySourceCurrencyAta.address, + destination = serverParams.payer, + owner = swapAuthority, + ).instruction() + ) + + // 12. VM::CloseSwapAccountIfEmpty (close source VM swap ATA if empty) + add( + VirtualMachineProgram_CloseSwapAccountIfEmpty( + vmAuthority = sourceVm.authority, + vm = sourceVm.vm, + swapper = authority, + swapPda = sourceTimelockAccounts.pda.publicKey, + swapAta = sourceTimelockAccounts.ata.publicKey, + destination = serverParams.payer, + bump = sourceTimelockAccounts.pda.bump, + ).instruction() + ) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt index 5197a8389..b2fcfc36c 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/solana/swap/NewCurrencyBuyInstructions.kt @@ -15,6 +15,7 @@ import com.getcode.opencode.internal.solana.programs.ComputeBudgetProgram_SetCom import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_BuyTokens import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_InitializeCurrency import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_InitializePool +import com.getcode.opencode.internal.solana.programs.CurrencyCreatorProgram_SellTokens import com.getcode.opencode.internal.solana.programs.SystemProgram_AdvanceNonce import com.getcode.opencode.internal.solana.programs.TokenProgram_CloseAccount import com.getcode.opencode.internal.solana.programs.VirtualMachineProgram_InitVm @@ -213,3 +214,159 @@ internal fun buildNewCurrencyBuyInstructions( ) } } + +/** + * Builds the list of instructions for the treasury-funded new currency purchase flow, used when + * the initial buy for a new currency is funded from a source mint other than the core mint (i.e. + * a cross-currency purchase). This mirrors the server's + * `ReserveTreasuryFundedCreateAndBuySwapHandler`. + * + * Unlike [buildNewCurrencyBuyInstructions], the destination currency, its VM, and the buyer's + * destination VM deposit ATA are initialized in a prior transaction, so they are only referenced + * here. The full swap + fee amount of the source mint is collected into a server-controlled + * treasury, sold for the core mint (proceeds routed to the fee destination), and the new currency + * is then bought using the treasury's own core mint holdings for a pre-coordinated + * [StatefulSwapResponseServerParameters.NewCurrency.treasuryPurchaseAmount]. The treasury signs the + * sell/buy instructions server-side; the client only signs as the swapper. + * + * Instruction format (matches the server's "all other cases" reserve new currency layout): + * 1. System::AdvanceNonce + * 2. ComputeBudget::SetComputeUnitLimit + * 3. ComputeBudget::SetComputeUnitPrice + * 4. AssociatedTokenAccount::CreateIdempotent (open treasury's from_mint ATA) + * 5. VM::TransferForSwapWithFee (from_mint VM swap ATA -> treasury's from_mint ATA (swap + fee)) + * 6. Reserve::SellTokens (sell the full swap + fee amount, core mint proceeds -> fee destination) + * 7. Reserve::BuyTokens (buy funded by the treasury's core mint ATA -> to_mint VM deposit ATA) + * + * @param serverParameters Parameters provided by the server for the new currency flow. Must carry a + * non-null [StatefulSwapResponseServerParameters.NewCurrency.treasury]. + * @param nonce The nonce account to use for the transaction. + * @param authority The currency creator / buyer (owner == swapAuthority for the new currency flow). + * @param sourceMintMetadata Metadata for the source (from) mint being sold to fund the purchase. + * @param coreMintMetadata Metadata for the core mint (the reserve base currency). + * @param swapAmount The amount of the source mint to swap (in quarks). + * @param feeAmount The fee amount of the source mint to collect (in quarks). + * @return A list of [Instruction]s to execute the treasury-funded new currency buy. + */ +internal fun buildTreasuryFundedNewCurrencyBuyInstructions( + serverParameters: StatefulSwapResponseServerParameters.NewCurrency, + nonce: PublicKey, + authority: PublicKey, + sourceMintMetadata: MintMetadata, + coreMintMetadata: MintMetadata, + swapAmount: Long, + feeAmount: Long, +): List { + val serverParams = extractServerParameters(serverParameters) + + val treasury = serverParams.treasury + ?: throw IllegalStateException("treasury is required for the treasury-funded new currency buy") + val purchaseCoreQuarks = serverParams.treasuryPurchaseAmount + + val sourceLaunchpad = sourceMintMetadata.launchpadMetadata + ?: throw IllegalStateException("source mint has no launchpad metadata") + val sourceVm = sourceMintMetadata.vmMetadata + + // Derive the destination (new) currency accounts deterministically from the server params. + // The currency, pool, VM and deposit ATA are initialized in a prior transaction and only + // referenced here. + val targetMint = PublicKey.deriveCurrencyMintAddress( + authority = serverParams.authority, + name = serverParams.name, + seed = serverParams.seed, + ).publicKey + + val currencyConfig = PublicKey.deriveCurrencyConfigAddress(targetMint).publicKey + val pool = PublicKey.deriveLiquidityPoolAddress(currencyConfig).publicKey + val vaultTarget = PublicKey.deriveVaultAddress(pool, targetMint).publicKey + val vaultBase = PublicKey.deriveVaultAddress(pool, coreMintMetadata.address).publicKey + + val vm = PublicKey.deriveVirtualMachineAccount( + mint = targetMint, + authority = serverParams.authority, + lockout = serverParams.vmLockDurationInDays.toUByte(), + ).publicKey + val depositPda = PublicKey.deriveDepositAccount(vm, authority) + val vmDepositAta = AssociatedTokenProgram_CreateIdempotent( + subsidizer = serverParams.payer, + owner = depositPda.publicKey, + mint = targetMint, + ).address + + // Treasury accounts. The from_mint ATA is opened by this transaction; the core mint ATA is + // pre-funded server-side and only referenced. + val createTreasuryFromMintAta = AssociatedTokenProgram_CreateIdempotent( + subsidizer = serverParams.payer, + owner = treasury, + mint = sourceMintMetadata.address, + ) + val treasuryCoreMintAta = AssociatedTokenProgram_CreateIdempotent( + subsidizer = serverParams.payer, + owner = treasury, + mint = coreMintMetadata.address, + ).address + + val sourceTimelockAccounts = sourceMintMetadata.timelockSwapAccounts(authority) + + return buildList { + // 1. System::AdvanceNonce + add(SystemProgram_AdvanceNonce(nonce, serverParams.payer).instruction()) + + // 2. ComputeBudget::SetComputeUnitLimit + add(ComputeBudgetProgram_SetComputeUnitLimit(units = serverParams.computeUnitLimit).instruction()) + + // 3. ComputeBudget::SetComputeUnitPrice + add(ComputeBudgetProgram_SetComputeUnitPrice(microLamports = serverParams.computeUnitPrice).instruction()) + + // 4. AssociatedTokenAccount::CreateIdempotent (open treasury's from_mint ATA) + add(createTreasuryFromMintAta.instruction()) + + // 5. VM::TransferForSwapWithFee (from_mint VM swap ATA -> treasury's from_mint ATA (swap + fee)) + add( + VirtualMachineProgram_TransferForSwapWithFee( + vmAuthority = sourceVm.authority, + vm = sourceVm.vm, + swapper = authority, + swapPda = sourceTimelockAccounts.pda.publicKey, + swapAta = sourceTimelockAccounts.ata.publicKey, + destination = createTreasuryFromMintAta.address, + feeDestination = createTreasuryFromMintAta.address, + swapAmount = swapAmount, + feeAmount = feeAmount, + bump = sourceTimelockAccounts.pda.bump, + ).instruction() + ) + + // 6. Reserve::SellTokens (sell full swap + fee of from_mint; core mint proceeds -> fee destination) + add( + CurrencyCreatorProgram_SellTokens( + inAmount = swapAmount + feeAmount, + minOutAmount = 0, + seller = treasury, + pool = sourceLaunchpad.liquidityPool, + targetMint = sourceMintMetadata.address, + baseMint = coreMintMetadata.address, + vaultTarget = sourceLaunchpad.mintVault, + vaultBase = sourceLaunchpad.coreMintVault, + sellerTarget = createTreasuryFromMintAta.address, + sellerBase = serverParams.feeDestination, + ).instruction() + ) + + // 7. Reserve::BuyTokens (buy funded by the treasury's core mint ATA -> to_mint VM deposit ATA) + add( + CurrencyCreatorProgram_BuyTokens( + inAmount = purchaseCoreQuarks, + minOutAmount = 0, + buyer = treasury, + pool = pool, + targetMint = targetMint, + baseMint = coreMintMetadata.address, + vaultTarget = vaultTarget, + vaultBase = vaultBase, + buyerTarget = vmDepositAta, + buyerBase = treasuryCoreMintAta, + ).instruction() + ) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt new file mode 100644 index 000000000..6d12bf00f --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt @@ -0,0 +1,77 @@ +package com.getcode.opencode.model.financial + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * Fiat-domain port of iOS's `LaunchpadSellFeeTests`. Where iOS asserts on token + * quarks, we assert on the fiat value (Android computes the fee in fiat, not + * token quarks — the pool deducts the real fee on-chain). + */ +class LaunchpadSellFeeTest { + + @Test + fun `fee is bps of the amount`() { + val gross = Fiat(20.20) + + val fee = gross.launchpadSellFee(bps = 100) + + // 1% of 20.20 = 0.202 → "$0.20" + assertEquals(gross.quarks / 100, fee.quarks) + assertEquals("$0.20", fee.formatted()) + } + + @Test + fun `a sub-cent fee displays as zero`() { + val tiny = Fiat(0.00005) + + val fee = tiny.launchpadSellFee(bps = 100) + + // Android rounds fiat micro-units rather than flooring token quarks + // (iOS's model), but a sub-threshold fee still shows as $0.00. + assertEquals("$0.00", fee.formatted()) + assertFalse(fee.hasDisplayableValue) + } + + @Test + fun `grossing up nets back to the original after the fee`() { + assertEquals("$20.20", Fiat(20.0).grossingUpLaunchpadSellFee(bps = 100).formatted()) + assertEquals("$10.10", Fiat(10.0).grossingUpLaunchpadSellFee(bps = 100).formatted()) + assertEquals("$0.01", Fiat(0.01).grossingUpLaunchpadSellFee(bps = 100).formatted()) + } + + @Test + fun `buy maximum shape - full balance nets to balance times one minus fee`() { + val balance = Fiat(20.0) + + val fee = balance.launchpadSellFee(bps = 100) + val net = balance - fee + + assertEquals("$0.20", fee.formatted()) + assertEquals("$19.80", net.formatted()) + } + + @Test + fun `grossing up a 100 percent fee returns the amount unchanged instead of dividing by zero`() { + val net = Fiat(20.0) + + assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 10_000)) + } + + @Test + fun `bps above 100 percent is clamped rather than producing a negative gross-up`() { + val net = Fiat(20.0) + + // Clamped to 10_000 → guard returns self, never a negative divisor. + assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 12_000)) + } + + @Test + fun `zero bps is a no-op`() { + val net = Fiat(20.0) + + assertEquals(Fiat.Zero.quarks, net.launchpadSellFee(bps = 0).quarks) + assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 0)) + } +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt new file mode 100644 index 000000000..3fd449ec5 --- /dev/null +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/solana/SolanaTransactionLookupTableTest.kt @@ -0,0 +1,77 @@ +package com.getcode.opencode.solana + +import com.getcode.opencode.model.transactions.AddressLookupTable +import com.getcode.solana.keys.AccountMeta +import com.getcode.solana.keys.Hash +import com.getcode.solana.keys.PublicKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class SolanaTransactionLookupTableTest { + + // Seed lives in byte 0 (rest zero), so lexicographic ordering tracks the + // seed value — enough to make the two tables' accounts sort out of table + // order. + private fun publicKey(seed: Int): PublicKey { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return PublicKey(bytes.toList()) + } + + private fun hash(seed: Int): Hash { + val bytes = ByteArray(32) { if (it == 0) seed.toByte() else 0 } + return Hash(bytes.toList()) + } + + @Test + fun `multi-table lookups compile instruction indexes in table-grouped order`() { + // Loaded accounts resolve on-chain grouped BY TABLE (every table's + // writables, then every table's readonlys) — not in global sort order. + // Arrange keys so the two orderings differ: the second table's accounts + // sort lexicographically BEFORE the first table's. + val payer = publicKey(1) + val program = publicKey(2) + val writableA = publicKey(40) // in table A (sorts after writableB) + val writableB = publicKey(30) // in table B + val readonlyA = publicKey(41) // in table A (sorts after readonlyB) + val readonlyB = publicKey(31) // in table B + + val tableA = AddressLookupTable(publicKey(10), listOf(writableA, readonlyA)) + val tableB = AddressLookupTable(publicKey(11), listOf(writableB, readonlyB)) + + val instruction = Instruction( + program = program, + accounts = listOf( + AccountMeta.writable(writableA), + AccountMeta.writable(writableB), + AccountMeta.readonly(readonlyA), + AccountMeta.readonly(readonlyB), + ), + data = listOf(7.toByte()), + ) + + val transaction = SolanaTransaction.newV0Instance( + payer = payer, + recentBlockhash = hash(9), + addressLookupTables = listOf(tableA, tableB), + instructions = listOf(instruction), + ) + + val message = (transaction.message as Message.VersionedV0).message + + // Static: payer(0), program(1). Loaded: tableA.writable(2), + // tableB.writable(3), tableA.readonly(4), tableB.readonly(5). + assertEquals(listOf(payer, program), message.staticAccountKeys) + assertEquals( + listOf(2, 3, 4, 5), + message.instructions[0].accountIndexes, + ) + + assertEquals(2, message.addressLookupTables.size) + assertEquals(tableA.publicKey, message.addressLookupTables[0].publicKey) + assertEquals(listOf(0), message.addressLookupTables[0].writableIndexes) + assertEquals(listOf(1), message.addressLookupTables[0].readonlyIndexes) + assertEquals(tableB.publicKey, message.addressLookupTables[1].publicKey) + assertEquals(listOf(0), message.addressLookupTables[1].writableIndexes) + assertEquals(listOf(1), message.addressLookupTables[1].readonlyIndexes) + } +}