From e7f551b8dd4cfe364a15a13956336cfea98ad7c8 Mon Sep 17 00:00:00 2001 From: Minimons Date: Mon, 10 Aug 2026 14:48:03 +0200 Subject: [PATCH] 51: Add exact-input Jupiter Swap V2 service --- .../.openspec.yaml | 2 + .../design.md | 65 ++ .../proposal.md | 26 + .../specs/jupiter-swap-service/spec.md | 94 +++ .../tasks.md | 25 + openspec/specs/jupiter-swap-service/spec.md | 96 +++ .../libs/jupiter/swap/JupiterSwapResult.tjava | 56 ++ .../jupiter/swap/JupiterSwapService.tjava | 51 ++ .../impl/ref/JupiterSwapServiceImpl.tjava | 712 ++++++++++++++++++ 9 files changed, 1127 insertions(+) create mode 100644 openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/design.md create mode 100644 openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/proposal.md create mode 100644 openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/specs/jupiter-swap-service/spec.md create mode 100644 openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/tasks.md create mode 100644 openspec/specs/jupiter-swap-service/spec.md create mode 100644 src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapResult.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava create mode 100644 src/main/tjava/com/r35157/libs/jupiter/swap/impl/ref/JupiterSwapServiceImpl.tjava diff --git a/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/.openspec.yaml b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/design.md b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/design.md new file mode 100644 index 0000000..6a68570 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/design.md @@ -0,0 +1,65 @@ +## Context + +See `proposal.md` for motivation. Issues #48 through #50 already established `SolanaBlockChain` for RPC-backed account and mint metadata, and `SolanaWallet` for isolated transaction signing. The Swap V2 integration must compose these APIs with Jupiter's `/order` and `/execute` endpoints while retaining module-oriented API and implementation package boundaries. + +Jupiter's `/execute` manages transaction landing and can return actual wallet-level totals. Once an execution request has left this process, a transport failure cannot distinguish an unexecuted request from a completed swap whose response was lost. + +## Goals / Non-Goals + +**Goals:** + +- Keep the public API expressed in human-readable ValueTagged amounts while using raw integer units at the Jupiter boundary. +- Validate all locally knowable invariants before wallet signing and execution submission. +- Share the required order pacing across every reference-implementation instance in one JVM. +- Preserve interruption and expose actionable endpoint and response failures through the declared checked exceptions. + +**Non-Goals:** + +- Exact-output swaps, JupiterZ/RFQ, API-key configuration, retries, direct DEX integration, or a reusable throttling subsystem. +- Constructing an Evelyn burner or changing existing Solana wallet/blockchain responsibilities. +- Adding automated tests in this change; the existing repository verification suites will still be run. + +## Decisions + +### Compose the established Solana APIs + +`JupiterSwapServiceImpl` receives `SolanaBlockChain` and `SolanaWallet`. It obtains the wallet address from the wallet, loads each mint account and supply from the blockchain, and passes Jupiter's Base64 transaction through `SolanaWallet.signTransaction`. Duplicating RPC or signing logic inside the Jupiter implementation was rejected because it would bypass the module contracts established by issues #48 through #50. + +### Detect each mint's token program independently + +For both input and output, the implementation reads the mint account owner and matches it against `SolanaSPLTokenProgram`. It then requests supply metadata with that program. This supports legacy SPL Token and Token-2022 pairs in any combination and prevents assumptions based on one side of the pair. + +### Use exact decimal conversion at the boundary + +The requested `BigDecimal` is shifted by the input mint's decimal count and converted with `toBigIntegerExact()`. Actual raw execution totals are parsed as non-negative integers and shifted left with their respective decimal counts. Rounding was rejected because it would silently change the amount authorized by the caller. + +### Keep wire types private to the reference implementation + +Small private records model only the `/order` and `/execute` fields needed for validation and results. Jackson ignores additional response fields, allowing Jupiter to add metadata without expanding the public API. The stable API package contains only `JupiterSwapService` and `JupiterSwapResult`. + +### Use the keyless Swap V2 endpoint and explicitly exclude JupiterZ + +The reference implementation calls `https://api.jup.ag/swap/v2/order` and `/execute` without API-key headers. Every order includes `swapMode=ExactIn` and `excludeRouters=jupiterz`; relying on a router default was rejected because JupiterZ/RFQ support is explicitly deferred to issue #66. + +### Serialize only order-request starts with a JVM-wide gate + +A private static monitor and monotonic timestamp separate request starts by two seconds. Callers wait interruptibly while holding the gate, and the gate remains held for that order exchange so a delayed thread cannot begin out of its reserved sequence. A generalized limiter was rejected as unnecessary scope, and `/execute` bypasses this gate. + +### Treat execution submission as a non-retry boundary + +The implementation performs one `HttpClient.send` for `/execute` and contains no retry loop around it or the whole swap. Any failure after submission propagates, with a message warning that execution may be unknown and balances must be reloaded. Retrying was rejected because it can duplicate a financially consequential action. + +### Validate response integrity in phases + +Mint and amount checks occur before `/order`; order identity, taker, slippage, Base64 transaction content, and transaction metadata checks occur before signing; execution status and explicit result code, signature, and actual totals are checked before constructing the public result. Numeric wire fields that must distinguish an absent value from zero use nullable DTO types. Non-2xx responses retain status and body context, while malformed JSON is wrapped as `IOException`. + +## Risks / Trade-offs + +- [Jupiter may change its wire schema or endpoint behavior] → Decode a minimal tolerant DTO set, but strictly validate every field used for signing and result construction. +- [A submitted execution can succeed despite a local timeout or interruption] → Never retry and report the outcome as unknown so callers reload balances before deciding what to do. +- [A slow order exchange serializes later order callers] → This intentionally small implementation-specific gate prioritizes strict JVM-wide start spacing and remains completely separate from execution requests. +- [Keyless service availability or limits can change] → Surface HTTP response status/body clearly; API-key configuration remains intentionally out of scope. + +## Migration Plan + +This is an additive API and implementation. Downstream composition can instantiate the reference implementation with its existing blockchain and wallet instances. Rollback consists of removing the new package because no existing API or persisted data is changed. diff --git a/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/proposal.md b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/proposal.md new file mode 100644 index 0000000..e8633ef --- /dev/null +++ b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/proposal.md @@ -0,0 +1,26 @@ +## Why + +Nenjim needs a reusable service that can exchange an exact, human-readable SPL-token amount through Jupiter while preserving the repository's existing Solana wallet and blockchain boundaries. Jupiter Swap V2 supplies the quote, transaction construction, and managed execution required for this flow. + +## What Changes + +- Add a public `JupiterSwapService` API and immutable `JupiterSwapResult` value describing the confirmed transaction and actual amounts spent and received. +- Add a reference implementation for exact-input Jupiter Swap V2 `/order`, wallet signing, and `/execute` processing. +- Resolve both mint programs and decimal precision through `SolanaBlockChain`, supporting the legacy SPL Token Program and Token-2022. +- Validate caller input, mint metadata, order integrity, transaction metadata, and execution results before returning success. +- Exclude the `jupiterz` router from every order while JupiterZ/RFQ support remains deferred. +- Enforce a small JVM-wide two-second minimum interval between `/order` requests, without delaying `/execute` or automatically retrying submitted executions. + +## Capabilities + +### New Capabilities + +- `jupiter-swap-service`: Exact-input SPL-token swaps through Jupiter Swap V2, including validation, signing, managed execution, throttling, and failure semantics. + +### Modified Capabilities + +None. + +## Impact + +This adds public API types under `com.r35157.libs.jupiter.swap` and a reference implementation under `com.r35157.libs.jupiter.swap.impl.ref`. It depends on the existing `SolanaBlockChain` and `SolanaWallet` APIs and Jupiter's keyless Swap V2 HTTP endpoints; it does not add API-key configuration, Evelyn burning, RFQ/JupiterZ support, or a general throttling framework. diff --git a/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/specs/jupiter-swap-service/spec.md b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/specs/jupiter-swap-service/spec.md new file mode 100644 index 0000000..72532ce --- /dev/null +++ b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/specs/jupiter-swap-service/spec.md @@ -0,0 +1,94 @@ +## Purpose + +Defines safe exact-input SPL-token swaps through Jupiter Swap V2 using human-readable amounts and an existing Solana wallet. + +## ADDED Requirements + +### Requirement: Public exact-input swap contract +The service SHALL accept distinct input and output SPL mint addresses, a positive human-readable input amount, and a maximum slippage in basis points. It SHALL return the confirmed Solana transaction signature together with the actual human-readable input amount spent and output amount received. + +#### Scenario: Successful exact-input swap +- **WHEN** a caller requests a valid exact-input swap that Jupiter executes successfully +- **THEN** the result contains the confirmed signature and the actual spent and received amounts converted with their respective mint decimal precision + +#### Scenario: Invalid caller input +- **WHEN** either mint is blank, both mints are equal, the amount is null or non-positive, or the maximum slippage is outside 0 through 10000 basis points +- **THEN** the service rejects the request before requesting a Jupiter order + +### Requirement: On-chain mint validation and exact amount conversion +Before requesting an order, the service SHALL load both mint accounts through the Solana blockchain, require each mint to exist and be owned by either the legacy SPL Token Program or Token-2022, and resolve each mint's decimal precision. The service SHALL convert the input amount exactly to a positive raw integer and SHALL reject values that cannot be represented with the input mint's precision. + +#### Scenario: Supported legacy and Token-2022 mints +- **WHEN** the input and output mint accounts are owned by either supported SPL token program and their supplies provide valid decimal precision +- **THEN** the service uses those independently resolved precisions for raw request and human-readable result amounts + +#### Scenario: Missing or unsupported mint +- **WHEN** either mint account is absent or is owned by an unsupported program +- **THEN** the service fails before requesting an order or signing a transaction + +#### Scenario: Fraction smaller than the mint unit +- **WHEN** the requested input amount has a non-zero fraction beyond the input mint's decimal precision +- **THEN** the service rejects the amount rather than rounding it + +### Requirement: Safe Jupiter order acquisition +The service SHALL use Jupiter's keyless Swap V2 order endpoint in `ExactIn` mode with the wallet as taker, the caller's slippage limit, and `excludeRouters=jupiterz`. Across all reference-implementation instances in one JVM, starts of order HTTP requests SHALL be separated by at least two seconds. This limiter SHALL remain local to the implementation and SHALL NOT delay execution requests. + +#### Scenario: Every order excludes JupiterZ +- **WHEN** the service requests an order +- **THEN** the request identifies the exact input and output mints, raw input amount, wallet taker, exact-input mode, slippage limit, and excludes the `jupiterz` router + +#### Scenario: Concurrent service instances request orders +- **WHEN** multiple reference-implementation instances concurrently need Jupiter orders +- **THEN** their order HTTP requests begin at least two seconds apart JVM-wide while execution requests remain unthrottled + +#### Scenario: Order wait is interrupted +- **WHEN** a caller is interrupted while waiting for its permitted order-request time +- **THEN** the service propagates interruption without sending that order request + +### Requirement: Order integrity validation before signing +Before signing, the service SHALL require a successful HTTP response containing a well-formed order whose input mint, output mint, raw input amount, exact-input mode, and taker match the request. The returned slippage SHALL be present, valid, and no greater than the caller's maximum. The service SHALL also require a non-blank, valid Base64 unsigned transaction that decodes to at least one byte, a non-blank request identifier, and a positive valid last block height. Any order build error, malformed body, mismatch, or missing required value SHALL fail before signing. + +#### Scenario: Valid matching order +- **WHEN** Jupiter returns a well-formed order matching both requested mints, the exact raw input amount, wallet taker, and permitted slippage with all required transaction metadata +- **THEN** the service passes the returned unsigned transaction to the configured Solana wallet for signing + +#### Scenario: Mismatching or incomplete order +- **WHEN** an order changes a mint, amount, swap mode, or taker; returns missing, invalid, or excessive slippage; or lacks a valid non-empty Base64 transaction, request identifier, or last valid block height +- **THEN** the service rejects the order without signing or executing it + +#### Scenario: Jupiter order HTTP or decoding failure +- **WHEN** the order endpoint returns a non-success status or malformed JSON +- **THEN** the service reports an I/O failure with useful endpoint response context and does not sign a transaction + +### Requirement: Single-attempt managed execution +After successful signing, the service SHALL reject a blank signed transaction and submit exactly one Swap V2 execution request containing the signed transaction, order request identifier, and last valid block height. The service SHALL never automatically retry after the execution request has been submitted, because transport failure, timeout, or interruption can leave the execution outcome unknown. + +#### Scenario: Signed transaction is executed once +- **WHEN** wallet signing returns a non-blank transaction +- **THEN** the service sends one execution request without applying the order limiter + +#### Scenario: Execution outcome is unknown +- **WHEN** the execution HTTP exchange times out, is interrupted, loses its response, or otherwise fails after submission +- **THEN** the service reports the failure and does not automatically request another order or resubmit execution + +#### Scenario: Blank signed transaction +- **WHEN** wallet signing returns a blank serialized transaction +- **THEN** the service fails without submitting an execution request + +### Requirement: Confirmed execution result validation +The service SHALL return success only when the execution response has status `Success`, explicitly has result code `0`, contains a non-blank transaction signature, and contains positive valid actual total input and output raw amounts. It SHALL convert each actual amount using the independently resolved precision of its mint. Non-success HTTP responses, malformed responses, expired or rejected swaps, failed or contradictory status/code combinations, missing result codes, blank signatures, and invalid result amounts SHALL be reported as failures. + +#### Scenario: Successful execution response +- **WHEN** Jupiter reports status `Success`, code `0`, a signature, and valid actual total input and output amounts +- **THEN** the service returns those actual amounts in human-readable units and the reported transaction signature + +#### Scenario: Jupiter rejects or fails execution +- **WHEN** Jupiter returns a failed status, a missing or non-zero result code, a contradictory status/code combination, expiration, rejection, non-success HTTP status, malformed body, blank signature, or invalid actual amount +- **THEN** the service reports an I/O failure and does not represent the swap as successful + +### Requirement: API and implementation separation +The stable service interface and result value SHALL reside in the Jupiter swap API package, while HTTP DTOs, endpoint handling, throttling, and the reference implementation SHALL remain in the reference-implementation package. + +#### Scenario: Downstream API consumer +- **WHEN** downstream code depends only on the Jupiter swap API package +- **THEN** it can invoke swaps and inspect results without depending on private wire DTOs or reference-implementation mechanics diff --git a/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/tasks.md b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/tasks.md new file mode 100644 index 0000000..21c85b2 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-51-add-exact-input-token-swaps-through-jupiter-swap-v2/tasks.md @@ -0,0 +1,25 @@ +## 1. Public API + +- [x] 1.1 Add the ValueTagged `JupiterSwapService` exact-input method with complete public Javadoc and validation/failure contracts. +- [x] 1.2 Add the immutable `JupiterSwapResult` value containing the signature and actual human-readable amounts. + +## 2. Reference Implementation + +- [x] 2.1 Add the reference implementation with injected `SolanaBlockChain` and `SolanaWallet` dependencies and private Swap V2 wire DTOs. +- [x] 2.2 Validate arguments, both on-chain mint accounts and programs, mint decimal metadata, and exact raw input conversion before order acquisition. +- [x] 2.3 Implement keyless `/order` acquisition in exact-input mode, always excluding JupiterZ, with JVM-wide interruptible two-second request-start pacing. +- [x] 2.4 Validate order identity and transaction metadata before signing through `SolanaWallet`. +- [x] 2.5 Submit a signed transaction once through `/execute`, without retries or order throttling, and validate the confirmed result and actual amounts. + +## 3. Verification + +- [x] 3.1 Compile Detag-generated main and test source sets and run the repository's existing test/check tasks without adding automated tests. +- [x] 3.2 Run strict OpenSpec validation and `git diff --check`. +- [x] 3.3 Review the complete diff for stale imports, generated-file edits, unrelated refactors, tests, and any implementation that violates the module or retry boundaries. + +## 4. Review Follow-up Validation + +- [x] 4.1 Validate the returned taker and nullable slippage before signing. +- [x] 4.2 Base64-decode the returned transaction and reject invalid or empty decoded content before signing. +- [x] 4.3 Require both execution status `Success` and an explicit result code `0`. +- [x] 4.4 Re-run compilation, Detag, existing tests, strict OpenSpec validation, and diff checks without adding tests. diff --git a/openspec/specs/jupiter-swap-service/spec.md b/openspec/specs/jupiter-swap-service/spec.md new file mode 100644 index 0000000..ae36015 --- /dev/null +++ b/openspec/specs/jupiter-swap-service/spec.md @@ -0,0 +1,96 @@ +# jupiter-swap-service Specification + +## Purpose + +Defines safe exact-input SPL-token swaps through Jupiter Swap V2 using human-readable amounts and an existing Solana wallet. + +## Requirements + +### Requirement: Public exact-input swap contract +The service SHALL accept distinct input and output SPL mint addresses, a positive human-readable input amount, and a maximum slippage in basis points. It SHALL return the confirmed Solana transaction signature together with the actual human-readable input amount spent and output amount received. + +#### Scenario: Successful exact-input swap +- **WHEN** a caller requests a valid exact-input swap that Jupiter executes successfully +- **THEN** the result contains the confirmed signature and the actual spent and received amounts converted with their respective mint decimal precision + +#### Scenario: Invalid caller input +- **WHEN** either mint is blank, both mints are equal, the amount is null or non-positive, or the maximum slippage is outside 0 through 10000 basis points +- **THEN** the service rejects the request before requesting a Jupiter order + +### Requirement: On-chain mint validation and exact amount conversion +Before requesting an order, the service SHALL load both mint accounts through the Solana blockchain, require each mint to exist and be owned by either the legacy SPL Token Program or Token-2022, and resolve each mint's decimal precision. The service SHALL convert the input amount exactly to a positive raw integer and SHALL reject values that cannot be represented with the input mint's precision. + +#### Scenario: Supported legacy and Token-2022 mints +- **WHEN** the input and output mint accounts are owned by either supported SPL token program and their supplies provide valid decimal precision +- **THEN** the service uses those independently resolved precisions for raw request and human-readable result amounts + +#### Scenario: Missing or unsupported mint +- **WHEN** either mint account is absent or is owned by an unsupported program +- **THEN** the service fails before requesting an order or signing a transaction + +#### Scenario: Fraction smaller than the mint unit +- **WHEN** the requested input amount has a non-zero fraction beyond the input mint's decimal precision +- **THEN** the service rejects the amount rather than rounding it + +### Requirement: Safe Jupiter order acquisition +The service SHALL use Jupiter's keyless Swap V2 order endpoint in `ExactIn` mode with the wallet as taker, the caller's slippage limit, and `excludeRouters=jupiterz`. Across all reference-implementation instances in one JVM, starts of order HTTP requests SHALL be separated by at least two seconds. This limiter SHALL remain local to the implementation and SHALL NOT delay execution requests. + +#### Scenario: Every order excludes JupiterZ +- **WHEN** the service requests an order +- **THEN** the request identifies the exact input and output mints, raw input amount, wallet taker, exact-input mode, slippage limit, and excludes the `jupiterz` router + +#### Scenario: Concurrent service instances request orders +- **WHEN** multiple reference-implementation instances concurrently need Jupiter orders +- **THEN** their order HTTP requests begin at least two seconds apart JVM-wide while execution requests remain unthrottled + +#### Scenario: Order wait is interrupted +- **WHEN** a caller is interrupted while waiting for its permitted order-request time +- **THEN** the service propagates interruption without sending that order request + +### Requirement: Order integrity validation before signing +Before signing, the service SHALL require a successful HTTP response containing a well-formed order whose input mint, output mint, raw input amount, exact-input mode, and taker match the request. The returned slippage SHALL be present, valid, and no greater than the caller's maximum. The service SHALL also require a non-blank, valid Base64 unsigned transaction that decodes to at least one byte, a non-blank request identifier, and a positive valid last block height. Any order build error, malformed body, mismatch, or missing required value SHALL fail before signing. + +#### Scenario: Valid matching order +- **WHEN** Jupiter returns a well-formed order matching both requested mints, the exact raw input amount, wallet taker, and permitted slippage with all required transaction metadata +- **THEN** the service passes the returned unsigned transaction to the configured Solana wallet for signing + +#### Scenario: Mismatching or incomplete order +- **WHEN** an order changes a mint, amount, swap mode, or taker; returns missing, invalid, or excessive slippage; or lacks a valid non-empty Base64 transaction, request identifier, or last valid block height +- **THEN** the service rejects the order without signing or executing it + +#### Scenario: Jupiter order HTTP or decoding failure +- **WHEN** the order endpoint returns a non-success status or malformed JSON +- **THEN** the service reports an I/O failure with useful endpoint response context and does not sign a transaction + +### Requirement: Single-attempt managed execution +After successful signing, the service SHALL reject a blank signed transaction and submit exactly one Swap V2 execution request containing the signed transaction, order request identifier, and last valid block height. The service SHALL never automatically retry after the execution request has been submitted, because transport failure, timeout, or interruption can leave the execution outcome unknown. + +#### Scenario: Signed transaction is executed once +- **WHEN** wallet signing returns a non-blank transaction +- **THEN** the service sends one execution request without applying the order limiter + +#### Scenario: Execution outcome is unknown +- **WHEN** the execution HTTP exchange times out, is interrupted, loses its response, or otherwise fails after submission +- **THEN** the service reports the failure and does not automatically request another order or resubmit execution + +#### Scenario: Blank signed transaction +- **WHEN** wallet signing returns a blank serialized transaction +- **THEN** the service fails without submitting an execution request + +### Requirement: Confirmed execution result validation +The service SHALL return success only when the execution response has status `Success`, explicitly has result code `0`, contains a non-blank transaction signature, and contains positive valid actual total input and output raw amounts. It SHALL convert each actual amount using the independently resolved precision of its mint. Non-success HTTP responses, malformed responses, expired or rejected swaps, failed or contradictory status/code combinations, missing result codes, blank signatures, and invalid result amounts SHALL be reported as failures. + +#### Scenario: Successful execution response +- **WHEN** Jupiter reports status `Success`, code `0`, a signature, and valid actual total input and output amounts +- **THEN** the service returns those actual amounts in human-readable units and the reported transaction signature + +#### Scenario: Jupiter rejects or fails execution +- **WHEN** Jupiter returns a failed status, a missing or non-zero result code, a contradictory status/code combination, expiration, rejection, non-success HTTP status, malformed body, blank signature, or invalid actual amount +- **THEN** the service reports an I/O failure and does not represent the swap as successful + +### Requirement: API and implementation separation +The stable service interface and result value SHALL reside in the Jupiter swap API package, while HTTP DTOs, endpoint handling, throttling, and the reference implementation SHALL remain in the reference-implementation package. + +#### Scenario: Downstream API consumer +- **WHEN** downstream code depends only on the Jupiter swap API package +- **THEN** it can invoke swaps and inspect results without depending on private wire DTOs or reference-implementation mechanics diff --git a/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapResult.tjava b/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapResult.tjava new file mode 100644 index 0000000..ad9e6f9 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapResult.tjava @@ -0,0 +1,56 @@ +package com.r35157.libs.jupiter.swap; + +import com.r35157.libs.valuetypes.basic.MoneyAmount; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Result of a successfully executed Jupiter token swap. + * + * @param transactionSignature confirmed Solana transaction signature + * @param spentInputTokenAmount actual input-token amount deducted from the + * wallet, in human-readable units + * @param receivedOutputTokenAmount actual output-token amount received by the + * wallet, in human-readable units + */ +public record JupiterSwapResult( + @NotNull ΩSolanaTransactionSignatureΩ transactionSignature, + @NotNull ΩAmountΩ spentInputTokenAmount, + @NotNull ΩAmountΩ receivedOutputTokenAmount +) { + /** + * Validates the successful swap result. + */ + public JupiterSwapResult { + Objects.requireNonNull( + transactionSignature, + "transactionSignature" + ); + Objects.requireNonNull( + spentInputTokenAmount, + "spentInputTokenAmount" + ); + Objects.requireNonNull( + receivedOutputTokenAmount, + "receivedOutputTokenAmount" + ); + + if (transactionSignature.isBlank()) { + throw new IllegalArgumentException( + "Transaction signature must not be blank" + ); + } + if (spentInputTokenAmount.signum() <= 0) { + throw new IllegalArgumentException( + "Spent input-token amount must be greater than zero" + ); + } + if (receivedOutputTokenAmount.signum() <= 0) { + throw new IllegalArgumentException( + "Received output-token amount must be greater than zero" + ); + } + } +} diff --git a/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava b/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava new file mode 100644 index 0000000..9ecc0ab --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/swap/JupiterSwapService.tjava @@ -0,0 +1,51 @@ +package com.r35157.libs.jupiter.swap; + +import com.r35157.libs.valuetypes.basic.MoneyAmount; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.math.BigDecimal; + +/** + * Exchanges an exact human-readable SPL-token amount through Jupiter. + * + *

