From d0a162335c8d1c182d27ce6f0d1d4882e6c19696 Mon Sep 17 00:00:00 2001 From: nyg Date: Mon, 21 Sep 2026 18:59:06 +0200 Subject: [PATCH] feat: complete typed Funding (Beta) API --- AGENTS.md | 4 +- README.md | 48 +- docs/ARCHITECTURE.md | 60 +- .../kraken/example/SimpleExamples.java | 11 + .../dev/andstuff/kraken/api/KrakenAPI.java | 337 ++++++++++- .../kraken/api/endpoint/Endpoint.java | 4 +- .../kraken/api/endpoint/KrakenException.java | 2 +- .../ClaimFundingDepositAddressEndpoint.java | 21 + .../CreateFundingAddressEndpoint.java | 21 + .../CreateFundingWithdrawalEndpoint.java | 21 + .../DeleteFundingAddressEndpoint.java | 30 + .../fundingbeta/FundingAddressesEndpoint.java | 28 + .../fundingbeta/FundingAssetsEndpoint.java | 31 + .../fundingbeta/FundingBetaEndpoint.java | 103 ++++ .../fundingbeta/FundingBetaParams.java | 82 +++ .../FundingDepositAddressesEndpoint.java | 28 + .../FundingDepositLimitsEndpoint.java | 21 + .../fundingbeta/FundingDepositsEndpoint.java | 28 + .../fundingbeta/FundingFeesEndpoint.java | 21 + .../fundingbeta/FundingMethodsEndpoint.java | 31 + .../fundingbeta/FundingNetworksEndpoint.java | 28 + .../FundingWithdrawalLimitsEndpoint.java | 21 + .../FundingWithdrawalsEndpoint.java | 28 + .../UpdateFundingAddressEndpoint.java | 21 + .../endpoint/fundingbeta/params/Asset.java | 35 ++ .../fundingbeta/params/AssetAmount.java | 25 + .../fundingbeta/params/AssetClass.java | 21 + .../ClaimFundingDepositAddressParams.java | 42 ++ .../params/CreateFundingAddressParams.java | 77 +++ .../params/CreateFundingWithdrawalParams.java | 123 ++++ .../params/DeleteFundingAddressParams.java | 35 ++ .../fundingbeta/params/Direction.java | 21 + .../params/FundingAddressesParams.java | 48 ++ .../params/FundingAssetsParams.java | 41 ++ .../params/FundingDepositAddressesParams.java | 63 +++ .../params/FundingDepositsParams.java | 76 +++ .../fundingbeta/params/FundingFeesParams.java | 62 ++ .../params/FundingLimitsParams.java | 49 ++ .../params/FundingMethodsParams.java | 62 ++ .../params/FundingNetworksParams.java | 28 + .../params/FundingWithdrawalStatus.java | 22 + .../params/FundingWithdrawalsParams.java | 82 +++ .../endpoint/fundingbeta/params/Scope.java | 77 +++ .../params/UpdateFundingAddressParams.java | 53 ++ .../fundingbeta/response/AddressDetails.java | 134 +++++ .../ClaimedFundingDepositAddress.java | 10 + .../response/FundingAddressCreated.java | 12 + .../response/FundingAddressDeleted.java | 10 + .../response/FundingAddressUpdated.java | 8 + .../response/FundingAddresses.java | 47 ++ .../fundingbeta/response/FundingAssets.java | 22 + .../response/FundingDepositAddresses.java | 33 ++ .../response/FundingDepositLimits.java | 12 + .../fundingbeta/response/FundingDeposits.java | 52 ++ .../fundingbeta/response/FundingFees.java | 32 ++ .../fundingbeta/response/FundingMethods.java | 109 ++++ .../fundingbeta/response/FundingNetworks.java | 35 ++ .../response/FundingWithdrawalCreated.java | 31 + .../response/FundingWithdrawalLimits.java | 16 + .../response/FundingWithdrawals.java | 42 ++ .../fundingbeta/response/LimitValue.java | 40 ++ .../response/LimitValueDeserializer.java | 34 ++ .../fundingbeta/response/MethodLimits.java | 80 +++ .../api/rest/DefaultKrakenRestRequester.java | 61 +- .../kraken/api/rest/KrakenCredentials.java | 16 +- .../kraken/api/rest/KrakenRestRequester.java | 16 + .../kraken/api/KrakenAPIFundingBetaTest.java | 529 ++++++++++++++++++ ...laimFundingDepositAddressEndpointTest.java | 83 +++ .../CreateFundingAddressEndpointTest.java | 86 +++ .../CreateFundingWithdrawalEndpointTest.java | 156 ++++++ .../DeleteFundingAddressEndpointTest.java | 67 +++ .../FundingAddressesEndpointTest.java | 114 ++++ .../FundingAssetsEndpointTest.java | 71 +++ .../FundingDepositAddressesEndpointTest.java | 102 ++++ .../FundingDepositLimitsEndpointTest.java | 104 ++++ .../FundingDepositsEndpointTest.java | 101 ++++ .../fundingbeta/FundingFeesEndpointTest.java | 86 +++ .../FundingMethodsEndpointTest.java | 118 ++++ .../FundingNetworksEndpointTest.java | 68 +++ .../FundingWithdrawalLimitsEndpointTest.java | 71 +++ .../FundingWithdrawalsEndpointTest.java | 105 ++++ .../UpdateFundingAddressEndpointTest.java | 68 +++ ...ultKrakenRestRequesterFundingBetaTest.java | 175 ++++++ .../api/rest/KrakenCredentialsTest.java | 19 + .../fundingbeta/CalculateFundingFees.json | 34 ++ .../ClaimFundingDepositAddress.json | 7 + .../fundingbeta/CreateFundingAddress.json | 4 + .../fundingbeta/CreateFundingWithdrawal.json | 30 + .../fundingbeta/DeleteFundingAddress.json | 3 + .../fundingbeta/ListFundingAddresses.json | 44 ++ .../fundingbeta/ListFundingAssets.json | 30 + .../ListFundingClaimedAddresses.json | 13 + .../fundingbeta/ListFundingDepositLimits.json | 19 + .../fundingbeta/ListFundingDeposits.json | 47 ++ .../fundingbeta/ListFundingMethods.json | 36 ++ .../fundingbeta/ListFundingNetworks.json | 27 + .../ListFundingWithdrawalLimits.json | 99 ++++ .../fundingbeta/ListFundingWithdrawals.json | 47 ++ .../src/test/resources/fundingbeta/README.md | 1 + .../fundingbeta/UpdateFundingAddress.json | 3 + 100 files changed, 5477 insertions(+), 14 deletions(-) create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpoint.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Asset.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetAmount.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetClass.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/ClaimFundingDepositAddressParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingAddressParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingWithdrawalParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/DeleteFundingAddressParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Direction.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAddressesParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAssetsParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositAddressesParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositsParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingFeesParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingLimitsParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingMethodsParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingNetworksParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalStatus.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalsParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Scope.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/UpdateFundingAddressParams.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/AddressDetails.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/ClaimedFundingDepositAddress.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressCreated.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressDeleted.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressUpdated.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddresses.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAssets.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositAddresses.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositLimits.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDeposits.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingFees.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingMethods.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingNetworks.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalCreated.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalLimits.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawals.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValue.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValueDeserializer.java create mode 100644 library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/MethodLimits.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/KrakenAPIFundingBetaTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpointTest.java create mode 100644 library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterFundingBetaTest.java create mode 100644 library/src/test/resources/fundingbeta/CalculateFundingFees.json create mode 100644 library/src/test/resources/fundingbeta/ClaimFundingDepositAddress.json create mode 100644 library/src/test/resources/fundingbeta/CreateFundingAddress.json create mode 100644 library/src/test/resources/fundingbeta/CreateFundingWithdrawal.json create mode 100644 library/src/test/resources/fundingbeta/DeleteFundingAddress.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingAddresses.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingAssets.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingClaimedAddresses.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingDepositLimits.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingDeposits.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingMethods.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingNetworks.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingWithdrawalLimits.json create mode 100644 library/src/test/resources/fundingbeta/ListFundingWithdrawals.json create mode 100644 library/src/test/resources/fundingbeta/README.md create mode 100644 library/src/test/resources/fundingbeta/UpdateFundingAddress.json diff --git a/AGENTS.md b/AGENTS.md index f03319a..dd6b18d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,11 @@ Every endpoint extends `Endpoint`, either `PublicEndpoint` (GET on `/0/pub `funding/` contains the private Funding (Legacy) endpoints for deposits, withdrawals and wallet transfers, using the same endpoint, parameter and response layout. `trading/` contains the private Trading endpoints for placing, amending, editing and cancelling orders, the dead man's switch and WebSocket tokens. +`fundingbeta/` contains the Funding (Beta) endpoints, which extend `FundingBetaEndpoint` instead: any HTTP method on `/funding/{path}`, e.g. `GET /funding/v1/methods/withdraw` or `DELETE /funding/v1/addresses/{id}`, with path parameters encoded by the endpoint, query parameters and an optional JSON body from `FundingBetaParams`, and nested query objects in bracket notation, e.g. `asset[class]=currency`. The nonce goes in the `API-Nonce` header, the signed path includes the query string, and responses are not wrapped in the `{error, result}` envelope: HTTP error statuses become a `KrakenException`. Shared value types such as `Asset`, `AssetAmount` and `Scope` live in `fundingbeta/params/` and are used by both parameters and responses. + Parameters are form-encoded by default. Parameters with arrays or nested objects extend `JsonPostParams`, which sends a JSON body with a numeric nonce, and their endpoint overrides `getContentType()` to return `application/json`, e.g. `TradeVolume`, `AddOrderBatch` and `CancelOrderBatch`. -`KrakenRestRequester` performs the HTTP calls and can be swapped for another HTTP client. Responses are unwrapped from the Kraken `{error, result}` envelope by `KrakenResponse`; ZIP responses (report exports) go through `Endpoint.processZipResponse()`. +`KrakenRestRequester` performs the HTTP calls and can be swapped for another HTTP client; its Funding (Beta) `execute` method is a default method throwing `UnsupportedOperationException`, so requesters written before it keep compiling. Responses are unwrapped from the Kraken `{error, result}` envelope by `KrakenResponse`; ZIP responses (report exports) go through `Endpoint.processZipResponse()`. ## Conventions diff --git a/README.md b/README.md index dbc09de..c8126a8 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ KrakenAPI api = new KrakenAPI(); Map books = api.query(new MyOrderBookEndpoint("XBTUSD")); ``` -The endpoint is run through the same `KrakenRestRequester` as the built-in ones, and a `PrivateEndpoint` is signed with the credentials and nonce generator the `KrakenAPI` instance was built with. Querying one on an instance built without credentials throws an `IllegalStateException`. +The endpoint is run through the same `KrakenRestRequester` as the built-in ones, and a `PrivateEndpoint` is signed with the credentials and nonce generator the `KrakenAPI` instance was built with. Querying one on an instance built without credentials throws an `IllegalStateException`. Funding (Beta) endpoints extend `FundingBetaEndpoint` instead, taking the HTTP method and the path under `/funding`, e.g. `new FundingBetaEndpoint<>("GET", "v1/networks", new TypeReference() {})`. Pull requests adding such an endpoint to the library are welcome, see the [architecture documentation](docs/ARCHITECTURE.md). @@ -246,6 +246,49 @@ Status methods return the same response record for paginated objects and unpagin `withdraw` submits a withdrawal to a saved key, `cancelWithdrawal` requests cancellation, and `walletTransfer` moves assets from the Spot Wallet to the Futures Wallet. Withdrawal parameters support address confirmation and `maxFee`. A false cancellation result means Kraken did not accept the cancellation. Deposit address parameters support generating a new address and specifying the amount for Lightning invoices; responses preserve destination tags and memos. +### Funding (Beta) + +All 15 [Funding (Beta)](https://docs.kraken.com/api-reference/funding-beta/list-funding-methods) operations have typed methods. They use stable method, network and address identifiers, withdrawal addresses saved for a method, a network or a network group, and fee quotes that pin the fee rate of a withdrawal. + +| Operation | Typed method | Response | +|---|---|---| +| `GET /funding/v1/methods/{direction}` | `fundingMethods(direction)` / `fundingMethods(params)` | `FundingMethods` | +| `GET /funding/v1/assets/{direction}` | `fundingAssets(direction)` / `fundingAssets(params)` | `FundingAssets` | +| `GET /funding/v1/networks` | `fundingNetworks()` / `fundingNetworks(params)` | `FundingNetworks` | +| `GET /funding/v1/fees/{method_id}` | `fundingFees(params)` | `FundingFees` | +| `GET /funding/v1/limits/deposit/{asset_class}/{asset}` | `fundingDepositLimits(params)` | `FundingDepositLimits` | +| `GET /funding/v1/limits/withdrawal/{asset_class}/{asset}` | `fundingWithdrawalLimits(params)` | `FundingWithdrawalLimits` | +| `PUT /funding/v1/deposit/address` | `claimFundingDepositAddress(params)` | `ClaimedFundingDepositAddress` | +| `GET /funding/v2/deposit/addresses` | `fundingDepositAddresses()` / `fundingDepositAddresses(params)` | `FundingDepositAddresses` | +| `GET /funding/v1/deposits` | `fundingDeposits()` / `fundingDeposits(params)` | `FundingDeposits` | +| `GET /funding/v1/addresses` | `fundingAddresses()` / `fundingAddresses(params)` | `FundingAddresses` | +| `POST /funding/v1/addresses` | `createFundingAddress(params)` | `FundingAddressCreated` | +| `PUT /funding/v1/addresses/{id}` | `updateFundingAddress(params)` | `FundingAddressUpdated` | +| `DELETE /funding/v1/addresses/{id}` | `deleteFundingAddress(id)` / `deleteFundingAddress(params)` | `boolean` | +| `GET /funding/v1/withdrawals` | `fundingWithdrawals()` / `fundingWithdrawals(params)` | `FundingWithdrawals` | +| `POST /funding/v1/withdrawals` | `createFundingWithdrawal(params)` | `FundingWithdrawalCreated` | + +```java +FundingMethods methods = api.fundingMethods(FundingMethodsParams.builder() + .direction(Direction.WITHDRAW).asset(new Asset(AssetClass.CURRENCY, "USDC")).build()); +String methodId = methods.methods().getFirst().methodId(); + +FundingAddressCreated address = api.createFundingAddress(CreateFundingAddressParams.builder() + .scope(Scope.network(methods.methods().getFirst().network().networkId())) + .address("0xBef7B36845cA31045E86D0B46DBCac4e6752").name("Hardware wallet").build()); + +FundingFees quote = api.fundingFees(FundingFeesParams.builder() + .methodId(methodId).amount(new BigDecimal("5")).feeIncluded(true).build()); +FundingWithdrawalCreated withdrawal = api.createFundingWithdrawal(CreateFundingWithdrawalParams.builder() + .scope(Scope.method(methodId)).addressId(address.addressId()) + .amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "USDC"), new BigDecimal("5"))) + .withdrawalFeeToken(quote.withdrawalFeeToken()).feeIncluded(true).build()); +``` + +Pass a `withdrawalFeeToken` to pin the quoted fee rate for 5 minutes, or a `maxFee` to cap the current fee; `feeIncluded` must then be set and match the quote. List operations return a `nextCursor()`, null on the last page, to pass back as `cursor` without the other filters. Amounts use `BigDecimal`, times use `Instant`, and limit time windows use `Duration`; a limit value is a `count()` for attempt and success limits and `amounts()` otherwise. Every parameter builder accepts an `accountId`. + +These endpoints live under `/funding` instead of `/0/private`: the nonce is sent in the `API-Nonce` header, the signed path includes the query string, nested query objects use bracket notation, e.g. `asset[class]=currency`, and bodies are JSON. Kraken answers errors with an HTTP error status, thrown as a `KrakenException` whose only error is the status code followed by the response body. The deposit `status` filter is not supported yet, as the specification doesn't define how its list and range forms are encoded. + ### Custom REST requester The current implementation of the library uses the JDK's HttpsURLConnection to make HTTP request. If that doesn't suit your needs and wish to use something else (e.g. Spring RestTemplate, Apache HttpComponents, OkHttp), you can implement the KrakenRestRequester interface and pass it to the KrakenAPI constructor: @@ -254,12 +297,13 @@ The current implementation of the library uses the JDK's HttpsURLConnection to m public class MyRestTemplateRestRequester implements KrakenRestRequester { public T execute(PublicEndpoint endpoint) { /* your implementation */ } public T execute(PrivateEndpoint endpoint, KrakenCredentials credentials, KrakenNonceGenerator nonceGenerator) { /* your implementation */ } + public T execute(FundingBetaEndpoint endpoint, KrakenCredentials credentials, KrakenNonceGenerator nonceGenerator) { /* optional */ } } KrakenAPI api = new KrakenAPI(new KrakenCredentials(key, secret), new MyRestTemplateRestRequester()); ``` -See `DefaultKrakenRestRequester` for the default implementation. +The Funding (Beta) `execute` method has a default implementation throwing an `UnsupportedOperationException`, so existing requesters keep compiling; implement it to query Funding (Beta) endpoints: send `endpoint.encodedBody()` unchanged, sign it with `credentials.sign(url.getFile(), nonce, body)`, send the nonce in the `API-Nonce` header, and deserialize the whole response body into `endpoint.getResponseType()`. See `DefaultKrakenRestRequester` for the default implementation. ### Custom nonce generator diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d2e2121..0a15690 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ The library is a Java client for the [Kraken REST API](https://docs.kraken.com/rest/). It is organized around four core concepts: - **`KrakenAPI`** — The main entry point. A facade that exposes typed methods for implemented endpoints and generic methods for any endpoint. -- **`Endpoint`** — Represents a single API call. Knows its HTTP method, URL path, parameters, and response type. Splits into `PublicEndpoint` (GET) and `PrivateEndpoint` (POST with HMAC signing). +- **`Endpoint`** — Represents a single API call. Knows its HTTP method, URL path, parameters, and response type. Splits into `PublicEndpoint` (GET), `PrivateEndpoint` (POST with HMAC signing) and `FundingBetaEndpoint` (any HTTP method on `/funding`, with the nonce in a header). - **`KrakenRestRequester`** — Interface that performs the actual HTTP request and response parsing. `DefaultKrakenRestRequester` is the built-in implementation using `HttpsURLConnection`. - **Params / Response types** — Each endpoint has dedicated parameter objects (`QueryParams` for public, `PostParams` for private) and response records deserialized via Jackson. @@ -84,6 +84,48 @@ sequenceDiagram KrakenAPI-->>User: LedgerInfo ``` +### Funding (Beta) Endpoint + +Funding (Beta) endpoints live under `/funding/v1` and `/funding/v2` and follow a different protocol: path parameters, query parameters in bracket notation for nested objects, `GET`, `POST`, `PUT` and `DELETE` requests, an optional JSON body, the nonce in the `API-Nonce` header, and responses that are not wrapped in the `{error, result}` envelope. + +```mermaid +sequenceDiagram + participant User + participant KrakenAPI + participant FundingBetaEndpoint + participant FundingBetaParams + participant NonceGen as KrakenNonceGenerator + participant Credentials as KrakenCredentials + participant RestRequester as DefaultKrakenRestRequester + participant Kraken as Kraken API + + User->>KrakenAPI: fundingFees(params) + KrakenAPI->>FundingBetaEndpoint: new FundingFeesEndpoint(params) + KrakenAPI->>RestRequester: execute(endpoint, credentials, nonceGenerator) + + RestRequester->>NonceGen: generate() + NonceGen-->>RestRequester: "1712750400000" + + RestRequester->>FundingBetaEndpoint: encodedBody() + FundingBetaEndpoint->>FundingBetaParams: encodedBody() + FundingBetaParams-->>RestRequester: "" (GET) or a JSON object + + RestRequester->>FundingBetaEndpoint: buildURL() + FundingBetaEndpoint->>FundingBetaParams: toMap() + FundingBetaEndpoint-->>RestRequester: https://api.kraken.com/funding/v1/fees/{method_id}?amount=5 + + RestRequester->>Credentials: sign(path + "?" + query, nonce, body) + Note over Credentials: SHA-256(nonce + body)
HMAC-SHA512(base64(secret), signed path + sha256) + Credentials-->>RestRequester: Base64 signature + + RestRequester->>Kraken: GET with API-Key + API-Sign + API-Nonce headers + Kraken-->>RestRequester: {"fee": {…}, "withdrawal_fee_token": "…"} + + RestRequester->>RestRequester: deserialize into FundingFees, or throw KrakenException on an HTTP error status + RestRequester-->>KrakenAPI: FundingFees + KrakenAPI-->>User: FundingFees +``` + ## Component Diagram ```mermaid @@ -98,6 +140,7 @@ classDiagram +ledgerInfo(params) LedgerInfo +query(PublicEndpoint~T~) T +query(PrivateEndpoint~T~) T + +query(FundingBetaEndpoint~T~) T +query(endpoint) JsonNode +queryPublic(path) JsonNode +queryPrivate(path) JsonNode @@ -123,10 +166,17 @@ classDiagram +buildURL() URL } + class FundingBetaEndpoint~T~ { + -FundingBetaParams params + +encodedBody() String + +buildURL() URL + } + class KrakenRestRequester { <> +execute(PublicEndpoint~T~) T +execute(PrivateEndpoint~T~, credentials, nonceGenerator) T + +execute(FundingBetaEndpoint~T~, credentials, nonceGenerator) T } class DefaultKrakenRestRequester { @@ -145,6 +195,7 @@ classDiagram class KrakenCredentials { +sign(url, nonce, params) String + +sign(signedPath, nonce, body) String } class KrakenNonceGenerator { @@ -154,6 +205,7 @@ classDiagram Endpoint <|-- PublicEndpoint Endpoint <|-- PrivateEndpoint + Endpoint <|-- FundingBetaEndpoint KrakenRestRequester <|.. DefaultKrakenRestRequester KrakenAPI --> KrakenRestRequester KrakenAPI --> KrakenCredentials @@ -168,7 +220,7 @@ classDiagram | Tier | Methods | Return type | When to use | |------|---------|-------------|-------------| -| **Custom endpoint** | `query(myEndpoint)` | Whatever the endpoint declares | You wrote your own `PublicEndpoint`/`PrivateEndpoint` for an endpoint the library doesn't implement | +| **Custom endpoint** | `query(myEndpoint)` | Whatever the endpoint declares | You wrote your own `PublicEndpoint`/`PrivateEndpoint`/`FundingBetaEndpoint` for an endpoint the library doesn't implement | | **Typed** | `assetInfo()`, `ledgerInfo()`, etc. | Domain records | Endpoint has a dedicated implementation | | **Enum-based** | `query(Public.TICKER, params)` | `JsonNode` | Endpoint is in the `Public`/`Private` enum but not yet typed | | **Raw path** | `queryPublic("Trades", params)` | `JsonNode` | Endpoint isn't in the enum yet (e.g., newly added by Kraken) | @@ -178,8 +230,8 @@ classDiagram To add a new typed endpoint: 1. Create a response record in the appropriate `response/` package -2. Create a params class implementing `QueryParams` (public) or extending `PostParams` (private); extend `JsonPostParams` instead when the body contains arrays or nested objects, and override `getContentType()` on the endpoint to return `application/json` -3. Create an endpoint class extending `PublicEndpoint` or `PrivateEndpoint` +2. Create a params class implementing `QueryParams` (public) or extending `PostParams` (private); extend `JsonPostParams` instead when the body contains arrays or nested objects, and override `getContentType()` on the endpoint to return `application/json`. Funding (Beta) params extend `FundingBetaParams`, returning query parameters from `toMap()` and a JSON body from `body()` +3. Create an endpoint class extending `PublicEndpoint`, `PrivateEndpoint` or `FundingBetaEndpoint` 4. Add a convenience method to `KrakenAPI` Steps 1 to 3 work just as well from outside the library, for an endpoint you need before it is implemented here. Step 4 is then replaced by handing the endpoint to `KrakenAPI.query(...)`, which runs it through the configured `KrakenRestRequester` and signs private requests with the credentials and nonce generator the instance was built with: diff --git a/examples/src/main/java/dev/andstuff/kraken/example/SimpleExamples.java b/examples/src/main/java/dev/andstuff/kraken/example/SimpleExamples.java index a404c1f..743e74d 100644 --- a/examples/src/main/java/dev/andstuff/kraken/example/SimpleExamples.java +++ b/examples/src/main/java/dev/andstuff/kraken/example/SimpleExamples.java @@ -10,6 +10,11 @@ import com.fasterxml.jackson.databind.JsonNode; import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingMethodsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingMethods; import dev.andstuff.kraken.api.endpoint.market.params.AssetPairParams; import dev.andstuff.kraken.api.endpoint.market.response.AssetInfo; import dev.andstuff.kraken.api.endpoint.market.response.AssetPairs; @@ -88,5 +93,11 @@ static void main() { .validate(true) .build()); log.info("{}", typedOrder); + + FundingMethods withdrawalMethods = api.fundingMethods(FundingMethodsParams.builder() + .direction(Direction.WITHDRAW) + .asset(new Asset(AssetClass.CURRENCY, "USDC")) + .build()); + log.info("{}", withdrawalMethods); } } diff --git a/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java b/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java index 557d6db..3bf884f 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java +++ b/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java @@ -108,6 +108,51 @@ import dev.andstuff.kraken.api.endpoint.funding.response.WithdrawalInfo; import dev.andstuff.kraken.api.endpoint.funding.response.WithdrawalMethod; import dev.andstuff.kraken.api.endpoint.funding.response.WithdrawalStatus; +import dev.andstuff.kraken.api.endpoint.fundingbeta.ClaimFundingDepositAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.CreateFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.CreateFundingWithdrawalEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.DeleteFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingAddressesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingAssetsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingDepositAddressesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingDepositLimitsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingDepositsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingFeesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingMethodsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingNetworksEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingWithdrawalLimitsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingWithdrawalsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.UpdateFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.ClaimFundingDepositAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingWithdrawalParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.DeleteFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAssetsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingFeesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingLimitsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingMethodsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingNetworksParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.UpdateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.ClaimedFundingDepositAddress; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressCreated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressUpdated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddresses; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAssets; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositAddresses; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositLimits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDeposits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingFees; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingMethods; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingNetworks; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalCreated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalLimits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawals; import dev.andstuff.kraken.api.endpoint.market.AssetInfoEndpoint; import dev.andstuff.kraken.api.endpoint.market.AssetPairEndpoint; import dev.andstuff.kraken.api.endpoint.market.GroupedOrderBookEndpoint; @@ -191,7 +236,7 @@ * *

Endpoints can be queried in four ways, from the most to the least typed: *

    - *
  1. through {@link #query(PublicEndpoint)} or {@link #query(PrivateEndpoint)}, taking an endpoint written outside the library and returning the type that endpoint declares;
  2. + *
  3. through {@link #query(PublicEndpoint)}, {@link #query(PrivateEndpoint)} or {@link #query(FundingBetaEndpoint)}, taking an endpoint written outside the library and returning the type that endpoint declares;
  4. *
  5. through a typed method, for the endpoints implemented by the library, e.g. {@link #assetInfo(List)};
  6. *
  7. through a generic {@code query} method, taking a {@link Public} or {@link Private} enum value and returning a {@link JsonNode};
  8. *
  9. through a raw {@code queryPublic} or {@code queryPrivate} method, taking the endpoint path as a string, for endpoints Kraken added but the library doesn't know about yet.
  10. @@ -1185,6 +1230,279 @@ public FundingReference walletTransfer(WalletTransferParams params) { return query(new WalletTransferEndpoint(params)); } + /** + * Queries the Funding (Beta) {@code GET /funding/v1/fees/{method_id}} endpoint, calculating the fee of a deposit or withdrawal amount. For withdrawals, the returned {@link FundingFees#withdrawalFeeToken()} pins the fee rate for 5 minutes. Requires the Funds permissions - Query API key permission. + * + * @param params the funding method, the amount and the fee options + * @return the fee, gross and net amounts, and the withdrawal fee token + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingFees fundingFees(FundingFeesParams params) { + return query(new FundingFeesEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/methods/{direction}} endpoint for the first page of deposit or withdrawal methods. Requires the Funds permissions - Query API key permission. + * + * @param direction whether deposit or withdrawal methods are listed + * @return the funding methods and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingMethods fundingMethods(Direction direction) { + return query(new FundingMethodsEndpoint(direction)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/methods/{direction}} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the direction, the asset filter and the pagination parameters + * @return the funding methods and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingMethods fundingMethods(FundingMethodsParams params) { + return query(new FundingMethodsEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/assets/{direction}} endpoint for the assets available for deposit or withdrawal. Requires the Funds permissions - Query API key permission. + * + * @param direction whether assets available for deposit or for withdrawal are listed + * @return the assets, by asset class + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingAssets fundingAssets(Direction direction) { + return query(new FundingAssetsEndpoint(direction)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/assets/{direction}} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the direction and the asset class filter + * @return the assets, by asset class + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingAssets fundingAssets(FundingAssetsParams params) { + return query(new FundingAssetsEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/networks} endpoint for the networks and the network groups sharing an address format. Requires the Funds permissions - Query API key permission. + * + * @return the networks and network groups + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingNetworks fundingNetworks() { + return query(new FundingNetworksEndpoint()); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/networks} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the request options + * @return the networks and network groups + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingNetworks fundingNetworks(FundingNetworksParams params) { + return query(new FundingNetworksEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/limits/deposit/{asset_class}/{asset}} endpoint for the deposit limits of an asset. Requires the Funds permissions - Query API key permission. + * + * @param params the asset and the asset limit amounts are expressed in + * @return the limits of each deposit method of the asset + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingDepositLimits fundingDepositLimits(FundingLimitsParams params) { + return query(new FundingDepositLimitsEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/limits/withdrawal/{asset_class}/{asset}} endpoint for the withdrawal limits of an asset. Requires the Funds permissions - Query API key permission. + * + * @param params the asset and the asset limit amounts are expressed in + * @return the available balance and the limits of each withdrawal method of the asset + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingWithdrawalLimits fundingWithdrawalLimits(FundingLimitsParams params) { + return query(new FundingWithdrawalLimitsEndpoint(params)); + } + + /** + * Claims a deposit address using the Funding (Beta) {@code PUT /funding/v1/deposit/address} endpoint. Methods sharing addresses with another method on the same network return the shared address. Requires the Funds permissions - Deposit API key permission. + * + * @param params the deposit method + * @return the claimed address + * @throws KrakenException if Kraken rejects the request, e.g. with {@code TooManyDepositAddresses} + * @throws IllegalStateException if credentials are missing + */ + public ClaimedFundingDepositAddress claimFundingDepositAddress(ClaimFundingDepositAddressParams params) { + return query(new ClaimFundingDepositAddressEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v2/deposit/addresses} endpoint for the first page of claimed deposit addresses. Requires the Funds permissions - Query API key permission. + * + * @return the claimed deposit addresses and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingDepositAddresses fundingDepositAddresses() { + return query(new FundingDepositAddressesEndpoint()); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v2/deposit/addresses} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the method, network and asset filters and the pagination parameters + * @return the claimed deposit addresses and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + * @throws IllegalArgumentException if the scope is a network group + */ + public FundingDepositAddresses fundingDepositAddresses(FundingDepositAddressesParams params) { + return query(new FundingDepositAddressesEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/deposits} endpoint for the first page of deposits, newest first. Requires the Funds permissions - Query API key permission. + * + * @return the deposits and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingDeposits fundingDeposits() { + return query(new FundingDepositsEndpoint()); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/deposits} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the asset, scope and time filters and the pagination parameters + * @return the deposits and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingDeposits fundingDeposits(FundingDepositsParams params) { + return query(new FundingDepositsEndpoint(params)); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/addresses} endpoint for the first page of saved withdrawal addresses. Requires the Funds permissions - Query API key permission. + * + * @return the withdrawal addresses and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingAddresses fundingAddresses() { + return query(new FundingAddressesEndpoint()); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/addresses} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the scope filter and the pagination parameters + * @return the withdrawal addresses and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingAddresses fundingAddresses(FundingAddressesParams params) { + return query(new FundingAddressesEndpoint(params)); + } + + /** + * Saves a crypto withdrawal address using the Funding (Beta) {@code POST /funding/v1/addresses} endpoint. Kraken verifies addresses created this way without email confirmation. Requires the Funds permissions - Add withdrawal addresses API key permission. + * + * @param params the scope, address, name and description + * @return the identifier of the address and whether it is verified + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingAddressCreated createFundingAddress(CreateFundingAddressParams params) { + return query(new CreateFundingAddressEndpoint(params)); + } + + /** + * Renames or describes a saved withdrawal address using the Funding (Beta) {@code PUT /funding/v1/addresses/{id}} endpoint. Requires the Funds permissions - Add withdrawal addresses API key permission. + * + * @param params the address and its new name and description + * @return whether the address is verified + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingAddressUpdated updateFundingAddress(UpdateFundingAddressParams params) { + return query(new UpdateFundingAddressEndpoint(params)); + } + + /** + * Deletes a saved withdrawal address using the Funding (Beta) {@code DELETE /funding/v1/addresses/{id}} endpoint. Requires the Funds permissions - Add withdrawal addresses API key permission. + * + * @param addressId the identifier of the address to delete + * @return whether the address was deleted + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public boolean deleteFundingAddress(String addressId) { + return query(new DeleteFundingAddressEndpoint(addressId)).deleted(); + } + + /** + * Deletes a saved withdrawal address using the Funding (Beta) {@code DELETE /funding/v1/addresses/{id}} endpoint. Requires the Funds permissions - Add withdrawal addresses API key permission. + * + * @param params the address to delete and the account it belongs to + * @return whether the address was deleted + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public boolean deleteFundingAddress(DeleteFundingAddressParams params) { + return query(new DeleteFundingAddressEndpoint(params)).deleted(); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/withdrawals} endpoint for the first page of withdrawals, newest first. Requires the Funds permissions - Query API key permission. + * + * @return the withdrawals and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingWithdrawals fundingWithdrawals() { + return query(new FundingWithdrawalsEndpoint()); + } + + /** + * Queries the Funding (Beta) {@code GET /funding/v1/withdrawals} endpoint. Requires the Funds permissions - Query API key permission. + * + * @param params the asset, scope, status and time filters and the pagination parameters + * @return the withdrawals and the cursor of the next page + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + */ + public FundingWithdrawals fundingWithdrawals(FundingWithdrawalsParams params) { + return query(new FundingWithdrawalsEndpoint(params)); + } + + /** + * Withdraws to a saved address using the Funding (Beta) {@code POST /funding/v1/withdrawals} endpoint. Pass a fee token from {@link #fundingFees(FundingFeesParams)} to pin the quoted fee rate. Requires the Funds permissions - Withdraw API key permission. + * + * @param params the scope, address, amount and fee options + * @return the identifier and the amounts of the withdrawal + * @throws KrakenException if Kraken rejects the request + * @throws IllegalStateException if credentials are missing + * @throws IllegalArgumentException if the scope is a network group or the fee options are inconsistent + */ + public FundingWithdrawalCreated createFundingWithdrawal(CreateFundingWithdrawalParams params) { + return query(new CreateFundingWithdrawalEndpoint(params)); + } + /** * Queries the private {@code CreateSubaccount} endpoint, creating a trading subaccount. It must be called with an API key of the master account, having the withdraw funds permission. * @@ -1324,6 +1642,23 @@ public T query(PrivateEndpoint endpoint) { return restRequester.execute(endpoint, credentials, nonceGenerator); } + /** + * Queries a Funding (Beta) endpoint the library doesn't implement, described by a {@link FundingBetaEndpoint} written outside the library. The request is signed with the credentials of this instance, the nonce being sent in the {@code API-Nonce} header. + * + * @param the type the response is deserialized into + * @param endpoint the Funding (Beta) endpoint to query + * @return the deserialized response body + * @throws IllegalStateException if this instance was built without credentials + * @throws KrakenException if Kraken answers with an HTTP error status + */ + public T query(FundingBetaEndpoint endpoint) { + if (credentials == null) { + throw new IllegalStateException("Funding endpoint %s requires credentials, build KrakenAPI with a KrakenCredentials instance".formatted(endpoint.getPath())); + } + + return restRequester.execute(endpoint, credentials, nonceGenerator); + } + /** * Queries a public endpoint the library doesn't implement, without parameters. * diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/Endpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/Endpoint.java index c500667..3ddc2b4 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/endpoint/Endpoint.java +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/Endpoint.java @@ -15,7 +15,7 @@ /** * A single call to the Kraken REST API: its HTTP method, path, parameters and response type. * - *

    Endpoints are not queried directly but handed to a {@link dev.andstuff.kraken.api.rest.KrakenRestRequester KrakenRestRequester}. Concrete endpoints extend either {@link dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint PublicEndpoint} or {@link dev.andstuff.kraken.api.endpoint.priv.PrivateEndpoint PrivateEndpoint}. + *

    Endpoints are not queried directly but handed to a {@link dev.andstuff.kraken.api.rest.KrakenRestRequester KrakenRestRequester}. Concrete endpoints extend {@link dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint PublicEndpoint}, {@link dev.andstuff.kraken.api.endpoint.priv.PrivateEndpoint PrivateEndpoint} or {@link dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaEndpoint FundingBetaEndpoint}. * * @param the type the response is deserialized into */ @@ -27,7 +27,7 @@ public abstract class Endpoint { private final String httpMethod; /** - * The path of the endpoint, relative to {@code /0/public} or {@code /0/private}, e.g. {@code Assets}. + * The path of the endpoint, relative to {@code /0/public}, {@code /0/private} or {@code /funding}, e.g. {@code Assets} or {@code v1/networks}. */ @Getter protected final String path; diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/KrakenException.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/KrakenException.java index 6442232..cb38aa8 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/endpoint/KrakenException.java +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/KrakenException.java @@ -6,7 +6,7 @@ import lombok.ToString; /** - * Unchecked exception thrown when Kraken answers a request with a non-empty {@code error} field, e.g. {@code EGeneral:Permission denied}. + * Unchecked exception thrown when Kraken answers a request with a non-empty {@code error} field, e.g. {@code EGeneral:Permission denied}, or a Funding (Beta) request with an HTTP error status, in which case the only error is the status code followed by the response body. */ @Getter @ToString diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpoint.java new file mode 100644 index 0000000..d85b9b8 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.ClaimFundingDepositAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.ClaimedFundingDepositAddress; + +/** + * The Funding (Beta) Claim Funding Deposit Address endpoint ({@code PUT /funding/v1/deposit/address}), generating a deposit address for a deposit method. + */ +public class ClaimFundingDepositAddressEndpoint extends FundingBetaEndpoint { + + /** + * Creates the Claim Funding Deposit Address endpoint. + * + * @param params the request parameters + */ + public ClaimFundingDepositAddressEndpoint(ClaimFundingDepositAddressParams params) { + super("PUT", "v1/deposit/address", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpoint.java new file mode 100644 index 0000000..e357056 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressCreated; + +/** + * The Funding (Beta) Create Funding Address endpoint ({@code POST /funding/v1/addresses}), saving a crypto withdrawal address. + */ +public class CreateFundingAddressEndpoint extends FundingBetaEndpoint { + + /** + * Creates the Create Funding Address endpoint. + * + * @param params the request parameters + */ + public CreateFundingAddressEndpoint(CreateFundingAddressParams params) { + super("POST", "v1/addresses", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpoint.java new file mode 100644 index 0000000..4c62a92 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingWithdrawalParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalCreated; + +/** + * The Funding (Beta) Create Funding Withdrawal endpoint ({@code POST /funding/v1/withdrawals}), withdrawing to a saved address. + */ +public class CreateFundingWithdrawalEndpoint extends FundingBetaEndpoint { + + /** + * Creates the Create Funding Withdrawal endpoint. + * + * @param params the request parameters + */ + public CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams params) { + super("POST", "v1/withdrawals", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpoint.java new file mode 100644 index 0000000..1ae6d14 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpoint.java @@ -0,0 +1,30 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.DeleteFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressDeleted; + +/** + * The Funding (Beta) Delete Funding Address endpoint ({@code DELETE /funding/v1/addresses/{id}}), deleting a saved withdrawal address. + */ +public class DeleteFundingAddressEndpoint extends FundingBetaEndpoint { + + /** + * Creates the Delete Funding Address endpoint using the required parameters. + * + * @param addressId the identifier of the address to delete + */ + public DeleteFundingAddressEndpoint(String addressId) { + this(DeleteFundingAddressParams.builder().addressId(addressId).build()); + } + + /** + * Creates the Delete Funding Address endpoint. + * + * @param params the request parameters + */ + public DeleteFundingAddressEndpoint(DeleteFundingAddressParams params) { + super("DELETE", "v1/addresses/" + pathSegment(params.getAddressId()), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpoint.java new file mode 100644 index 0000000..13cc2ed --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpoint.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddresses; + +/** + * The Funding (Beta) List Funding Addresses endpoint ({@code GET /funding/v1/addresses}), listing the saved withdrawal addresses. + */ +public class FundingAddressesEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Addresses endpoint using Kraken's default options. + */ + public FundingAddressesEndpoint() { + this(FundingAddressesParams.builder().build()); + } + + /** + * Creates the List Funding Addresses endpoint. + * + * @param params the request parameters + */ + public FundingAddressesEndpoint(FundingAddressesParams params) { + super("GET", "v1/addresses", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpoint.java new file mode 100644 index 0000000..21caa11 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAssetsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAssets; + +/** + * The Funding (Beta) List Funding Assets endpoint ({@code GET /funding/v1/assets/{direction}}), listing the assets available for deposit or withdrawal. + */ +public class FundingAssetsEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Assets endpoint using the required parameters. + * + * @param direction whether assets available for deposit or for withdrawal are listed + */ + public FundingAssetsEndpoint(Direction direction) { + this(FundingAssetsParams.builder().direction(direction).build()); + } + + /** + * Creates the List Funding Assets endpoint. + * + * @param params the request parameters + */ + public FundingAssetsEndpoint(FundingAssetsParams params) { + super("GET", "v1/assets/" + pathSegment(params.getDirection().getValue()), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaEndpoint.java new file mode 100644 index 0000000..9a2f7ef --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaEndpoint.java @@ -0,0 +1,103 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static java.util.stream.Collectors.joining; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.Endpoint; +import lombok.Getter; + +/** + * An endpoint of Kraken's Funding (Beta) API, queried on {@code /funding/{path}} with the HTTP method of the operation, e.g. {@code GET}, {@code PUT} or {@code DELETE}. + * + *

    Unlike {@link dev.andstuff.kraken.api.endpoint.priv.PrivateEndpoint PrivateEndpoint}, parameters are sent in the URL query string and in a JSON body, the nonce is sent in the {@code API-Nonce} header, the signed path includes the query string, and responses are not wrapped in the {@code {error, result}} envelope: the response type is deserialized from the whole body, and errors are returned with an HTTP error status. + * + * @param the type the response is deserialized into + */ +@Getter +public class FundingBetaEndpoint extends Endpoint { + + private final FundingBetaParams params; + + /** + * Creates a Funding (Beta) endpoint taking neither query parameters nor a body. + * + * @param httpMethod the HTTP method of the operation, e.g. {@code GET} + * @param path the endpoint path, relative to {@code /funding}, e.g. {@code v1/networks} + * @param responseType the type the response is deserialized into + */ + public FundingBetaEndpoint(String httpMethod, String path, TypeReference responseType) { + this(httpMethod, path, FundingBetaParams.EMPTY, responseType); + } + + /** + * Creates a Funding (Beta) endpoint. + * + * @param httpMethod the HTTP method of the operation, e.g. {@code POST} + * @param path the endpoint path, relative to {@code /funding}, with its path parameters already encoded, e.g. {@code v1/addresses} + * @param params the query and body parameters + * @param responseType the type the response is deserialized into + */ + public FundingBetaEndpoint(String httpMethod, String path, FundingBetaParams params, TypeReference responseType) { + super(httpMethod, path, responseType); + this.params = params; + } + + /** + * Returns the request body, which is also part of the signed message. + * + * @return the JSON request body, or an empty string for an endpoint sending no body + */ + public String encodedBody() { + return params.encodedBody(); + } + + /** + * Returns the media type of the request body. + * + * @return {@code application/json} + */ + public String getContentType() { + return "application/json"; + } + + /** + * Builds the endpoint URL, appending the URL encoded query parameters. The path and query of this URL are the path Kraken expects to be signed. + * + * @return the endpoint URL + * @throws IllegalStateException if the URL is malformed + */ + @Override + public URL buildURL() { + + String queryString = params.toMap() + .entrySet().stream() + .map(e -> "%s=%s".formatted(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8), URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))) + .collect(joining("&")); + + try { + String baseURL = "https://api.kraken.com/funding/%s".formatted(path); + return new URI(baseURL + (queryString.isEmpty() ? "" : "?" + queryString)).toURL(); + } + catch (MalformedURLException | URISyntaxException e) { + throw new IllegalStateException("Error while building endpoint URL", e); + } + } + + /** + * Encodes a path parameter so that it can be inserted in the endpoint path. + * + * @param value the path parameter value + * @return the percent-encoded value + */ + protected static String pathSegment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaParams.java new file mode 100644 index 0000000..6c14a8d --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingBetaParams.java @@ -0,0 +1,82 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import dev.andstuff.kraken.api.endpoint.pub.QueryParams; + +/** + * The parameters of a {@link FundingBetaEndpoint}: URL query parameters and, for endpoints taking one, a JSON request body. Nested query objects use Kraken's bracket notation, e.g. {@code asset[class]=currency}. The nonce is not part of the parameters: it is sent in the {@code API-Nonce} header. + */ +public abstract class FundingBetaParams implements QueryParams { + + /** + * Parameters of an endpoint that takes neither query parameters nor a body. + */ + public static final FundingBetaParams EMPTY = new FundingBetaParams() { + + @Override + public Map toMap() { + return Map.of(); + } + }; + + private static final JsonMapper OBJECT_MAPPER = JsonMapper.builder().build(); + + /** + * Returns the request body as name/value pairs, using the names expected by Kraken. Values can be nested maps and lists. + * + * @return the body parameters, or {@code null} for an endpoint sending no body + */ + protected Map body() { + return null; + } + + /** + * Returns the request body encoded as a JSON object. + * + * @return the JSON request body, or an empty string for an endpoint sending no body + * @throws IllegalStateException if a parameter cannot be serialized + */ + public String encodedBody() { + Map body = body(); + if (body == null) { + return ""; + } + try { + return OBJECT_MAPPER.writeValueAsString(body); + } + catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot encode %s".formatted(getClass().getSimpleName()), e); + } + } + + /** + * Adds a query parameter to the given map, unless its value is {@code null}. + * + * @param the type of the parameter value + * @param map the map to add the parameter to + * @param key the parameter name, as expected by Kraken + * @param value the parameter value, possibly {@code null} + * @param apply the function converting the value to its string representation + */ + protected static void putIfNonNull(Map map, String key, T value, Function apply) { + QueryParams.putIfNonNull(map, key, value, apply); + } + + /** + * Adds a body parameter to the given map, unless its value is {@code null}. + * + * @param map the map to add the parameter to + * @param key the parameter name, as expected by Kraken + * @param value the parameter value, possibly {@code null} + */ + protected static void putIfNonNull(Map map, String key, Object value) { + if (value != null) { + map.put(key, value); + } + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpoint.java new file mode 100644 index 0000000..e2966c7 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpoint.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositAddresses; + +/** + * The Funding (Beta) List Funding Claimed Addresses endpoint ({@code GET /funding/v2/deposit/addresses}), listing the claimed deposit addresses. + */ +public class FundingDepositAddressesEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Claimed Addresses endpoint using Kraken's default options. + */ + public FundingDepositAddressesEndpoint() { + this(FundingDepositAddressesParams.builder().build()); + } + + /** + * Creates the List Funding Claimed Addresses endpoint. + * + * @param params the request parameters + */ + public FundingDepositAddressesEndpoint(FundingDepositAddressesParams params) { + super("GET", "v2/deposit/addresses", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpoint.java new file mode 100644 index 0000000..8a0995e --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingLimitsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositLimits; + +/** + * The Funding (Beta) List Funding Deposit Limits endpoint ({@code GET /funding/v1/limits/deposit/{asset_class}/{asset}}), returning the deposit limits of an asset for each deposit method. + */ +public class FundingDepositLimitsEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Deposit Limits endpoint. + * + * @param params the request parameters + */ + public FundingDepositLimitsEndpoint(FundingLimitsParams params) { + super("GET", "v1/limits/deposit/%s/%s".formatted(pathSegment(params.getAssetClass().getValue()), pathSegment(params.getAsset())), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpoint.java new file mode 100644 index 0000000..afe5f1c --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpoint.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDeposits; + +/** + * The Funding (Beta) List Funding Deposits endpoint ({@code GET /funding/v1/deposits}), listing deposits, newest first. + */ +public class FundingDepositsEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Deposits endpoint using Kraken's default options. + */ + public FundingDepositsEndpoint() { + this(FundingDepositsParams.builder().build()); + } + + /** + * Creates the List Funding Deposits endpoint. + * + * @param params the request parameters + */ + public FundingDepositsEndpoint(FundingDepositsParams params) { + super("GET", "v1/deposits", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpoint.java new file mode 100644 index 0000000..f8cbbf5 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingFeesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingFees; + +/** + * The Funding (Beta) Calculate Funding Fees endpoint ({@code GET /funding/v1/fees/{method_id}}), quoting the fee of a deposit or withdrawal amount and, for withdrawals, a token pinning the fee rate. + */ +public class FundingFeesEndpoint extends FundingBetaEndpoint { + + /** + * Creates the Calculate Funding Fees endpoint. + * + * @param params the request parameters + */ + public FundingFeesEndpoint(FundingFeesParams params) { + super("GET", "v1/fees/" + pathSegment(params.getMethodId()), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpoint.java new file mode 100644 index 0000000..cf8856f --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingMethodsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingMethods; + +/** + * The Funding (Beta) List Funding Methods endpoint ({@code GET /funding/v1/methods/{direction}}), listing the deposit or withdrawal methods available to the account. + */ +public class FundingMethodsEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Methods endpoint using the required parameters. + * + * @param direction whether deposit or withdrawal methods are listed + */ + public FundingMethodsEndpoint(Direction direction) { + this(FundingMethodsParams.builder().direction(direction).build()); + } + + /** + * Creates the List Funding Methods endpoint. + * + * @param params the request parameters + */ + public FundingMethodsEndpoint(FundingMethodsParams params) { + super("GET", "v1/methods/" + pathSegment(params.getDirection().getValue()), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpoint.java new file mode 100644 index 0000000..6499256 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpoint.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingNetworksParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingNetworks; + +/** + * The Funding (Beta) List Funding Networks endpoint ({@code GET /funding/v1/networks}), listing the networks and the network groups sharing an address format. + */ +public class FundingNetworksEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Networks endpoint using Kraken's default options. + */ + public FundingNetworksEndpoint() { + this(FundingNetworksParams.builder().build()); + } + + /** + * Creates the List Funding Networks endpoint. + * + * @param params the request parameters + */ + public FundingNetworksEndpoint(FundingNetworksParams params) { + super("GET", "v1/networks", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpoint.java new file mode 100644 index 0000000..b4740ef --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingLimitsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalLimits; + +/** + * The Funding (Beta) List Funding Withdrawal Limits endpoint ({@code GET /funding/v1/limits/withdrawal/{asset_class}/{asset}}), returning the available balance and the withdrawal limits of an asset for each withdrawal method. + */ +public class FundingWithdrawalLimitsEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Withdrawal Limits endpoint. + * + * @param params the request parameters + */ + public FundingWithdrawalLimitsEndpoint(FundingLimitsParams params) { + super("GET", "v1/limits/withdrawal/%s/%s".formatted(pathSegment(params.getAssetClass().getValue()), pathSegment(params.getAsset())), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpoint.java new file mode 100644 index 0000000..ab480ca --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpoint.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawals; + +/** + * The Funding (Beta) List Funding Withdrawals endpoint ({@code GET /funding/v1/withdrawals}), listing withdrawals, newest first. + */ +public class FundingWithdrawalsEndpoint extends FundingBetaEndpoint { + + /** + * Creates the List Funding Withdrawals endpoint using Kraken's default options. + */ + public FundingWithdrawalsEndpoint() { + this(FundingWithdrawalsParams.builder().build()); + } + + /** + * Creates the List Funding Withdrawals endpoint. + * + * @param params the request parameters + */ + public FundingWithdrawalsEndpoint(FundingWithdrawalsParams params) { + super("GET", "v1/withdrawals", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpoint.java new file mode 100644 index 0000000..be2af23 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpoint.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.UpdateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressUpdated; + +/** + * The Funding (Beta) Update Funding Address endpoint ({@code PUT /funding/v1/addresses/{id}}), renaming or describing a saved withdrawal address. + */ +public class UpdateFundingAddressEndpoint extends FundingBetaEndpoint { + + /** + * Creates the Update Funding Address endpoint. + * + * @param params the request parameters + */ + public UpdateFundingAddressEndpoint(UpdateFundingAddressParams params) { + super("PUT", "v1/addresses/" + pathSegment(params.getAddressId()), params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Asset.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Asset.java new file mode 100644 index 0000000..b06f858 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Asset.java @@ -0,0 +1,35 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * An asset of the Funding (Beta) API, identified by its class and name. As a filter, an asset may leave out its name to match a whole class, or its class where Kraken documents it as optional. + * + * @param assetClass the asset class + * @param name the asset name, e.g. {@code USDC} + */ +public record Asset(@JsonProperty("class") AssetClass assetClass, + String name) { + + void putQuery(Map query, String key) { + if (assetClass != null) { + query.put(key + "[class]", assetClass.getValue()); + } + if (name != null) { + query.put(key + "[name]", name); + } + } + + Map json() { + if (assetClass == null || name == null) { + throw new IllegalArgumentException("Asset requires a class and a name"); + } + Map json = new LinkedHashMap<>(); + json.put("class", assetClass.getValue()); + json.put("name", name); + return json; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetAmount.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetAmount.java new file mode 100644 index 0000000..eb684d0 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetAmount.java @@ -0,0 +1,25 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * An amount of the Funding (Beta) API and the asset it is denominated in. + * + * @param asset the asset + * @param amount the decimal amount + */ +public record AssetAmount(Asset asset, + BigDecimal amount) { + + Map json() { + if (asset == null || amount == null) { + throw new IllegalArgumentException("AssetAmount requires an asset and an amount"); + } + Map json = new LinkedHashMap<>(); + json.put("asset", asset.json()); + json.put("amount", amount.toPlainString()); + return json; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetClass.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetClass.java new file mode 100644 index 0000000..9d3207f --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/AssetClass.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * The asset classes of the Funding (Beta) API. + */ +@Getter +@RequiredArgsConstructor +public enum AssetClass { + CURRENCY("currency"), + TOKENIZED_ASSET("tokenized_asset"), + @JsonEnumDefaultValue UNKNOWN("unknown"); + + @JsonValue + private final String value; +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/ClaimFundingDepositAddressParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/ClaimFundingDepositAddressParams.java new file mode 100644 index 0000000..e7c8df6 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/ClaimFundingDepositAddressParams.java @@ -0,0 +1,42 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of Claim Funding Deposit Address ({@code PUT /funding/v1/deposit/address}). + */ +@Getter +@Builder(toBuilder = true) +public class ClaimFundingDepositAddressParams extends FundingBetaParams { + + /** + * The deposit method to claim an address for. Methods sharing addresses with another method on the same network return the shared address. + */ + @NonNull + private final String methodId; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } + + @Override + protected Map body() { + Map body = new LinkedHashMap<>(); + body.put("method_id", methodId); + return body; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingAddressParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingAddressParams.java new file mode 100644 index 0000000..592240f --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingAddressParams.java @@ -0,0 +1,77 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of Create Funding Address ({@code POST /funding/v1/addresses}), saving a crypto withdrawal address. Kraken verifies addresses created through the API automatically, without email confirmation; fiat addresses cannot be created this way. + */ +@Getter +@Builder(toBuilder = true) +public class CreateFundingAddressParams extends FundingBetaParams { + + /** + * The method, network or network group the address can be used with. + */ + @NonNull + private final Scope scope; + + /** + * The crypto address. + */ + @NonNull + private final String address; + + /** + * The destination tag, on blockchains using one. Kraken accepts a tag or a memo, whichever the network uses. + */ + private final String tag; + + /** + * The memo, on blockchains using one. Kraken accepts a tag or a memo, whichever the network uses. + */ + private final String memo; + + /** + * The unique name of the address, with at least one non-whitespace character. + */ + @NonNull + private final String name; + + /** + * The description of the address. + */ + private final String description; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } + + @Override + protected Map body() { + Map crypto = new LinkedHashMap<>(); + crypto.put("address", address); + putIfNonNull(crypto, "tag", tag); + putIfNonNull(crypto, "memo", memo); + + Map body = new LinkedHashMap<>(); + body.put("scope", scope.json()); + body.put("address_details", Map.of("crypto", crypto)); + body.put("name", name); + putIfNonNull(body, "description", description); + return body; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingWithdrawalParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingWithdrawalParams.java new file mode 100644 index 0000000..c068db9 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/CreateFundingWithdrawalParams.java @@ -0,0 +1,123 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of Create Funding Withdrawal ({@code POST /funding/v1/withdrawals}), withdrawing to a saved address. The fee is either pinned by a {@code withdrawalFeeToken} from Calculate Funding Fees or the current fee, optionally capped by {@code maxFee}; omitting both uses the current fee. + */ +@Getter +@Builder(toBuilder = true) +public class CreateFundingWithdrawalParams extends FundingBetaParams { + + /** + * The method or network to withdraw with. A network scope fails when the asset has several withdrawal methods on that network, e.g. USDC and USDC.e on Arbitrum. + */ + @NonNull + private final Scope scope; + + /** + * The identifier of the saved address to withdraw to. + */ + @NonNull + private final String addressId; + + /** + * The amount to withdraw and its asset. + */ + @NonNull + private final AssetAmount amount; + + /** + * Whether the amount includes the fee. Required with {@code withdrawalFeeToken} or {@code maxFee}, and must match the setting used for the fee quote. + */ + private final Boolean feeIncluded; + + /** + * The {@code withdrawalFeeToken} of a fee quote less than 5 minutes old, pinning its fee rate. Mutually exclusive with {@code maxFee}. + */ + private final String withdrawalFeeToken; + + /** + * The maximum current fee accepted; the withdrawal fails if the fee exceeds it. Mutually exclusive with {@code withdrawalFeeToken}. + */ + private final AssetAmount maxFee; + + /** + * Whether the amount, the maximum fee and the quoted fee of tokenized assets use rebased or base units. + */ + private final RebaseMultiplier rebaseMultiplier; + + /** + * The crypto address stored for {@code addressId}; Kraken rejects the withdrawal if they differ. + */ + private final String expectedAddress; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } + + /** + * Returns the request body sent to Kraken. + * + * @return the body parameters + * @throws IllegalArgumentException if the scope is a network group, if both {@code withdrawalFeeToken} and {@code maxFee} are set, or if one of them is set without {@code feeIncluded} + */ + @Override + protected Map body() { + scope.requireMethodOrNetwork("Create Funding Withdrawal"); + + Map body = new LinkedHashMap<>(); + body.put("scope", scope.json()); + body.put("address_id", addressId); + body.put("amount", rebasable("asset_amount", amount.json())); + putIfNonNull(body, "fee", fee()); + putIfNonNull(body, "expected_address", expectedAddress); + return body; + } + + private Map fee() { + if (withdrawalFeeToken != null && maxFee != null) { + throw new IllegalArgumentException("Specify at most one of withdrawalFeeToken or maxFee"); + } + if (withdrawalFeeToken == null && maxFee == null && feeIncluded == null) { + return null; + } + if (feeIncluded == null) { + throw new IllegalArgumentException("feeIncluded is required with withdrawalFeeToken or maxFee"); + } + + Map fee = new LinkedHashMap<>(); + if (withdrawalFeeToken != null) { + fee.put("quoted_fee", rebasable("token", withdrawalFeeToken)); + } + else { + Map currentFee = new LinkedHashMap<>(); + putIfNonNull(currentFee, "max_fee", maxFee == null ? null : rebasable("asset_amount", maxFee.json())); + fee.put("current_fee", currentFee); + } + fee.put("fee_included", feeIncluded); + return fee; + } + + private Map rebasable(String key, Object value) { + Map rebasable = new LinkedHashMap<>(); + rebasable.put(key, value); + putIfNonNull(rebasable, "rebase_multiplier", rebaseMultiplier == null ? null : rebaseMultiplier.getValue()); + return rebasable; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/DeleteFundingAddressParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/DeleteFundingAddressParams.java new file mode 100644 index 0000000..d18fd7b --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/DeleteFundingAddressParams.java @@ -0,0 +1,35 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of Delete Funding Address ({@code DELETE /funding/v1/addresses/{id}}). + */ +@Getter +@Builder(toBuilder = true) +public class DeleteFundingAddressParams extends FundingBetaParams { + + /** + * The identifier of the address to delete, sent in the path. + */ + @NonNull + private final String addressId; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Direction.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Direction.java new file mode 100644 index 0000000..955204a --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Direction.java @@ -0,0 +1,21 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * The funding direction selecting deposit or withdrawal methods and assets. + */ +@Getter +@RequiredArgsConstructor +public enum Direction { + DEPOSIT("deposit"), + WITHDRAW("withdraw"), + @JsonEnumDefaultValue UNKNOWN("unknown"); + + @JsonValue + private final String value; +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAddressesParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAddressesParams.java new file mode 100644 index 0000000..97cce52 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAddressesParams.java @@ -0,0 +1,48 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; + +/** + * The parameters of List Funding Addresses ({@code GET /funding/v1/addresses}); omitted options use Kraken's defaults. A cursor must be sent without the other filters. + */ +@Getter +@Builder(toBuilder = true) +public class FundingAddressesParams extends FundingBetaParams { + + /** + * The scope to filter on. A method scope also returns the addresses saved for its network and network group, a network scope those saved for its network group. + */ + private final Scope scope; + + /** + * The {@code nextCursor} of the previous page. + */ + private final String cursor; + + /** + * The maximum number of addresses returned, up to 500. + */ + private final Integer limit; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + if (scope != null) { + scope.putQuery(query, "scope"); + } + putIfNonNull(query, "cursor", cursor, v -> v); + putIfNonNull(query, "limit", limit, String::valueOf); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAssetsParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAssetsParams.java new file mode 100644 index 0000000..55f3bc2 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingAssetsParams.java @@ -0,0 +1,41 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of List Funding Assets ({@code GET /funding/v1/assets/{direction}}); omitted options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class FundingAssetsParams extends FundingBetaParams { + + /** + * Whether assets available for deposit or for withdrawal are listed, sent in the path. + */ + @NonNull + private final Direction direction; + + /** + * The asset class to filter on. + */ + private final AssetClass assetClass; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "asset_class", assetClass, AssetClass::getValue); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositAddressesParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositAddressesParams.java new file mode 100644 index 0000000..bd825c2 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositAddressesParams.java @@ -0,0 +1,63 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; + +/** + * The parameters of List Funding Claimed Addresses ({@code GET /funding/v2/deposit/addresses}); omitted options use Kraken's defaults. A cursor must be sent without the other filters. + */ +@Getter +@Builder(toBuilder = true) +public class FundingDepositAddressesParams extends FundingBetaParams { + + /** + * The method or network to filter on. Filtering on a method also returns the addresses it shares with another method. + */ + private final Scope scope; + + /** + * The {@code nextCursor} of the previous page. + */ + private final String cursor; + + /** + * The maximum number of addresses returned, up to 500. + */ + private final Integer limit; + + /** + * The asset to filter on; its class is required, its name is optional. + */ + private final Asset asset; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + /** + * Returns the query parameters sent to Kraken. + * + * @return the query parameters + * @throws IllegalArgumentException if the scope is a network group + */ + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + if (scope != null) { + scope.requireMethodOrNetwork("List Funding Claimed Addresses"); + scope.putQuery(query, "scope"); + } + putIfNonNull(query, "cursor", cursor, v -> v); + putIfNonNull(query, "limit", limit, String::valueOf); + if (asset != null) { + asset.putQuery(query, "asset"); + } + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositsParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositsParams.java new file mode 100644 index 0000000..c41619e --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingDepositsParams.java @@ -0,0 +1,76 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; +import lombok.Builder; +import lombok.Getter; + +/** + * The parameters of List Funding Deposits ({@code GET /funding/v1/deposits}); omitted options use Kraken's defaults. A cursor must be sent without the other filters. + */ +@Getter +@Builder(toBuilder = true) +public class FundingDepositsParams extends FundingBetaParams { + + /** + * The asset to filter on; its class is required, its name is optional. + */ + private final Asset asset; + + /** + * The method, network or network group to filter on. + */ + private final Scope scope; + + /** + * The {@code nextCursor} of the previous page. + */ + private final String cursor; + + /** + * The maximum number of deposits returned, up to 500. + */ + private final Integer limit; + + /** + * The earliest creation time of the deposits returned. + */ + private final Instant startTime; + + /** + * The latest creation time of the deposits returned. + */ + private final Instant endTime; + + /** + * Whether amounts of tokenized assets use rebased or base units. + */ + private final RebaseMultiplier rebaseMultiplier; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + if (asset != null) { + asset.putQuery(query, "asset"); + } + if (scope != null) { + scope.putQuery(query, "scope"); + } + putIfNonNull(query, "cursor", cursor, v -> v); + putIfNonNull(query, "limit", limit, String::valueOf); + putIfNonNull(query, "start_time", startTime, Instant::toString); + putIfNonNull(query, "end_time", endTime, Instant::toString); + putIfNonNull(query, "rebase_multiplier", rebaseMultiplier, RebaseMultiplier::getValue); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingFeesParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingFeesParams.java new file mode 100644 index 0000000..143d5e7 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingFeesParams.java @@ -0,0 +1,62 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of Calculate Funding Fees ({@code GET /funding/v1/fees/{method_id}}); omitted options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class FundingFeesParams extends FundingBetaParams { + + /** + * The funding method to calculate fees for, sent in the path. + */ + @NonNull + private final String methodId; + + /** + * The amount to calculate fees for. + */ + @NonNull + private final BigDecimal amount; + + /** + * Whether the amount includes the fee. + */ + private final Boolean feeIncluded; + + /** + * A token of a previous withdrawal fee quote, valid for 5 minutes. + */ + private final String withdrawalFeeToken; + + /** + * Whether amounts of tokenized assets use rebased or base units. + */ + private final RebaseMultiplier rebaseMultiplier; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + query.put("amount", amount.toPlainString()); + putIfNonNull(query, "fee_included", feeIncluded, String::valueOf); + putIfNonNull(query, "withdrawal_fee_token", withdrawalFeeToken, v -> v); + putIfNonNull(query, "rebase_multiplier", rebaseMultiplier, RebaseMultiplier::getValue); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingLimitsParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingLimitsParams.java new file mode 100644 index 0000000..c0a173e --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingLimitsParams.java @@ -0,0 +1,49 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of List Funding Deposit Limits and List Funding Withdrawal Limits ({@code GET /funding/v1/limits/{direction}/{asset_class}/{asset}}); omitted options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class FundingLimitsParams extends FundingBetaParams { + + /** + * The class of the asset whose limits are returned, sent in the path. + */ + @NonNull + private final AssetClass assetClass; + + /** + * The asset whose limits are returned, e.g. {@code BTC}, sent in the path. + */ + @NonNull + private final String asset; + + /** + * The asset limit amounts are expressed in; its name is required, its class is optional. Kraken uses the asset of each funding method when omitted. + */ + private final Asset preferredAsset; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + if (preferredAsset != null) { + preferredAsset.putQuery(query, "preferred_asset"); + } + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingMethodsParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingMethodsParams.java new file mode 100644 index 0000000..abe6d00 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingMethodsParams.java @@ -0,0 +1,62 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of List Funding Methods ({@code GET /funding/v1/methods/{direction}}); omitted options use Kraken's defaults. A cursor must be sent without the other filters. + */ +@Getter +@Builder(toBuilder = true) +public class FundingMethodsParams extends FundingBetaParams { + + /** + * Whether deposit or withdrawal methods are listed, sent in the path. + */ + @NonNull + private final Direction direction; + + /** + * The asset to filter on; its class is required, its name is optional. + */ + private final Asset asset; + + /** + * Whether amounts of tokenized assets use rebased or base units. + */ + private final RebaseMultiplier rebaseMultiplier; + + /** + * The maximum number of methods returned, up to 10000. + */ + private final Integer limit; + + /** + * The {@code nextCursor} of the previous page. + */ + private final String cursor; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + if (asset != null) { + asset.putQuery(query, "asset"); + } + putIfNonNull(query, "rebase_multiplier", rebaseMultiplier, RebaseMultiplier::getValue); + putIfNonNull(query, "limit", limit, String::valueOf); + putIfNonNull(query, "cursor", cursor, v -> v); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingNetworksParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingNetworksParams.java new file mode 100644 index 0000000..dba7b58 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingNetworksParams.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; + +/** + * The parameters of List Funding Networks ({@code GET /funding/v1/networks}). + */ +@Getter +@Builder(toBuilder = true) +public class FundingNetworksParams extends FundingBetaParams { + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalStatus.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalStatus.java new file mode 100644 index 0000000..d879b88 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalStatus.java @@ -0,0 +1,22 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonValue; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * The status of a withdrawal of the Funding (Beta) API. + */ +@Getter +@RequiredArgsConstructor +public enum FundingWithdrawalStatus { + PENDING("pending"), + SUCCESS("success"), + FAILED("failed"), + @JsonEnumDefaultValue UNKNOWN("unknown"); + + @JsonValue + private final String value; +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalsParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalsParams.java new file mode 100644 index 0000000..8db568f --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/FundingWithdrawalsParams.java @@ -0,0 +1,82 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; +import lombok.Builder; +import lombok.Getter; + +/** + * The parameters of List Funding Withdrawals ({@code GET /funding/v1/withdrawals}); omitted options use Kraken's defaults. A cursor must be sent without the other filters. + */ +@Getter +@Builder(toBuilder = true) +public class FundingWithdrawalsParams extends FundingBetaParams { + + /** + * The asset to filter on; its class is required, its name is optional. + */ + private final Asset asset; + + /** + * The method, network or network group to filter on. + */ + private final Scope scope; + + /** + * The status to filter on. + */ + private final FundingWithdrawalStatus status; + + /** + * The {@code nextCursor} of the previous page. + */ + private final String cursor; + + /** + * The maximum number of withdrawals returned, up to 500. + */ + private final Integer limit; + + /** + * The earliest creation time of the withdrawals returned. + */ + private final Instant startTime; + + /** + * The latest creation time of the withdrawals returned. + */ + private final Instant endTime; + + /** + * Whether amounts of tokenized assets use rebased or base units. + */ + private final RebaseMultiplier rebaseMultiplier; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + if (asset != null) { + asset.putQuery(query, "asset"); + } + if (scope != null) { + scope.putQuery(query, "scope"); + } + putIfNonNull(query, "status", status, FundingWithdrawalStatus::getValue); + putIfNonNull(query, "cursor", cursor, v -> v); + putIfNonNull(query, "limit", limit, String::valueOf); + putIfNonNull(query, "start_time", startTime, Instant::toString); + putIfNonNull(query, "end_time", endTime, Instant::toString); + putIfNonNull(query, "rebase_multiplier", rebaseMultiplier, RebaseMultiplier::getValue); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Scope.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Scope.java new file mode 100644 index 0000000..2f05db2 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/Scope.java @@ -0,0 +1,77 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.NonNull; + +/** + * The scope of a withdrawal address or a filter of the Funding (Beta) API: a funding method, a network or a network group. Exactly one identifier is set; create scopes with {@link #method(String)}, {@link #network(String)} or {@link #networkGroup(String)}. + * + * @param methodId the funding method defining the scope + * @param networkId the network defining the scope + * @param networkGroupId the network group defining the scope + */ +public record Scope(@JsonProperty("method_id") String methodId, + @JsonProperty("network_id") String networkId, + @JsonProperty("network_group_id") String networkGroupId) { + + /** + * Creates a scope restricted to a single funding method. + * + * @param methodId the funding method identifier + * @return the scope + */ + public static Scope method(@NonNull String methodId) { + return new Scope(methodId, null, null); + } + + /** + * Creates a scope covering every funding method of a network. + * + * @param networkId the network identifier + * @return the scope + */ + public static Scope network(@NonNull String networkId) { + return new Scope(null, networkId, null); + } + + /** + * Creates a scope covering every network of a network group, e.g. EVM networks. + * + * @param networkGroupId the network group identifier + * @return the scope + */ + public static Scope networkGroup(@NonNull String networkGroupId) { + return new Scope(null, null, networkGroupId); + } + + void requireMethodOrNetwork(String operation) { + if (networkGroupId != null) { + throw new IllegalArgumentException("%s accepts a method or network scope, not a network group".formatted(operation)); + } + } + + void putQuery(Map query, String key) { + json().forEach((name, value) -> query.put("%s[%s]".formatted(key, name), value)); + } + + Map json() { + Map json = new LinkedHashMap<>(); + if (methodId != null) { + json.put("method_id", methodId); + } + if (networkId != null) { + json.put("network_id", networkId); + } + if (networkGroupId != null) { + json.put("network_group_id", networkGroupId); + } + if (json.size() != 1) { + throw new IllegalArgumentException("Scope requires exactly one of methodId, networkId or networkGroupId"); + } + return json; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/UpdateFundingAddressParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/UpdateFundingAddressParams.java new file mode 100644 index 0000000..e2c96db --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/params/UpdateFundingAddressParams.java @@ -0,0 +1,53 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.params; + +import java.util.LinkedHashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of Update Funding Address ({@code PUT /funding/v1/addresses/{id}}), renaming or describing a saved withdrawal address. Omitted fields are left unchanged. + */ +@Getter +@Builder(toBuilder = true) +public class UpdateFundingAddressParams extends FundingBetaParams { + + /** + * The identifier of the address to update, sent in the path. + */ + @NonNull + private final String addressId; + + /** + * The new unique name of the address, with at least one non-whitespace character. + */ + private final String name; + + /** + * The new description of the address. + */ + private final String description; + + /** + * The account ID; omit to use the account of the API key. + */ + private final String accountId; + + @Override + public Map toMap() { + Map query = new LinkedHashMap<>(); + putIfNonNull(query, "account_id", accountId, v -> v); + return query; + } + + @Override + protected Map body() { + Map body = new LinkedHashMap<>(); + putIfNonNull(body, "name", name); + putIfNonNull(body, "description", description); + return body; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/AddressDetails.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/AddressDetails.java new file mode 100644 index 0000000..ad968eb --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/AddressDetails.java @@ -0,0 +1,134 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The details of a deposit or withdrawal address of the Funding (Beta) API. Exactly one of the components is set; Kraken only sends the fields that apply to the address. + * + * @param crypto the details of a crypto address + * @param fiat the details of a fiat address + */ +public record AddressDetails(Crypto crypto, + Fiat fiat) { + + /** + * The details of a crypto address. + * + * @param address the crypto address + * @param tag the destination tag, on blockchains using one + * @param memo the memo, on blockchains using one + * @param beneficiary the beneficiary of a withdrawal address + */ + public record Crypto(String address, + String tag, + String memo, + Beneficiary beneficiary) {} + + /** + * The details of a fiat address: a bank account for deposits, a bank account, card or payment account for withdrawals. + * + * @param account the bank account number + * @param accountType the bank account type + * @param address the bank or wallet address + * @param bank the bank name + * @param bankCode the bank code + * @param bic the bank identifier code + * @param branch the bank branch + * @param branchCode the bank branch code + * @param bankAddress the bank address + * @param bsb the Bank State Branch code + * @param beneficiary the beneficiary of a withdrawal address + * @param cardNumber the card number + * @param cardNumberLast the last four digits of the card number + * @param cardType the card type + * @param cvc the card verification code + * @param expireMonth the card expiration month + * @param expireYear the card expiration year + * @param iban the International Bank Account Number + * @param maskedIban the masked IBAN + * @param intermediaryAddress the intermediary bank address + * @param intermediaryBank the intermediary bank name + * @param intermediaryBranch the intermediary bank branch + * @param intermediaryRouting the intermediary bank routing number + * @param intermediarySwift the intermediary bank SWIFT or BIC code + * @param memo the payment memo + * @param merchantReference the merchant reference + * @param nameOnAccount the account holder name + * @param notes additional notes + * @param password the account password + * @param routing the bank routing number + * @param sort the bank sort code + * @param swift the SWIFT code + * @param tag the destination tag + * @param thirdPartyEmail the third-party email address + * @param transactionId the payment provider transaction identifier + * @param transit the bank transit number + * @param username the account username + * @param signature the digital signature proving address ownership + */ + public record Fiat(String account, + @JsonProperty("account_type") String accountType, + String address, + String bank, + @JsonProperty("bank_code") String bankCode, + String bic, + String branch, + @JsonProperty("branch_code") String branchCode, + @JsonProperty("bank_address") String bankAddress, + String bsb, + Beneficiary beneficiary, + @JsonProperty("card_number") String cardNumber, + @JsonProperty("card_number_last") String cardNumberLast, + @JsonProperty("card_type") String cardType, + String cvc, + @JsonProperty("expire_month") String expireMonth, + @JsonProperty("expire_year") String expireYear, + String iban, + @JsonProperty("masked_iban") String maskedIban, + @JsonProperty("intermediary_address") String intermediaryAddress, + @JsonProperty("intermediary_bank") String intermediaryBank, + @JsonProperty("intermediary_branch") String intermediaryBranch, + @JsonProperty("intermediary_routing") String intermediaryRouting, + @JsonProperty("intermediary_swift") String intermediarySwift, + String memo, + @JsonProperty("merchant_reference") String merchantReference, + @JsonProperty("name_on_account") String nameOnAccount, + String notes, + String password, + String routing, + String sort, + String swift, + String tag, + @JsonProperty("third_party_email") String thirdPartyEmail, + @JsonProperty("transaction_id") String transactionId, + String transit, + String username, + String signature) {} + + /** + * The beneficiary of a withdrawal address. + * + * @param recipient whether the beneficiary is the user ({@code sender}) or another party ({@code other}) + * @param type the beneficiary type, {@code individual} or {@code business} + * @param name the beneficiary first name + * @param lastName the beneficiary last name + * @param country the beneficiary country + * @param stateProvince the beneficiary state or province + * @param address1 the first line of the beneficiary address + * @param address2 the second line of the beneficiary address + * @param city the beneficiary city + * @param postalCode the beneficiary postal code + * @param counterpartyVaspName the name of the counterparty virtual asset service provider + */ + public record Beneficiary(String recipient, + @JsonProperty("typ") String type, + String name, + @JsonProperty("last_name") String lastName, + String country, + @JsonProperty("state_province") String stateProvince, + @JsonProperty("address_1") String address1, + @JsonProperty("address_2") String address2, + String city, + @JsonProperty("postal_code") String postalCode, + @JsonProperty("counterparty_vasp_name") String counterpartyVaspName) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/ClaimedFundingDepositAddress.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/ClaimedFundingDepositAddress.java new file mode 100644 index 0000000..9619fce --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/ClaimedFundingDepositAddress.java @@ -0,0 +1,10 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The deposit address returned by Claim Funding Deposit Address. + * + * @param addressDetails the details of the claimed address + */ +public record ClaimedFundingDepositAddress(@JsonProperty("address_details") AddressDetails addressDetails) {} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressCreated.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressCreated.java new file mode 100644 index 0000000..6e93220 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressCreated.java @@ -0,0 +1,12 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The withdrawal address saved by Create Funding Address. + * + * @param addressId the identifier of the created address, used to withdraw to it + * @param verified whether the address is verified and ready for use + */ +public record FundingAddressCreated(@JsonProperty("address_id") String addressId, + boolean verified) {} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressDeleted.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressDeleted.java new file mode 100644 index 0000000..5b5a05b --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressDeleted.java @@ -0,0 +1,10 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The response of Delete Funding Address. + * + * @param deleted whether the address was deleted + */ +public record FundingAddressDeleted(@JsonProperty("result") boolean deleted) {} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressUpdated.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressUpdated.java new file mode 100644 index 0000000..861d85f --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddressUpdated.java @@ -0,0 +1,8 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +/** + * The withdrawal address updated by Update Funding Address. + * + * @param verified whether the address is verified and ready for use + */ +public record FundingAddressUpdated(boolean verified) {} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddresses.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddresses.java new file mode 100644 index 0000000..025206f --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAddresses.java @@ -0,0 +1,47 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; + +/** + * A page of saved withdrawal addresses returned by List Funding Addresses. + * + * @param addresses the withdrawal addresses of the page + * @param nextCursor the cursor of the next page, {@code null} on the last one + */ +public record FundingAddresses(List

    addresses, + @JsonProperty("next_cursor") String nextCursor) { + + /** + * A saved withdrawal address. + * + * @param addressId the identifier of the address, used to withdraw to it + * @param scope the method, network or network group the address was saved for + * @param addressDetails the details of the address + * @param name the unique name of the address + * @param description the description of the address + * @param modifier {@link Modifier#THIRD_PARTY} if the address belongs to a third party, {@code null} otherwise + * @param verified whether the address is verified and ready for use + */ + public record Address(@JsonProperty("address_id") String addressId, + Scope scope, + @JsonProperty("address_details") AddressDetails addressDetails, + String name, + String description, + Modifier modifier, + boolean verified) {} + + /** + * A qualifier of a withdrawal address. + */ + public enum Modifier { + THIRD_PARTY, + + @JsonEnumDefaultValue + UNKNOWN + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAssets.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAssets.java new file mode 100644 index 0000000..ac68e53 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingAssets.java @@ -0,0 +1,22 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The assets available for deposit or withdrawal returned by List Funding Assets, by asset class. + * + * @param currency the assets of the {@code currency} class, {@code null} when filtered out + * @param tokenizedAsset the assets of the {@code tokenized_asset} class, {@code null} when filtered out + */ +public record FundingAssets(List currency, + @JsonProperty("tokenized_asset") List tokenizedAsset) { + + /** + * An asset available for funding. + * + * @param name the asset name, e.g. {@code USDC} + */ + public record AssetName(String name) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositAddresses.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositAddresses.java new file mode 100644 index 0000000..f198943 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositAddresses.java @@ -0,0 +1,33 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A page of claimed deposit addresses returned by List Funding Claimed Addresses. + * + * @param addresses the claimed deposit addresses of the page + * @param nextCursor the cursor of the next page, {@code null} on the last one + */ +public record FundingDepositAddresses(List
    addresses, + @JsonProperty("next_cursor") String nextCursor) { + + /** + * A claimed deposit address. + * + * @param methodId the method of the address: the method filtered on when the filter is a method, the method the address is stored against otherwise + * @param sharesAddressesWithMethodId the method the address is stored against, when it is shared with the method filtered on + * @param id the identifier of the claimed address + * @param lastUsed when the address last received a deposit, {@code null} if it never did + * @param expireTime when the unused address expires, {@code null} if it doesn't + * @param addressDetails the details of the address + */ + public record Address(@JsonProperty("method_id") String methodId, + @JsonProperty("shares_addresses_with_method_id") String sharesAddressesWithMethodId, + String id, + @JsonProperty("last_used") Instant lastUsed, + @JsonProperty("expire_time") Instant expireTime, + @JsonProperty("address_details") AddressDetails addressDetails) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositLimits.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositLimits.java new file mode 100644 index 0000000..0a0a4a9 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDepositLimits.java @@ -0,0 +1,12 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The deposit limits of an asset returned by List Funding Deposit Limits. + * + * @param depositLimits the limits of each deposit method of the asset + */ +public record FundingDepositLimits(@JsonProperty("deposit_limits") List depositLimits) {} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDeposits.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDeposits.java new file mode 100644 index 0000000..6a5f020 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingDeposits.java @@ -0,0 +1,52 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; + +/** + * A page of deposits returned by List Funding Deposits, newest first. + * + * @param deposits the deposits of the page + * @param nextCursor the cursor of the next page, {@code null} on the last one + */ +public record FundingDeposits(List deposits, + @JsonProperty("next_cursor") String nextCursor) { + + /** + * A deposit. + * + * @param depositId the identifier of the deposit + * @param methodId the funding method of the deposit + * @param networkId the network of the deposit + * @param status the settlement status + * @param amount the deposited amount, {@code null} while unknown + * @param fee the fee charged, {@code null} until the deposit completes + * @param createTime when the deposit was created + */ + public record Deposit(@JsonProperty("deposit_id") String depositId, + @JsonProperty("method_id") String methodId, + @JsonProperty("network_id") String networkId, + Status status, + AssetAmount amount, + AssetAmount fee, + @JsonProperty("create_time") Instant createTime) {} + + /** + * The settlement status of a deposit. + */ + public enum Status { + INITIAL, + PENDING, + SETTLED, + SUCCESS, + FAILURE, + + @JsonEnumDefaultValue + UNKNOWN + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingFees.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingFees.java new file mode 100644 index 0000000..621cf44 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingFees.java @@ -0,0 +1,32 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.math.BigDecimal; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; + +/** + * The fee quote returned by Calculate Funding Fees. + * + * @param fee the calculated fee + * @param grossAmount the amount including the fee + * @param netAmount the amount excluding the fee + * @param feeDetails the fixed and percentage components of the fee + * @param withdrawalFeeToken the token pinning the quoted fee rate for withdrawals, valid for 5 minutes and reusable; null for deposit methods + */ +public record FundingFees(AssetAmount fee, + @JsonProperty("gross_amount") AssetAmount grossAmount, + @JsonProperty("net_amount") AssetAmount netAmount, + @JsonProperty("fee_details") FeeDetails feeDetails, + @JsonProperty("withdrawal_fee_token") String withdrawalFeeToken) { + + /** + * The components of a calculated fee. + * + * @param baseFee the fixed fee component + * @param feePercentage the percentage fee component + */ + public record FeeDetails(@JsonProperty("base_fee") AssetAmount baseFee, + @JsonProperty("fee_percentage") BigDecimal feePercentage) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingMethods.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingMethods.java new file mode 100644 index 0000000..dff8b8e --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingMethods.java @@ -0,0 +1,109 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; + +/** + * A page of funding methods returned by List Funding Methods. + * + * @param methods the funding methods of the page + * @param nextCursor the cursor of the next page, {@code null} on the last one + */ +public record FundingMethods(List methods, + @JsonProperty("next_cursor") String nextCursor) { + + /** + * A deposit or withdrawal method for an asset on a network. + * + * @param asset the asset of the method + * @param methodId the stable identifier of the method + * @param methodName the display name of the method + * @param minimumAmount the minimum amount per transaction, {@code null} if there is none + * @param maximumAmount the maximum amount per transaction, {@code null} if there is none + * @param fees the fees charged per transaction + * @param network the network of the method, {@code null} for methods without one + * @param deposit the deposit specific details, present only for deposit methods + * @param withdrawal the withdrawal specific details, present only for withdrawal methods and currently empty + */ + public record Method(Asset asset, + @JsonProperty("method_id") String methodId, + @JsonProperty("method_name") String methodName, + @JsonProperty("minimum_amount") BigDecimal minimumAmount, + @JsonProperty("maximum_amount") BigDecimal maximumAmount, + Fees fees, + Network network, + Deposit deposit, + Map withdrawal) {} + + /** + * The fees charged per transaction by a funding method. + * + * @param base the fixed fee + * @param percentage the percentage fee, {@code null} if there is none + * @param included whether the fee is included in the transaction amount + * @param min the minimum fee, {@code null} if there is none + * @param max the maximum fee, {@code null} if there is none + */ + public record Fees(AssetAmount base, + BigDecimal percentage, + boolean included, + AssetAmount min, + AssetAmount max) {} + + /** + * The network used by a funding method. + * + * @param networkId the stable identifier of the network + * @param networkName the network name + * @param contractAddress the token contract address on the network, {@code null} for native assets + * @param onChainAssetSymbol the asset symbol used on the network + */ + public record Network(@JsonProperty("network_id") String networkId, + @JsonProperty("network_name") String networkName, + @JsonProperty("contract_address") String contractAddress, + @JsonProperty("on_chain_asset_symbol") String onChainAssetSymbol) {} + + /** + * The details specific to a deposit method. + * + * @param sharesAddressesWithMethodId the method whose deposit addresses this method reuses, {@code null} if it has its own + * @param addressSetupFee the fee charged when the first address is claimed, {@code null} if there is none + * @param addressGeneration whether and how many addresses can be claimed + * @param allowCreditCards whether credit cards are allowed, only provided for card methods; {@code false} means debit cards only + * @param exemptedWithdrawalHold whether deposits are exempt from the withdrawal hold, only provided for methods imposing one + */ + public record Deposit(@JsonProperty("shares_addresses_with_method_id") String sharesAddressesWithMethodId, + @JsonProperty("address_setup_fee") AssetAmount addressSetupFee, + @JsonProperty("address_generation") AddressGeneration addressGeneration, + @JsonProperty("allow_credit_cards") Boolean allowCreditCards, + @JsonProperty("exempted_withdrawal_hold") Boolean exemptedWithdrawalHold) {} + + /** + * The availability of deposit address generation. + * + * @param status whether addresses can be generated, without or with a limit + * @param limit the maximum number of addresses when the status is {@link Status#LIMITED} + */ + public record AddressGeneration(Status status, + Integer limit) { + + /** + * Whether deposit addresses can be generated. + */ + public enum Status { + UNSUPPORTED, + UNLIMITED, + LIMITED, + + @JsonEnumDefaultValue + UNKNOWN + } + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingNetworks.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingNetworks.java new file mode 100644 index 0000000..5ac895c --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingNetworks.java @@ -0,0 +1,35 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The networks and network groups returned by List Funding Networks. + * + * @param networkGroups the groups of networks sharing an address format, e.g. EVM networks + * @param networks the networks available for funding + */ +public record FundingNetworks(@JsonProperty("network_groups") List networkGroups, + List networks) { + + /** + * A group of networks sharing compatible addresses. + * + * @param networkGroupId the stable identifier of the group + * @param name the group name + * @param networkIds the identifiers of the networks of the group + */ + public record NetworkGroup(@JsonProperty("network_group_id") String networkGroupId, + String name, + @JsonProperty("network_ids") List networkIds) {} + + /** + * A network available for funding. + * + * @param networkId the stable identifier of the network + * @param name the network name + */ + public record Network(@JsonProperty("network_id") String networkId, + String name) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalCreated.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalCreated.java new file mode 100644 index 0000000..384d96c --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalCreated.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; + +/** + * The withdrawal created by Create Funding Withdrawal. + * + * @param withdrawalId the identifier of the withdrawal + * @param netAmount the amount sent after fees + * @param grossAmount the total amount deducted, fees included + * @param fee the fee charged + * @param approvalRequestId set when the withdrawal waits for an approval, and is cancelled if the approval is not granted + */ +public record FundingWithdrawalCreated(@JsonProperty("withdrawal_id") String withdrawalId, + @JsonProperty("net_amount") Amount netAmount, + @JsonProperty("gross_amount") Amount grossAmount, + Amount fee, + @JsonProperty("approval_request_id") String approvalRequestId) { + + /** + * An amount of a created withdrawal. + * + * @param assetAmount the asset and amount + * @param rebaseMultiplier whether the amount of a tokenized asset uses rebased or base units, {@code null} if not provided + */ + public record Amount(@JsonProperty("asset_amount") AssetAmount assetAmount, + @JsonProperty("rebase_multiplier") RebaseMultiplier rebaseMultiplier) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalLimits.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalLimits.java new file mode 100644 index 0000000..fe1b4fc --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawalLimits.java @@ -0,0 +1,16 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; + +/** + * The withdrawal limits of an asset returned by List Funding Withdrawal Limits. + * + * @param availableBalance the balance available for withdrawal after holds, open positions, margin requirements and other restrictions, which method limits may reduce further + * @param withdrawalLimits the limits of each withdrawal method of the asset + */ +public record FundingWithdrawalLimits(@JsonProperty("available_balance") AssetAmount availableBalance, + @JsonProperty("withdrawal_limits") List withdrawalLimits) {} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawals.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawals.java new file mode 100644 index 0000000..77da273 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/FundingWithdrawals.java @@ -0,0 +1,42 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalStatus; + +/** + * A page of withdrawals returned by List Funding Withdrawals, newest first. + * + * @param withdrawals the withdrawals of the page + * @param nextCursor the cursor of the next page, {@code null} on the last one + */ +public record FundingWithdrawals(List withdrawals, + @JsonProperty("next_cursor") String nextCursor) { + + /** + * A withdrawal. + * + * @param withdrawalId the identifier of the withdrawal + * @param amount the withdrawn amount + * @param fee the fee charged + * @param methodId the funding method of the withdrawal + * @param status the withdrawal status + * @param createTime when the withdrawal was created + * @param addressId the saved address withdrawn to, {@code null} for withdrawals created without one + * @param onchainTransaction the on-chain transaction identifier, {@code null} until available + * @param utxoVout the Bitcoin transaction output index, {@code null} if not applicable + */ + public record Withdrawal(@JsonProperty("withdrawal_id") String withdrawalId, + AssetAmount amount, + AssetAmount fee, + @JsonProperty("method_id") String methodId, + FundingWithdrawalStatus status, + @JsonProperty("create_time") Instant createTime, + @JsonProperty("address_id") String addressId, + @JsonProperty("onchain_transaction") String onchainTransaction, + @JsonProperty("utxo_vout") Long utxoVout) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValue.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValue.java new file mode 100644 index 0000000..9e70611 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValue.java @@ -0,0 +1,40 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; + +/** + * A value of a funding limit, which Kraken returns as a count for attempt and success limits, or as amounts for amount based limits. Exactly one of the components is set. + * + * @param count the number of transactions, for attempt and success limits + * @param amounts the amounts, for amount based limits + */ +@JsonDeserialize(using = LimitValueDeserializer.class) +public record LimitValue(Long count, Amounts amounts) { + + /** + * Creates either a count or an amount value. + * + * @param count the number of transactions + * @param amounts the amounts + * @throws IllegalArgumentException if not exactly one of count and amounts is set + */ + public LimitValue { + if ((count == null) == (amounts == null)) { + throw new IllegalArgumentException("A limit value is either a count or amounts"); + } + } + + /** + * The amounts of an amount based limit. + * + * @param policyAssetAmount the amount in the asset used by the limit policy + * @param usdAmount the equivalent amount in USD + * @param requestedAssetAmount the equivalent amount in the preferred asset, or in the asset of the funding method when no preferred asset was requested + */ + public record Amounts(@JsonProperty("policy_asset_amount") AssetAmount policyAssetAmount, + @JsonProperty("usd_amount") AssetAmount usdAmount, + @JsonProperty("requested_asset_amount") AssetAmount requestedAssetAmount) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValueDeserializer.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValueDeserializer.java new file mode 100644 index 0000000..4a45738 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/LimitValueDeserializer.java @@ -0,0 +1,34 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +final class LimitValueDeserializer extends StdDeserializer { + + LimitValueDeserializer() { + super(LimitValue.class); + } + + @Override + public LimitValue deserialize(JsonParser parser, DeserializationContext context) throws IOException { + if (parser.hasToken(JsonToken.START_OBJECT)) { + return new LimitValue(null, context.readValue(parser, LimitValue.Amounts.class)); + } + if (parser.hasToken(JsonToken.VALUE_NUMBER_INT)) { + return new LimitValue(parser.getLongValue(), null); + } + if (parser.hasToken(JsonToken.VALUE_STRING)) { + try { + return new LimitValue(Long.valueOf(parser.getText()), null); + } + catch (NumberFormatException e) { + return context.reportInputMismatch(LimitValue.class, "Expected a count or limit amounts"); + } + } + return (LimitValue) context.handleUnexpectedToken(LimitValue.class, parser); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/MethodLimits.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/MethodLimits.java new file mode 100644 index 0000000..830944a --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/fundingbeta/response/MethodLimits.java @@ -0,0 +1,80 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta.response; + +import java.time.Duration; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonProperty; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; + +/** + * The deposit or withdrawal limits of a funding method, returned by List Funding Deposit Limits and List Funding Withdrawal Limits. + * + * @param methodId the funding method the limits apply to + * @param maximumAmount the maximum amount allowed by the amount based limits and, for withdrawals, the available balance; attempt and transaction count limits are not included + * @param maximumReason whether the available balance or the limits determine the maximum withdrawal amount, {@code null} for deposits + * @param limits the limits of each time window + */ +public record MethodLimits(@JsonProperty("method_id") String methodId, + @JsonProperty("maximum_amount") AssetAmount maximumAmount, + @JsonProperty("maximum_reason") MaximumReason maximumReason, + List limits) { + + /** + * What determines the maximum withdrawal amount. + */ + public enum MaximumReason { + BALANCE, + LIMITS, + + @JsonEnumDefaultValue + UNKNOWN + } + + /** + * A limit applying over a rolling time window. + * + * @param timeWindow the length of the time window + * @param limit the limited metric and its remaining, maximum and used values + */ + public record TimeWindowLimit(Duration timeWindow, + Limit limit) { + + @JsonCreator + TimeWindowLimit(@JsonProperty("time_window") long timeWindowSeconds, + @JsonProperty("limit") Limit limit) { + this(Duration.ofSeconds(timeWindowSeconds), limit); + } + } + + /** + * The values of a limit. Amount based limits have {@link LimitValue#amounts()} values, attempt and success limits have {@link LimitValue#count()} values. + * + * @param limitType the limited metric + * @param remaining what remains in the time window + * @param maximum the maximum for the time window + * @param used what was used in the time window, {@code null} if not provided + */ + public record Limit(@JsonProperty("limit_type") LimitType limitType, + LimitValue remaining, + LimitValue maximum, + LimitValue used) {} + + /** + * The metric a limit applies to. + */ + public enum LimitType { + EQUIV_AMOUNT_USD, + EQUIV_AMOUNT_EUR, + EQUIV_AMOUNT_CAD, + EQUIV_AMOUNT_GBP, + AMOUNT, + ATTEMPT, + SUCCESS, + + @JsonEnumDefaultValue + UNKNOWN + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java b/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java index e04e6fb..b578b78 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java +++ b/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java @@ -1,8 +1,11 @@ package dev.andstuff.kraken.api.rest; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStreamWriter; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.zip.ZipInputStream; import javax.net.ssl.HttpsURLConnection; @@ -15,7 +18,9 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import dev.andstuff.kraken.api.endpoint.Endpoint; +import dev.andstuff.kraken.api.endpoint.KrakenException; import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaEndpoint; import dev.andstuff.kraken.api.endpoint.priv.PrivateEndpoint; import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; import lombok.extern.slf4j.Slf4j; @@ -23,7 +28,7 @@ /** * {@link KrakenRestRequester} implementation using {@link HttpsURLConnection}. * - *

    JSON responses are deserialized with a Jackson mapper configured to be lenient with unknown properties and enum values, so that new fields returned by Kraken don't break deserialization. Responses of type {@code application/zip}, e.g. report exports, are handed to {@link Endpoint#processZipResponse(java.util.zip.ZipInputStream)}. + *

    JSON responses are deserialized with a Jackson mapper configured to be lenient with unknown properties and enum values, so that new fields returned by Kraken don't break deserialization. Responses of type {@code application/zip}, e.g. report exports, are handed to {@link Endpoint#processZipResponse(java.util.zip.ZipInputStream)}. Funding (Beta) responses are deserialized from the whole body, and their HTTP error statuses are raised as a {@link KrakenException}. */ @Slf4j public class DefaultKrakenRestRequester implements KrakenRestRequester { @@ -100,6 +105,40 @@ public T execute(PrivateEndpoint endpoint, KrakenCredentials credentials, } } + /** + * {@inheritDoc} + * + * @throws IllegalStateException if the request fails or the response has an unsupported content type + */ + @Override + public T execute(FundingBetaEndpoint endpoint, KrakenCredentials credentials, KrakenNonceGenerator nonceGenerator) { + String nonce = nonceGenerator.generate(); + String body = endpoint.encodedBody(); + + try { + HttpsURLConnection connection = createHttpsConnection(endpoint); + URL url = connection.getURL(); + log.info("Fetching funding endpoint: {}", url); + connection.addRequestProperty("API-Key", credentials.getKey()); + connection.addRequestProperty("API-Sign", credentials.sign(url.getFile(), nonce, body)); + connection.addRequestProperty("API-Nonce", nonce); + + if (!body.isEmpty()) { + connection.addRequestProperty("Content-Type", endpoint.getContentType()); + connection.setDoOutput(true); + + try (OutputStreamWriter out = new OutputStreamWriter(connection.getOutputStream(), StandardCharsets.UTF_8)) { + out.write(body); + } + } + + return parseFundingResponse(connection, endpoint); + } + catch (IOException e) { + throw new IllegalStateException("Error while making request to Kraken API", e); + } + } + private HttpsURLConnection createHttpsConnection(Endpoint endpoint) throws IOException { HttpsURLConnection connection = connectionFactory.open(endpoint.buildURL()); connection.setRequestMethod(endpoint.getHttpMethod()); @@ -123,4 +162,24 @@ else if ("application/zip".equals(contentType)) { throw new IllegalStateException("Unsupported HTTP Content-Type"); } } + + private static T parseFundingResponse(HttpsURLConnection connection, FundingBetaEndpoint endpoint) throws IOException { + int status = connection.getResponseCode(); + if (status >= 400) { + throw new KrakenException(List.of("HTTP %d %s".formatted(status, readErrorBody(connection)).strip())); + } + + String contentType = connection.getHeaderField("Content-Type"); + if (contentType == null || !contentType.startsWith("application/json")) { + throw new IllegalStateException("Unsupported HTTP Content-Type"); + } + + return OBJECT_MAPPER.readValue(connection.getInputStream(), endpoint.getResponseType()); + } + + private static String readErrorBody(HttpsURLConnection connection) throws IOException { + try (InputStream errorStream = connection.getErrorStream()) { + return errorStream == null ? "" : new String(errorStream.readAllBytes(), StandardCharsets.UTF_8); + } + } } diff --git a/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenCredentials.java b/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenCredentials.java index 5aaf075..292e5a0 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenCredentials.java +++ b/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenCredentials.java @@ -36,11 +36,23 @@ public class KrakenCredentials { * @return the Base64 encoded signature, to be sent in the {@code API-Sign} header */ public String sign(URL url, String nonce, String urlEncodedParams) { + return sign(url.getPath(), nonce, urlEncodedParams); + } + + /** + * Signs a request with the given signed path: the message is the signed path followed by the SHA-256 digest of the nonce and the request body, and it is signed with HMAC-SHA512 using the Base64 decoded secret. Funding (Beta) endpoints sign the URL path followed by its query string, e.g. {@code /funding/v1/fees/{method_id}?amount=5}. + * + * @param signedPath the path Kraken expects to be signed + * @param nonce the nonce of the request + * @param body the request body, empty for requests without one + * @return the Base64 encoded signature, to be sent in the {@code API-Sign} header + */ + public String sign(String signedPath, String nonce, String body) { byte[] hmacKey = Base64.getDecoder().decode(secret); - byte[] sha256 = sha256(nonce + urlEncodedParams); - byte[] hmacMessage = concat(url.getPath().getBytes(StandardCharsets.UTF_8), sha256); + byte[] sha256 = sha256(nonce + body); + byte[] hmacMessage = concat(signedPath.getBytes(StandardCharsets.UTF_8), sha256); byte[] hmac = hmacSha512(hmacKey, hmacMessage); return Base64.getEncoder().encodeToString(hmac); diff --git a/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenRestRequester.java b/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenRestRequester.java index 6ffa940..0434c7d 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenRestRequester.java +++ b/library/src/main/java/dev/andstuff/kraken/api/rest/KrakenRestRequester.java @@ -1,6 +1,7 @@ package dev.andstuff.kraken.api.rest; import dev.andstuff.kraken.api.endpoint.KrakenException; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaEndpoint; import dev.andstuff.kraken.api.endpoint.priv.PrivateEndpoint; import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; import lombok.NonNull; @@ -33,4 +34,19 @@ public interface KrakenRestRequester { * @throws KrakenException if Kraken returns an error */ T execute(PrivateEndpoint endpoint, @NonNull KrakenCredentials credentials, @NonNull KrakenNonceGenerator nonceGenerator); + + /** + * Queries a Funding (Beta) endpoint, sending the nonce in the {@code API-Nonce} header and signing the URL path, its query string and the JSON body with the given credentials. Requesters written before Funding (Beta) support don't implement this method. + * + * @param the type the response is deserialized into + * @param endpoint the endpoint to query + * @param credentials the credentials used to sign the request + * @param nonceGenerator the generator providing the nonce of the request + * @return the deserialized response + * @throws KrakenException if Kraken answers with an HTTP error status + * @throws UnsupportedOperationException if the requester doesn't support Funding (Beta) endpoints + */ + default T execute(FundingBetaEndpoint endpoint, @NonNull KrakenCredentials credentials, @NonNull KrakenNonceGenerator nonceGenerator) { + throw new UnsupportedOperationException("%s doesn't support Funding (Beta) endpoints".formatted(getClass().getSimpleName())); + } } diff --git a/library/src/test/java/dev/andstuff/kraken/api/KrakenAPIFundingBetaTest.java b/library/src/test/java/dev/andstuff/kraken/api/KrakenAPIFundingBetaTest.java new file mode 100644 index 0000000..894617b --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/KrakenAPIFundingBetaTest.java @@ -0,0 +1,529 @@ +package dev.andstuff.kraken.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.ClaimFundingDepositAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.CreateFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.CreateFundingWithdrawalEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.DeleteFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingAddressesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingAssetsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingBetaEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingDepositAddressesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingDepositLimitsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingDepositsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingFeesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingMethodsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingNetworksEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingWithdrawalLimitsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingWithdrawalsEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.UpdateFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.ClaimFundingDepositAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingWithdrawalParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.DeleteFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAssetsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingFeesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingLimitsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingMethodsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingNetworksParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalStatus; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.UpdateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.ClaimedFundingDepositAddress; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressCreated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressDeleted; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressUpdated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddresses; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAssets; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositAddresses; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositLimits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDeposits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingFees; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingMethods; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingNetworks; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalCreated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalLimits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawals; +import dev.andstuff.kraken.api.rest.KrakenCredentials; +import dev.andstuff.kraken.api.rest.KrakenNonceGenerator; +import dev.andstuff.kraken.api.rest.KrakenRestRequester; + +@ExtendWith(MockitoExtension.class) +class KrakenAPIFundingBetaTest { + + @Mock private KrakenCredentials credentials; + @Mock private KrakenNonceGenerator nonceGenerator; + @Mock private KrakenRestRequester requester; + + @Test + void should_route_fundingFees_options_when_called() { + FundingFees response = new FundingFees(null, null, null, null, "token"); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingFeesParams params = FundingFeesParams.builder().methodId("d4ec4d52-b159-428e-ba64-f45455a978a1").amount(new BigDecimal("5")).build(); + when(requester.execute(any(FundingFeesEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingFees result = unit.fundingFees(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingFeesEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingFees_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingFeesParams params = FundingFeesParams.builder().methodId("d4ec4d52-b159-428e-ba64-f45455a978a1").amount(new BigDecimal("5")).build(); + + assertThatThrownBy(() -> unit.fundingFees(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingMethods_options_when_called() { + FundingMethods response = new FundingMethods(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingMethodsParams params = FundingMethodsParams.builder().direction(Direction.WITHDRAW).limit(10).build(); + when(requester.execute(any(FundingMethodsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingMethods result = unit.fundingMethods(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingMethodsEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingMethods_direction_when_called() { + FundingMethods response = new FundingMethods(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingMethodsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingMethods result = unit.fundingMethods(Direction.DEPOSIT); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingMethodsEndpoint endpoint) -> ((FundingMethodsParams) endpoint.getParams()).getDirection() == Direction.DEPOSIT), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingMethods_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingMethodsParams params = FundingMethodsParams.builder().direction(Direction.WITHDRAW).limit(10).build(); + + assertThatThrownBy(() -> unit.fundingMethods(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingAssets_options_when_called() { + FundingAssets response = new FundingAssets(List.of(), List.of()); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingAssetsParams params = FundingAssetsParams.builder().direction(Direction.DEPOSIT).assetClass(AssetClass.CURRENCY).build(); + when(requester.execute(any(FundingAssetsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingAssets result = unit.fundingAssets(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingAssetsEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingAssets_direction_when_called() { + FundingAssets response = new FundingAssets(List.of(), List.of()); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingAssetsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingAssets result = unit.fundingAssets(Direction.WITHDRAW); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingAssetsEndpoint endpoint) -> ((FundingAssetsParams) endpoint.getParams()).getDirection() == Direction.WITHDRAW), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingAssets_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingAssetsParams params = FundingAssetsParams.builder().direction(Direction.DEPOSIT).assetClass(AssetClass.CURRENCY).build(); + + assertThatThrownBy(() -> unit.fundingAssets(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingNetworks_options_when_called() { + FundingNetworks response = new FundingNetworks(List.of(), List.of()); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingNetworksParams params = FundingNetworksParams.builder().accountId("AA12").build(); + when(requester.execute(any(FundingNetworksEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingNetworks result = unit.fundingNetworks(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingNetworksEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingNetworks_defaults_when_called() { + FundingNetworks response = new FundingNetworks(List.of(), List.of()); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingNetworksEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingNetworks result = unit.fundingNetworks(); + + assertThat(result).isSameAs(response); + verify(requester).execute(any(FundingNetworksEndpoint.class), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingNetworks_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingNetworksParams params = FundingNetworksParams.builder().accountId("AA12").build(); + + assertThatThrownBy(() -> unit.fundingNetworks(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingDepositLimits_options_when_called() { + FundingDepositLimits response = new FundingDepositLimits(List.of()); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingLimitsParams params = FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("BTC").build(); + when(requester.execute(any(FundingDepositLimitsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingDepositLimits result = unit.fundingDepositLimits(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingDepositLimitsEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingDepositLimits_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingLimitsParams params = FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("BTC").build(); + + assertThatThrownBy(() -> unit.fundingDepositLimits(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingWithdrawalLimits_options_when_called() { + FundingWithdrawalLimits response = new FundingWithdrawalLimits(null, List.of()); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingLimitsParams params = FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("BTC").build(); + when(requester.execute(any(FundingWithdrawalLimitsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingWithdrawalLimits result = unit.fundingWithdrawalLimits(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingWithdrawalLimitsEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingWithdrawalLimits_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingLimitsParams params = FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("BTC").build(); + + assertThatThrownBy(() -> unit.fundingWithdrawalLimits(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_claimFundingDepositAddress_options_when_called() { + ClaimedFundingDepositAddress response = new ClaimedFundingDepositAddress(null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + ClaimFundingDepositAddressParams params = ClaimFundingDepositAddressParams.builder().methodId("27ede8db-804b-4d91-8e25-46b7b9668730").build(); + when(requester.execute(any(ClaimFundingDepositAddressEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + ClaimedFundingDepositAddress result = unit.claimFundingDepositAddress(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((ClaimFundingDepositAddressEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_claimFundingDepositAddress_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + ClaimFundingDepositAddressParams params = ClaimFundingDepositAddressParams.builder().methodId("27ede8db-804b-4d91-8e25-46b7b9668730").build(); + + assertThatThrownBy(() -> unit.claimFundingDepositAddress(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingDepositAddresses_options_when_called() { + FundingDepositAddresses response = new FundingDepositAddresses(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingDepositAddressesParams params = FundingDepositAddressesParams.builder().scope(Scope.method("m")).build(); + when(requester.execute(any(FundingDepositAddressesEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingDepositAddresses result = unit.fundingDepositAddresses(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingDepositAddressesEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingDepositAddresses_defaults_when_called() { + FundingDepositAddresses response = new FundingDepositAddresses(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingDepositAddressesEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingDepositAddresses result = unit.fundingDepositAddresses(); + + assertThat(result).isSameAs(response); + verify(requester).execute(any(FundingDepositAddressesEndpoint.class), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingDepositAddresses_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingDepositAddressesParams params = FundingDepositAddressesParams.builder().scope(Scope.method("m")).build(); + + assertThatThrownBy(() -> unit.fundingDepositAddresses(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingDeposits_options_when_called() { + FundingDeposits response = new FundingDeposits(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingDepositsParams params = FundingDepositsParams.builder().limit(20).build(); + when(requester.execute(any(FundingDepositsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingDeposits result = unit.fundingDeposits(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingDepositsEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingDeposits_defaults_when_called() { + FundingDeposits response = new FundingDeposits(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingDepositsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingDeposits result = unit.fundingDeposits(); + + assertThat(result).isSameAs(response); + verify(requester).execute(any(FundingDepositsEndpoint.class), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingDeposits_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingDepositsParams params = FundingDepositsParams.builder().limit(20).build(); + + assertThatThrownBy(() -> unit.fundingDeposits(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingAddresses_options_when_called() { + FundingAddresses response = new FundingAddresses(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingAddressesParams params = FundingAddressesParams.builder().limit(20).build(); + when(requester.execute(any(FundingAddressesEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingAddresses result = unit.fundingAddresses(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingAddressesEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingAddresses_defaults_when_called() { + FundingAddresses response = new FundingAddresses(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingAddressesEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingAddresses result = unit.fundingAddresses(); + + assertThat(result).isSameAs(response); + verify(requester).execute(any(FundingAddressesEndpoint.class), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingAddresses_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingAddressesParams params = FundingAddressesParams.builder().limit(20).build(); + + assertThatThrownBy(() -> unit.fundingAddresses(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_createFundingAddress_options_when_called() { + FundingAddressCreated response = new FundingAddressCreated("AB7J4FF-BGM7G-V2JMIH", true); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + CreateFundingAddressParams params = CreateFundingAddressParams.builder().scope(Scope.network("n")).address("0xBef7").name("Wallet").build(); + when(requester.execute(any(CreateFundingAddressEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingAddressCreated result = unit.createFundingAddress(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((CreateFundingAddressEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_createFundingAddress_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + CreateFundingAddressParams params = CreateFundingAddressParams.builder().scope(Scope.network("n")).address("0xBef7").name("Wallet").build(); + + assertThatThrownBy(() -> unit.createFundingAddress(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_updateFundingAddress_options_when_called() { + FundingAddressUpdated response = new FundingAddressUpdated(true); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + UpdateFundingAddressParams params = UpdateFundingAddressParams.builder().addressId("AB7J4FF-BGM7G-V2JMIH").name("Wallet").build(); + when(requester.execute(any(UpdateFundingAddressEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingAddressUpdated result = unit.updateFundingAddress(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((UpdateFundingAddressEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_updateFundingAddress_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + UpdateFundingAddressParams params = UpdateFundingAddressParams.builder().addressId("AB7J4FF-BGM7G-V2JMIH").name("Wallet").build(); + + assertThatThrownBy(() -> unit.updateFundingAddress(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_fundingWithdrawals_options_when_called() { + FundingWithdrawals response = new FundingWithdrawals(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingWithdrawalsParams params = FundingWithdrawalsParams.builder().status(FundingWithdrawalStatus.PENDING).build(); + when(requester.execute(any(FundingWithdrawalsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingWithdrawals result = unit.fundingWithdrawals(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((FundingWithdrawalsEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_route_fundingWithdrawals_defaults_when_called() { + FundingWithdrawals response = new FundingWithdrawals(List.of(), null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(FundingWithdrawalsEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingWithdrawals result = unit.fundingWithdrawals(); + + assertThat(result).isSameAs(response); + verify(requester).execute(any(FundingWithdrawalsEndpoint.class), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_fundingWithdrawals_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingWithdrawalsParams params = FundingWithdrawalsParams.builder().status(FundingWithdrawalStatus.PENDING).build(); + + assertThatThrownBy(() -> unit.fundingWithdrawals(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_createFundingWithdrawal_options_when_called() { + FundingWithdrawalCreated response = new FundingWithdrawalCreated("FTVZiTI-e02T84mm87JmibnObWNdnW", null, null, null, null); + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + CreateFundingWithdrawalParams params = CreateFundingWithdrawalParams.builder().scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "USDC"), new BigDecimal("5"))).build(); + when(requester.execute(any(CreateFundingWithdrawalEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(response); + + FundingWithdrawalCreated result = unit.createFundingWithdrawal(params); + + assertThat(result).isSameAs(response); + verify(requester).execute(argThat((CreateFundingWithdrawalEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_createFundingWithdrawal_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + CreateFundingWithdrawalParams params = CreateFundingWithdrawalParams.builder().scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "USDC"), new BigDecimal("5"))).build(); + + assertThatThrownBy(() -> unit.createFundingWithdrawal(params)).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_unwrap_deletion_result_when_deleting_address_by_identifier() { + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + when(requester.execute(any(DeleteFundingAddressEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(new FundingAddressDeleted(true)); + + boolean result = unit.deleteFundingAddress("AB7J4FF-BGM7G-V2JMIH"); + + assertThat(result).isTrue(); + verify(requester).execute(argThat((DeleteFundingAddressEndpoint endpoint) -> endpoint.getPath().equals("v1/addresses/AB7J4FF-BGM7G-V2JMIH")), same(credentials), same(nonceGenerator)); + } + + @Test + void should_unwrap_deletion_result_when_deleting_address_with_options() { + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + DeleteFundingAddressParams params = DeleteFundingAddressParams.builder().addressId("AB1").accountId("AA12").build(); + when(requester.execute(any(DeleteFundingAddressEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(new FundingAddressDeleted(false)); + + boolean result = unit.deleteFundingAddress(params); + + assertThat(result).isFalse(); + verify(requester).execute(argThat((DeleteFundingAddressEndpoint endpoint) -> endpoint.getParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_deleteFundingAddress_when_credentials_are_missing() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + + assertThatThrownBy(() -> unit.deleteFundingAddress("AB1")).isInstanceOf(IllegalStateException.class); + verifyNoInteractions(requester); + } + + @Test + void should_route_custom_funding_endpoint_when_queried() { + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + FundingBetaEndpoint endpoint = new FundingBetaEndpoint<>("GET", "v1/networks", new TypeReference<>() {}); + JsonNode response = JsonNodeFactory.instance.objectNode(); + when(requester.execute(same(endpoint), same(credentials), same(nonceGenerator))).thenReturn(response); + + JsonNode result = unit.query(endpoint); + + assertThat(result).isSameAs(response); + } + + @Test + void should_name_path_when_custom_funding_endpoint_lacks_credentials() { + KrakenAPI unit = new KrakenAPI(null, nonceGenerator, requester); + FundingBetaEndpoint endpoint = new FundingBetaEndpoint<>("GET", "v1/networks", new TypeReference<>() {}); + + assertThatThrownBy(() -> unit.query(endpoint)).isInstanceOf(IllegalStateException.class).hasMessageContaining("v1/networks"); + verifyNoInteractions(requester); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpointTest.java new file mode 100644 index 0000000..4a537f9 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/ClaimFundingDepositAddressEndpointTest.java @@ -0,0 +1,83 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.ClaimFundingDepositAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.ClaimedFundingDepositAddress; + +@ExtendWith(MockitoExtension.class) +class ClaimFundingDepositAddressEndpointTest { + + @Test + void should_send_method_in_json_body_when_claiming_address() { + ClaimFundingDepositAddressEndpoint unit = new ClaimFundingDepositAddressEndpoint(ClaimFundingDepositAddressParams.builder() + .methodId("27ede8db-804b-4d91-8e25-46b7b9668730").accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/deposit/address?account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("PUT"); + assertThat(unit.getContentType()).isEqualTo("application/json"); + assertThat(unit.encodedBody()).isEqualTo("{\"method_id\":\"27ede8db-804b-4d91-8e25-46b7b9668730\"}"); + } + + @Test + void should_reject_missing_method_when_building_parameters() { + assertThatThrownBy(() -> ClaimFundingDepositAddressParams.builder().accountId("AA12").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("methodId"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + ClaimFundingDepositAddressEndpoint unit = new ClaimFundingDepositAddressEndpoint(ClaimFundingDepositAddressParams.builder().methodId("27ede8db-804b-4d91-8e25-46b7b9668730").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ClaimFundingDepositAddress.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + ClaimedFundingDepositAddress result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.addressDetails().crypto().address()).isEqualTo("AikyzpZZcNAeovd6XnyEaEe6YB1tPKkXZF37rUWD..."); + assertThat(result.addressDetails().fiat()).isNull(); + } + + @Test + void should_read_bank_details_when_claiming_fiat_address() throws Exception { + ClaimFundingDepositAddressEndpoint unit = new ClaimFundingDepositAddressEndpoint(ClaimFundingDepositAddressParams.builder().methodId("method").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + ClaimedFundingDepositAddress result = mapper.readValue(""" + {"address_details":{"fiat":{"iban":"DE89370400440532013000","bic":"COBADEFFXXX","name_on_account":"Payward Ltd.","bank_address":"Frankfurt","memo":"REF123"}}} + """, unit.getResponseType()); + + assertThat(result.addressDetails().crypto()).isNull(); + assertThat(result.addressDetails().fiat().iban()).isEqualTo("DE89370400440532013000"); + assertThat(result.addressDetails().fiat().bic()).isEqualTo("COBADEFFXXX"); + assertThat(result.addressDetails().fiat().nameOnAccount()).isEqualTo("Payward Ltd."); + assertThat(result.addressDetails().fiat().bankAddress()).isEqualTo("Frankfurt"); + assertThat(result.addressDetails().fiat().memo()).isEqualTo("REF123"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpointTest.java new file mode 100644 index 0000000..1bf7676 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingAddressEndpointTest.java @@ -0,0 +1,86 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressCreated; + +@ExtendWith(MockitoExtension.class) +class CreateFundingAddressEndpointTest { + + @Test + void should_encode_documented_json_body_when_creating_address() throws Exception { + CreateFundingAddressEndpoint unit = new CreateFundingAddressEndpoint(CreateFundingAddressParams.builder() + .scope(Scope.network("d9d375da-44b7-4be1-8a00-8b281acfe366")).address("0xBef7B36845cA31045E86D0B46DBCac4e6752") + .name("Personal Wallet").description("My hardware wallet address").accountId("AA12").build()); + JsonMapper mapper = JsonMapper.builder().build(); + + String result = unit.encodedBody(); + + assertThat(mapper.readTree(result)).isEqualTo(mapper.readTree(""" + {"scope":{"network_id":"d9d375da-44b7-4be1-8a00-8b281acfe366"},"address_details":{"crypto":{"address":"0xBef7B36845cA31045E86D0B46DBCac4e6752"}},"name":"Personal Wallet","description":"My hardware wallet address"} + """)); + assertThat(unit.buildURL().getFile()).isEqualTo("/funding/v1/addresses?account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("POST"); + assertThat(unit.getContentType()).isEqualTo("application/json"); + } + + @Test + void should_send_tag_and_memo_when_supplied() { + CreateFundingAddressEndpoint unit = new CreateFundingAddressEndpoint(CreateFundingAddressParams.builder() + .scope(Scope.method("m")).address("rAddress").tag("123").memo("note \"quoted\"").name("XRP").build()); + + String result = unit.encodedBody(); + + assertThat(result).isEqualTo("{\"scope\":{\"method_id\":\"m\"},\"address_details\":{\"crypto\":{\"address\":\"rAddress\",\"tag\":\"123\",\"memo\":\"note \\\"quoted\\\"\"}},\"name\":\"XRP\"}"); + } + + @Test + void should_reject_missing_scope_when_building_parameters() { + assertThatThrownBy(() -> CreateFundingAddressParams.builder().address("a").name("n").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("scope"); + } + + @Test + void should_reject_missing_address_when_building_parameters() { + assertThatThrownBy(() -> CreateFundingAddressParams.builder().scope(Scope.method("m")).name("n").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("address"); + } + + @Test + void should_reject_missing_name_when_building_parameters() { + assertThatThrownBy(() -> CreateFundingAddressParams.builder().scope(Scope.method("m")).address("a").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("name"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + CreateFundingAddressEndpoint unit = new CreateFundingAddressEndpoint(CreateFundingAddressParams.builder().scope(Scope.method("m")).address("a").name("n").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/CreateFundingAddress.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingAddressCreated result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.addressId()).isEqualTo("AB7J4FF-BGM7G-V2JMIH"); + assertThat(result.verified()).isTrue(); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpointTest.java new file mode 100644 index 0000000..dca67ac --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/CreateFundingWithdrawalEndpointTest.java @@ -0,0 +1,156 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetAmount; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingWithdrawalParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalCreated; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; + +@ExtendWith(MockitoExtension.class) +class CreateFundingWithdrawalEndpointTest { + + @Test + void should_encode_documented_json_body_when_using_quoted_fee() throws Exception { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("d4ec4d52-b159-428e-ba64-f45455a978a1")).addressId("ABR6SXP-SF6CY-VJMONY") + .amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "USDC"), new BigDecimal("5"))) + .withdrawalFeeToken("YOUR_WITHDRAWAL_FEE_TOKEN").feeIncluded(true).accountId("AA12").build()); + JsonMapper mapper = JsonMapper.builder().build(); + + String result = unit.encodedBody(); + + assertThat(mapper.readTree(result)).isEqualTo(mapper.readTree(""" + {"scope":{"method_id":"d4ec4d52-b159-428e-ba64-f45455a978a1"},"address_id":"ABR6SXP-SF6CY-VJMONY","amount":{"asset_amount":{"asset":{"class":"currency","name":"USDC"},"amount":"5"}},"fee":{"quoted_fee":{"token":"YOUR_WITHDRAWAL_FEE_TOKEN"},"fee_included":true}} + """)); + assertThat(unit.buildURL().getFile()).isEqualTo("/funding/v1/withdrawals?account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("POST"); + assertThat(unit.getContentType()).isEqualTo("application/json"); + } + + @Test + void should_encode_capped_current_fee_and_rebase_multiplier_when_supplied() throws Exception { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.network("n1")).addressId("AB1") + .amount(new AssetAmount(new Asset(AssetClass.TOKENIZED_ASSET, "TSLAx"), new BigDecimal("1.30000000"))) + .maxFee(new AssetAmount(new Asset(AssetClass.TOKENIZED_ASSET, "TSLAx"), new BigDecimal("0.013"))) + .feeIncluded(false).rebaseMultiplier(RebaseMultiplier.BASE).expectedAddress("0xabc").build()); + JsonMapper mapper = JsonMapper.builder().build(); + + String result = unit.encodedBody(); + + assertThat(mapper.readTree(result)).isEqualTo(mapper.readTree(""" + {"scope":{"network_id":"n1"},"address_id":"AB1", + "amount":{"asset_amount":{"asset":{"class":"tokenized_asset","name":"TSLAx"},"amount":"1.30000000"},"rebase_multiplier":"base"}, + "fee":{"current_fee":{"max_fee":{"asset_amount":{"asset":{"class":"tokenized_asset","name":"TSLAx"},"amount":"0.013"},"rebase_multiplier":"base"}},"fee_included":false}, + "expected_address":"0xabc"} + """)); + } + + @Test + void should_send_uncapped_current_fee_when_only_fee_inclusion_is_supplied() { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), new BigDecimal("0.01"))).feeIncluded(true).build()); + + String result = unit.encodedBody(); + + assertThat(result).endsWith(",\"fee\":{\"current_fee\":{},\"fee_included\":true}}"); + } + + @Test + void should_omit_fee_when_no_fee_option_is_supplied() { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), new BigDecimal("0.01"))).build()); + + String result = unit.encodedBody(); + + assertThat(result).isEqualTo("{\"scope\":{\"method_id\":\"m\"},\"address_id\":\"AB1\",\"amount\":{\"asset_amount\":{\"asset\":{\"class\":\"currency\",\"name\":\"BTC\"},\"amount\":\"0.01\"}}}"); + } + + @Test + void should_reject_token_and_maximum_fee_when_both_are_supplied() { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), BigDecimal.ONE)) + .withdrawalFeeToken("token").maxFee(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), BigDecimal.ONE)).feeIncluded(true).build()); + + assertThatThrownBy(unit::encodedBody).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("at most one"); + } + + @Test + void should_reject_fee_token_when_fee_inclusion_is_missing() { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), BigDecimal.ONE)).withdrawalFeeToken("token").build()); + + assertThatThrownBy(unit::encodedBody).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("feeIncluded"); + } + + @Test + void should_reject_network_group_scope_when_encoding_request() { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.networkGroup("g")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), BigDecimal.ONE)).build()); + + assertThatThrownBy(unit::encodedBody).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("network group"); + } + + @Test + void should_reject_amount_without_asset_name_when_encoding_request() { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, null), BigDecimal.ONE)).build()); + + assertThatThrownBy(unit::encodedBody).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("class and a name"); + } + + @Test + void should_reject_missing_amount_when_building_parameters() { + assertThatThrownBy(() -> CreateFundingWithdrawalParams.builder().scope(Scope.method("m")).addressId("AB1").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("amount"); + } + + @Test + void should_reject_missing_address_when_building_parameters() { + assertThatThrownBy(() -> CreateFundingWithdrawalParams.builder().scope(Scope.method("m")).amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "BTC"), BigDecimal.ONE)).build()) + .isInstanceOf(NullPointerException.class).hasMessageContaining("addressId"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + CreateFundingWithdrawalEndpoint unit = new CreateFundingWithdrawalEndpoint(CreateFundingWithdrawalParams.builder() + .scope(Scope.method("m")).addressId("AB1").amount(new AssetAmount(new Asset(AssetClass.CURRENCY, "USDC"), new BigDecimal("5"))).build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/CreateFundingWithdrawal.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingWithdrawalCreated result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.withdrawalId()).isEqualTo("FTVZiTI-e02T84mm87JmibnObWNdnW"); + assertThat(result.netAmount().assetAmount().amount()).isEqualTo(new BigDecimal("4.00000000")); + assertThat(result.grossAmount().assetAmount().amount()).isEqualTo(new BigDecimal("5.00000000")); + assertThat(result.fee().assetAmount().amount()).isEqualTo(new BigDecimal("1.00000000")); + assertThat(result.fee().assetAmount().asset()).isEqualTo(new Asset(AssetClass.CURRENCY, "USDC")); + assertThat(result.fee().rebaseMultiplier()).isNull(); + assertThat(result.approvalRequestId()).isNull(); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpointTest.java new file mode 100644 index 0000000..a86c145 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/DeleteFundingAddressEndpointTest.java @@ -0,0 +1,67 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.DeleteFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressDeleted; + +@ExtendWith(MockitoExtension.class) +class DeleteFundingAddressEndpointTest { + + @Test + void should_encode_address_in_path_without_body_when_deleting() { + DeleteFundingAddressEndpoint unit = new DeleteFundingAddressEndpoint(DeleteFundingAddressParams.builder().addressId("AB7J4FF-BGM7G-V2JMIH").accountId("AA12").build()); + + String result = unit.buildURL().getFile(); + + assertThat(result).isEqualTo("/funding/v1/addresses/AB7J4FF-BGM7G-V2JMIH?account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("DELETE"); + assertThat(unit.encodedBody()).isEmpty(); + } + + @Test + void should_use_address_only_when_created_from_identifier() { + DeleteFundingAddressEndpoint unit = new DeleteFundingAddressEndpoint("AB7J4FF-BGM7G-V2JMIH"); + + String result = unit.buildURL().getFile(); + + assertThat(result).isEqualTo("/funding/v1/addresses/AB7J4FF-BGM7G-V2JMIH"); + } + + @Test + void should_reject_missing_address_when_building_parameters() { + assertThatThrownBy(() -> DeleteFundingAddressParams.builder().accountId("AA12").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("addressId"); + } + + @Test + void should_deserialize_typed_response_when_reading_fixture() throws Exception { + DeleteFundingAddressEndpoint unit = new DeleteFundingAddressEndpoint("AB1"); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/DeleteFundingAddress.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingAddressDeleted result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.deleted()).isTrue(); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpointTest.java new file mode 100644 index 0000000..a354157 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAddressesEndpointTest.java @@ -0,0 +1,114 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddresses; + +@ExtendWith(MockitoExtension.class) +class FundingAddressesEndpointTest { + + @Test + void should_encode_scope_with_brackets_when_supplied() { + FundingAddressesEndpoint unit = new FundingAddressesEndpoint(FundingAddressesParams.builder() + .scope(Scope.method("67b765fd-4efd-42dc-8ce7-63352971d566")).cursor("c").limit(20).accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/addresses?scope%5Bmethod_id%5D=67b765fd-4efd-42dc-8ce7-63352971d566&cursor=c&limit=20&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(unit.encodedBody()).isEmpty(); + } + + @Test + void should_reject_scope_with_several_identifiers_when_encoding_request() { + FundingAddressesEndpoint unit = new FundingAddressesEndpoint(FundingAddressesParams.builder().scope(new Scope("m", "n", null)).build()); + + assertThatThrownBy(unit::buildURL).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("exactly one"); + } + + @Test + void should_reject_null_identifier_when_creating_scope() { + assertThatThrownBy(() -> Scope.network(null)).isInstanceOf(NullPointerException.class).hasMessageContaining("networkId"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingAddressesEndpoint unit = new FundingAddressesEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingAddresses.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingAddresses result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.nextCursor()).isNull(); + assertThat(result.addresses()).extracting(FundingAddresses.Address::scope).containsExactly( + Scope.network("d9d375da-44b7-4be1-8a00-8b281acfe366"), + Scope.method("67b765fd-4efd-42dc-8ce7-63352971d566"), + Scope.networkGroup("f95acdb7-48fb-4441-b5b4-843d3bf60e61")); + assertThat(result.addresses().getFirst()).satisfies(address -> { + assertThat(address.addressId()).isEqualTo("AB7J4FF-BGM7G-V2JMIH"); + assertThat(address.addressDetails().crypto().address()).isEqualTo("0xBef7B36845cA31045E86D0B46DBCac4e6752..."); + assertThat(address.name()).isEqualTo("Personal Wallet"); + assertThat(address.description()).isEqualTo("My Ethereum hardware wallet address"); + assertThat(address.modifier()).isNull(); + assertThat(address.verified()).isTrue(); + }); + assertThat(result.addresses().getLast().name()).isNull(); + } + + @Test + void should_read_beneficiary_and_modifier_when_address_belongs_to_third_party() throws Exception { + FundingAddressesEndpoint unit = new FundingAddressesEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + FundingAddresses result = mapper.readValue(""" + {"addresses":[{"address_id":"AB1","scope":{"method_id":"m"},"modifier":"third_party","verified":false, + "address_details":{"crypto":{"address":"bc1q","beneficiary":{"recipient":"other","typ":"business","name":"Acme","address_1":"1 Main St","postal_code":"10001","counterparty_vasp_name":"Exchange"}}}}, + {"address_id":"AB2","scope":{"network_id":"n"},"modifier":"trusted","verified":true, + "address_details":{"fiat":{"masked_iban":"DE89****3000","intermediary_swift":"CHASUS33","beneficiary":{"recipient":"sender","last_name":"Doe"}}}}]} + """, unit.getResponseType()); + + assertThat(result.addresses().getFirst()).satisfies(address -> { + assertThat(address.modifier()).isEqualTo(FundingAddresses.Modifier.THIRD_PARTY); + assertThat(address.verified()).isFalse(); + assertThat(address.addressDetails().crypto().beneficiary().recipient()).isEqualTo("other"); + assertThat(address.addressDetails().crypto().beneficiary().type()).isEqualTo("business"); + assertThat(address.addressDetails().crypto().beneficiary().address1()).isEqualTo("1 Main St"); + assertThat(address.addressDetails().crypto().beneficiary().postalCode()).isEqualTo("10001"); + assertThat(address.addressDetails().crypto().beneficiary().counterpartyVaspName()).isEqualTo("Exchange"); + }); + assertThat(result.addresses().getLast()).satisfies(address -> { + assertThat(address.modifier()).isEqualTo(FundingAddresses.Modifier.UNKNOWN); + assertThat(address.addressDetails().fiat().maskedIban()).isEqualTo("DE89****3000"); + assertThat(address.addressDetails().fiat().intermediarySwift()).isEqualTo("CHASUS33"); + assertThat(address.addressDetails().fiat().beneficiary().lastName()).isEqualTo("Doe"); + }); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpointTest.java new file mode 100644 index 0000000..b5b3fcd --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingAssetsEndpointTest.java @@ -0,0 +1,71 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingAssetsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAssets; + +@ExtendWith(MockitoExtension.class) +class FundingAssetsEndpointTest { + + @Test + void should_encode_direction_in_path_and_class_in_query_when_supplied() { + FundingAssetsEndpoint unit = new FundingAssetsEndpoint(FundingAssetsParams.builder() + .direction(Direction.WITHDRAW).assetClass(AssetClass.TOKENIZED_ASSET).accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/assets/withdraw?asset_class=tokenized_asset&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_send_no_query_when_only_direction_is_supplied() { + FundingAssetsEndpoint unit = new FundingAssetsEndpoint(Direction.DEPOSIT); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/assets/deposit"); + } + + @Test + void should_reject_missing_direction_when_building_parameters() { + assertThatThrownBy(() -> FundingAssetsParams.builder().build()).isInstanceOf(NullPointerException.class).hasMessageContaining("direction"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingAssetsEndpoint unit = new FundingAssetsEndpoint(Direction.DEPOSIT); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingAssets.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingAssets result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.currency()).extracting(FundingAssets.AssetName::name).containsExactly("USDC", "USDT", "BTC", "ETH", "SOL"); + assertThat(result.tokenizedAsset()).extracting(FundingAssets.AssetName::name).containsExactly("TONXx", "NVDAx", "AAPLx"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpointTest.java new file mode 100644 index 0000000..9312d22 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositAddressesEndpointTest.java @@ -0,0 +1,102 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositAddressesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositAddresses; + +@ExtendWith(MockitoExtension.class) +class FundingDepositAddressesEndpointTest { + + @Test + void should_encode_scope_and_asset_with_brackets_when_supplied() { + FundingDepositAddressesEndpoint unit = new FundingDepositAddressesEndpoint(FundingDepositAddressesParams.builder() + .scope(Scope.method("27ede8db-804b-4d91-8e25-46b7b9668730")).cursor("c").limit(20).asset(new Asset(AssetClass.CURRENCY, "USDC")).accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getPath()).isEqualTo("/funding/v2/deposit/addresses"); + assertThat(result.getQuery()).isEqualTo("scope%5Bmethod_id%5D=27ede8db-804b-4d91-8e25-46b7b9668730&cursor=c&limit=20&asset%5Bclass%5D=currency&asset%5Bname%5D=USDC&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_encode_network_scope_when_supplied() { + FundingDepositAddressesEndpoint unit = new FundingDepositAddressesEndpoint(FundingDepositAddressesParams.builder().scope(Scope.network("d9d375da")).build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v2/deposit/addresses?scope%5Bnetwork_id%5D=d9d375da"); + } + + @Test + void should_reject_network_group_scope_when_encoding_request() { + FundingDepositAddressesEndpoint unit = new FundingDepositAddressesEndpoint(FundingDepositAddressesParams.builder().scope(Scope.networkGroup("f95acdb7")).build()); + + assertThatThrownBy(unit::buildURL).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("network group"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingDepositAddressesEndpoint unit = new FundingDepositAddressesEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingClaimedAddresses.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingDepositAddresses result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.nextCursor()).isNull(); + assertThat(result.addresses()).singleElement().satisfies(address -> { + assertThat(address.methodId()).isEqualTo("27ede8db-804b-4d91-8e25-46b7b9668730"); + assertThat(address.sharesAddressesWithMethodId()).isEqualTo("a001231f-488e-48c0-b36c-6e0d2c1ee247"); + assertThat(address.addressDetails().crypto().address()).isEqualTo("0x0d5cff23d40bcc6b98537c7ad5a839a247f546..."); + }); + } + + @Test + void should_read_usage_times_when_supplied() throws Exception { + FundingDepositAddressesEndpoint unit = new FundingDepositAddressesEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + FundingDepositAddresses result = mapper.readValue(""" + {"addresses":[{"method_id":"m","id":"ADDR1","last_used":"2026-08-12T10:10:30.123Z","expire_time":"2026-08-19T10:10:30Z","address_details":{"crypto":{"address":"r9cZA1","tag":"12345"}}}],"next_cursor":"next"} + """, unit.getResponseType()); + + assertThat(result.nextCursor()).isEqualTo("next"); + assertThat(result.addresses()).singleElement().satisfies(address -> { + assertThat(address.id()).isEqualTo("ADDR1"); + assertThat(address.lastUsed()).isEqualTo(Instant.parse("2026-08-12T10:10:30.123Z")); + assertThat(address.expireTime()).isEqualTo(Instant.parse("2026-08-19T10:10:30Z")); + assertThat(address.addressDetails().crypto().tag()).isEqualTo("12345"); + }); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpointTest.java new file mode 100644 index 0000000..3d54000 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositLimitsEndpointTest.java @@ -0,0 +1,104 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingLimitsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDepositLimits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.MethodLimits; + +@ExtendWith(MockitoExtension.class) +class FundingDepositLimitsEndpointTest { + + @Test + void should_encode_asset_in_path_and_preferred_asset_in_query_when_supplied() { + FundingDepositLimitsEndpoint unit = new FundingDepositLimitsEndpoint(FundingLimitsParams.builder() + .assetClass(AssetClass.CURRENCY).asset("BTC").preferredAsset(new Asset(null, "USD")).accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/limits/deposit/currency/BTC?preferred_asset%5Bname%5D=USD&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_reject_missing_asset_class_when_building_parameters() { + assertThatThrownBy(() -> FundingLimitsParams.builder().asset("BTC").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("assetClass"); + } + + @Test + void should_reject_missing_asset_when_building_parameters() { + assertThatThrownBy(() -> FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).build()).isInstanceOf(NullPointerException.class).hasMessageContaining("asset"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingDepositLimitsEndpoint unit = new FundingDepositLimitsEndpoint(FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("USDC").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingDepositLimits.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingDepositLimits result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.depositLimits()).hasSize(2); + assertThat(result.depositLimits().getFirst().methodId()).isEqualTo("742656ee-ecc8-4287-a301-5a472ca16c24"); + assertThat(result.depositLimits().getFirst().maximumAmount().amount()).isEqualTo(new BigDecimal("100032.01024328")); + assertThat(result.depositLimits().getFirst().maximumReason()).isNull(); + assertThat(result.depositLimits().getFirst().limits()).isEmpty(); + assertThat(result.depositLimits().getLast().maximumAmount()).isNull(); + } + + @Test + void should_read_counts_and_amounts_when_limits_have_both_kinds() throws Exception { + FundingDepositLimitsEndpoint unit = new FundingDepositLimitsEndpoint(FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("EUR").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + FundingDepositLimits result = mapper.readValue(""" + {"deposit_limits":[{"method_id":"m","limits":[ + {"time_window":"3600","limit":{"limit_type":"attempt","remaining":"4","maximum":"5","used":"1"}}, + {"time_window":"2592000","limit":{"limit_type":"equiv_amount_eur","remaining":{"policy_asset_amount":{"asset":{"class":"currency","name":"EUR"},"amount":"900.5"}},"maximum":{"policy_asset_amount":{"asset":{"class":"currency","name":"EUR"},"amount":"1000"},"usd_amount":{"asset":{"class":"currency","name":"USD"},"amount":"1080.25"}}}}, + {"time_window":"60","limit":{"limit_type":"velocity","remaining":"1","maximum":"2"}}]}]} + """, unit.getResponseType()); + + MethodLimits limits = result.depositLimits().getFirst(); + assertThat(limits.limits()).extracting(MethodLimits.TimeWindowLimit::timeWindow).containsExactly(Duration.ofHours(1), Duration.ofDays(30), Duration.ofMinutes(1)); + assertThat(limits.limits().getFirst().limit().limitType()).isEqualTo(MethodLimits.LimitType.ATTEMPT); + assertThat(limits.limits().getFirst().limit().remaining().count()).isEqualTo(4L); + assertThat(limits.limits().getFirst().limit().maximum().count()).isEqualTo(5L); + assertThat(limits.limits().getFirst().limit().used().count()).isEqualTo(1L); + assertThat(limits.limits().get(1).limit().limitType()).isEqualTo(MethodLimits.LimitType.EQUIV_AMOUNT_EUR); + assertThat(limits.limits().get(1).limit().remaining().count()).isNull(); + assertThat(limits.limits().get(1).limit().remaining().amounts().policyAssetAmount().amount()).isEqualTo(new BigDecimal("900.5")); + assertThat(limits.limits().get(1).limit().maximum().amounts().usdAmount().amount()).isEqualTo(new BigDecimal("1080.25")); + assertThat(limits.limits().get(1).limit().used()).isNull(); + assertThat(limits.limits().getLast().limit().limitType()).isEqualTo(MethodLimits.LimitType.UNKNOWN); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpointTest.java new file mode 100644 index 0000000..0c86985 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingDepositsEndpointTest.java @@ -0,0 +1,101 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingDepositsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingDeposits; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; + +@ExtendWith(MockitoExtension.class) +class FundingDepositsEndpointTest { + + @Test + void should_encode_all_filters_when_supplied() { + FundingDepositsEndpoint unit = new FundingDepositsEndpoint(FundingDepositsParams.builder() + .asset(new Asset(AssetClass.CURRENCY, "USDC")).scope(Scope.networkGroup("f95acdb7")).cursor("c").limit(500) + .startTime(Instant.parse("2026-07-01T00:00:00Z")).endTime(Instant.parse("2026-07-31T23:59:59.5Z")) + .rebaseMultiplier(RebaseMultiplier.BASE).accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getPath()).isEqualTo("/funding/v1/deposits"); + assertThat(result.getQuery()).isEqualTo("asset%5Bclass%5D=currency&asset%5Bname%5D=USDC&scope%5Bnetwork_group_id%5D=f95acdb7&cursor=c&limit=500" + + "&start_time=2026-07-01T00%3A00%3A00Z&end_time=2026-07-31T23%3A59%3A59.500Z&rebase_multiplier=base&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_send_no_query_when_using_defaults() { + FundingDepositsEndpoint unit = new FundingDepositsEndpoint(); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/deposits"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingDepositsEndpoint unit = new FundingDepositsEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingDeposits.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingDeposits result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.nextCursor()).startsWith("gqNrZXm-RlREc1IxRC1oOTd3c1Y4RFJKUWpPQ0ZTTjVic0Nx"); + assertThat(result.deposits()).hasSize(2); + assertThat(result.deposits().getFirst()).satisfies(deposit -> { + assertThat(deposit.depositId()).isEqualTo("FTcQ4qW-fWGQbQwUfqdnZo4dsMn1ao"); + assertThat(deposit.methodId()).isEqualTo("3e7f8072-cc6d-4394-982a-5f4ca6ab27dd"); + assertThat(deposit.networkId()).isEqualTo("b336ce74-8d60-42b8-8714-b1095e06b711"); + assertThat(deposit.status()).isEqualTo(FundingDeposits.Status.SUCCESS); + assertThat(deposit.amount().amount()).isEqualTo(new BigDecimal("20.00000000")); + assertThat(deposit.fee().amount()).isEqualTo(new BigDecimal("0.00000000")); + assertThat(deposit.createTime()).isEqualTo(Instant.parse("2026-07-01T08:31:33Z")); + }); + assertThat(result.deposits().getLast().amount().amount()).isEqualTo(new BigDecimal("5555.0000000000")); + } + + @Test + void should_keep_missing_amounts_null_when_deposit_is_pending() throws Exception { + FundingDepositsEndpoint unit = new FundingDepositsEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + FundingDeposits result = mapper.readValue(""" + {"deposits":[{"deposit_id":"FT1","status":"initial"},{"deposit_id":"FT2","status":"on_hold"}]} + """, unit.getResponseType()); + + assertThat(result.deposits()).extracting(FundingDeposits.Deposit::status).containsExactly(FundingDeposits.Status.INITIAL, FundingDeposits.Status.UNKNOWN); + assertThat(result.deposits().getFirst().amount()).isNull(); + assertThat(result.deposits().getFirst().fee()).isNull(); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpointTest.java new file mode 100644 index 0000000..fc8ddc6 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingFeesEndpointTest.java @@ -0,0 +1,86 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingFeesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingFees; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; + +@ExtendWith(MockitoExtension.class) +class FundingFeesEndpointTest { + + @Test + void should_encode_method_in_path_and_options_in_query_when_supplied() { + FundingFeesEndpoint unit = new FundingFeesEndpoint(FundingFeesParams.builder() + .methodId("d4ec4d52-b159-428e-ba64-f45455a978a1").amount(new BigDecimal("0.0000000012300")).feeIncluded(true) + .withdrawalFeeToken("token +/&=").rebaseMultiplier(RebaseMultiplier.BASE).accountId("AA12 3456").build()); + + URL result = unit.buildURL(); + + assertThat(result.getPath()).isEqualTo("/funding/v1/fees/d4ec4d52-b159-428e-ba64-f45455a978a1"); + assertThat(result.getQuery()).isEqualTo("amount=0.0000000012300&fee_included=true&withdrawal_fee_token=token+%2B%2F%26%3D&rebase_multiplier=base&account_id=AA12+3456"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(unit.encodedBody()).isEmpty(); + } + + @Test + void should_percent_encode_method_when_building_path() { + FundingFeesEndpoint unit = new FundingFeesEndpoint(FundingFeesParams.builder().methodId("a b/c").amount(BigDecimal.ONE).build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/fees/a%20b%2Fc?amount=1"); + } + + @Test + void should_reject_missing_method_when_building_parameters() { + assertThatThrownBy(() -> FundingFeesParams.builder().amount(BigDecimal.ONE).build()).isInstanceOf(NullPointerException.class).hasMessageContaining("methodId"); + } + + @Test + void should_reject_missing_amount_when_building_parameters() { + assertThatThrownBy(() -> FundingFeesParams.builder().methodId("d4ec4d52-b159-428e-ba64-f45455a978a1").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("amount"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingFeesEndpoint unit = new FundingFeesEndpoint(FundingFeesParams.builder().methodId("d4ec4d52-b159-428e-ba64-f45455a978a1").amount(new BigDecimal("5")).build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/CalculateFundingFees.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingFees result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.fee().asset().assetClass()).isEqualTo(AssetClass.CURRENCY); + assertThat(result.fee().asset().name()).isEqualTo("USDC"); + assertThat(result.fee().amount()).isEqualByComparingTo("1"); + assertThat(result.grossAmount().amount()).isEqualTo(new BigDecimal("5.00000000")); + assertThat(result.netAmount().amount()).isEqualTo(new BigDecimal("4.00000000")); + assertThat(result.feeDetails().baseFee().amount()).isEqualTo(new BigDecimal("1.00000000")); + assertThat(result.feeDetails().feePercentage()).isZero(); + assertThat(result.withdrawalFeeToken()).startsWith("AAAAAAAAAAHG33Wc1eES6QpeGgMok_gUnlZC7Y5niezI2"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpointTest.java new file mode 100644 index 0000000..0064250 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingMethodsEndpointTest.java @@ -0,0 +1,118 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Direction; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingMethodsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingMethods; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; + +@ExtendWith(MockitoExtension.class) +class FundingMethodsEndpointTest { + + @Test + void should_encode_asset_filter_with_brackets_when_supplied() { + FundingMethodsEndpoint unit = new FundingMethodsEndpoint(FundingMethodsParams.builder() + .direction(Direction.WITHDRAW).asset(new Asset(AssetClass.TOKENIZED_ASSET, "AAPLx")).rebaseMultiplier(RebaseMultiplier.REBASED) + .limit(50).cursor("next page").accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getPath()).isEqualTo("/funding/v1/methods/withdraw"); + assertThat(result.getQuery()).isEqualTo("asset%5Bclass%5D=tokenized_asset&asset%5Bname%5D=AAPLx&rebase_multiplier=rebased&limit=50&cursor=next+page&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_send_class_only_when_asset_name_is_omitted() { + FundingMethodsEndpoint unit = new FundingMethodsEndpoint(FundingMethodsParams.builder().direction(Direction.DEPOSIT).asset(new Asset(AssetClass.CURRENCY, null)).build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/methods/deposit?asset%5Bclass%5D=currency"); + } + + @Test + void should_send_no_query_when_only_direction_is_supplied() { + FundingMethodsEndpoint unit = new FundingMethodsEndpoint(Direction.DEPOSIT); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/methods/deposit"); + } + + @Test + void should_reject_missing_direction_when_building_parameters() { + assertThatThrownBy(() -> FundingMethodsParams.builder().limit(10).build()).isInstanceOf(NullPointerException.class).hasMessageContaining("direction"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingMethodsEndpoint unit = new FundingMethodsEndpoint(Direction.DEPOSIT); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingMethods.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingMethods result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.nextCursor()).isNull(); + assertThat(result.methods()).singleElement().satisfies(method -> { + assertThat(method.asset()).isEqualTo(new Asset(AssetClass.CURRENCY, "USDC")); + assertThat(method.methodId()).isEqualTo("27ede8db-804b-4d91-8e25-46b7b9668730"); + assertThat(method.methodName()).isEqualTo("Standard"); + assertThat(method.minimumAmount()).isEqualTo(new BigDecimal("2")); + assertThat(method.maximumAmount()).isNull(); + assertThat(method.fees().base().amount()).isEqualTo(new BigDecimal("0.00000000")); + assertThat(method.fees().included()).isTrue(); + assertThat(method.network().networkName()).isEqualTo("Ethereum"); + assertThat(method.network().contractAddress()).isEqualTo("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + assertThat(method.network().onChainAssetSymbol()).isEqualTo("USDC"); + assertThat(method.deposit().addressGeneration().status()).isEqualTo(FundingMethods.AddressGeneration.Status.LIMITED); + assertThat(method.deposit().addressGeneration().limit()).isEqualTo(5); + assertThat(method.deposit().sharesAddressesWithMethodId()).isEqualTo("a001231f-488e-48c0-b36c-6e0d2c1ee247"); + assertThat(method.withdrawal()).isNull(); + }); + } + + @Test + void should_fall_back_to_unknown_when_address_generation_status_is_new() throws Exception { + FundingMethodsEndpoint unit = new FundingMethodsEndpoint(Direction.WITHDRAW); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + FundingMethods result = mapper.readValue(""" + {"methods":[{"method_id":"m","deposit":{"address_generation":{"status":"on_request"}},"withdrawal":{}}],"next_cursor":"abc"} + """, unit.getResponseType()); + + assertThat(result.nextCursor()).isEqualTo("abc"); + assertThat(result.methods().getFirst().deposit().addressGeneration().status()).isEqualTo(FundingMethods.AddressGeneration.Status.UNKNOWN); + assertThat(result.methods().getFirst().withdrawal()).isEmpty(); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpointTest.java new file mode 100644 index 0000000..14a33fa --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingNetworksEndpointTest.java @@ -0,0 +1,68 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingNetworksParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingNetworks; + +@ExtendWith(MockitoExtension.class) +class FundingNetworksEndpointTest { + + @Test + void should_send_account_in_query_when_supplied() { + FundingNetworksEndpoint unit = new FundingNetworksEndpoint(FundingNetworksParams.builder().accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/networks?account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_send_no_query_when_using_defaults() { + FundingNetworksEndpoint unit = new FundingNetworksEndpoint(); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/networks"); + assertThat(unit.encodedBody()).isEmpty(); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingNetworksEndpoint unit = new FundingNetworksEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingNetworks.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingNetworks result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.networkGroups()).singleElement().satisfies(group -> { + assertThat(group.networkGroupId()).isEqualTo("f95acdb7-48fb-4441-b5b4-843d3bf60e61"); + assertThat(group.name()).isEqualTo("EVM"); + assertThat(group.networkIds()).containsExactly("2c6eeff9-bd5f-4c09-982d-a5855fa9f0dd", "bc7562cf-1e51-4308-b52b-d062aaa6aa3b", "473365c2-4c95-4bd9-b5c8-974fb91c7d6b"); + }); + assertThat(result.networks()).extracting(FundingNetworks.Network::name).containsExactly("Arbitrum Nova", "Arbitrum One", "Avalanche C Chain"); + assertThat(result.networks().getFirst().networkId()).isEqualTo("2c6eeff9-bd5f-4c09-982d-a5855fa9f0dd"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpointTest.java new file mode 100644 index 0000000..34f1b16 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalLimitsEndpointTest.java @@ -0,0 +1,71 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingLimitsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawalLimits; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.MethodLimits; + +@ExtendWith(MockitoExtension.class) +class FundingWithdrawalLimitsEndpointTest { + + @Test + void should_encode_asset_in_path_and_preferred_asset_in_query_when_supplied() { + FundingWithdrawalLimitsEndpoint unit = new FundingWithdrawalLimitsEndpoint(FundingLimitsParams.builder() + .assetClass(AssetClass.TOKENIZED_ASSET).asset("AAPLx").preferredAsset(new Asset(AssetClass.CURRENCY, "USD")).build()); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/limits/withdrawal/tokenized_asset/AAPLx?preferred_asset%5Bclass%5D=currency&preferred_asset%5Bname%5D=USD"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingWithdrawalLimitsEndpoint unit = new FundingWithdrawalLimitsEndpoint(FundingLimitsParams.builder().assetClass(AssetClass.CURRENCY).asset("USDC").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingWithdrawalLimits.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingWithdrawalLimits result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.availableBalance().amount()).isEqualTo(new BigDecimal("689005.55897887")); + assertThat(result.withdrawalLimits()).singleElement().satisfies(limits -> { + assertThat(limits.methodId()).isEqualTo("ec344850-a2bf-4fd8-b5c4-eb902a61cd03"); + assertThat(limits.maximumAmount().amount()).isEqualTo(new BigDecimal("500155.05091629")); + assertThat(limits.maximumReason()).isEqualTo(MethodLimits.MaximumReason.LIMITS); + assertThat(limits.limits()).singleElement().satisfies(windowLimit -> { + assertThat(windowLimit.timeWindow()).isEqualTo(Duration.ofDays(1)); + assertThat(windowLimit.limit().limitType()).isEqualTo(MethodLimits.LimitType.EQUIV_AMOUNT_USD); + assertThat(windowLimit.limit().remaining().amounts().policyAssetAmount().amount()).isEqualTo(new BigDecimal("499995.0013")); + assertThat(windowLimit.limit().remaining().amounts().requestedAssetAmount().asset()).isEqualTo(new Asset(AssetClass.CURRENCY, "USDC")); + assertThat(windowLimit.limit().maximum().amounts().usdAmount().amount()).isEqualTo(new BigDecimal("500000.0000")); + assertThat(windowLimit.limit().used().amounts().requestedAssetAmount().amount()).isEqualTo(new BigDecimal("5.00030010")); + }); + }); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpointTest.java new file mode 100644 index 0000000..6f47ace --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/FundingWithdrawalsEndpointTest.java @@ -0,0 +1,105 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Asset; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.AssetClass; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalStatus; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingWithdrawalsParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingWithdrawals; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; + +@ExtendWith(MockitoExtension.class) +class FundingWithdrawalsEndpointTest { + + @Test + void should_encode_all_filters_when_supplied() { + FundingWithdrawalsEndpoint unit = new FundingWithdrawalsEndpoint(FundingWithdrawalsParams.builder() + .asset(new Asset(AssetClass.TOKENIZED_ASSET, null)).scope(Scope.network("n1")).status(FundingWithdrawalStatus.PENDING) + .cursor("c").limit(20).startTime(Instant.parse("2026-08-01T00:00:00Z")).endTime(Instant.parse("2026-08-31T00:00:00Z")) + .rebaseMultiplier(RebaseMultiplier.REBASED).accountId("AA12").build()); + + URL result = unit.buildURL(); + + assertThat(result.getPath()).isEqualTo("/funding/v1/withdrawals"); + assertThat(result.getQuery()).isEqualTo("asset%5Bclass%5D=tokenized_asset&scope%5Bnetwork_id%5D=n1&status=pending&cursor=c&limit=20" + + "&start_time=2026-08-01T00%3A00%3A00Z&end_time=2026-08-31T00%3A00%3A00Z&rebase_multiplier=rebased&account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + } + + @Test + void should_send_no_query_when_using_defaults() { + FundingWithdrawalsEndpoint unit = new FundingWithdrawalsEndpoint(); + + URL result = unit.buildURL(); + + assertThat(result.getFile()).isEqualTo("/funding/v1/withdrawals"); + } + + @Test + void should_deserialize_typed_response_when_reading_kraken_fixture() throws Exception { + FundingWithdrawalsEndpoint unit = new FundingWithdrawalsEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/ListFundingWithdrawals.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingWithdrawals result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.nextCursor()).startsWith("gqNrZXm-RlRSeFJ2WS1PSHBXSUlrTkYwQU1tM0MzNDg2dkxF"); + assertThat(result.withdrawals()).hasSize(2); + assertThat(result.withdrawals().getFirst()).satisfies(withdrawal -> { + assertThat(withdrawal.withdrawalId()).isEqualTo("FTVZiTI-e02T84mm87JmibnObWNdnW"); + assertThat(withdrawal.amount().amount()).isEqualTo(new BigDecimal("4.00000000")); + assertThat(withdrawal.fee().amount()).isEqualTo(new BigDecimal("1.00000000")); + assertThat(withdrawal.methodId()).isEqualTo("d4ec4d52-b159-428e-ba64-f45455a978a1"); + assertThat(withdrawal.status()).isEqualTo(FundingWithdrawalStatus.PENDING); + assertThat(withdrawal.createTime()).isEqualTo(Instant.parse("2026-08-12T10:10:30Z")); + assertThat(withdrawal.addressId()).isEqualTo("ABR6SXP-SF6CY-VJMONY"); + assertThat(withdrawal.onchainTransaction()).isNull(); + assertThat(withdrawal.utxoVout()).isNull(); + }); + assertThat(result.withdrawals().getLast().amount().asset()).isEqualTo(new Asset(AssetClass.TOKENIZED_ASSET, "TSLAx")); + } + + @Test + void should_read_onchain_details_when_withdrawal_is_broadcast() throws Exception { + FundingWithdrawalsEndpoint unit = new FundingWithdrawalsEndpoint(); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + + FundingWithdrawals result = mapper.readValue(""" + {"withdrawals":[{"withdrawal_id":"FT1","status":"success","onchain_transaction":"7f3a","utxo_vout":"2"},{"withdrawal_id":"FT2","status":"on_hold"}]} + """, unit.getResponseType()); + + assertThat(result.withdrawals().getFirst().status()).isEqualTo(FundingWithdrawalStatus.SUCCESS); + assertThat(result.withdrawals().getFirst().onchainTransaction()).isEqualTo("7f3a"); + assertThat(result.withdrawals().getFirst().utxoVout()).isEqualTo(2L); + assertThat(result.withdrawals().getLast().status()).isEqualTo(FundingWithdrawalStatus.UNKNOWN); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpointTest.java new file mode 100644 index 0000000..369df1e --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/fundingbeta/UpdateFundingAddressEndpointTest.java @@ -0,0 +1,68 @@ +package dev.andstuff.kraken.api.endpoint.fundingbeta; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.UpdateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressUpdated; + +@ExtendWith(MockitoExtension.class) +class UpdateFundingAddressEndpointTest { + + @Test + void should_encode_address_in_path_and_changes_in_body_when_supplied() { + UpdateFundingAddressEndpoint unit = new UpdateFundingAddressEndpoint(UpdateFundingAddressParams.builder() + .addressId("AB7J4FF-BGM7G-V2JMIH").name("Personal Wallet").description("My hardware wallet address").accountId("AA12").build()); + + String result = unit.encodedBody(); + + assertThat(result).isEqualTo("{\"name\":\"Personal Wallet\",\"description\":\"My hardware wallet address\"}"); + assertThat(unit.buildURL().getFile()).isEqualTo("/funding/v1/addresses/AB7J4FF-BGM7G-V2JMIH?account_id=AA12"); + assertThat(unit.getHttpMethod()).isEqualTo("PUT"); + } + + @Test + void should_send_only_description_when_name_is_omitted() { + UpdateFundingAddressEndpoint unit = new UpdateFundingAddressEndpoint(UpdateFundingAddressParams.builder().addressId("AB1").description("new").build()); + + String result = unit.encodedBody(); + + assertThat(result).isEqualTo("{\"description\":\"new\"}"); + } + + @Test + void should_reject_missing_address_when_building_parameters() { + assertThatThrownBy(() -> UpdateFundingAddressParams.builder().name("n").build()).isInstanceOf(NullPointerException.class).hasMessageContaining("addressId"); + } + + @Test + void should_deserialize_typed_response_when_reading_fixture() throws Exception { + UpdateFundingAddressEndpoint unit = new UpdateFundingAddressEndpoint(UpdateFundingAddressParams.builder().addressId("AB1").name("n").build()); + JsonMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()).build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/fundingbeta/UpdateFundingAddress.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + FundingAddressUpdated result = mapper.readValue(json, unit.getResponseType()); + + assertThat(result.verified()).isTrue(); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterFundingBetaTest.java b/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterFundingBetaTest.java new file mode 100644 index 0000000..2da0743 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterFundingBetaTest.java @@ -0,0 +1,175 @@ +package dev.andstuff.kraken.api.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.HttpsURLConnection; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import dev.andstuff.kraken.api.endpoint.KrakenException; +import dev.andstuff.kraken.api.endpoint.fundingbeta.ClaimFundingDepositAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.CreateFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.DeleteFundingAddressEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingFeesEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.FundingNetworksEndpoint; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.ClaimFundingDepositAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.CreateFundingAddressParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.FundingFeesParams; +import dev.andstuff.kraken.api.endpoint.fundingbeta.params.Scope; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressCreated; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingAddressDeleted; +import dev.andstuff.kraken.api.endpoint.fundingbeta.response.FundingFees; + +@ExtendWith(MockitoExtension.class) +class DefaultKrakenRestRequesterFundingBetaTest { + + @Mock private DefaultKrakenRestRequester.ConnectionFactory connectionFactory; + @Mock private HttpsURLConnection connection; + @Mock private KrakenCredentials credentials; + @Mock private KrakenNonceGenerator nonceGenerator; + @Mock(answer = Answers.CALLS_REAL_METHODS) private KrakenRestRequester legacyRequester; + + @Test + void should_sign_path_with_query_and_send_nonce_header_when_querying_without_body() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + KrakenCredentials documentedCredentials = new KrakenCredentials("test-key", "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg=="); + FundingFeesEndpoint endpoint = new FundingFeesEndpoint(FundingFeesParams.builder() + .methodId("d4ec4d52-b159-428e-ba64-f45455a978a1").amount(new BigDecimal("5")).feeIncluded(true).build()); + URL url = endpoint.buildURL(); + when(nonceGenerator.generate()).thenReturn("1616492376594"); + when(connectionFactory.open(url)).thenReturn(connection); + when(connection.getURL()).thenReturn(url); + when(connection.getResponseCode()).thenReturn(200); + when(connection.getHeaderField("Content-Type")).thenReturn("application/json; charset=utf-8"); + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream(""" + {"fee":{"asset":{"class":"currency","name":"USDC"},"amount":"1.00000000"},"withdrawal_fee_token":"token"} + """.getBytes(StandardCharsets.UTF_8))); + + FundingFees result = unit.execute(endpoint, documentedCredentials, nonceGenerator); + + assertThat(result.fee().amount()).isEqualTo(new BigDecimal("1.00000000")); + assertThat(result.withdrawalFeeToken()).isEqualTo("token"); + verify(connection).setRequestMethod("GET"); + verify(connection).addRequestProperty("API-Key", "test-key"); + verify(connection).addRequestProperty("API-Nonce", "1616492376594"); + verify(connection).addRequestProperty("API-Sign", "+EH4mm7c+g2d6XOdsx+0rElDQTXFVxuzLIYLgZJwEe80Wi1TMWKDRoQA1dIXD/QFCGEwtvrp32APAoXMXT2k3w=="); + verify(connection, never()).setDoOutput(anyBoolean()); + verify(connection, never()).addRequestProperty(eq("Content-Type"), anyString()); + } + + @Test + void should_sign_and_send_identical_json_when_querying_with_body() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + CreateFundingAddressEndpoint endpoint = new CreateFundingAddressEndpoint(CreateFundingAddressParams.builder() + .scope(Scope.network("d9d375da-44b7-4be1-8a00-8b281acfe366")).address("0xBef7B36845cA31045E86D0B46DBCac4e6752").name("Personal Wallet").build()); + URL url = endpoint.buildURL(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + String body = "{\"scope\":{\"network_id\":\"d9d375da-44b7-4be1-8a00-8b281acfe366\"},\"address_details\":{\"crypto\":{\"address\":\"0xBef7B36845cA31045E86D0B46DBCac4e6752\"}},\"name\":\"Personal Wallet\"}"; + when(nonceGenerator.generate()).thenReturn("123"); + when(connectionFactory.open(url)).thenReturn(connection); + when(connection.getURL()).thenReturn(url); + when(credentials.getKey()).thenReturn("test-key"); + when(credentials.sign("/funding/v1/addresses", "123", body)).thenReturn("test-signature"); + when(connection.getOutputStream()).thenReturn(output); + when(connection.getResponseCode()).thenReturn(200); + when(connection.getHeaderField("Content-Type")).thenReturn("application/json"); + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream("{\"address_id\":\"AB7J4FF-BGM7G-V2JMIH\",\"verified\":true}".getBytes(StandardCharsets.UTF_8))); + + FundingAddressCreated result = unit.execute(endpoint, credentials, nonceGenerator); + + assertThat(result.addressId()).isEqualTo("AB7J4FF-BGM7G-V2JMIH"); + assertThat(output.toString(StandardCharsets.UTF_8)).isEqualTo(body); + verify(connection).setRequestMethod("POST"); + verify(connection).setDoOutput(true); + verify(connection).addRequestProperty("Content-Type", "application/json"); + verify(connection).addRequestProperty("API-Key", "test-key"); + verify(connection).addRequestProperty("API-Sign", "test-signature"); + verify(connection).addRequestProperty("API-Nonce", "123"); + } + + @Test + void should_send_delete_without_body_when_deleting_address() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + DeleteFundingAddressEndpoint endpoint = new DeleteFundingAddressEndpoint("AB7J4FF-BGM7G-V2JMIH"); + URL url = endpoint.buildURL(); + when(nonceGenerator.generate()).thenReturn("123"); + when(connectionFactory.open(url)).thenReturn(connection); + when(connection.getURL()).thenReturn(url); + when(credentials.sign("/funding/v1/addresses/AB7J4FF-BGM7G-V2JMIH", "123", "")).thenReturn("test-signature"); + when(connection.getResponseCode()).thenReturn(200); + when(connection.getHeaderField("Content-Type")).thenReturn("application/json"); + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream("{\"result\":true}".getBytes(StandardCharsets.UTF_8))); + + FundingAddressDeleted result = unit.execute(endpoint, credentials, nonceGenerator); + + assertThat(result.deleted()).isTrue(); + verify(connection).setRequestMethod("DELETE"); + verify(connection).addRequestProperty("API-Sign", "test-signature"); + verify(connection, never()).getOutputStream(); + } + + @Test + void should_raise_status_and_body_when_kraken_answers_with_http_error() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + ClaimFundingDepositAddressEndpoint endpoint = new ClaimFundingDepositAddressEndpoint(ClaimFundingDepositAddressParams.builder().methodId("27ede8db-804b-4d91-8e25-46b7b9668730").build()); + when(nonceGenerator.generate()).thenReturn("123"); + when(connectionFactory.open(any(URL.class))).thenReturn(connection); + when(connection.getURL()).thenReturn(endpoint.buildURL()); + when(connection.getOutputStream()).thenReturn(new ByteArrayOutputStream()); + when(connection.getResponseCode()).thenReturn(409); + when(connection.getErrorStream()).thenReturn(new ByteArrayInputStream("{\"error\":\"TooManyDepositAddresses\"}".getBytes(StandardCharsets.UTF_8))); + + assertThatThrownBy(() -> unit.execute(endpoint, credentials, nonceGenerator)) + .isInstanceOfSatisfying(KrakenException.class, e -> assertThat(e.getErrors()).containsExactly("HTTP 409 {\"error\":\"TooManyDepositAddresses\"}")); + } + + @Test + void should_raise_status_only_when_http_error_has_no_body() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + FundingNetworksEndpoint endpoint = new FundingNetworksEndpoint(); + when(nonceGenerator.generate()).thenReturn("123"); + when(connectionFactory.open(any(URL.class))).thenReturn(connection); + when(connection.getURL()).thenReturn(endpoint.buildURL()); + when(connection.getResponseCode()).thenReturn(401); + + assertThatThrownBy(() -> unit.execute(endpoint, credentials, nonceGenerator)) + .isInstanceOfSatisfying(KrakenException.class, e -> assertThat(e.getErrors()).containsExactly("HTTP 401")); + } + + @Test + void should_reject_response_when_content_type_is_not_json() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + FundingNetworksEndpoint endpoint = new FundingNetworksEndpoint(); + when(nonceGenerator.generate()).thenReturn("123"); + when(connectionFactory.open(any(URL.class))).thenReturn(connection); + when(connection.getURL()).thenReturn(endpoint.buildURL()); + when(connection.getResponseCode()).thenReturn(200); + when(connection.getHeaderField("Content-Type")).thenReturn("text/html"); + + assertThatThrownBy(() -> unit.execute(endpoint, credentials, nonceGenerator)).isInstanceOf(IllegalStateException.class).hasMessage("Unsupported HTTP Content-Type"); + } + + @Test + void should_reject_funding_endpoint_when_requester_predates_funding_beta() { + FundingNetworksEndpoint endpoint = new FundingNetworksEndpoint(); + + assertThatThrownBy(() -> legacyRequester.execute(endpoint, credentials, nonceGenerator)).isInstanceOf(UnsupportedOperationException.class).hasMessageContaining("Funding (Beta)"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/rest/KrakenCredentialsTest.java b/library/src/test/java/dev/andstuff/kraken/api/rest/KrakenCredentialsTest.java index ecaa056..c8870b7 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/rest/KrakenCredentialsTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/rest/KrakenCredentialsTest.java @@ -36,4 +36,23 @@ void should_match_reference_digest_when_signing_a_json_body() throws Exception { assertThat(result).isEqualTo("JRJDryVhouSrIMbOftfDi5V0ILl6Mm6LMoX/LfS28me0PEos74LxkJ18uW9lfw1Y7osOoM78imN0uJjti1DJRw=="); } + + @Test + void should_match_reference_digest_when_signing_a_funding_path_with_query() { + KrakenCredentials unit = new KrakenCredentials("public-example", "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg=="); + + String result = unit.sign("/funding/v1/fees/d4ec4d52-b159-428e-ba64-f45455a978a1?amount=5&fee_included=true", "1616492376594", ""); + + assertThat(result).isEqualTo("+EH4mm7c+g2d6XOdsx+0rElDQTXFVxuzLIYLgZJwEe80Wi1TMWKDRoQA1dIXD/QFCGEwtvrp32APAoXMXT2k3w=="); + } + + @Test + void should_match_reference_digest_when_signing_a_funding_json_body() { + KrakenCredentials unit = new KrakenCredentials("public-example", "kQH5HW/8p1uGOVjbgWA7FunAmGO8lsSUXNsu3eow76sz84Q18fWxnyRzBHCd3pd5nE9qa99HAZtuZuj6F1huXg=="); + String body = "{\"scope\":{\"network_id\":\"d9d375da-44b7-4be1-8a00-8b281acfe366\"},\"address_details\":{\"crypto\":{\"address\":\"0xBef7B36845cA31045E86D0B46DBCac4e6752\"}},\"name\":\"Personal Wallet\"}"; + + String result = unit.sign("/funding/v1/addresses", "1616492376594", body); + + assertThat(result).isEqualTo("Lc9l+liE00GWbh8QHewQEfGH3S6pYOjHIiTRyb9s9/Cynw9lIc1Y/z7LW+u8XUAVZ7AUdCrK7wmWYmBXHBp7Kg=="); + } } diff --git a/library/src/test/resources/fundingbeta/CalculateFundingFees.json b/library/src/test/resources/fundingbeta/CalculateFundingFees.json new file mode 100644 index 0000000..459b843 --- /dev/null +++ b/library/src/test/resources/fundingbeta/CalculateFundingFees.json @@ -0,0 +1,34 @@ +{ + "fee": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "1.00000000" + }, + "gross_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "5.00000000" + }, + "net_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "4.00000000" + }, + "fee_details": { + "base_fee": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "1.00000000" + }, + "fee_percentage": "0" + }, + "withdrawal_fee_token": "AAAAAAAAAAHG33Wc1eES6QpeGgMok_gUnlZC7Y5niezI2,MBriLCyg8oqCX7SH1bSqY6SfmC2ZLf_SgTxNZj4Cd1RLPaTyZE..." +} diff --git a/library/src/test/resources/fundingbeta/ClaimFundingDepositAddress.json b/library/src/test/resources/fundingbeta/ClaimFundingDepositAddress.json new file mode 100644 index 0000000..b91fffb --- /dev/null +++ b/library/src/test/resources/fundingbeta/ClaimFundingDepositAddress.json @@ -0,0 +1,7 @@ +{ + "address_details": { + "crypto": { + "address": "AikyzpZZcNAeovd6XnyEaEe6YB1tPKkXZF37rUWD..." + } + } +} diff --git a/library/src/test/resources/fundingbeta/CreateFundingAddress.json b/library/src/test/resources/fundingbeta/CreateFundingAddress.json new file mode 100644 index 0000000..d659ccb --- /dev/null +++ b/library/src/test/resources/fundingbeta/CreateFundingAddress.json @@ -0,0 +1,4 @@ +{ + "address_id": "AB7J4FF-BGM7G-V2JMIH", + "verified": true +} diff --git a/library/src/test/resources/fundingbeta/CreateFundingWithdrawal.json b/library/src/test/resources/fundingbeta/CreateFundingWithdrawal.json new file mode 100644 index 0000000..8cdb4a3 --- /dev/null +++ b/library/src/test/resources/fundingbeta/CreateFundingWithdrawal.json @@ -0,0 +1,30 @@ +{ + "withdrawal_id": "FTVZiTI-e02T84mm87JmibnObWNdnW", + "net_amount": { + "asset_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "4.00000000" + } + }, + "gross_amount": { + "asset_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "5.00000000" + } + }, + "fee": { + "asset_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "1.00000000" + } + } +} diff --git a/library/src/test/resources/fundingbeta/DeleteFundingAddress.json b/library/src/test/resources/fundingbeta/DeleteFundingAddress.json new file mode 100644 index 0000000..8c87d25 --- /dev/null +++ b/library/src/test/resources/fundingbeta/DeleteFundingAddress.json @@ -0,0 +1,3 @@ +{ + "result": true +} diff --git a/library/src/test/resources/fundingbeta/ListFundingAddresses.json b/library/src/test/resources/fundingbeta/ListFundingAddresses.json new file mode 100644 index 0000000..17ee404 --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingAddresses.json @@ -0,0 +1,44 @@ +{ + "addresses": [ + { + "address_id": "AB7J4FF-BGM7G-V2JMIH", + "scope": { + "network_id": "d9d375da-44b7-4be1-8a00-8b281acfe366" + }, + "address_details": { + "crypto": { + "address": "0xBef7B36845cA31045E86D0B46DBCac4e6752..." + } + }, + "name": "Personal Wallet", + "description": "My Ethereum hardware wallet address", + "verified": true + }, + { + "address_id": "ABSXEMA-3FXQS-T7Q2FG", + "scope": { + "method_id": "67b765fd-4efd-42dc-8ce7-63352971d566" + }, + "address_details": { + "crypto": { + "address": "0xBef7B36845cA31045E86D0B46DBCac4e6752..." + } + }, + "verified": true, + "name": "Trading Desk Wallet", + "description": "Shared EVM withdrawal address" + }, + { + "address_id": "ABR6SXP-SF6CY-VJMONY", + "scope": { + "network_group_id": "f95acdb7-48fb-4441-b5b4-843d3bf60e61" + }, + "address_details": { + "crypto": { + "address": "0xBef7B36845cA31045E86D0B46DBCac4e6752..." + } + }, + "verified": true + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingAssets.json b/library/src/test/resources/fundingbeta/ListFundingAssets.json new file mode 100644 index 0000000..59e470f --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingAssets.json @@ -0,0 +1,30 @@ +{ + "currency": [ + { + "name": "USDC" + }, + { + "name": "USDT" + }, + { + "name": "BTC" + }, + { + "name": "ETH" + }, + { + "name": "SOL" + } + ], + "tokenized_asset": [ + { + "name": "TONXx" + }, + { + "name": "NVDAx" + }, + { + "name": "AAPLx" + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingClaimedAddresses.json b/library/src/test/resources/fundingbeta/ListFundingClaimedAddresses.json new file mode 100644 index 0000000..0679804 --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingClaimedAddresses.json @@ -0,0 +1,13 @@ +{ + "addresses": [ + { + "method_id": "27ede8db-804b-4d91-8e25-46b7b9668730", + "shares_addresses_with_method_id": "a001231f-488e-48c0-b36c-6e0d2c1ee247", + "address_details": { + "crypto": { + "address": "0x0d5cff23d40bcc6b98537c7ad5a839a247f546..." + } + } + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingDepositLimits.json b/library/src/test/resources/fundingbeta/ListFundingDepositLimits.json new file mode 100644 index 0000000..87914c5 --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingDepositLimits.json @@ -0,0 +1,19 @@ +{ + "deposit_limits": [ + { + "method_id": "742656ee-ecc8-4287-a301-5a472ca16c24", + "maximum_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "100032.01024328" + }, + "limits": [] + }, + { + "method_id": "98de2213-0e3c-422c-9b08-81078557ad3a", + "limits": [] + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingDeposits.json b/library/src/test/resources/fundingbeta/ListFundingDeposits.json new file mode 100644 index 0000000..d9759ad --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingDeposits.json @@ -0,0 +1,47 @@ +{ + "deposits": [ + { + "deposit_id": "FTcQ4qW-fWGQbQwUfqdnZo4dsMn1ao", + "method_id": "3e7f8072-cc6d-4394-982a-5f4ca6ab27dd", + "network_id": "b336ce74-8d60-42b8-8714-b1095e06b711", + "status": "success", + "amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "20.00000000" + }, + "fee": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "0.00000000" + }, + "create_time": "2026-07-01T08:31:33Z" + }, + { + "deposit_id": "FTAsxe1-PHt8Ni7NodvtOEw8FBvLAJ", + "method_id": "a897bb0b-e7bc-4eff-969f-433bc811e4ad", + "network_id": "d9d375da-44b7-4be1-8a00-8b281acfe366", + "status": "success", + "amount": { + "asset": { + "class": "currency", + "name": "LINK" + }, + "amount": "5555.0000000000" + }, + "fee": { + "asset": { + "class": "currency", + "name": "LINK" + }, + "amount": "0.0000000000" + }, + "create_time": "2026-06-23T08:41:17Z" + } + ], + "next_cursor": "gqNrZXm-RlREc1IxRC1oOTd3c1Y4RFJKUWpPQ0ZTTjVic0NxpnBhcmFtc4qudXNlcl9zZWxlY3Rpb27ArGFzc2V0X2ZpbHRlcsClc2NvcGXArXN0YXR1c19maWx0ZXLArGZsYWdzX2ZpbHRlcsCzZXF1aXZfYW1vdW50X2ZpbHRlcsCqc3RhcnRfdGltZcCoZW5kX3RpbWXAqG9yZGVyaW5nqmRlc2NlbmRpbmexcmViYXNlX211bHRpcGxpZXLA" +} diff --git a/library/src/test/resources/fundingbeta/ListFundingMethods.json b/library/src/test/resources/fundingbeta/ListFundingMethods.json new file mode 100644 index 0000000..53a27ae --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingMethods.json @@ -0,0 +1,36 @@ +{ + "methods": [ + { + "asset": { + "class": "currency", + "name": "USDC" + }, + "method_id": "27ede8db-804b-4d91-8e25-46b7b9668730", + "method_name": "Standard", + "minimum_amount": "2", + "fees": { + "base": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "0.00000000" + }, + "included": true + }, + "network": { + "network_id": "d9d375da-44b7-4be1-8a00-8b281acfe366", + "network_name": "Ethereum", + "contract_address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "on_chain_asset_symbol": "USDC" + }, + "deposit": { + "address_generation": { + "status": "limited", + "limit": 5 + }, + "shares_addresses_with_method_id": "a001231f-488e-48c0-b36c-6e0d2c1ee247" + } + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingNetworks.json b/library/src/test/resources/fundingbeta/ListFundingNetworks.json new file mode 100644 index 0000000..4036b9e --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingNetworks.json @@ -0,0 +1,27 @@ +{ + "network_groups": [ + { + "network_group_id": "f95acdb7-48fb-4441-b5b4-843d3bf60e61", + "name": "EVM", + "network_ids": [ + "2c6eeff9-bd5f-4c09-982d-a5855fa9f0dd", + "bc7562cf-1e51-4308-b52b-d062aaa6aa3b", + "473365c2-4c95-4bd9-b5c8-974fb91c7d6b" + ] + } + ], + "networks": [ + { + "network_id": "2c6eeff9-bd5f-4c09-982d-a5855fa9f0dd", + "name": "Arbitrum Nova" + }, + { + "network_id": "bc7562cf-1e51-4308-b52b-d062aaa6aa3b", + "name": "Arbitrum One" + }, + { + "network_id": "473365c2-4c95-4bd9-b5c8-974fb91c7d6b", + "name": "Avalanche C Chain" + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingWithdrawalLimits.json b/library/src/test/resources/fundingbeta/ListFundingWithdrawalLimits.json new file mode 100644 index 0000000..09d93f2 --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingWithdrawalLimits.json @@ -0,0 +1,99 @@ +{ + "available_balance": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "689005.55897887" + }, + "withdrawal_limits": [ + { + "method_id": "ec344850-a2bf-4fd8-b5c4-eb902a61cd03", + "maximum_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "500155.05091629" + }, + "maximum_reason": "limits", + "limits": [ + { + "time_window": "86400", + "limit": { + "remaining": { + "policy_asset_amount": { + "asset": { + "class": "currency", + "name": "USD" + }, + "amount": "499995.0013" + }, + "usd_amount": { + "asset": { + "class": "currency", + "name": "USD" + }, + "amount": "499995.0013" + }, + "requested_asset_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "500155.05091629" + } + }, + "maximum": { + "policy_asset_amount": { + "asset": { + "class": "currency", + "name": "USD" + }, + "amount": "500000.0000" + }, + "usd_amount": { + "asset": { + "class": "currency", + "name": "USD" + }, + "amount": "500000.0000" + }, + "requested_asset_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "500160.05121639" + } + }, + "used": { + "policy_asset_amount": { + "asset": { + "class": "currency", + "name": "USD" + }, + "amount": "4.9987" + }, + "usd_amount": { + "asset": { + "class": "currency", + "name": "USD" + }, + "amount": "4.9987" + }, + "requested_asset_amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "5.00030010" + } + }, + "limit_type": "equiv_amount_usd" + } + } + ] + } + ] +} diff --git a/library/src/test/resources/fundingbeta/ListFundingWithdrawals.json b/library/src/test/resources/fundingbeta/ListFundingWithdrawals.json new file mode 100644 index 0000000..a492f5e --- /dev/null +++ b/library/src/test/resources/fundingbeta/ListFundingWithdrawals.json @@ -0,0 +1,47 @@ +{ + "withdrawals": [ + { + "withdrawal_id": "FTVZiTI-e02T84mm87JmibnObWNdnW", + "amount": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "4.00000000" + }, + "fee": { + "asset": { + "class": "currency", + "name": "USDC" + }, + "amount": "1.00000000" + }, + "method_id": "d4ec4d52-b159-428e-ba64-f45455a978a1", + "status": "pending", + "create_time": "2026-08-12T10:10:30Z", + "address_id": "ABR6SXP-SF6CY-VJMONY" + }, + { + "withdrawal_id": "FTa55AO-u7dazcZpBR1nS3Uk42k6LI", + "amount": { + "asset": { + "class": "tokenized_asset", + "name": "TSLAx" + }, + "amount": "1.30000000" + }, + "fee": { + "asset": { + "class": "tokenized_asset", + "name": "TSLAx" + }, + "amount": "0.01300000" + }, + "method_id": "5104c28e-9da8-43c8-8194-33d9ea1fbdc0", + "status": "pending", + "create_time": "2026-08-12T10:07:04Z", + "address_id": "ABVXBF7-RC3Z5-WYUBPD" + } + ], + "next_cursor": "gqNrZXm-RlRSeFJ2WS1PSHBXSUlrTkYwQU1tM0MzNDg2dkxFpnBhcmFtc4qudXNlcl9zZWxlY3Rpb27ArGFzc2V0X2ZpbHRlcsCzbXVsdGlfbmV0d29ya19zY29wZcCtc3RhdHVzX2ZpbHRlcsCsZmxhZ3NfZmlsdGVywK1hbW91bnRfZmlsdGVywKpzdGFydF90aW1lwKhlbmRfdGltZcCob3JkZXJpbmeqZGVzY2VuZGluZ7FyZWJhc2VfbXVsdGlwbGllcsA" +} diff --git a/library/src/test/resources/fundingbeta/README.md b/library/src/test/resources/fundingbeta/README.md new file mode 100644 index 0000000..489bb6a --- /dev/null +++ b/library/src/test/resources/fundingbeta/README.md @@ -0,0 +1 @@ +Fixtures are response examples from [Kraken’s Spot REST OpenAPI specification](https://docs.kraken.com/openapi/spot-rest.yaml), version `1.1.0`, retrieved on 2026-09-21. Update Funding Address and Delete Funding Address have no documented example, so their fixtures follow the response schemas. Supplemental inline cases cover fiat and beneficiary address details, count and amount limits, unknown enum values, pending deposits without amounts, and on-chain withdrawal details. Request signatures are checked against digests computed with the signing helper of Kraken's [Funding (Beta) guide](https://docs.kraken.com/exchange/guides/rest/funding). All tests run locally without API keys or network calls, and create, change or delete no addresses, deposits or withdrawals. diff --git a/library/src/test/resources/fundingbeta/UpdateFundingAddress.json b/library/src/test/resources/fundingbeta/UpdateFundingAddress.json new file mode 100644 index 0000000..94734c9 --- /dev/null +++ b/library/src/test/resources/fundingbeta/UpdateFundingAddress.json @@ -0,0 +1,3 @@ +{ + "verified": true +}