From 5241e5cec6d661f331266833bc0c6458b6e1a2d4 Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Mon, 3 Aug 2026 16:42:42 -0400 Subject: [PATCH 1/5] feat: add CDP payments Java scripts Co-authored-by: Pi --- .env.example | 12 ++ .github/workflows/ci.yml | 23 +++ .gitignore | 8 + LICENSE | 184 ++++++++++++++++++ Makefile | 22 +++ README.md | 110 ++++++++++- SECURITY.md | 5 + THIRD-PARTY-NOTICES | 7 + examples/requests/create-account.json | 3 + .../requests/create-deposit-destination.json | 14 ++ examples/requests/create-transfer.json | 14 ++ examples/requests/submit-travel-rule.json | 4 + pom.xml | 79 ++++++++ .../cdp/scripts/accounts/CreateAccount.java | 44 +++++ .../cdp/scripts/accounts/GetAccount.java | 27 +++ .../cdp/scripts/accounts/GetBalance.java | 37 ++++ .../cdp/scripts/accounts/ListAccounts.java | 45 +++++ .../cdp/scripts/accounts/ListBalances.java | 34 ++++ .../cdp/scripts/common/ApiResponse.java | 9 + .../cdp/scripts/common/CdpClient.java | 76 ++++++++ .../cdp/scripts/common/CdpCommand.java | 41 ++++ .../cdp/scripts/common/CdpConfig.java | 86 ++++++++ .../cdp/scripts/common/CommandSupport.java | 55 ++++++ .../cdp/scripts/common/JwtSigner.java | 174 +++++++++++++++++ .../cdp/scripts/common/QueryParameters.java | 38 ++++ .../CreateDepositDestination.java | 45 +++++ .../GetDepositDestination.java | 31 +++ .../ListDepositDestinations.java | 48 +++++ .../paymentmethods/GetPaymentMethod.java | 34 ++++ .../paymentmethods/ListPaymentMethods.java | 30 +++ .../cdp/scripts/transfers/CreateTransfer.java | 41 ++++ .../scripts/transfers/ExecuteTransfer.java | 37 ++++ .../cdp/scripts/transfers/GetTransfer.java | 31 +++ .../cdp/scripts/transfers/ListTransfers.java | 92 +++++++++ .../scripts/transfers/SubmitTravelRule.java | 52 +++++ .../cdp/scripts/ScriptInventoryTest.java | 56 ++++++ .../cdp/scripts/common/CdpClientTest.java | 97 +++++++++ .../cdp/scripts/common/CdpConfigTest.java | 59 ++++++ .../scripts/common/CommandSupportTest.java | 64 ++++++ .../cdp/scripts/common/JwtSignerTest.java | 86 ++++++++ 40 files changed, 1953 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 THIRD-PARTY-NOTICES create mode 100644 examples/requests/create-account.json create mode 100644 examples/requests/create-deposit-destination.json create mode 100644 examples/requests/create-transfer.json create mode 100644 examples/requests/submit-travel-rule.json create mode 100644 pom.xml create mode 100644 src/main/java/com/coinbase/cdp/scripts/accounts/CreateAccount.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/accounts/GetAccount.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/accounts/GetBalance.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/ApiResponse.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/CdpClient.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/CdpCommand.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/CommandSupport.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/JwtSigner.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/QueryParameters.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/depositdestinations/CreateDepositDestination.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/depositdestinations/GetDepositDestination.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/paymentmethods/GetPaymentMethod.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/transfers/CreateTransfer.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/transfers/ExecuteTransfer.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/transfers/GetTransfer.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java create mode 100644 src/main/java/com/coinbase/cdp/scripts/transfers/SubmitTravelRule.java create mode 100644 src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java create mode 100644 src/test/java/com/coinbase/cdp/scripts/common/CdpClientTest.java create mode 100644 src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java create mode 100644 src/test/java/com/coinbase/cdp/scripts/common/CommandSupportTest.java create mode 100644 src/test/java/com/coinbase/cdp/scripts/common/JwtSignerTest.java diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7ac769 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Preferred: path to the JSON file downloaded from the CDP Portal. +CDP_API_KEY_FILE=/absolute/path/to/cdp_api_key.json + +# Alternative to CDP_API_KEY_FILE. +# CDP_KEY_ID=organizations/.../apiKeys/... +# CDP_KEY_SECRET=base64-ed25519-secret-or-ecdsa-pem + +# Defaults to sandbox. Supported values: sandbox, production. +CDP_ENVIRONMENT=sandbox + +# Optional custom endpoint, primarily for local testing. +# CDP_API_BASE_URL=https://sandbox.cdp.coinbase.com/platform diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b1327b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + - run: make lint + - run: make test + - run: make build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e62b76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +target/ +.idea/ +.vscode/ +*.iml +.DS_Store +.env +cdp_api_key.json +*.pem diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a4e672 --- /dev/null +++ b/LICENSE @@ -0,0 +1,184 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a cross-claim + or counterclaim in a lawsuit) alleging that the Work or any + Contribution embodied within the Work constitutes direct or + contributory patent infringement, then any patent licenses granted + to You under this License for that Work shall terminate as of the + date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text + file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with the + Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the + Contribution, either as-is or with modifications, whether in Source + or Object form. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or reproducing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026-present Coinbase Global, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..020bf83 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: build test lint format clean run + +build: + mvn -q -DskipTests package + +test: + mvn -q test + +lint: + mvn -q spotless:check + +format: + mvn -q spotless:apply + +clean: + mvn -q clean + +run: + @test -n "$(SCRIPT)" || (echo "Usage: make run SCRIPT=accounts.ListAccounts ARGS='...'" >&2; exit 2) + mvn -q compile exec:java \ + -Dexec.mainClass=com.coinbase.cdp.scripts.$(SCRIPT) \ + -Dexec.args="$(ARGS)" diff --git a/README.md b/README.md index 3d25ed9..6597201 100644 --- a/README.md +++ b/README.md @@ -1 +1,109 @@ -# CDP Payments Java Scripts +# CDP Payments Java scripts + +Runnable Java examples for the [CDP Payments and Custodial Accounts APIs](https://docs.cdp.coinbase.com/api-reference/v2/introduction). Each API operation has one `main` class. Scripts call the REST API directly, print formatted responses, and default to CDP Sandbox. + +## Requirements + +- JDK 17 or newer +- Maven 3.9 or newer +- A Sandbox API key with the permissions required by the endpoint + +Create and fund a simulated account using the [Custodial Wallets quickstart](https://docs.cdp.coinbase.com/wallets/custodial-wallets/quickstart). Sandbox never moves real funds. + +## Configure credentials + +Keep credentials outside this repository. Point to the JSON file downloaded from the CDP Portal: + +```bash +export CDP_API_KEY_FILE="$HOME/Downloads/cdp_api_key.json" +``` + +You can instead export its values directly: + +```bash +export CDP_KEY_ID='organizations/.../apiKeys/...' +export CDP_KEY_SECRET='base64 Ed25519 secret or ECDSA PEM' +``` + +`CDP_API_KEY_FILE` takes precedence. Both recommended Ed25519 keys and legacy ECDSA keys are supported. + +Scripts use `https://sandbox.cdp.coinbase.com/platform` unless configured otherwise: + +```bash +export CDP_ENVIRONMENT=production +# Or, for a custom server: +export CDP_API_BASE_URL=https://example.test/platform +``` + +## Run scripts + +Build and see a script's options: + +```bash +make build +make run SCRIPT=accounts.ListAccounts ARGS='--help' +``` + +List accounts and balances: + +```bash +make run SCRIPT=accounts.ListAccounts +make run SCRIPT=accounts.ListBalances ARGS='--account-id account_123' +``` + +Create an account from a sample request: + +```bash +make run SCRIPT=accounts.CreateAccount \ + ARGS='--body-file examples/requests/create-account.json' +``` + +Create a Sandbox transfer after replacing `account_REPLACE_ME` in the sample: + +```bash +make run SCRIPT=transfers.CreateTransfer \ + ARGS='--body-file examples/requests/create-transfer.json' +``` + +Mutating scripts accept `--body '{...}'` or `--body-file path`, never both. Eligible operations also accept `--idempotency-key UUID`. When omitted, the script generates a UUID v4 and prints it to stderr so the request can be retried safely. + +Every non-2xx response returns a nonzero exit code. JSON responses are formatted on stdout. Diagnostics and generated idempotency keys go to stderr. + +## Endpoint scripts + +| API | Script | Method and path | +| --- | --- | --- | +| Accounts | `accounts.ListAccounts` | `GET /v2/accounts` | +| Accounts | `accounts.CreateAccount` | `POST /v2/accounts` | +| Accounts | `accounts.GetAccount` | `GET /v2/accounts/{accountId}` | +| Accounts | `accounts.ListBalances` | `GET /v2/accounts/{accountId}/balances` | +| Accounts | `accounts.GetBalance` | `GET /v2/accounts/{accountId}/balances/{asset}` | +| Deposit Destinations | `depositdestinations.ListDepositDestinations` | `GET /v2/deposit-destinations` | +| Deposit Destinations | `depositdestinations.CreateDepositDestination` | `POST /v2/deposit-destinations` | +| Deposit Destinations | `depositdestinations.GetDepositDestination` | `GET /v2/deposit-destinations/{depositDestinationId}` | +| Transfers | `transfers.CreateTransfer` | `POST /v2/transfers` | +| Transfers | `transfers.ListTransfers` | `GET /v2/transfers` | +| Transfers | `transfers.GetTransfer` | `GET /v2/transfers/{transferId}` | +| Transfers | `transfers.ExecuteTransfer` | `POST /v2/transfers/{transferId}/execute` | +| Transfers | `transfers.SubmitTravelRule` | `POST /v2/transfers/{transferId}/travel-rule` | +| Payment Methods | `paymentmethods.ListPaymentMethods` | `GET /v2/payment-methods` | +| Payment Methods | `paymentmethods.GetPaymentMethod` | `GET /v2/payment-methods/{paymentMethodId}` | + +Run any script with `--help` for its path and query options. List operations expose every filter and pagination field in the [v2 OpenAPI specification](https://docs.cdp.coinbase.com/api-reference/v2/cdp-api-v2.yaml). + +## Development + +Use Make targets rather than invoking their underlying tools: + +```bash +make format +make lint +make test +make build +``` + +Automated tests use a local HTTP server and do not contact CDP. + +## License + +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e64871a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security + +Never commit CDP API keys, private keys, production identifiers, or customer data. Use environment variables or an external key file. + +Report suspected vulnerabilities through Coinbase's [HackerOne program](https://hackerone.com/coinbase). Do not disclose vulnerabilities in a public issue. diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES new file mode 100644 index 0000000..741a8cb --- /dev/null +++ b/THIRD-PARTY-NOTICES @@ -0,0 +1,7 @@ +This project uses open source libraries distributed under their respective licenses. + +- Jackson, Apache License 2.0 +- Nimbus JOSE + JWT, Apache License 2.0 +- Bouncy Castle, MIT License +- Picocli, Apache License 2.0 +- JUnit, Eclipse Public License 2.0 diff --git a/examples/requests/create-account.json b/examples/requests/create-account.json new file mode 100644 index 0000000..7096e5a --- /dev/null +++ b/examples/requests/create-account.json @@ -0,0 +1,3 @@ +{ + "name": "My Sandbox Account" +} diff --git a/examples/requests/create-deposit-destination.json b/examples/requests/create-deposit-destination.json new file mode 100644 index 0000000..4c2d5a7 --- /dev/null +++ b/examples/requests/create-deposit-destination.json @@ -0,0 +1,14 @@ +{ + "accountId": "account_REPLACE_ME", + "type": "crypto", + "crypto": { + "network": "base" + }, + "target": { + "accountId": "account_REPLACE_ME", + "asset": "usd" + }, + "metadata": { + "reference": "java-script-example" + } +} diff --git a/examples/requests/create-transfer.json b/examples/requests/create-transfer.json new file mode 100644 index 0000000..8dce965 --- /dev/null +++ b/examples/requests/create-transfer.json @@ -0,0 +1,14 @@ +{ + "source": { + "accountId": "account_REPLACE_ME", + "asset": "usdc" + }, + "target": { + "network": "base", + "address": "0x1111111111111111111111111111111111111111", + "asset": "usdc" + }, + "amount": "5.00", + "asset": "usdc", + "execute": true +} diff --git a/examples/requests/submit-travel-rule.json b/examples/requests/submit-travel-rule.json new file mode 100644 index 0000000..c162e3b --- /dev/null +++ b/examples/requests/submit-travel-rule.json @@ -0,0 +1,4 @@ +{ + "isSelf": true, + "attestVerifiedWalletOwnership": true +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..63db9b0 --- /dev/null +++ b/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + com.coinbase.cdp + cdp-payments-scripts-java + 1.0.0-SNAPSHOT + CDP Payments Java Scripts + + + 17 + UTF-8 + 2.18.3 + 5.11.4 + + + + + info.picocli + picocli + 4.7.6 + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.nimbusds + nimbus-jose-jwt + 9.40 + + + org.bouncycastle + bcpkix-jdk18on + 1.78.1 + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + com.diffplug.spotless + spotless-maven-plugin + 2.44.3 + + + + 1.24.0 + + + + + + + + diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/CreateAccount.java b/src/main/java/com/coinbase/cdp/scripts/accounts/CreateAccount.java new file mode 100644 index 0000000..d4f0e63 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/CreateAccount.java @@ -0,0 +1,44 @@ +package com.coinbase.cdp.scripts.accounts; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import java.nio.file.Path; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create-account", + description = "Create an account.", + mixinStandardHelpOptions = true) +public final class CreateAccount extends CdpCommand { + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private BodyInput input; + + @CommandLine.Option( + names = "--idempotency-key", + description = "Idempotency key. A UUID is generated when omitted.") + private String idempotencyKey; + + static final class BodyInput { + @CommandLine.Option( + names = "--body", + description = "JSON request body, for example {\"name\":\"My Business Account\"}.") + private String body; + + @CommandLine.Option(names = "--body-file", description = "Path to a JSON request body file.") + private Path bodyFile; + } + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.post( + "/v2/accounts", + CommandSupport.readBody(input.body, input.bodyFile), + CommandSupport.idempotencyKey(idempotencyKey)); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new CreateAccount()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/GetAccount.java b/src/main/java/com/coinbase/cdp/scripts/accounts/GetAccount.java new file mode 100644 index 0000000..792de59 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/GetAccount.java @@ -0,0 +1,27 @@ +package com.coinbase.cdp.scripts.accounts; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get-account", + description = "Get an account by ID.", + mixinStandardHelpOptions = true) +public final class GetAccount extends CdpCommand { + @CommandLine.Option(names = "--account-id", required = true, description = "Account ID.") + private String accountId; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get( + "/v2/accounts/" + CommandSupport.pathSegment(accountId), new QueryParameters()); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new GetAccount()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/GetBalance.java b/src/main/java/com/coinbase/cdp/scripts/accounts/GetBalance.java new file mode 100644 index 0000000..d5c3c80 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/GetBalance.java @@ -0,0 +1,37 @@ +package com.coinbase.cdp.scripts.accounts; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get-balance", + description = "Get an account balance by asset symbol.", + mixinStandardHelpOptions = true) +public final class GetBalance extends CdpCommand { + @CommandLine.Option(names = "--account-id", required = true, description = "Account ID.") + private String accountId; + + @CommandLine.Option( + names = "--asset", + required = true, + description = "Asset symbol, for example btc.") + private String asset; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get( + "/v2/accounts/" + + CommandSupport.pathSegment(accountId) + + "/balances/" + + CommandSupport.pathSegment(asset), + new QueryParameters()); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new GetBalance()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java b/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java new file mode 100644 index 0000000..f5c6b68 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java @@ -0,0 +1,45 @@ +package com.coinbase.cdp.scripts.accounts; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.QueryParameters; +import java.util.List; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list-accounts", + description = "List accounts accessible to the API key.", + mixinStandardHelpOptions = true) +public final class ListAccounts extends CdpCommand { + @CommandLine.Option(names = "--page-size", description = "Number of results to return.") + private Integer pageSize; + + @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") + private String pageToken; + + @CommandLine.Option( + names = "--owner", + split = ",", + description = "Owner ID or wildcard. Repeat or comma-separate values.") + private List owners; + + @CommandLine.Option(names = "--type", description = "Account type, for example cdp or prime.") + private String type; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + String owner = owners == null ? null : String.join(",", owners); + return client.get( + "/v2/accounts", + new QueryParameters() + .add("pageSize", pageSize) + .add("pageToken", pageToken) + .add("owner", owner) + .add("type", type)); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new ListAccounts()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java b/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java new file mode 100644 index 0000000..de7c56b --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java @@ -0,0 +1,34 @@ +package com.coinbase.cdp.scripts.accounts; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list-balances", + description = "List balances for an account.", + mixinStandardHelpOptions = true) +public final class ListBalances extends CdpCommand { + @CommandLine.Option(names = "--account-id", required = true, description = "Account ID.") + private String accountId; + + @CommandLine.Option(names = "--page-size", description = "Number of results to return.") + private Integer pageSize; + + @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") + private String pageToken; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get( + "/v2/accounts/" + CommandSupport.pathSegment(accountId) + "/balances", + new QueryParameters().add("pageSize", pageSize).add("pageToken", pageToken)); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new ListBalances()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/ApiResponse.java b/src/main/java/com/coinbase/cdp/scripts/common/ApiResponse.java new file mode 100644 index 0000000..1f2111c --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/ApiResponse.java @@ -0,0 +1,9 @@ +package com.coinbase.cdp.scripts.common; + +import java.net.http.HttpHeaders; + +public record ApiResponse(int statusCode, HttpHeaders headers, String body) { + public boolean isSuccess() { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/CdpClient.java b/src/main/java/com/coinbase/cdp/scripts/common/CdpClient.java new file mode 100644 index 0000000..086f1d0 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/CdpClient.java @@ -0,0 +1,76 @@ +package com.coinbase.cdp.scripts.common; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** Minimal authenticated CDP REST client used by the runnable scripts. */ +public final class CdpClient { + private final CdpConfig config; + private final HttpClient http; + private final JwtSigner signer; + + public CdpClient(CdpConfig config) { + this(config, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build()); + } + + public CdpClient(CdpConfig config, HttpClient http) { + this.config = config; + this.http = http; + this.signer = new JwtSigner(config.keyId(), config.keySecret()); + } + + public ApiResponse get(String path, QueryParameters query) + throws IOException, InterruptedException { + return request("GET", path, query, null, null); + } + + public ApiResponse post(String path, String jsonBody, String idempotencyKey) + throws IOException, InterruptedException { + return request("POST", path, new QueryParameters(), jsonBody, idempotencyKey); + } + + public ApiResponse post( + String path, QueryParameters query, String jsonBody, String idempotencyKey) + throws IOException, InterruptedException { + return request("POST", path, query, jsonBody, idempotencyKey); + } + + public ApiResponse request( + String method, String path, QueryParameters query, String jsonBody, String idempotencyKey) + throws IOException, InterruptedException { + URI unsignedUri = endpoint(path); + String queryString = query == null ? "" : query.toString(); + URI requestUri = + queryString.isEmpty() + ? unsignedUri + : URI.create(unsignedUri.toString() + "?" + queryString); + HttpRequest.BodyPublisher body = + jsonBody == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(jsonBody); + HttpRequest.Builder builder = + HttpRequest.newBuilder(requestUri) + .timeout(Duration.ofSeconds(60)) + .header("Authorization", "Bearer " + signer.sign(method, unsignedUri)) + .header("Accept", "application/json") + .method(method.toUpperCase(), body); + if (jsonBody != null) builder.header("Content-Type", "application/json"); + if (idempotencyKey != null && !idempotencyKey.isBlank()) { + builder.header("X-Idempotency-Key", idempotencyKey); + } + HttpResponse response = + http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new ApiResponse(response.statusCode(), response.headers(), response.body()); + } + + private URI endpoint(String path) { + if (path == null || path.isBlank()) throw new IllegalArgumentException("API path is required"); + String base = config.baseUri().toString().replaceAll("/+$", ""); + String relative = path.startsWith("/") ? path : "/" + path; + return URI.create(base + relative); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/CdpCommand.java b/src/main/java/com/coinbase/cdp/scripts/common/CdpCommand.java new file mode 100644 index 0000000..39cfcd2 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/CdpCommand.java @@ -0,0 +1,41 @@ +package com.coinbase.cdp.scripts.common; + +import java.io.PrintWriter; +import java.util.concurrent.Callable; +import picocli.CommandLine; + +/** Picocli base class that handles configuration, output, and consistent process status codes. */ +public abstract class CdpCommand implements Callable { + @CommandLine.Spec private CommandLine.Model.CommandSpec spec; + + protected abstract ApiResponse execute(CdpClient client) throws Exception; + + @Override + public final Integer call() { + PrintWriter out = + spec == null ? new PrintWriter(System.out, true) : spec.commandLine().getOut(); + PrintWriter err = + spec == null ? new PrintWriter(System.err, true) : spec.commandLine().getErr(); + try { + ApiResponse response = execute(new CdpClient(CdpConfig.load())); + String rendered = CommandSupport.prettyJson(response.body()); + if (response.isSuccess()) { + if (!rendered.isEmpty()) out.println(rendered); + return 0; + } + err.println("CDP API returned HTTP " + response.statusCode()); + if (!rendered.isEmpty()) err.println(rendered); + return 1; + } catch (IllegalArgumentException e) { + err.println("Error: " + e.getMessage()); + return 2; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + err.println("Request interrupted"); + return 1; + } catch (Exception e) { + err.println("Request failed: " + e.getMessage()); + return 1; + } + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java b/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java new file mode 100644 index 0000000..cdf691e --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java @@ -0,0 +1,86 @@ +package com.coinbase.cdp.scripts.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** Authentication and endpoint configuration loaded from the process environment. */ +public record CdpConfig(String keyId, String keySecret, URI baseUri) { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final URI SANDBOX = URI.create("https://sandbox.cdp.coinbase.com/platform"); + private static final URI PRODUCTION = URI.create("https://api.cdp.coinbase.com/platform"); + + public CdpConfig { + if (keyId == null || keyId.isBlank()) { + throw new IllegalArgumentException("Missing CDP API key ID"); + } + if (keySecret == null || keySecret.isBlank()) { + throw new IllegalArgumentException("Missing CDP API key secret"); + } + if (baseUri == null || !baseUri.isAbsolute() || baseUri.getHost() == null) { + throw new IllegalArgumentException("CDP API base URL must be an absolute HTTP(S) URL"); + } + String scheme = baseUri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + throw new IllegalArgumentException("CDP API base URL must use HTTP or HTTPS"); + } + } + + public static CdpConfig load() { + return load(System.getenv()); + } + + public static CdpConfig load(Map environment) { + String keyId = trimToNull(environment.get("CDP_KEY_ID")); + String keySecret = trimToNull(environment.get("CDP_KEY_SECRET")); + String keyFile = trimToNull(environment.get("CDP_API_KEY_FILE")); + if (keyFile != null) { + try { + JsonNode credentials = JSON.readTree(Files.readString(Path.of(keyFile))); + keyId = requiredText(credentials, "name", keyFile); + keySecret = requiredText(credentials, "privateKey", keyFile); + } catch (IOException | RuntimeException e) { + throw new IllegalArgumentException("Unable to read CDP API key file " + keyFile, e); + } + } + + String override = trimToNull(environment.get("CDP_API_BASE_URL")); + URI baseUri; + if (override != null) { + try { + baseUri = URI.create(override); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid CDP_API_BASE_URL", e); + } + } else { + String environmentName = environment.getOrDefault("CDP_ENVIRONMENT", "sandbox").trim(); + baseUri = + switch (environmentName.toLowerCase()) { + case "sandbox" -> SANDBOX; + case "production" -> PRODUCTION; + default -> + throw new IllegalArgumentException("CDP_ENVIRONMENT must be sandbox or production"); + }; + } + return new CdpConfig(keyId, keySecret, baseUri); + } + + private static String requiredText(JsonNode node, String field, String file) { + String value = node.path(field).asText(null); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("CDP API key file " + file + " is missing " + field); + } + return value; + } + + private static String trimToNull(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/CommandSupport.java b/src/main/java/com/coinbase/cdp/scripts/common/CommandSupport.java new file mode 100644 index 0000000..0484b94 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/CommandSupport.java @@ -0,0 +1,55 @@ +package com.coinbase.cdp.scripts.common; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +public final class CommandSupport { + private static final ObjectMapper JSON = new ObjectMapper(); + + private CommandSupport() {} + + public static String readBody(String body, Path bodyFile) { + if (body != null && bodyFile != null) { + throw new IllegalArgumentException("Use either --body or --body-file, not both"); + } + if (body == null && bodyFile == null) { + throw new IllegalArgumentException("One of --body or --body-file is required"); + } + try { + String value = body != null ? body : Files.readString(bodyFile); + JSON.readTree(value); + return value; + } catch (IOException e) { + throw new IllegalArgumentException("Request body must be valid JSON", e); + } + } + + public static String idempotencyKey(String supplied) { + if (supplied != null && !supplied.isBlank()) return supplied; + String generated = UUID.randomUUID().toString(); + System.err.println("Generated idempotency key: " + generated); + return generated; + } + + /** Percent-encodes an untrusted identifier for use as one URI path segment. */ + public static String pathSegment(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Path value is required"); + } + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static String prettyJson(String body) { + if (body == null || body.isBlank()) return ""; + try { + return JSON.writerWithDefaultPrettyPrinter().writeValueAsString(JSON.readTree(body)); + } catch (IOException e) { + return body; + } + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/JwtSigner.java b/src/main/java/com/coinbase/cdp/scripts/common/JwtSigner.java new file mode 100644 index 0000000..b9e9cd7 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/JwtSigner.java @@ -0,0 +1,174 @@ +package com.coinbase.cdp.scripts.common; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.StringReader; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +/** Creates short-lived request JWTs accepted by CDP APIs. */ +public final class JwtSigner { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Base64.Encoder B64_URL = Base64.getUrlEncoder().withoutPadding(); + private static final byte[] ED25519_PKCS8_PREFIX = hex("302e020100300506032b657004220420"); + + private final String keyId; + private final PrivateKey privateKey; + private final String jwtAlgorithm; + private final String signatureAlgorithm; + private final Clock clock; + + public JwtSigner(String keyId, String keySecret) { + this(keyId, keySecret, Clock.systemUTC()); + } + + JwtSigner(String keyId, String keySecret, Clock clock) { + this.keyId = keyId; + this.clock = clock; + try { + if (keySecret.contains("BEGIN")) { + privateKey = readPem(keySecret); + if (!(privateKey instanceof ECPrivateKey)) { + throw new IllegalArgumentException("PEM key must be an EC private key"); + } + jwtAlgorithm = "ES256"; + signatureAlgorithm = "SHA256withECDSA"; + } else { + privateKey = readEd25519(keySecret); + jwtAlgorithm = "EdDSA"; + signatureAlgorithm = "Ed25519"; + } + } catch (GeneralSecurityException | IOException | RuntimeException e) { + throw new IllegalArgumentException("Invalid CDP API key secret", e); + } + } + + /** Signs method and URI. Query parameters are deliberately excluded from the uri claim. */ + public String sign(String method, java.net.URI uri) { + Instant now = clock.instant(); + Map header = new LinkedHashMap<>(); + header.put("alg", jwtAlgorithm); + header.put("kid", keyId); + header.put("nonce", UUID.randomUUID().toString().replace("-", "")); + header.put("typ", "JWT"); + + Map claims = new LinkedHashMap<>(); + claims.put("sub", keyId); + claims.put("iss", "cdp"); + claims.put("nbf", now.getEpochSecond()); + claims.put("exp", now.plusSeconds(120).getEpochSecond()); + claims.put("uri", method.toUpperCase() + " " + uri.getAuthority() + uri.getRawPath()); + try { + String signingInput = encodeJson(header) + "." + encodeJson(claims); + Signature signer = Signature.getInstance(signatureAlgorithm); + signer.initSign(privateKey); + signer.update(signingInput.getBytes(StandardCharsets.US_ASCII)); + byte[] signature = signer.sign(); + if ("ES256".equals(jwtAlgorithm)) { + signature = derToJose(signature, 32); + } + return signingInput + "." + B64_URL.encodeToString(signature); + } catch (GeneralSecurityException | JsonProcessingException e) { + throw new IllegalStateException("Unable to sign CDP request", e); + } + } + + private static String encodeJson(Map value) throws JsonProcessingException { + return B64_URL.encodeToString(JSON.writeValueAsBytes(value)); + } + + private static PrivateKey readEd25519(String secret) throws GeneralSecurityException { + byte[] decoded = Base64.getMimeDecoder().decode(secret); + byte[] encoded; + if (decoded.length == 32 || decoded.length == 64) { + encoded = new byte[ED25519_PKCS8_PREFIX.length + 32]; + System.arraycopy(ED25519_PKCS8_PREFIX, 0, encoded, 0, ED25519_PKCS8_PREFIX.length); + System.arraycopy(decoded, 0, encoded, ED25519_PKCS8_PREFIX.length, 32); + } else { + encoded = decoded; + } + return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(encoded)); + } + + private static PrivateKey readPem(String pem) throws IOException { + try (PEMParser parser = new PEMParser(new StringReader(pem))) { + Object parsed = parser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + if (parsed instanceof PEMKeyPair pair) { + return converter.getKeyPair(pair).getPrivate(); + } + if (parsed instanceof PrivateKeyInfo info) { + return converter.getPrivateKey(info); + } + throw new IllegalArgumentException("Unsupported PEM private key format"); + } + } + + private static byte[] derToJose(byte[] der, int coordinateLength) { + int offset = 0; + if ((der[offset++] & 0xff) != 0x30) { + throw new IllegalArgumentException("Invalid ECDSA signature"); + } + int sequenceLength = readLength(der, offset); + offset += lengthBytes(der[offset]); + if (sequenceLength > der.length - offset || (der[offset++] & 0xff) != 0x02) { + throw new IllegalArgumentException("Invalid ECDSA signature"); + } + int rLength = readLength(der, offset); + offset += lengthBytes(der[offset]); + byte[] r = java.util.Arrays.copyOfRange(der, offset, offset + rLength); + offset += rLength; + if ((der[offset++] & 0xff) != 0x02) { + throw new IllegalArgumentException("Invalid ECDSA signature"); + } + int sLength = readLength(der, offset); + offset += lengthBytes(der[offset]); + byte[] s = java.util.Arrays.copyOfRange(der, offset, offset + sLength); + byte[] jose = new byte[coordinateLength * 2]; + copyUnsigned(r, jose, 0, coordinateLength); + copyUnsigned(s, jose, coordinateLength, coordinateLength); + return jose; + } + + private static void copyUnsigned(byte[] source, byte[] target, int offset, int length) { + byte[] value = new BigInteger(1, source).toByteArray(); + int sourceOffset = value.length > length ? value.length - length : 0; + int copyLength = Math.min(value.length, length); + System.arraycopy(value, sourceOffset, target, offset + length - copyLength, copyLength); + } + + private static int readLength(byte[] value, int offset) { + int first = value[offset] & 0xff; + if (first < 128) return first; + int count = first & 0x7f; + int result = 0; + for (int i = 1; i <= count; i++) result = (result << 8) | (value[offset + i] & 0xff); + return result; + } + + private static int lengthBytes(byte first) { + int value = first & 0xff; + return value < 128 ? 1 : 1 + (value & 0x7f); + } + + private static byte[] hex(String value) { + return java.util.HexFormat.of().parseHex(value); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/common/QueryParameters.java b/src/main/java/com/coinbase/cdp/scripts/common/QueryParameters.java new file mode 100644 index 0000000..cdb5661 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/QueryParameters.java @@ -0,0 +1,38 @@ +package com.coinbase.cdp.scripts.common; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Ordered, repeatable query parameters. */ +public final class QueryParameters { + private final List entries = new ArrayList<>(); + + public QueryParameters add(String name, Object value) { + if (value != null) entries.add(new Entry(name, value.toString())); + return this; + } + + public QueryParameters addAll(String name, Iterable values) { + if (values != null) values.forEach(value -> add(name, value)); + return this; + } + + public boolean isEmpty() { + return entries.isEmpty(); + } + + @Override + public String toString() { + return entries.stream() + .map(entry -> encode(entry.name()) + "=" + encode(entry.value())) + .collect(java.util.stream.Collectors.joining("&")); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private record Entry(String name, String value) {} +} diff --git a/src/main/java/com/coinbase/cdp/scripts/depositdestinations/CreateDepositDestination.java b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/CreateDepositDestination.java new file mode 100644 index 0000000..9160729 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/CreateDepositDestination.java @@ -0,0 +1,45 @@ +package com.coinbase.cdp.scripts.depositdestinations; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import java.nio.file.Path; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create-deposit-destination", + description = "Create a crypto or fiat deposit destination.", + mixinStandardHelpOptions = true) +public final class CreateDepositDestination extends CdpCommand { + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private BodyInput input; + + @CommandLine.Option( + names = "--idempotency-key", + description = "Idempotency key. A UUID is generated when omitted.") + private String idempotencyKey; + + static final class BodyInput { + @CommandLine.Option( + names = "--body", + description = + "JSON request body, for example {\"accountId\":\"account_...\",\"type\":\"crypto\",\"crypto\":{\"network\":\"base\"},\"target\":{\"accountId\":\"account_...\",\"asset\":\"usd\"}}.") + private String body; + + @CommandLine.Option(names = "--body-file", description = "Path to a JSON request body file.") + private Path bodyFile; + } + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.post( + "/v2/deposit-destinations", + CommandSupport.readBody(input.body, input.bodyFile), + CommandSupport.idempotencyKey(idempotencyKey)); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new CreateDepositDestination()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/depositdestinations/GetDepositDestination.java b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/GetDepositDestination.java new file mode 100644 index 0000000..e64c299 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/GetDepositDestination.java @@ -0,0 +1,31 @@ +package com.coinbase.cdp.scripts.depositdestinations; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get-deposit-destination", + description = "Get a deposit destination by ID.", + mixinStandardHelpOptions = true) +public final class GetDepositDestination extends CdpCommand { + @CommandLine.Option( + names = "--deposit-destination-id", + required = true, + description = "Deposit destination ID.") + private String depositDestinationId; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get( + "/v2/deposit-destinations/" + CommandSupport.pathSegment(depositDestinationId), + new QueryParameters()); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new GetDepositDestination()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java new file mode 100644 index 0000000..80eb6c5 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java @@ -0,0 +1,48 @@ +package com.coinbase.cdp.scripts.depositdestinations; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list-deposit-destinations", + description = "List deposit destinations.", + mixinStandardHelpOptions = true) +public final class ListDepositDestinations extends CdpCommand { + @CommandLine.Option(names = "--account-id", description = "Filter by account ID.") + private String accountId; + + @CommandLine.Option(names = "--address", description = "Filter by cryptocurrency address.") + private String address; + + @CommandLine.Option(names = "--type", description = "Filter by type, crypto or fiat.") + private String type; + + @CommandLine.Option(names = "--network", description = "Filter by blockchain network.") + private String network; + + @CommandLine.Option(names = "--page-size", description = "Number of results to return.") + private Integer pageSize; + + @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") + private String pageToken; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get( + "/v2/deposit-destinations", + new QueryParameters() + .add("accountId", accountId) + .add("address", address) + .add("type", type) + .add("network", network) + .add("pageSize", pageSize) + .add("pageToken", pageToken)); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new ListDepositDestinations()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/paymentmethods/GetPaymentMethod.java b/src/main/java/com/coinbase/cdp/scripts/paymentmethods/GetPaymentMethod.java new file mode 100644 index 0000000..35e7a8b --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/paymentmethods/GetPaymentMethod.java @@ -0,0 +1,34 @@ +package com.coinbase.cdp.scripts.paymentmethods; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.QueryParameters; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get-payment-method", + description = "Get a payment method by ID.", + mixinStandardHelpOptions = true) +public final class GetPaymentMethod extends CdpCommand { + @CommandLine.Option( + names = "--payment-method-id", + required = true, + description = "Payment method ID.") + private String paymentMethodId; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get("/v2/payment-methods/" + segment(paymentMethodId), new QueryParameters()); + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new GetPaymentMethod()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java b/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java new file mode 100644 index 0000000..1609244 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java @@ -0,0 +1,30 @@ +package com.coinbase.cdp.scripts.paymentmethods; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list-payment-methods", + description = "List linked payment methods.", + mixinStandardHelpOptions = true) +public final class ListPaymentMethods extends CdpCommand { + @CommandLine.Option(names = "--page-size", description = "Maximum results to return.") + private Integer pageSize; + + @CommandLine.Option(names = "--page-token", description = "Pagination token.") + private String pageToken; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + QueryParameters query = + new QueryParameters().add("pageSize", pageSize).add("pageToken", pageToken); + return client.get("/v2/payment-methods", query); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new ListPaymentMethods()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/transfers/CreateTransfer.java b/src/main/java/com/coinbase/cdp/scripts/transfers/CreateTransfer.java new file mode 100644 index 0000000..faac516 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/transfers/CreateTransfer.java @@ -0,0 +1,41 @@ +package com.coinbase.cdp.scripts.transfers; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import java.nio.file.Path; +import picocli.CommandLine; + +@CommandLine.Command( + name = "create-transfer", + description = "Create a transfer.", + mixinStandardHelpOptions = true) +public final class CreateTransfer extends CdpCommand { + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private RequestBody requestBody; + + @CommandLine.Option( + names = "--idempotency-key", + description = "Idempotency key. A UUID is generated when omitted.") + private String idempotencyKey; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + String key = CommandSupport.idempotencyKey(idempotencyKey); + return client.post( + "/v2/transfers", CommandSupport.readBody(requestBody.body, requestBody.bodyFile), key); + } + + private static final class RequestBody { + @CommandLine.Option(names = "--body", description = "Inline JSON request body.") + private String body; + + @CommandLine.Option(names = "--body-file", description = "Path to a JSON request body.") + private Path bodyFile; + } + + public static void main(String[] args) { + System.exit(new CommandLine(new CreateTransfer()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/transfers/ExecuteTransfer.java b/src/main/java/com/coinbase/cdp/scripts/transfers/ExecuteTransfer.java new file mode 100644 index 0000000..0f39906 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/transfers/ExecuteTransfer.java @@ -0,0 +1,37 @@ +package com.coinbase.cdp.scripts.transfers; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import picocli.CommandLine; + +@CommandLine.Command( + name = "execute-transfer", + description = "Execute a quoted transfer.", + mixinStandardHelpOptions = true) +public final class ExecuteTransfer extends CdpCommand { + @CommandLine.Option(names = "--transfer-id", required = true, description = "Transfer ID.") + private String transferId; + + @CommandLine.Option( + names = "--idempotency-key", + description = "Idempotency key. A UUID is generated when omitted.") + private String idempotencyKey; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + String key = CommandSupport.idempotencyKey(idempotencyKey); + return client.post("/v2/transfers/" + segment(transferId) + "/execute", null, key); + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new ExecuteTransfer()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/transfers/GetTransfer.java b/src/main/java/com/coinbase/cdp/scripts/transfers/GetTransfer.java new file mode 100644 index 0000000..87c6383 --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/transfers/GetTransfer.java @@ -0,0 +1,31 @@ +package com.coinbase.cdp.scripts.transfers; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.QueryParameters; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import picocli.CommandLine; + +@CommandLine.Command( + name = "get-transfer", + description = "Get a transfer by ID.", + mixinStandardHelpOptions = true) +public final class GetTransfer extends CdpCommand { + @CommandLine.Option(names = "--transfer-id", required = true, description = "Transfer ID.") + private String transferId; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + return client.get("/v2/transfers/" + segment(transferId), new QueryParameters()); + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new GetTransfer()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java b/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java new file mode 100644 index 0000000..63e4faf --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java @@ -0,0 +1,92 @@ +package com.coinbase.cdp.scripts.transfers; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.QueryParameters; +import picocli.CommandLine; + +@CommandLine.Command( + name = "list-transfers", + description = "List transfers, optionally filtered.", + mixinStandardHelpOptions = true) +public final class ListTransfers extends CdpCommand { + @CommandLine.Option(names = "--status", description = "Transfer status.") + private String status; + + @CommandLine.Option(names = "--account-id", description = "Source or target account ID.") + private String accountId; + + @CommandLine.Option(names = "--source-account-id", description = "Source account ID.") + private String sourceAccountId; + + @CommandLine.Option(names = "--target-account-id", description = "Target account ID.") + private String targetAccountId; + + @CommandLine.Option(names = "--created-after", description = "Created at or after, ISO 8601.") + private String createdAfter; + + @CommandLine.Option(names = "--created-before", description = "Created at or before, ISO 8601.") + private String createdBefore; + + @CommandLine.Option(names = "--updated-after", description = "Updated at or after, ISO 8601.") + private String updatedAfter; + + @CommandLine.Option(names = "--updated-before", description = "Updated at or before, ISO 8601.") + private String updatedBefore; + + @CommandLine.Option(names = "--source-asset", description = "Source asset symbol.") + private String sourceAsset; + + @CommandLine.Option(names = "--target-asset", description = "Target asset symbol.") + private String targetAsset; + + @CommandLine.Option(names = "--source-address", description = "Onchain source address.") + private String sourceAddress; + + @CommandLine.Option(names = "--target-address", description = "Onchain target address.") + private String targetAddress; + + @CommandLine.Option(names = "--target-email", description = "Target recipient email.") + private String targetEmail; + + @CommandLine.Option(names = "--transfer-id", description = "Exact transfer ID.") + private String transferId; + + @CommandLine.Option(names = "--page-size", description = "Maximum results to return.") + private Integer pageSize; + + @CommandLine.Option(names = "--page-token", description = "Pagination token.") + private String pageToken; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + if (accountId != null && (sourceAccountId != null || targetAccountId != null)) { + throw new IllegalArgumentException( + "--account-id cannot be combined with --source-account-id or --target-account-id"); + } + QueryParameters query = + new QueryParameters() + .add("status", status) + .add("accountId", accountId) + .add("sourceAccountId", sourceAccountId) + .add("targetAccountId", targetAccountId) + .add("createdAfter", createdAfter) + .add("createdBefore", createdBefore) + .add("updatedAfter", updatedAfter) + .add("updatedBefore", updatedBefore) + .add("sourceAsset", sourceAsset) + .add("targetAsset", targetAsset) + .add("sourceAddress", sourceAddress) + .add("targetAddress", targetAddress) + .add("targetEmail", targetEmail) + .add("transferId", transferId) + .add("pageSize", pageSize) + .add("pageToken", pageToken); + return client.get("/v2/transfers", query); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new ListTransfers()).execute(args)); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/transfers/SubmitTravelRule.java b/src/main/java/com/coinbase/cdp/scripts/transfers/SubmitTravelRule.java new file mode 100644 index 0000000..4c6a66d --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/transfers/SubmitTravelRule.java @@ -0,0 +1,52 @@ +package com.coinbase.cdp.scripts.transfers; + +import com.coinbase.cdp.scripts.common.ApiResponse; +import com.coinbase.cdp.scripts.common.CdpClient; +import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.CommandSupport; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import picocli.CommandLine; + +@CommandLine.Command( + name = "submit-travel-rule", + description = "Submit travel rule information for a deposit transfer.", + mixinStandardHelpOptions = true) +public final class SubmitTravelRule extends CdpCommand { + @CommandLine.Option(names = "--transfer-id", required = true, description = "Transfer ID.") + private String transferId; + + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private RequestBody requestBody; + + @CommandLine.Option( + names = "--idempotency-key", + description = "Idempotency key. A UUID is generated when omitted.") + private String idempotencyKey; + + @Override + protected ApiResponse execute(CdpClient client) throws Exception { + String key = CommandSupport.idempotencyKey(idempotencyKey); + return client.post( + "/v2/transfers/" + segment(transferId) + "/travel-rule", + CommandSupport.readBody(requestBody.body, requestBody.bodyFile), + key); + } + + private static final class RequestBody { + @CommandLine.Option(names = "--body", description = "Inline JSON request body.") + private String body; + + @CommandLine.Option(names = "--body-file", description = "Path to a JSON request body.") + private Path bodyFile; + } + + private static String segment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + public static void main(String[] args) { + System.exit(new CommandLine(new SubmitTravelRule()).execute(args)); + } +} diff --git a/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java b/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java new file mode 100644 index 0000000..5d8e5ce --- /dev/null +++ b/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java @@ -0,0 +1,56 @@ +package com.coinbase.cdp.scripts; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.coinbase.cdp.scripts.accounts.CreateAccount; +import com.coinbase.cdp.scripts.accounts.GetAccount; +import com.coinbase.cdp.scripts.accounts.GetBalance; +import com.coinbase.cdp.scripts.accounts.ListAccounts; +import com.coinbase.cdp.scripts.accounts.ListBalances; +import com.coinbase.cdp.scripts.depositdestinations.CreateDepositDestination; +import com.coinbase.cdp.scripts.depositdestinations.GetDepositDestination; +import com.coinbase.cdp.scripts.depositdestinations.ListDepositDestinations; +import com.coinbase.cdp.scripts.paymentmethods.GetPaymentMethod; +import com.coinbase.cdp.scripts.paymentmethods.ListPaymentMethods; +import com.coinbase.cdp.scripts.transfers.CreateTransfer; +import com.coinbase.cdp.scripts.transfers.ExecuteTransfer; +import com.coinbase.cdp.scripts.transfers.GetTransfer; +import com.coinbase.cdp.scripts.transfers.ListTransfers; +import com.coinbase.cdp.scripts.transfers.SubmitTravelRule; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +class ScriptInventoryTest { + @Test + void allFifteenScriptsExposeHelpWithoutCredentials() throws Exception { + List> scripts = + List.of( + ListAccounts.class, + CreateAccount.class, + GetAccount.class, + ListBalances.class, + GetBalance.class, + ListDepositDestinations.class, + CreateDepositDestination.class, + GetDepositDestination.class, + CreateTransfer.class, + ListTransfers.class, + GetTransfer.class, + ExecuteTransfer.class, + SubmitTravelRule.class, + ListPaymentMethods.class, + GetPaymentMethod.class); + + assertEquals(15, scripts.size()); + for (Class script : scripts) { + StringWriter output = new StringWriter(); + CommandLine command = new CommandLine(script.getDeclaredConstructor().newInstance()); + command.setOut(new PrintWriter(output)); + assertEquals(0, command.execute("--help"), script.getSimpleName()); + org.junit.jupiter.api.Assertions.assertTrue(output.toString().contains("Usage:")); + } + } +} diff --git a/src/test/java/com/coinbase/cdp/scripts/common/CdpClientTest.java b/src/test/java/com/coinbase/cdp/scripts/common/CdpClientTest.java new file mode 100644 index 0000000..c89adea --- /dev/null +++ b/src/test/java/com/coinbase/cdp/scripts/common/CdpClientTest.java @@ -0,0 +1,97 @@ +package com.coinbase.cdp.scripts.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CdpClientTest { + private HttpServer server; + private final AtomicReference captured = new AtomicReference<>(); + + @BeforeEach + void startServer() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/platform/v2/transfers", this::handle); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void sendsAuthenticatedPostWithBodyAndIdempotencyKey() throws Exception { + ApiResponse response = client().post("/v2/transfers", "{\"amount\":\"5.00\"}", "request-key"); + + assertEquals(201, response.statusCode()); + assertEquals("POST", captured.get().method()); + assertEquals("/platform/v2/transfers", captured.get().uri().getPath()); + assertEquals("{\"amount\":\"5.00\"}", captured.get().body()); + assertEquals("request-key", captured.get().idempotencyKey()); + assertTrue(captured.get().authorization().startsWith("Bearer ")); + assertEquals("application/json", captured.get().contentType()); + } + + @Test + void encodesGetQueryWithoutSendingBody() throws Exception { + ApiResponse response = + client() + .get( + "/v2/transfers", + new QueryParameters().add("pageSize", 20).add("targetEmail", "a+b@example.com")); + + assertEquals(201, response.statusCode()); + assertEquals("GET", captured.get().method()); + assertEquals("pageSize=20&targetEmail=a%2Bb%40example.com", captured.get().uri().getRawQuery()); + assertEquals("", captured.get().body()); + } + + private CdpClient client() throws Exception { + String secret = + Base64.getEncoder() + .encodeToString( + KeyPairGenerator.getInstance("Ed25519") + .generateKeyPair() + .getPrivate() + .getEncoded()); + URI baseUri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/platform"); + return new CdpClient(new CdpConfig("test-key", secret, baseUri), HttpClient.newHttpClient()); + } + + private void handle(HttpExchange exchange) throws java.io.IOException { + captured.set( + new CapturedRequest( + exchange.getRequestMethod(), + exchange.getRequestURI(), + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("Content-Type"), + exchange.getRequestHeaders().getFirst("X-Idempotency-Key"))); + byte[] response = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(201, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + private record CapturedRequest( + String method, + URI uri, + String body, + String authorization, + String contentType, + String idempotencyKey) {} +} diff --git a/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java b/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java new file mode 100644 index 0000000..9676fed --- /dev/null +++ b/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java @@ -0,0 +1,59 @@ +package com.coinbase.cdp.scripts.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CdpConfigTest { + @TempDir Path tempDir; + + @Test + void defaultsToSandbox() { + CdpConfig config = + CdpConfig.load(Map.of("CDP_KEY_ID", "key", "CDP_KEY_SECRET", validDummySecret())); + assertEquals("https://sandbox.cdp.coinbase.com/platform", config.baseUri().toString()); + } + + @Test + void selectsProductionAndCustomBaseUrl() { + Map values = baseEnvironment(); + values.put("CDP_ENVIRONMENT", "production"); + assertEquals( + "https://api.cdp.coinbase.com/platform", CdpConfig.load(values).baseUri().toString()); + values.put("CDP_API_BASE_URL", "http://localhost:8080/test"); + assertEquals("http://localhost:8080/test", CdpConfig.load(values).baseUri().toString()); + } + + @Test + void keyFileTakesPrecedence() throws Exception { + Path file = tempDir.resolve("key.json"); + Files.writeString(file, "{\"name\":\"file-key\",\"privateKey\":\"file-secret\"}"); + Map values = baseEnvironment(); + values.put("CDP_API_KEY_FILE", file.toString()); + CdpConfig config = CdpConfig.load(values); + assertEquals("file-key", config.keyId()); + assertEquals("file-secret", config.keySecret()); + } + + @Test + void rejectsUnknownEnvironmentAndMissingCredentials() { + Map values = baseEnvironment(); + values.put("CDP_ENVIRONMENT", "staging"); + assertThrows(IllegalArgumentException.class, () -> CdpConfig.load(values)); + assertThrows(IllegalArgumentException.class, () -> CdpConfig.load(Map.of())); + } + + private Map baseEnvironment() { + return new HashMap<>(Map.of("CDP_KEY_ID", "env-key", "CDP_KEY_SECRET", validDummySecret())); + } + + private String validDummySecret() { + return java.util.Base64.getEncoder().encodeToString(new byte[32]); + } +} diff --git a/src/test/java/com/coinbase/cdp/scripts/common/CommandSupportTest.java b/src/test/java/com/coinbase/cdp/scripts/common/CommandSupportTest.java new file mode 100644 index 0000000..c4465db --- /dev/null +++ b/src/test/java/com/coinbase/cdp/scripts/common/CommandSupportTest.java @@ -0,0 +1,64 @@ +package com.coinbase.cdp.scripts.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class CommandSupportTest { + @TempDir Path tempDir; + + @Test + void readsAndValidatesBodySources() throws Exception { + Path file = tempDir.resolve("body.json"); + Files.writeString(file, "{\"from\":\"file\"}"); + assertEquals("{\"from\":\"file\"}", CommandSupport.readBody(null, file)); + assertEquals("[]", CommandSupport.readBody("[]", null)); + assertThrows(IllegalArgumentException.class, () -> CommandSupport.readBody("{}", file)); + assertThrows(IllegalArgumentException.class, () -> CommandSupport.readBody("not-json", null)); + } + + @Test + void generatedIdempotencyKeyIsPrinted() { + PrintStream original = System.err; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + try { + System.setErr(new PrintStream(captured)); + String key = CommandSupport.idempotencyKey(null); + UUID.fromString(key); + assertTrue(captured.toString().contains(key)); + } finally { + System.setErr(original); + } + assertEquals("provided", CommandSupport.idempotencyKey("provided")); + } + + @Test + void encodesOrderedRepeatedQueryValues() { + QueryParameters query = + new QueryParameters() + .add("page size", 10) + .addAll("asset", java.util.List.of("USDC", "a/b")); + assertEquals("page%20size=10&asset=USDC&asset=a%2Fb", query.toString()); + } + + @Test + void prettyPrintsJsonAndPreservesText() { + assertTrue(CommandSupport.prettyJson("{\"a\":1}").contains("\n")); + assertEquals("plain text", CommandSupport.prettyJson("plain text")); + } + + @Test + void encodesPathSegments() { + assertEquals("account%2Fwith%20space", CommandSupport.pathSegment("account/with space")); + assertEquals("%E2%82%AC", CommandSupport.pathSegment("€")); + assertThrows(IllegalArgumentException.class, () -> CommandSupport.pathSegment(" ")); + } +} diff --git a/src/test/java/com/coinbase/cdp/scripts/common/JwtSignerTest.java b/src/test/java/com/coinbase/cdp/scripts/common/JwtSignerTest.java new file mode 100644 index 0000000..45233e0 --- /dev/null +++ b/src/test/java/com/coinbase/cdp/scripts/common/JwtSignerTest.java @@ -0,0 +1,86 @@ +package com.coinbase.cdp.scripts.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.crypto.ECDSAVerifier; +import com.nimbusds.jwt.SignedJWT; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Signature; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class JwtSignerTest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @Test + void signsEd25519ClaimsAndExcludesQuery() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519"); + KeyPair pair = generator.generateKeyPair(); + String secret = Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()); + Instant now = Instant.parse("2026-07-22T12:00:00Z"); + JwtSigner signer = + new JwtSigner("organizations/o/apiKeys/k", secret, Clock.fixed(now, ZoneOffset.UTC)); + + String jwt = signer.sign("get", URI.create("https://api.example/platform/v2/accounts?limit=2")); + String[] parts = jwt.split("\\."); + JsonNode header = decode(parts[0]); + JsonNode claims = decode(parts[1]); + assertEquals("EdDSA", header.path("alg").asText()); + assertEquals("organizations/o/apiKeys/k", header.path("kid").asText()); + assertEquals(32, header.path("nonce").asText().length()); + assertFalse(header.path("nonce").asText().contains("-")); + assertEquals("cdp", claims.path("iss").asText()); + assertEquals(now.getEpochSecond(), claims.path("nbf").asLong()); + assertEquals(now.plusSeconds(120).getEpochSecond(), claims.path("exp").asLong()); + assertEquals("GET api.example/platform/v2/accounts", claims.path("uri").asText()); + + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(pair.getPublic()); + verifier.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII)); + assertTrue(verifier.verify(Base64.getUrlDecoder().decode(parts[2]))); + } + + @Test + void eachTokenHasUniqueNonce() throws Exception { + KeyPair pair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + JwtSigner signer = + new JwtSigner("key", Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded())); + URI uri = URI.create("https://api.example/path"); + String first = decode(signer.sign("GET", uri).split("\\.")[0]).path("nonce").asText(); + String second = decode(signer.sign("GET", uri).split("\\.")[0]).path("nonce").asText(); + assertFalse(first.equals(second)); + } + + @Test + void signsEs256PemKeysInJoseFormat() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair pair = generator.generateKeyPair(); + String pem = + "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[] {'\n'}) + .encodeToString(pair.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----"; + + String token = new JwtSigner("key", pem).sign("POST", URI.create("https://api.example/path")); + SignedJWT jwt = SignedJWT.parse(token); + assertEquals("ES256", jwt.getHeader().getAlgorithm().getName()); + assertTrue(jwt.verify(new ECDSAVerifier((ECPublicKey) pair.getPublic()))); + } + + private JsonNode decode(String value) throws Exception { + return JSON.readTree(Base64.getUrlDecoder().decode(value)); + } +} From 83dd1a7e844a20ed1575ba37a1c9d3e0028f571b Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Mon, 3 Aug 2026 16:48:49 -0400 Subject: [PATCH 2/5] fix: accept standard CDP key file id Co-authored-by: Pi --- .../cdp/scripts/common/CdpConfig.java | 17 +++++++++------- .../cdp/scripts/common/CdpConfigTest.java | 20 ++++++++++++------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java b/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java index cdf691e..0a67cf4 100644 --- a/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java +++ b/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java @@ -41,8 +41,8 @@ public static CdpConfig load(Map environment) { if (keyFile != null) { try { JsonNode credentials = JSON.readTree(Files.readString(Path.of(keyFile))); - keyId = requiredText(credentials, "name", keyFile); - keySecret = requiredText(credentials, "privateKey", keyFile); + keyId = requiredText(credentials, keyFile, "id", "name"); + keySecret = requiredText(credentials, keyFile, "privateKey"); } catch (IOException | RuntimeException e) { throw new IllegalArgumentException("Unable to read CDP API key file " + keyFile, e); } @@ -69,12 +69,15 @@ public static CdpConfig load(Map environment) { return new CdpConfig(keyId, keySecret, baseUri); } - private static String requiredText(JsonNode node, String field, String file) { - String value = node.path(field).asText(null); - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("CDP API key file " + file + " is missing " + field); + private static String requiredText(JsonNode node, String file, String... fields) { + for (String field : fields) { + String value = node.path(field).asText(null); + if (value != null && !value.isBlank()) { + return value; + } } - return value; + throw new IllegalArgumentException( + "CDP API key file " + file + " is missing " + String.join(" or ", fields)); } private static String trimToNull(String value) { diff --git a/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java b/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java index 9676fed..bbdeb00 100644 --- a/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java +++ b/src/test/java/com/coinbase/cdp/scripts/common/CdpConfigTest.java @@ -31,14 +31,20 @@ void selectsProductionAndCustomBaseUrl() { } @Test - void keyFileTakesPrecedence() throws Exception { - Path file = tempDir.resolve("key.json"); - Files.writeString(file, "{\"name\":\"file-key\",\"privateKey\":\"file-secret\"}"); + void keyFileAcceptsStandardIdAndLegacyName() throws Exception { Map values = baseEnvironment(); - values.put("CDP_API_KEY_FILE", file.toString()); - CdpConfig config = CdpConfig.load(values); - assertEquals("file-key", config.keyId()); - assertEquals("file-secret", config.keySecret()); + Path standardFile = tempDir.resolve("standard-key.json"); + Files.writeString( + standardFile, "{\"id\":\"standard-file-key\",\"privateKey\":\"file-secret\"}"); + values.put("CDP_API_KEY_FILE", standardFile.toString()); + CdpConfig standardConfig = CdpConfig.load(values); + assertEquals("standard-file-key", standardConfig.keyId()); + assertEquals("file-secret", standardConfig.keySecret()); + + Path legacyFile = tempDir.resolve("legacy-key.json"); + Files.writeString(legacyFile, "{\"name\":\"legacy-file-key\",\"privateKey\":\"file-secret\"}"); + values.put("CDP_API_KEY_FILE", legacyFile.toString()); + assertEquals("legacy-file-key", CdpConfig.load(values).keyId()); } @Test From 1a28dd58db21ea72945849cd97c1ec21bbe989cf Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Mon, 3 Aug 2026 16:54:38 -0400 Subject: [PATCH 3/5] docs: add runnable endpoint examples Co-authored-by: Pi --- README.md | 2 + examples/README.md | 73 +++++++++++++++++++ .../cdp/scripts/ScriptInventoryTest.java | 54 +++++++++----- 3 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 examples/README.md diff --git a/README.md b/README.md index 6597201..2cde8e8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Mutating scripts accept `--body '{...}'` or `--body-file path`, never both. Elig Every non-2xx response returns a nonzero exit code. JSON responses are formatted on stdout. Diagnostics and generated idempotency keys go to stderr. +For a runnable Sandbox command for every account, deposit-destination, transfer, and payment-method endpoint, see [`examples/README.md`](examples/README.md). + ## Endpoint scripts | API | Script | Method and path | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a4fe6d2 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,73 @@ +# Runnable Sandbox examples + +These are runnable examples for every Java entrypoint under `src/main/java/com/coinbase/cdp/scripts/`. Configure `CDP_API_KEY_FILE` as described in the repository README, then run each command from the repository root. The default endpoint is CDP Sandbox. + +Replace placeholder IDs with values returned by the corresponding create or list command. Request-body templates live in [`requests/`](requests/). + +## Accounts + +```bash +# GET /v2/accounts +make run SCRIPT=accounts.ListAccounts + +# POST /v2/accounts +make run SCRIPT=accounts.CreateAccount \ + ARGS='--body-file examples/requests/create-account.json' + +# GET /v2/accounts/{accountId} +make run SCRIPT=accounts.GetAccount ARGS='--account-id account_REPLACE_ME' + +# GET /v2/accounts/{accountId}/balances +make run SCRIPT=accounts.ListBalances ARGS='--account-id account_REPLACE_ME' + +# GET /v2/accounts/{accountId}/balances/{asset} +make run SCRIPT=accounts.GetBalance \ + ARGS='--account-id account_REPLACE_ME --asset USDC' +``` + +## Deposit destinations + +```bash +# GET /v2/deposit-destinations +make run SCRIPT=depositdestinations.ListDepositDestinations + +# POST /v2/deposit-destinations +make run SCRIPT=depositdestinations.CreateDepositDestination \ + ARGS='--body-file examples/requests/create-deposit-destination.json' + +# GET /v2/deposit-destinations/{depositDestinationId} +make run SCRIPT=depositdestinations.GetDepositDestination \ + ARGS='--deposit-destination-id deposit_destination_REPLACE_ME' +``` + +## Transfers + +```bash +# POST /v2/transfers +make run SCRIPT=transfers.CreateTransfer \ + ARGS='--body-file examples/requests/create-transfer.json' + +# GET /v2/transfers +make run SCRIPT=transfers.ListTransfers + +# GET /v2/transfers/{transferId} +make run SCRIPT=transfers.GetTransfer ARGS='--transfer-id transfer_REPLACE_ME' + +# POST /v2/transfers/{transferId}/execute +make run SCRIPT=transfers.ExecuteTransfer ARGS='--transfer-id transfer_REPLACE_ME' + +# POST /v2/transfers/{transferId}/travel-rule +make run SCRIPT=transfers.SubmitTravelRule \ + ARGS='--transfer-id transfer_REPLACE_ME --body-file examples/requests/submit-travel-rule.json' +``` + +## Payment methods + +```bash +# GET /v2/payment-methods +make run SCRIPT=paymentmethods.ListPaymentMethods + +# GET /v2/payment-methods/{paymentMethodId} +make run SCRIPT=paymentmethods.GetPaymentMethod \ + ARGS='--payment-method-id payment_method_REPLACE_ME' +``` diff --git a/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java b/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java index 5d8e5ce..bdf179f 100644 --- a/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java +++ b/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java @@ -17,8 +17,11 @@ import com.coinbase.cdp.scripts.transfers.GetTransfer; import com.coinbase.cdp.scripts.transfers.ListTransfers; import com.coinbase.cdp.scripts.transfers.SubmitTravelRule; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; import picocli.CommandLine; @@ -26,23 +29,7 @@ class ScriptInventoryTest { @Test void allFifteenScriptsExposeHelpWithoutCredentials() throws Exception { - List> scripts = - List.of( - ListAccounts.class, - CreateAccount.class, - GetAccount.class, - ListBalances.class, - GetBalance.class, - ListDepositDestinations.class, - CreateDepositDestination.class, - GetDepositDestination.class, - CreateTransfer.class, - ListTransfers.class, - GetTransfer.class, - ExecuteTransfer.class, - SubmitTravelRule.class, - ListPaymentMethods.class, - GetPaymentMethod.class); + List> scripts = scripts(); assertEquals(15, scripts.size()); for (Class script : scripts) { @@ -53,4 +40,37 @@ void allFifteenScriptsExposeHelpWithoutCredentials() throws Exception { org.junit.jupiter.api.Assertions.assertTrue(output.toString().contains("Usage:")); } } + + @Test + void everyScriptHasARunnableSandboxExample() throws IOException { + String examples = Files.readString(Path.of("examples", "README.md")); + for (Class script : scripts()) { + String scriptName = + script.getPackageName().replace("com.coinbase.cdp.scripts.", "") + + "." + + script.getSimpleName(); + org.junit.jupiter.api.Assertions.assertTrue( + examples.contains("SCRIPT=" + scriptName), + () -> "Missing runnable example for " + scriptName); + } + } + + private static List> scripts() { + return List.of( + ListAccounts.class, + CreateAccount.class, + GetAccount.class, + ListBalances.class, + GetBalance.class, + ListDepositDestinations.class, + CreateDepositDestination.class, + GetDepositDestination.class, + CreateTransfer.class, + ListTransfers.class, + GetTransfer.class, + ExecuteTransfer.class, + SubmitTravelRule.class, + ListPaymentMethods.class, + GetPaymentMethod.class); + } } From 4b3a9a4827230d01a0856964daf9b97e6f6d6e16 Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Tue, 4 Aug 2026 10:08:14 -0400 Subject: [PATCH 4/5] feat: add executable endpoint examples Co-authored-by: Pi --- examples/README.md | 31 +++++++++++++ examples/scripts/accounts/create-account.sh | 9 ++++ examples/scripts/accounts/get-account.sh | 10 +++++ examples/scripts/accounts/get-balance.sh | 10 +++++ examples/scripts/accounts/list-accounts.sh | 6 +++ examples/scripts/accounts/list-balances.sh | 10 +++++ .../create-deposit-destination.sh | 9 ++++ .../get-deposit-destination.sh | 10 +++++ .../list-deposit-destinations.sh | 6 +++ .../payment-methods/get-payment-method.sh | 10 +++++ .../payment-methods/list-payment-methods.sh | 6 +++ examples/scripts/transfers/create-transfer.sh | 9 ++++ .../scripts/transfers/execute-transfer.sh | 10 +++++ examples/scripts/transfers/get-transfer.sh | 10 +++++ examples/scripts/transfers/list-transfers.sh | 6 +++ .../scripts/transfers/submit-travel-rule.sh | 10 +++++ .../cdp/scripts/ScriptInventoryTest.java | 45 +++++++++++++++---- 17 files changed, 198 insertions(+), 9 deletions(-) create mode 100755 examples/scripts/accounts/create-account.sh create mode 100755 examples/scripts/accounts/get-account.sh create mode 100755 examples/scripts/accounts/get-balance.sh create mode 100755 examples/scripts/accounts/list-accounts.sh create mode 100755 examples/scripts/accounts/list-balances.sh create mode 100755 examples/scripts/deposit-destinations/create-deposit-destination.sh create mode 100755 examples/scripts/deposit-destinations/get-deposit-destination.sh create mode 100755 examples/scripts/deposit-destinations/list-deposit-destinations.sh create mode 100755 examples/scripts/payment-methods/get-payment-method.sh create mode 100755 examples/scripts/payment-methods/list-payment-methods.sh create mode 100755 examples/scripts/transfers/create-transfer.sh create mode 100755 examples/scripts/transfers/execute-transfer.sh create mode 100755 examples/scripts/transfers/get-transfer.sh create mode 100755 examples/scripts/transfers/list-transfers.sh create mode 100755 examples/scripts/transfers/submit-travel-rule.sh diff --git a/examples/README.md b/examples/README.md index a4fe6d2..c7e5e46 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,37 @@ These are runnable examples for every Java entrypoint under `src/main/java/com/c Replace placeholder IDs with values returned by the corresponding create or list command. Request-body templates live in [`requests/`](requests/). +## Executable example scripts + +Each endpoint also has an executable wrapper under [`scripts/`](scripts/). Set `CDP_API_KEY_FILE` and run the applicable file; wrappers default to CDP Sandbox and forward additional command options. Scripts that need a resource ID read it from the named environment variable. + +```bash +# Accounts +./examples/scripts/accounts/list-accounts.sh +./examples/scripts/accounts/create-account.sh +ACCOUNT_ID=account_REPLACE_ME ./examples/scripts/accounts/get-account.sh +ACCOUNT_ID=account_REPLACE_ME ./examples/scripts/accounts/list-balances.sh +ACCOUNT_ID=account_REPLACE_ME ASSET=USDC ./examples/scripts/accounts/get-balance.sh + +# Deposit destinations +./examples/scripts/deposit-destinations/list-deposit-destinations.sh +./examples/scripts/deposit-destinations/create-deposit-destination.sh +DEPOSIT_DESTINATION_ID=deposit_destination_REPLACE_ME \ + ./examples/scripts/deposit-destinations/get-deposit-destination.sh + +# Transfers +./examples/scripts/transfers/create-transfer.sh +./examples/scripts/transfers/list-transfers.sh +TRANSFER_ID=transfer_REPLACE_ME ./examples/scripts/transfers/get-transfer.sh +TRANSFER_ID=transfer_REPLACE_ME ./examples/scripts/transfers/execute-transfer.sh +TRANSFER_ID=transfer_REPLACE_ME ./examples/scripts/transfers/submit-travel-rule.sh + +# Payment methods +./examples/scripts/payment-methods/list-payment-methods.sh +PAYMENT_METHOD_ID=payment_method_REPLACE_ME \ + ./examples/scripts/payment-methods/get-payment-method.sh +``` + ## Accounts ```bash diff --git a/examples/scripts/accounts/create-account.sh b/examples/scripts/accounts/create-account.sh new file mode 100755 index 0000000..76b8d07 --- /dev/null +++ b/examples/scripts/accounts/create-account.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +if (( $# == 0 )); then + set -- --body-file examples/requests/create-account.json +fi +exec make run SCRIPT=accounts.CreateAccount ARGS="$*" diff --git a/examples/scripts/accounts/get-account.sh b/examples/scripts/accounts/get-account.sh new file mode 100755 index 0000000..b591a62 --- /dev/null +++ b/examples/scripts/accounts/get-account.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${ACCOUNT_ID:?Set ACCOUNT_ID before running this example}" +if (( $# == 0 )); then + set -- --account-id "$ACCOUNT_ID" +fi +exec make run SCRIPT=accounts.GetAccount ARGS="$*" diff --git a/examples/scripts/accounts/get-balance.sh b/examples/scripts/accounts/get-balance.sh new file mode 100755 index 0000000..733eeab --- /dev/null +++ b/examples/scripts/accounts/get-balance.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${ACCOUNT_ID:?Set ACCOUNT_ID before running this example}" +if (( $# == 0 )); then + set -- --account-id "$ACCOUNT_ID" --asset "${ASSET:-USDC}" +fi +exec make run SCRIPT=accounts.GetBalance ARGS="$*" diff --git a/examples/scripts/accounts/list-accounts.sh b/examples/scripts/accounts/list-accounts.sh new file mode 100755 index 0000000..69761c6 --- /dev/null +++ b/examples/scripts/accounts/list-accounts.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +exec make run SCRIPT=accounts.ListAccounts ARGS="$*" diff --git a/examples/scripts/accounts/list-balances.sh b/examples/scripts/accounts/list-balances.sh new file mode 100755 index 0000000..706ecba --- /dev/null +++ b/examples/scripts/accounts/list-balances.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${ACCOUNT_ID:?Set ACCOUNT_ID before running this example}" +if (( $# == 0 )); then + set -- --account-id "$ACCOUNT_ID" +fi +exec make run SCRIPT=accounts.ListBalances ARGS="$*" diff --git a/examples/scripts/deposit-destinations/create-deposit-destination.sh b/examples/scripts/deposit-destinations/create-deposit-destination.sh new file mode 100755 index 0000000..1978778 --- /dev/null +++ b/examples/scripts/deposit-destinations/create-deposit-destination.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +if (( $# == 0 )); then + set -- --body-file examples/requests/create-deposit-destination.json +fi +exec make run SCRIPT=depositdestinations.CreateDepositDestination ARGS="$*" diff --git a/examples/scripts/deposit-destinations/get-deposit-destination.sh b/examples/scripts/deposit-destinations/get-deposit-destination.sh new file mode 100755 index 0000000..00b96dc --- /dev/null +++ b/examples/scripts/deposit-destinations/get-deposit-destination.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${DEPOSIT_DESTINATION_ID:?Set DEPOSIT_DESTINATION_ID before running this example}" +if (( $# == 0 )); then + set -- --deposit-destination-id "$DEPOSIT_DESTINATION_ID" +fi +exec make run SCRIPT=depositdestinations.GetDepositDestination ARGS="$*" diff --git a/examples/scripts/deposit-destinations/list-deposit-destinations.sh b/examples/scripts/deposit-destinations/list-deposit-destinations.sh new file mode 100755 index 0000000..c73d7c2 --- /dev/null +++ b/examples/scripts/deposit-destinations/list-deposit-destinations.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +exec make run SCRIPT=depositdestinations.ListDepositDestinations ARGS="$*" diff --git a/examples/scripts/payment-methods/get-payment-method.sh b/examples/scripts/payment-methods/get-payment-method.sh new file mode 100755 index 0000000..21a1aa3 --- /dev/null +++ b/examples/scripts/payment-methods/get-payment-method.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${PAYMENT_METHOD_ID:?Set PAYMENT_METHOD_ID before running this example}" +if (( $# == 0 )); then + set -- --payment-method-id "$PAYMENT_METHOD_ID" +fi +exec make run SCRIPT=paymentmethods.GetPaymentMethod ARGS="$*" diff --git a/examples/scripts/payment-methods/list-payment-methods.sh b/examples/scripts/payment-methods/list-payment-methods.sh new file mode 100755 index 0000000..7719548 --- /dev/null +++ b/examples/scripts/payment-methods/list-payment-methods.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +exec make run SCRIPT=paymentmethods.ListPaymentMethods ARGS="$*" diff --git a/examples/scripts/transfers/create-transfer.sh b/examples/scripts/transfers/create-transfer.sh new file mode 100755 index 0000000..11efb9a --- /dev/null +++ b/examples/scripts/transfers/create-transfer.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +if (( $# == 0 )); then + set -- --body-file examples/requests/create-transfer.json +fi +exec make run SCRIPT=transfers.CreateTransfer ARGS="$*" diff --git a/examples/scripts/transfers/execute-transfer.sh b/examples/scripts/transfers/execute-transfer.sh new file mode 100755 index 0000000..f43b320 --- /dev/null +++ b/examples/scripts/transfers/execute-transfer.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${TRANSFER_ID:?Set TRANSFER_ID before running this example}" +if (( $# == 0 )); then + set -- --transfer-id "$TRANSFER_ID" +fi +exec make run SCRIPT=transfers.ExecuteTransfer ARGS="$*" diff --git a/examples/scripts/transfers/get-transfer.sh b/examples/scripts/transfers/get-transfer.sh new file mode 100755 index 0000000..ff2a703 --- /dev/null +++ b/examples/scripts/transfers/get-transfer.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${TRANSFER_ID:?Set TRANSFER_ID before running this example}" +if (( $# == 0 )); then + set -- --transfer-id "$TRANSFER_ID" +fi +exec make run SCRIPT=transfers.GetTransfer ARGS="$*" diff --git a/examples/scripts/transfers/list-transfers.sh b/examples/scripts/transfers/list-transfers.sh new file mode 100755 index 0000000..5b6bf49 --- /dev/null +++ b/examples/scripts/transfers/list-transfers.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +exec make run SCRIPT=transfers.ListTransfers ARGS="$*" diff --git a/examples/scripts/transfers/submit-travel-rule.sh b/examples/scripts/transfers/submit-travel-rule.sh new file mode 100755 index 0000000..eef197a --- /dev/null +++ b/examples/scripts/transfers/submit-travel-rule.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$repo_root" +: "${TRANSFER_ID:?Set TRANSFER_ID before running this example}" +if (( $# == 0 )); then + set -- --transfer-id "$TRANSFER_ID" --body-file examples/requests/submit-travel-rule.json +fi +exec make run SCRIPT=transfers.SubmitTravelRule ARGS="$*" diff --git a/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java b/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java index bdf179f..8183566 100644 --- a/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java +++ b/src/test/java/com/coinbase/cdp/scripts/ScriptInventoryTest.java @@ -42,16 +42,18 @@ void allFifteenScriptsExposeHelpWithoutCredentials() throws Exception { } @Test - void everyScriptHasARunnableSandboxExample() throws IOException { - String examples = Files.readString(Path.of("examples", "README.md")); - for (Class script : scripts()) { - String scriptName = - script.getPackageName().replace("com.coinbase.cdp.scripts.", "") - + "." - + script.getSimpleName(); + void everyScriptHasAnExecutableSandboxExample() throws IOException { + List> scripts = scripts(); + List examples = exampleScripts(); + assertEquals(scripts.size(), examples.size()); + for (int index = 0; index < scripts.size(); index++) { + Class script = scripts.get(index); + Path example = examples.get(index); + org.junit.jupiter.api.Assertions.assertTrue( + Files.isExecutable(example), () -> "Missing executable example " + example); org.junit.jupiter.api.Assertions.assertTrue( - examples.contains("SCRIPT=" + scriptName), - () -> "Missing runnable example for " + scriptName); + Files.readString(example).contains("SCRIPT=" + scriptName(script)), + () -> "Example does not run " + scriptName(script)); } } @@ -73,4 +75,29 @@ private static List> scripts() { ListPaymentMethods.class, GetPaymentMethod.class); } + + private static List exampleScripts() { + return List.of( + Path.of("examples/scripts/accounts/list-accounts.sh"), + Path.of("examples/scripts/accounts/create-account.sh"), + Path.of("examples/scripts/accounts/get-account.sh"), + Path.of("examples/scripts/accounts/list-balances.sh"), + Path.of("examples/scripts/accounts/get-balance.sh"), + Path.of("examples/scripts/deposit-destinations/list-deposit-destinations.sh"), + Path.of("examples/scripts/deposit-destinations/create-deposit-destination.sh"), + Path.of("examples/scripts/deposit-destinations/get-deposit-destination.sh"), + Path.of("examples/scripts/transfers/create-transfer.sh"), + Path.of("examples/scripts/transfers/list-transfers.sh"), + Path.of("examples/scripts/transfers/get-transfer.sh"), + Path.of("examples/scripts/transfers/execute-transfer.sh"), + Path.of("examples/scripts/transfers/submit-travel-rule.sh"), + Path.of("examples/scripts/payment-methods/list-payment-methods.sh"), + Path.of("examples/scripts/payment-methods/get-payment-method.sh")); + } + + private static String scriptName(Class script) { + return script.getPackageName().replace("com.coinbase.cdp.scripts.", "") + + "." + + script.getSimpleName(); + } } From d47b28edfe16c75d53f9b3027651c3a66e802eba Mon Sep 17 00:00:00 2001 From: Nick Morgan Date: Tue, 4 Aug 2026 10:38:22 -0400 Subject: [PATCH 5/5] refactor: reuse validation and pagination helpers Co-authored-by: Pi --- pom.xml | 6 +++++ .../cdp/scripts/accounts/ListAccounts.java | 13 +++-------- .../cdp/scripts/accounts/ListBalances.java | 9 +++----- .../cdp/scripts/common/CdpConfig.java | 22 +++++++------------ .../cdp/scripts/common/PaginationOptions.java | 16 ++++++++++++++ .../ListDepositDestinations.java | 14 +++++------- .../paymentmethods/ListPaymentMethods.java | 10 +++------ .../cdp/scripts/transfers/ListTransfers.java | 14 +++++------- .../scripts/common/PaginationOptionsTest.java | 17 ++++++++++++++ 9 files changed, 66 insertions(+), 55 deletions(-) create mode 100644 src/main/java/com/coinbase/cdp/scripts/common/PaginationOptions.java create mode 100644 src/test/java/com/coinbase/cdp/scripts/common/PaginationOptionsTest.java diff --git a/pom.xml b/pom.xml index 63db9b0..3e2f5cc 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,7 @@ UTF-8 2.18.3 5.11.4 + 3.17.0 @@ -26,6 +27,11 @@ jackson-databind ${jackson.version} + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + com.nimbusds nimbus-jose-jwt diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java b/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java index f5c6b68..062be2e 100644 --- a/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/ListAccounts.java @@ -3,6 +3,7 @@ import com.coinbase.cdp.scripts.common.ApiResponse; import com.coinbase.cdp.scripts.common.CdpClient; import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.PaginationOptions; import com.coinbase.cdp.scripts.common.QueryParameters; import java.util.List; import picocli.CommandLine; @@ -12,11 +13,7 @@ description = "List accounts accessible to the API key.", mixinStandardHelpOptions = true) public final class ListAccounts extends CdpCommand { - @CommandLine.Option(names = "--page-size", description = "Number of results to return.") - private Integer pageSize; - - @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") - private String pageToken; + @CommandLine.Mixin private PaginationOptions pagination = new PaginationOptions(); @CommandLine.Option( names = "--owner", @@ -32,11 +29,7 @@ protected ApiResponse execute(CdpClient client) throws Exception { String owner = owners == null ? null : String.join(",", owners); return client.get( "/v2/accounts", - new QueryParameters() - .add("pageSize", pageSize) - .add("pageToken", pageToken) - .add("owner", owner) - .add("type", type)); + pagination.applyTo(new QueryParameters()).add("owner", owner).add("type", type)); } public static void main(String[] args) { diff --git a/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java b/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java index de7c56b..a719197 100644 --- a/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java +++ b/src/main/java/com/coinbase/cdp/scripts/accounts/ListBalances.java @@ -4,6 +4,7 @@ import com.coinbase.cdp.scripts.common.CdpClient; import com.coinbase.cdp.scripts.common.CdpCommand; import com.coinbase.cdp.scripts.common.CommandSupport; +import com.coinbase.cdp.scripts.common.PaginationOptions; import com.coinbase.cdp.scripts.common.QueryParameters; import picocli.CommandLine; @@ -15,17 +16,13 @@ public final class ListBalances extends CdpCommand { @CommandLine.Option(names = "--account-id", required = true, description = "Account ID.") private String accountId; - @CommandLine.Option(names = "--page-size", description = "Number of results to return.") - private Integer pageSize; - - @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") - private String pageToken; + @CommandLine.Mixin private PaginationOptions pagination = new PaginationOptions(); @Override protected ApiResponse execute(CdpClient client) throws Exception { return client.get( "/v2/accounts/" + CommandSupport.pathSegment(accountId) + "/balances", - new QueryParameters().add("pageSize", pageSize).add("pageToken", pageToken)); + pagination.applyTo(new QueryParameters())); } public static void main(String[] args) { diff --git a/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java b/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java index 0a67cf4..5f7bfdb 100644 --- a/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java +++ b/src/main/java/com/coinbase/cdp/scripts/common/CdpConfig.java @@ -7,6 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import org.apache.commons.lang3.StringUtils; /** Authentication and endpoint configuration loaded from the process environment. */ public record CdpConfig(String keyId, String keySecret, URI baseUri) { @@ -15,10 +16,10 @@ public record CdpConfig(String keyId, String keySecret, URI baseUri) { private static final URI PRODUCTION = URI.create("https://api.cdp.coinbase.com/platform"); public CdpConfig { - if (keyId == null || keyId.isBlank()) { + if (StringUtils.isBlank(keyId)) { throw new IllegalArgumentException("Missing CDP API key ID"); } - if (keySecret == null || keySecret.isBlank()) { + if (StringUtils.isBlank(keySecret)) { throw new IllegalArgumentException("Missing CDP API key secret"); } if (baseUri == null || !baseUri.isAbsolute() || baseUri.getHost() == null) { @@ -35,9 +36,9 @@ public static CdpConfig load() { } public static CdpConfig load(Map environment) { - String keyId = trimToNull(environment.get("CDP_KEY_ID")); - String keySecret = trimToNull(environment.get("CDP_KEY_SECRET")); - String keyFile = trimToNull(environment.get("CDP_API_KEY_FILE")); + String keyId = StringUtils.trimToNull(environment.get("CDP_KEY_ID")); + String keySecret = StringUtils.trimToNull(environment.get("CDP_KEY_SECRET")); + String keyFile = StringUtils.trimToNull(environment.get("CDP_API_KEY_FILE")); if (keyFile != null) { try { JsonNode credentials = JSON.readTree(Files.readString(Path.of(keyFile))); @@ -48,7 +49,7 @@ public static CdpConfig load(Map environment) { } } - String override = trimToNull(environment.get("CDP_API_BASE_URL")); + String override = StringUtils.trimToNull(environment.get("CDP_API_BASE_URL")); URI baseUri; if (override != null) { try { @@ -72,18 +73,11 @@ public static CdpConfig load(Map environment) { private static String requiredText(JsonNode node, String file, String... fields) { for (String field : fields) { String value = node.path(field).asText(null); - if (value != null && !value.isBlank()) { + if (StringUtils.isNotBlank(value)) { return value; } } throw new IllegalArgumentException( "CDP API key file " + file + " is missing " + String.join(" or ", fields)); } - - private static String trimToNull(String value) { - if (value == null || value.isBlank()) { - return null; - } - return value.trim(); - } } diff --git a/src/main/java/com/coinbase/cdp/scripts/common/PaginationOptions.java b/src/main/java/com/coinbase/cdp/scripts/common/PaginationOptions.java new file mode 100644 index 0000000..bb3b41f --- /dev/null +++ b/src/main/java/com/coinbase/cdp/scripts/common/PaginationOptions.java @@ -0,0 +1,16 @@ +package com.coinbase.cdp.scripts.common; + +import picocli.CommandLine; + +/** Shared pagination options for list commands. */ +public final class PaginationOptions { + @CommandLine.Option(names = "--page-size", description = "Number of results to return.") + private Integer pageSize; + + @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") + private String pageToken; + + public QueryParameters applyTo(QueryParameters query) { + return query.add("pageSize", pageSize).add("pageToken", pageToken); + } +} diff --git a/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java index 80eb6c5..f014842 100644 --- a/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java +++ b/src/main/java/com/coinbase/cdp/scripts/depositdestinations/ListDepositDestinations.java @@ -3,6 +3,7 @@ import com.coinbase.cdp.scripts.common.ApiResponse; import com.coinbase.cdp.scripts.common.CdpClient; import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.PaginationOptions; import com.coinbase.cdp.scripts.common.QueryParameters; import picocli.CommandLine; @@ -23,23 +24,18 @@ public final class ListDepositDestinations extends CdpCommand { @CommandLine.Option(names = "--network", description = "Filter by blockchain network.") private String network; - @CommandLine.Option(names = "--page-size", description = "Number of results to return.") - private Integer pageSize; - - @CommandLine.Option(names = "--page-token", description = "Token for the next results page.") - private String pageToken; + @CommandLine.Mixin private PaginationOptions pagination = new PaginationOptions(); @Override protected ApiResponse execute(CdpClient client) throws Exception { return client.get( "/v2/deposit-destinations", - new QueryParameters() + pagination + .applyTo(new QueryParameters()) .add("accountId", accountId) .add("address", address) .add("type", type) - .add("network", network) - .add("pageSize", pageSize) - .add("pageToken", pageToken)); + .add("network", network)); } public static void main(String[] args) { diff --git a/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java b/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java index 1609244..f3421d7 100644 --- a/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java +++ b/src/main/java/com/coinbase/cdp/scripts/paymentmethods/ListPaymentMethods.java @@ -3,6 +3,7 @@ import com.coinbase.cdp.scripts.common.ApiResponse; import com.coinbase.cdp.scripts.common.CdpClient; import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.PaginationOptions; import com.coinbase.cdp.scripts.common.QueryParameters; import picocli.CommandLine; @@ -11,16 +12,11 @@ description = "List linked payment methods.", mixinStandardHelpOptions = true) public final class ListPaymentMethods extends CdpCommand { - @CommandLine.Option(names = "--page-size", description = "Maximum results to return.") - private Integer pageSize; - - @CommandLine.Option(names = "--page-token", description = "Pagination token.") - private String pageToken; + @CommandLine.Mixin private PaginationOptions pagination = new PaginationOptions(); @Override protected ApiResponse execute(CdpClient client) throws Exception { - QueryParameters query = - new QueryParameters().add("pageSize", pageSize).add("pageToken", pageToken); + QueryParameters query = pagination.applyTo(new QueryParameters()); return client.get("/v2/payment-methods", query); } diff --git a/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java b/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java index 63e4faf..824e2d8 100644 --- a/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java +++ b/src/main/java/com/coinbase/cdp/scripts/transfers/ListTransfers.java @@ -3,6 +3,7 @@ import com.coinbase.cdp.scripts.common.ApiResponse; import com.coinbase.cdp.scripts.common.CdpClient; import com.coinbase.cdp.scripts.common.CdpCommand; +import com.coinbase.cdp.scripts.common.PaginationOptions; import com.coinbase.cdp.scripts.common.QueryParameters; import picocli.CommandLine; @@ -53,11 +54,7 @@ public final class ListTransfers extends CdpCommand { @CommandLine.Option(names = "--transfer-id", description = "Exact transfer ID.") private String transferId; - @CommandLine.Option(names = "--page-size", description = "Maximum results to return.") - private Integer pageSize; - - @CommandLine.Option(names = "--page-token", description = "Pagination token.") - private String pageToken; + @CommandLine.Mixin private PaginationOptions pagination = new PaginationOptions(); @Override protected ApiResponse execute(CdpClient client) throws Exception { @@ -66,7 +63,8 @@ protected ApiResponse execute(CdpClient client) throws Exception { "--account-id cannot be combined with --source-account-id or --target-account-id"); } QueryParameters query = - new QueryParameters() + pagination + .applyTo(new QueryParameters()) .add("status", status) .add("accountId", accountId) .add("sourceAccountId", sourceAccountId) @@ -80,9 +78,7 @@ protected ApiResponse execute(CdpClient client) throws Exception { .add("sourceAddress", sourceAddress) .add("targetAddress", targetAddress) .add("targetEmail", targetEmail) - .add("transferId", transferId) - .add("pageSize", pageSize) - .add("pageToken", pageToken); + .add("transferId", transferId); return client.get("/v2/transfers", query); } diff --git a/src/test/java/com/coinbase/cdp/scripts/common/PaginationOptionsTest.java b/src/test/java/com/coinbase/cdp/scripts/common/PaginationOptionsTest.java new file mode 100644 index 0000000..2ffd7fe --- /dev/null +++ b/src/test/java/com/coinbase/cdp/scripts/common/PaginationOptionsTest.java @@ -0,0 +1,17 @@ +package com.coinbase.cdp.scripts.common; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +class PaginationOptionsTest { + @Test + void addsParsedPaginationFieldsToAQuery() { + PaginationOptions pagination = new PaginationOptions(); + new CommandLine(pagination).parseArgs("--page-size", "25", "--page-token", "next"); + + assertEquals( + "pageSize=25&pageToken=next", pagination.applyTo(new QueryParameters()).toString()); + } +}