The service resolves token precision from Solana, obtains and validates a + * Jupiter Swap V2 order, asks its configured wallet to sign the transaction, + * and submits the signed transaction through Jupiter's managed execution + * endpoint.

+ */ +public interface JupiterSwapService { + /** + * Swaps an exact amount of one SPL token for another SPL token. + * + *

The returned amounts are the actual wallet-level amounts reported by + * Jupiter after successful execution, not the quoted amounts. If an error + * or interruption occurs after execution submission, the transaction's + * outcome can be unknown; callers must reload wallet balances before + * deciding whether to initiate another swap.

+ * + * @param inputTokenMint input SPL-token mint address + * @param inputTokenAmount exact input amount in human-readable token units + * @param outputTokenMint output SPL-token mint address + * @param maxSlippageBps maximum accepted slippage from 0 through 10000 + * basis points + * @return confirmed transaction signature and actual amounts spent and + * received + * @throws IllegalArgumentException if a mint, amount, or slippage value is + * invalid + * @throws IllegalStateException if a mint does not exist, is owned by an + * unsupported token program, or has invalid + * metadata + * @throws IOException if Solana or Jupiter communication, response + * decoding, signing, or execution fails + * @throws InterruptedException if order pacing, Solana access, signing, or + * Jupiter communication is interrupted + */ + @NotNull + JupiterSwapResult swap( + @NotNull ΩSPLMintAddressΩ inputTokenMint, + @NotNull ΩAmountΩ inputTokenAmount, + @NotNull ΩSPLMintAddressΩ outputTokenMint, + int maxSlippageBps + ) throws IOException, InterruptedException; +} diff --git a/src/main/tjava/com/r35157/libs/jupiter/swap/impl/ref/JupiterSwapServiceImpl.tjava b/src/main/tjava/com/r35157/libs/jupiter/swap/impl/ref/JupiterSwapServiceImpl.tjava new file mode 100644 index 0000000..bb367e3 --- /dev/null +++ b/src/main/tjava/com/r35157/libs/jupiter/swap/impl/ref/JupiterSwapServiceImpl.tjava @@ -0,0 +1,712 @@ +package com.r35157.libs.jupiter.swap.impl.ref; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.r35157.cryptowallet.solana.SolanaWallet; +import com.r35157.libs.jupiter.swap.JupiterSwapResult; +import com.r35157.libs.jupiter.swap.JupiterSwapService; +import com.r35157.libs.solana.SPLTokenSupply; +import com.r35157.libs.solana.SolanaAccountInfo; +import com.r35157.libs.solana.SolanaBlockChain; +import com.r35157.libs.solana.SolanaSignedTransaction; +import com.r35157.libs.solana.SolanaUnsignedTransaction; +import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram; +import com.r35157.libs.valuetypes.basic.MoneyAmount; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Reference implementation of exact-input token swaps through Jupiter Swap + * V2. + */ +public final class JupiterSwapServiceImpl implements JupiterSwapService { + /** + * Creates a Jupiter swap service using the supplied Solana services. + * + * @param solanaBlockChain blockchain access used to resolve mint metadata + * @param solanaWallet wallet used as taker and transaction signer + */ + public JupiterSwapServiceImpl( + @NotNull SolanaBlockChain solanaBlockChain, + @NotNull SolanaWallet solanaWallet + ) { + this.solanaBlockChain = Objects.requireNonNull( + solanaBlockChain, + "solanaBlockChain" + ); + this.solanaWallet = Objects.requireNonNull( + solanaWallet, + "solanaWallet" + ); + } + + @Override + public @NotNull JupiterSwapResult swap( + @NotNull ΩSPLMintAddressΩ inputTokenMint, + @NotNull ΩAmountΩ inputTokenAmount, + @NotNull ΩSPLMintAddressΩ outputTokenMint, + int maxSlippageBps + ) throws IOException, InterruptedException { + validateArguments( + inputTokenMint, + inputTokenAmount, + outputTokenMint, + maxSlippageBps + ); + + MintMetadata inputMetadata = resolveMint(inputTokenMint); + MintMetadata outputMetadata = resolveMint(outputTokenMint); + BigInteger rawInputAmount = toRawInputAmount( + inputTokenAmount, + inputMetadata.decimals() + ); + ΩSolanaWalletIdΩ taker = solanaWallet.getAddress(); + + OrderResponse order = requestOrder( + inputTokenMint, + outputTokenMint, + rawInputAmount, + taker, + maxSlippageBps + ); + ValidatedOrder validatedOrder = validateOrder( + order, + inputTokenMint, + outputTokenMint, + rawInputAmount, + taker, + maxSlippageBps + ); + + SolanaUnsignedTransaction unsignedTransaction = + new SolanaUnsignedTransaction( + validatedOrder.transaction(), + null, + validatedOrder.lastValidBlockHeight() + ); + SolanaSignedTransaction signedTransaction = + solanaWallet.signTransaction(unsignedTransaction); + + if (signedTransaction == null + || signedTransaction.serializedTransaction() == null + || signedTransaction.serializedTransaction().isBlank()) { + throw new IOException( + "Solana wallet returned a blank signed Jupiter " + + "transaction" + ); + } + + ExecuteResponse execution = executeOnce( + signedTransaction.serializedTransaction(), + validatedOrder.requestId(), + validatedOrder.lastValidBlockHeight() + ); + + return validateExecution( + execution, + inputMetadata.decimals(), + outputMetadata.decimals() + ); + } + + private static void validateArguments( + ΩSPLMintAddressΩ inputTokenMint, + BigDecimal inputTokenAmount, + ΩSPLMintAddressΩ outputTokenMint, + int maxSlippageBps + ) { + if (inputTokenMint == null || inputTokenMint.isBlank()) { + throw new IllegalArgumentException( + "Input SPL-token mint address must not be blank" + ); + } + if (outputTokenMint == null || outputTokenMint.isBlank()) { + throw new IllegalArgumentException( + "Output SPL-token mint address must not be blank" + ); + } + if (inputTokenMint.equals(outputTokenMint)) { + throw new IllegalArgumentException( + "Input and output SPL-token mints must be different" + ); + } + if (inputTokenAmount == null || inputTokenAmount.signum() <= 0) { + throw new IllegalArgumentException( + "Input token amount must be greater than zero" + ); + } + if (maxSlippageBps < 0 || maxSlippageBps > MAX_SLIPPAGE_BPS) { + throw new IllegalArgumentException( + "Maximum slippage must be between 0 and " + + MAX_SLIPPAGE_BPS + + " basis points" + ); + } + } + + private MintMetadata resolveMint(ΩSPLMintAddressΩ mintAddress) + throws IOException, InterruptedException { + SolanaAccountInfo mintAccount = solanaBlockChain.getAccountInfo( + mintAddress + ); + if (mintAccount == null) { + throw new IllegalStateException( + "SPL-token mint account does not exist: " + mintAddress + ); + } + + SolanaSPLTokenProgram tokenProgram = null; + for (SolanaSPLTokenProgram candidate + : SolanaSPLTokenProgram.values()) { + if (candidate.getAddress().equals(mintAccount.owner())) { + tokenProgram = candidate; + break; + } + } + if (tokenProgram == null) { + throw new IllegalStateException( + "SPL-token mint is not owned by a supported token " + + "program: " + + mintAddress + + " (owner " + + mintAccount.owner() + + ")" + ); + } + + SPLTokenSupply supply = solanaBlockChain.getSPLTokenSupply( + mintAddress, + tokenProgram + ); + if (supply == null) { + throw new IllegalStateException( + "Solana returned no supply metadata for mint " + + mintAddress + ); + } + if (!mintAddress.equals(supply.mintAddress())) { + throw new IllegalStateException( + "Solana returned supply metadata for an unexpected mint: " + + supply.mintAddress() + ); + } + if (!tokenProgram.getAddress().equals(supply.programId())) { + throw new IllegalStateException( + "Solana returned an unexpected token program for mint " + + mintAddress + ); + } + if (supply.decimals() < 0) { + throw new IllegalStateException( + "Solana returned negative decimal precision for mint " + + mintAddress + ); + } + + return new MintMetadata(supply.decimals()); + } + + private static BigInteger toRawInputAmount( + BigDecimal inputTokenAmount, + int decimals + ) { + BigInteger rawAmount; + try { + rawAmount = inputTokenAmount + .movePointRight(decimals) + .toBigIntegerExact(); + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + "Input token amount cannot be represented exactly with " + + decimals + + " decimal places", + e + ); + } + + if (rawAmount.signum() <= 0) { + throw new IllegalArgumentException( + "Input token amount is smaller than the mint's smallest " + + "unit" + ); + } + return rawAmount; + } + + private OrderResponse requestOrder( + ΩSPLMintAddressΩ inputTokenMint, + ΩSPLMintAddressΩ outputTokenMint, + BigInteger rawInputAmount, + ΩSolanaWalletIdΩ taker, + int maxSlippageBps + ) throws IOException, InterruptedException { + URI uri = URI.create( + ORDER_ENDPOINT + + "?inputMint=" + + encode(inputTokenMint) + + "&outputMint=" + + encode(outputTokenMint) + + "&amount=" + + rawInputAmount + + "&taker=" + + encode(taker) + + "&swapMode=ExactIn" + + "&slippageBps=" + + maxSlippageBps + + "&excludeRouters=jupiterz" + ); + HttpRequest request = HttpRequest.newBuilder() + .uri(uri) + .timeout(HTTP_TIMEOUT) + .header("Accept", "application/json") + .header("x-client-platform", "nenjim-hub") + .GET() + .build(); + + HttpResponse response; + synchronized (ORDER_REQUEST_GATE) { + awaitOrderRequestPermit(); + response = HTTP_CLIENT.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + } + requireSuccess("Jupiter order", response); + + try { + return OBJECT_MAPPER.readValue( + response.body(), + OrderResponse.class + ); + } catch (JsonProcessingException e) { + throw new IOException( + "Unable to decode Jupiter order response: " + + response.body(), + e + ); + } + } + + private static ValidatedOrder validateOrder( + OrderResponse order, + ΩSPLMintAddressΩ expectedInputMint, + ΩSPLMintAddressΩ expectedOutputMint, + BigInteger expectedInputAmount, + ΩSolanaWalletIdΩ expectedTaker, + int maxSlippageBps + ) throws IOException { + if (order == null) { + throw new IOException("Jupiter returned no order"); + } + if (order.errorCode() != null + || (order.error() != null && !order.error().isBlank())) { + throw new IOException( + "Jupiter could not build the order" + + optionalError(order) + ); + } + if (!expectedInputMint.equals(order.inputMint())) { + throw new IOException( + "Jupiter order input mint does not match the request: " + + order.inputMint() + ); + } + if (!expectedOutputMint.equals(order.outputMint())) { + throw new IOException( + "Jupiter order output mint does not match the request: " + + order.outputMint() + ); + } + if (!"ExactIn".equals(order.swapMode())) { + throw new IOException( + "Jupiter order is not an ExactIn swap: " + + order.swapMode() + ); + } + if (order.taker() == null || order.taker().isBlank()) { + throw new IOException( + "Jupiter order did not contain a taker" + ); + } + if (!expectedTaker.equals(order.taker())) { + throw new IOException( + "Jupiter order taker does not match the wallet: " + + order.taker() + ); + } + if (order.slippageBps() == null) { + throw new IOException( + "Jupiter order did not contain slippage basis points" + ); + } + if (order.slippageBps() < 0 + || order.slippageBps() > MAX_SLIPPAGE_BPS) { + throw new IOException( + "Jupiter order contained invalid slippage basis points: " + + order.slippageBps() + ); + } + if (order.slippageBps() > maxSlippageBps) { + throw new IOException( + "Jupiter order slippage exceeds the caller's maximum: " + + order.slippageBps() + + " > " + + maxSlippageBps + ); + } + + BigInteger actualInputAmount = parsePositiveRawAmount( + order.inAmount(), + "Jupiter order input amount" + ); + if (!expectedInputAmount.equals(actualInputAmount)) { + throw new IOException( + "Jupiter order input amount does not match the request: " + + order.inAmount() + ); + } + if (order.transaction() == null || order.transaction().isBlank()) { + throw new IOException( + "Jupiter order did not contain an unsigned transaction" + + optionalError(order) + ); + } + validateUnsignedTransaction(order.transaction()); + if (order.requestId() == null || order.requestId().isBlank()) { + throw new IOException( + "Jupiter order did not contain a request ID" + ); + } + + long lastValidBlockHeight; + try { + lastValidBlockHeight = Long.parseLong( + order.lastValidBlockHeight() + ); + } catch (NumberFormatException | NullPointerException e) { + throw new IOException( + "Jupiter order contained an invalid last valid block " + + "height: " + + order.lastValidBlockHeight(), + e + ); + } + if (lastValidBlockHeight <= 0) { + throw new IOException( + "Jupiter order contained a non-positive last valid block " + + "height: " + + lastValidBlockHeight + ); + } + + return new ValidatedOrder( + order.transaction(), + order.requestId(), + lastValidBlockHeight + ); + } + + private static void validateUnsignedTransaction( + ΩBase64StringΩ transaction + ) throws IOException { + byte[] decodedTransaction; + try { + decodedTransaction = Base64.getDecoder().decode(transaction); + } catch (IllegalArgumentException e) { + throw new IOException( + "Jupiter order contained an invalid Base64 unsigned " + + "transaction", + e + ); + } + if (decodedTransaction.length == 0) { + throw new IOException( + "Jupiter order unsigned transaction decoded to zero bytes" + ); + } + } + + private static ExecuteResponse executeOnce( + ΩBase64StringΩ signedTransaction, + String requestId, + long lastValidBlockHeight + ) throws IOException, InterruptedException { + ExecuteRequest requestBody = new ExecuteRequest( + signedTransaction, + requestId, + Long.toString(lastValidBlockHeight) + ); + String json = OBJECT_MAPPER.writeValueAsString(requestBody); + HttpRequest request = HttpRequest.newBuilder() + .uri(EXECUTE_ENDPOINT) + .timeout(HTTP_TIMEOUT) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("x-client-platform", "nenjim-hub") + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + + HttpResponse response; + try { + response = HTTP_CLIENT.send( + request, + HttpResponse.BodyHandlers.ofString() + ); + } catch (InterruptedException e) { + InterruptedException failure = new InterruptedException( + "Jupiter execution was interrupted after submission; " + + "the swap outcome is unknown and wallet " + + "balances must be reloaded before another swap" + ); + failure.initCause(e); + throw failure; + } catch (IOException e) { + throw new IOException( + "Jupiter execution failed after submission; the swap " + + "outcome is unknown and wallet balances must be " + + "reloaded before another swap", + e + ); + } + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException( + "Jupiter execution returned HTTP " + + response.statusCode() + + " after submission; the swap outcome may be " + + "unknown: " + + response.body() + ); + } + + try { + return OBJECT_MAPPER.readValue( + response.body(), + ExecuteResponse.class + ); + } catch (JsonProcessingException e) { + throw new IOException( + "Unable to decode Jupiter execution response after " + + "submission; the swap outcome may be unknown: " + + response.body(), + e + ); + } + } + + private static JupiterSwapResult validateExecution( + ExecuteResponse execution, + int inputDecimals, + int outputDecimals + ) throws IOException { + if (execution == null) { + throw new IOException( + "Jupiter returned no execution result after submission" + ); + } + if (!"Success".equals(execution.status())) { + throw new IOException( + "Jupiter execution failed" + + executionFailureDetails(execution) + ); + } + if (execution.code() == null) { + throw new IOException( + "Successful Jupiter execution did not contain a result " + + "code" + ); + } + if (execution.code() != 0) { + throw new IOException( + "Jupiter execution returned Success with a non-zero " + + "result code" + + executionFailureDetails(execution) + ); + } + if (execution.signature() == null + || execution.signature().isBlank()) { + throw new IOException( + "Successful Jupiter execution did not contain a " + + "transaction signature" + ); + } + + BigInteger spentRaw = parsePositiveRawAmount( + execution.totalInputAmount(), + "Jupiter execution total input amount" + ); + BigInteger receivedRaw = parsePositiveRawAmount( + execution.totalOutputAmount(), + "Jupiter execution total output amount" + ); + + return new JupiterSwapResult( + execution.signature(), + new BigDecimal(spentRaw).movePointLeft(inputDecimals), + new BigDecimal(receivedRaw).movePointLeft(outputDecimals) + ); + } + + private static BigInteger parsePositiveRawAmount( + String value, + String description + ) throws IOException { + BigInteger amount; + try { + amount = new BigInteger(value); + } catch (NumberFormatException | NullPointerException e) { + throw new IOException( + description + " is invalid: " + value, + e + ); + } + if (amount.signum() <= 0) { + throw new IOException( + description + " must be greater than zero: " + value + ); + } + return amount; + } + + private static void requireSuccess( + String operation, + HttpResponse response + ) throws IOException { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException( + operation + + " request failed with HTTP " + + response.statusCode() + + ": " + + response.body() + ); + } + } + + private static String optionalError(OrderResponse order) { + StringBuilder detail = new StringBuilder(); + if (order.errorCode() != null) { + detail.append(" (error code ").append(order.errorCode()).append(')'); + } + if (order.errorMessage() != null && !order.errorMessage().isBlank()) { + detail.append(": ").append(order.errorMessage()); + } else if (order.error() != null && !order.error().isBlank()) { + detail.append(": ").append(order.error()); + } + return detail.toString(); + } + + private static String executionFailureDetails(ExecuteResponse response) { + StringBuilder detail = new StringBuilder(); + if (response.code() != null) { + detail.append(" (code ").append(response.code()).append(')'); + } + if (response.error() != null && !response.error().isBlank()) { + detail.append(": ").append(response.error()); + } else if (response.status() != null && !response.status().isBlank()) { + detail.append(": status ").append(response.status()); + } + return detail.toString(); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static void awaitOrderRequestPermit() + throws InterruptedException { + while (lastOrderRequestStartNanos != NO_REQUEST_YET) { + long elapsed = System.nanoTime() + - lastOrderRequestStartNanos; + long remaining = ORDER_INTERVAL_NANOS - elapsed; + if (remaining <= 0) { + break; + } + TimeUnit.NANOSECONDS.sleep(remaining); + } + lastOrderRequestStartNanos = System.nanoTime(); + } + + private record MintMetadata(int decimals) { + } + + private record ValidatedOrder( + ΩBase64StringΩ transaction, + String requestId, + long lastValidBlockHeight + ) { + } + + private record OrderResponse( + String inputMint, + String outputMint, + String inAmount, + String swapMode, + String taker, + Integer slippageBps, + ΩBase64StringΩ transaction, + String requestId, + String lastValidBlockHeight, + Integer errorCode, + String errorMessage, + String error + ) { + } + + private record ExecuteRequest( + ΩBase64StringΩ signedTransaction, + String requestId, + String lastValidBlockHeight + ) { + } + + private record ExecuteResponse( + String status, + ΩSolanaTransactionSignatureΩ signature, + String totalInputAmount, + String totalOutputAmount, + Integer code, + String error + ) { + } + + private static final URI ORDER_ENDPOINT = URI.create( + "https://api.jup.ag/swap/v2/order" + ); + private static final URI EXECUTE_ENDPOINT = URI.create( + "https://api.jup.ag/swap/v2/execute" + ); + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(HTTP_TIMEOUT) + .build(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() + .configure( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + false + ); + private static final Object ORDER_REQUEST_GATE = new Object(); + private static final long ORDER_INTERVAL_NANOS = + TimeUnit.SECONDS.toNanos(2); + private static final long NO_REQUEST_YET = Long.MIN_VALUE; + private static final int MAX_SLIPPAGE_BPS = 10_000; + + private static long lastOrderRequestStartNanos = NO_REQUEST_YET; + + private final SolanaBlockChain solanaBlockChain; + private final SolanaWallet solanaWallet; +}