From 034af450862b832bf62db188faf90105215e199f Mon Sep 17 00:00:00 2001 From: koekiebox Date: Mon, 12 Oct 2026 14:40:31 +0200 Subject: [PATCH 1/3] feat(jav-15): Updated OpenPayments client to match v1.4 spec. --- README.md | 31 ++++ .../InterledgerApiClientHttpImpl.java | 7 + .../resources/test-config.properties | 21 ++- .../IOpenPaymentsGrantClient.java | 70 ++++++++ .../IOpenPaymentsPaymentClient.java | 13 ++ .../httpclient/OpenPaymentsHttpClient.java | 163 ++++++++++++++++- .../openpayments/mapper/ClientSerializer.java | 50 ++++-- .../openpayments/model/grant/AccessItem.java | 31 +++- .../openpayments/model/grant/Client.java | 134 +++++++++++--- .../openpayments/model/grant/Grant.java | 14 ++ .../model/grant/GrantRequest.java | 59 +++++++ .../openpayments/model/grant/Limits.java | 76 ++++++-- .../outgoingpayment/OutgoingPayment.java | 39 +++- .../OpenPaymentsHttpClientTest.java | 166 +++++++++++++++++- .../openpayments/util/testing/TestModels.java | 34 ++++ 15 files changed, 828 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 51dffbe..c364885 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,37 @@ Maven dependency (Please see [Maven Central](https://central.sonatype.com/artifa ``` +### Open Payments specification support + +The SDK implements [Open Payments specification v1.4.0](https://github.com/interledger/open-payments-specifications/releases/tag/v1.4.0), including: + +- Spent amounts for the current outgoing payment grant (`GET /outgoing-payment-grant`) +- Wallet address ownership verification using a `subject` grant +- Outgoing payment grants with `interval` limits (future and recurring payments) +- Card (EMV) authorization on outgoing payment grants +- Client identification using the `walletAddress` object form (default) or a directed identity `jwk` + +```java +var client = OpenPaymentsHttpClient.defaultClient(CLIENT_WALLET_ADDRESS, PRIVATE_KEY_PEM, KEY_ID); +var userWallet = client.walletAddress().get(WALLET_ADDRESS); + +// Request an outgoing payment grant for future payments: 100.00 CAD per month, 3 times. +var limits = Limits.debit(Amount.build(new BigDecimal("100.00"), "CAD"), "R3/2025-05-20T13:00:00Z/P1M"); +var pendingPayment = client.auth().grant().continuation(userWallet, limits, RETURN_URL, NONCE); +// ...redirect the user to pendingPayment.getInteract().getRedirect(), then: +var outgoingPaymentGrant = client.auth().grant().finalize(pendingPayment, INTERACT_REF); + +// Get the spent amounts for the current outgoing payment grant (current interval for recurring grants). +var spent = client.payment().getGrantSpentAmounts(userWallet, outgoingPaymentGrant); +System.out.println(spent.getSpentDebitAmount()); + +// Verify that the user has access to their wallet address. +var pendingVerification = client.auth().grant().verifyOwnership(userWallet, RETURN_URL, NONCE); +// ...redirect the user to pendingVerification.getInteract().getRedirect(), then: +var verified = client.auth().grant().finalize(pendingVerification, INTERACT_REF); +System.out.println(verified.getSubject().getSubIds().getFirst().getId()); +``` + Gradle commands: ```sh # Clean build (Linux) diff --git a/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java b/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java index 885e591..dcb5045 100644 --- a/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java +++ b/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java @@ -491,6 +491,13 @@ public void openPaymentsCreateOutgoingFromIncomingPayment() throws InterruptedEx assertNotNull(createdPayment_2Of2); assertFalse(createdPayment_2Of2.getFailed()); log.info("Payment (2 of 2) executed successfully: " + createdPayment_2Of2.getId()); + + // STEP 6C: Get the spent amounts for the outgoing payment grant. + // Amounts are null until payments have been processed. + var spentAmounts = this.client.payment().getGrantSpentAmounts(senderWallet, finalized); + assertNotNull(spentAmounts); + log.info("Grant spent amounts: debit=" + spentAmounts.getSpentDebitAmount() + + ", receive=" + spentAmounts.getSpentReceiveAmount()); } else { log.info("********"); log.error("Cannot execute payment - grant finalization failed: " + opContinueInteract.getInteract().getRedirect()); diff --git a/src/integrationTest/resources/test-config.properties b/src/integrationTest/resources/test-config.properties index 41af883..b3d654b 100644 --- a/src/integrationTest/resources/test-config.properties +++ b/src/integrationTest/resources/test-config.properties @@ -1,24 +1,29 @@ -# OpenPayments Integration Test Template +# OpenPayments Integration Test Template +# # This file contains configuration value examples for testing the OpenPayments API client. -# +# # IMPORTANT: Never commit the actual test-config.properties file to version control # as it contains sensitive information like private keys. + # ============================================================================= # CLIENT WALLET CONFIGURATION # ============================================================================= # This should be a valid Interledger wallet URL that can initiate payments -client.wallet.address=https://wallet-address/client +#TODO client.wallet.address=https://ilp.interledger-test.dev/fliqa-initiator +client.wallet.address=https://ilp.interledger-test.dev/56e321b2 # The private key for the client wallet in PEM format # This key is used to cryptographically sign requests to the Interledger network # Format: PEM-encoded Ed25519 private key -client.private.key=-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY----- +#TODO client.private.key=-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY----- +client.private.key=-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIBAEk4HmYYUaZKBtkrvkH8cukn+pVGBtK7vsGWGpxqg9\n-----END PRIVATE KEY----- # The key ID associated with the private key # This is used by the Interledger network to identify which public key to use for verification # Format: UUID string -client.key.id=164df9a0-46de-4f26-bde7-b0876fe30e9e +#TODO client.key.id=164df9a0-46de-4f26-bde7-b0876fe30e9e +client.key.id=ed76e05a-d831-423f-ac2d-5b0a643cb4c2 # ============================================================================= # TEST WALLET ADDRESSES @@ -26,11 +31,13 @@ client.key.id=164df9a0-46de-4f26-bde7-b0876fe30e9e # Test wallet address for the payment sender # This represents the wallet that will send money in test scenarios -sender.wallet.address=https://wallet-address/client/sender +#TODO sender.wallet.address=https://ilp.interledger-test.dev/fliqa-sender +sender.wallet.address=https://ilp.interledger-test.dev/56e321b2 # Test wallet address for the payment receiver # This represents the wallet that will receive money in test scenarios -receiver.wallet.address=https://wallet-address/client/receiver +#TODO receiver.wallet.address=https://ilp.interledger-test.dev/fliqa-receiver +receiver.wallet.address=https://ilp.interledger-test.dev/140d27e0 # ============================================================================= # ADDITIONAL CONFIGURATION diff --git a/src/main/java/org/interledger/openpayments/IOpenPaymentsGrantClient.java b/src/main/java/org/interledger/openpayments/IOpenPaymentsGrantClient.java index 27186c2..63c4bd8 100644 --- a/src/main/java/org/interledger/openpayments/IOpenPaymentsGrantClient.java +++ b/src/main/java/org/interledger/openpayments/IOpenPaymentsGrantClient.java @@ -2,7 +2,10 @@ import org.interledger.openpayments.model.common.Amount; import org.interledger.openpayments.model.grant.AccessAction; +import org.interledger.openpayments.model.grant.CardAuthorization; import org.interledger.openpayments.model.grant.Grant; +import org.interledger.openpayments.model.grant.Limits; +import org.interledger.openpayments.model.grant.PendingGrant; import org.interledger.openpayments.model.outgoingpayment.OutgoingPayment; import org.interledger.openpayments.model.walletaddress.WalletAddress; @@ -68,6 +71,73 @@ OutgoingPayment continuation( String nonce ); + /** + * Requests an interactive outgoing payment grant with the given limits. + * + *

Use this to request a grant for future (recurring) payments by setting an + * {@code interval}, or to limit by {@code receiveAmount} or {@code receiver}, e.g.: + *

+     * var limits = Limits.debit(Amount.build(new BigDecimal("100.00"), "CAD"), "R3/2025-05-20T13:00:00Z/P1M");
+     * var pending = client.auth().grant().continuation(sender, limits, returnUrl, nonce);
+     * 
+ * + * @param sender the wallet address of the payment sender + * @param limits the limits under which outgoing payments can be created, or null for none + * @param returnUrl the URI to which the user will be redirected after the interaction + * @param nonce a unique value used to secure the interaction callback + * @return an OutgoingPayment containing the interaction and continuation details + */ + OutgoingPayment continuation( + WalletAddress sender, + Limits limits, + URI returnUrl, + String nonce + ); + + /** + * Requests an interactive grant to verify that the user has access to the given wallet address. + * + *

No access token for payments is requested. Redirect the user to + * {@code pendingGrant.getInteract().getRedirect()}, then call + * {@link #finalize(PendingGrant, String)} with the {@code interact_ref} received on the + * return URL. The resulting {@link Grant#getSubject()} confirms the verified wallet address. + * + * @param walletAddress the wallet address the user must prove access to + * @param returnUrl the URI to which the user will be redirected after the interaction + * @param nonce a unique value used to secure the interaction callback + * @return the pending grant, containing interaction and continuation details + */ + PendingGrant verifyOwnership(WalletAddress walletAddress, URI returnUrl, String nonce); + + /** + * Requests a non-interactive outgoing payment grant that authorizes a card (EMV) payment + * (Open Payments v1.4.0+). + * + *

The authorization result is returned as {@link CardAuthorization} data on the + * outgoing payment access item of the granted access token. + * + * @param sender the wallet address of the card holder (payment sender) + * @param receiver the incoming payment URI the payment will be made to + * @param receiveAmount the amount to be received + * @param cardAuthorization the card credentials + * @return the grant containing the access token and the card authorization result + */ + Grant cardAuthorization( + WalletAddress sender, + URI receiver, + Amount receiveAmount, + CardAuthorization cardAuthorization + ); + + /** + * Continues a pending grant after the user has completed the interaction. + * + * @param pendingGrant the pending grant returned by the initial grant request + * @param interactRef the interaction reference received on the return URL + * @return the grant, containing an access token and/or subject information + */ + Grant finalize(PendingGrant pendingGrant, String interactRef); + /** * Finalizes the process for creating a grant associated with an outgoing payment. * This method completes the authorization flow for an outgoing payment by diff --git a/src/main/java/org/interledger/openpayments/IOpenPaymentsPaymentClient.java b/src/main/java/org/interledger/openpayments/IOpenPaymentsPaymentClient.java index ac877e7..623c4c3 100644 --- a/src/main/java/org/interledger/openpayments/IOpenPaymentsPaymentClient.java +++ b/src/main/java/org/interledger/openpayments/IOpenPaymentsPaymentClient.java @@ -6,6 +6,7 @@ import org.interledger.openpayments.model.incomingpayment.IncomingPayment; import org.interledger.openpayments.model.incomingpayment.IncomingPaymentsResult; import org.interledger.openpayments.model.outgoingpayment.OutgoingPayment; +import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentGrantSpentAmounts; import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentsResult; import org.interledger.openpayments.model.quote.Quote; import org.interledger.openpayments.model.walletaddress.WalletAddress; @@ -140,4 +141,16 @@ OutgoingPaymentsResult getOutgoingPayments( URI walletAddressUri, Grant grantRequest ); + + /** + * Retrieves the spent amounts for the current outgoing payment grant. + * + *

The grant is identified by its access token. For grants created with an + * {@code interval}, the amounts are for the current interval only. + * + * @param resourceServer the resource server of the sender, e.g. the sender's {@code WalletAddress} + * @param grant the outgoing payment grant (after a successful grant continuation) + * @return the spent debit and receive amounts; each is {@code null} if nothing has been spent yet + */ + OutgoingPaymentGrantSpentAmounts getGrantSpentAmounts(IResourceServer resourceServer, Grant grant); } diff --git a/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java b/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java index 2f83419..a45f317 100644 --- a/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java +++ b/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java @@ -14,6 +14,7 @@ import org.interledger.openpayments.model.incomingpayment.IncomingPaymentsResult; import org.interledger.openpayments.model.key.Ed25519Keys; import org.interledger.openpayments.model.outgoingpayment.OutgoingPayment; +import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentGrantSpentAmounts; import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentRequest; import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentsResult; import org.interledger.openpayments.model.payment.PaymentRequest; @@ -501,6 +502,31 @@ public OutgoingPaymentsResult getOutgoingPayments( return this.baseClient.send(request, OutgoingPaymentsResult.class); } + + /** + * Retrieves the spent amounts for the outgoing payment grant identified by the grant's access token. + * + * @param resourceServer The resource server of the sender. Must not be null. + * @param grant The outgoing payment grant. Must not be null. + * @return the spent debit and receive amounts for the current grant (or current interval). + * @throws IllegalArgumentException If the resourceServer or grant parameter is null. + */ + @Override + public OutgoingPaymentGrantSpentAmounts getGrantSpentAmounts(IResourceServer resourceServer, Grant grant) { + log.debug("getGrantSpentAmounts: {}", resourceServer); + UtilGlobal.ensureNotNull(resourceServer, "ResourceServer cannot be null"); + UtilGlobal.ensureNotNullOrEmpty(resourceServer.getResourceServer(), "ResourceServer cannot be empty"); + UtilGlobal.ensureNotNull(grant, "Grant cannot be null"); + + var request = new SignatureRequestBuilder(this.baseClient.privateKey, this.baseClient.keyId, this.baseClient.mapper) + .GET() + .target(this.baseClient.buildResourceUrl( + UtilGlobal.toUri(resourceServer.getResourceServer()), ResourcePath.OUTGOING_PAYMENT_GRANT)) + .accessToken(this.baseClient.extractAccessToken(grant)) + .getRequest(this.baseClient.options); + + return this.baseClient.send(request, OutgoingPaymentGrantSpentAmounts.class); + } } /** @@ -592,16 +618,39 @@ public OutgoingPayment continuation( ) { log.debug("grantContinuation: ({},{},{})", sender, debitAmount, returnUrl); - ensureNotNull(sender, "PaymentPointer sender cannot be null"); ensureNotNull(debitAmount, "DebitAmount cannot be null"); + return continuation(sender, Limits.debit(debitAmount), returnUrl, nonce); + } + + /** + * Requests an interactive outgoing payment grant with the given limits, e.g. a debit + * amount per {@code interval} for future payments. + * + * @param sender The wallet address of the entity initiating the payment. Must not be null. + * @param limits The limits under which outgoing payments can be created, or null for none. + * @param returnUrl The URL to which the user will be redirected after processing the grant. Must not be null. + * @param nonce A unique identifier to prevent replay attacks. Must not be null or empty. + * @return OutgoingPayment object containing the interaction and continuation details. + * @throws IllegalArgumentException if any of the required input parameters are null or invalid. + */ + @Override + public OutgoingPayment continuation( + WalletAddress sender, + Limits limits, + URI returnUrl, + String nonce + ) { + log.debug("grantContinuation: ({},{},{})", sender, limits, returnUrl); + + ensureNotNull(sender, "PaymentPointer sender cannot be null"); ensureNotNull(returnUrl, "Return URL cannot be null"); ensureNotNullOrEmpty(nonce, "Nonce cannot be null or empty"); var accessRequest = GrantRequest.outgoing( this.baseClient.clientWallet, - AccessItemType.outgoingPayment, Set.of(AccessAction.read, AccessAction.create), - sender.getIdAsURI(), debitAmount + sender.getIdAsURI(), + limits ).redirectInteract(returnUrl, nonce); var request = new SignatureRequestBuilder(this.baseClient.privateKey, this.baseClient.keyId, this.baseClient.mapper) @@ -612,6 +661,108 @@ public OutgoingPayment continuation( return this.baseClient.send(request, OutgoingPayment.class); } + /** + * Requests an interactive grant containing only a {@code subject}, to verify that the + * user has access to the given wallet address. + * + * @param walletAddress The wallet address the user must prove access to. Must not be null. + * @param returnUrl The URL to which the user will be redirected after the interaction. Must not be null. + * @param nonce A unique identifier to prevent replay attacks. Must not be null or empty. + * @return the pending grant, containing interaction and continuation details. + * @throws IllegalArgumentException if any of the input parameters are null or invalid. + */ + @Override + public PendingGrant verifyOwnership(WalletAddress walletAddress, URI returnUrl, String nonce) { + log.debug("grantVerifyOwnership: ({},{})", walletAddress, returnUrl); + + ensureNotNull(walletAddress, "WalletAddress cannot be null"); + ensureNotNullOrEmpty(walletAddress.getAuthServer(), "AuthServer cannot be empty"); + ensureNotNull(returnUrl, "Return URL cannot be null"); + ensureNotNullOrEmpty(nonce, "Nonce cannot be null or empty"); + + var subjectRequest = GrantRequest.subject( + this.baseClient.clientWallet, + Subject.walletAddress(walletAddress.getIdAsURI()) + ).redirectInteract(returnUrl, nonce); + + var request = new SignatureRequestBuilder(this.baseClient.privateKey, this.baseClient.keyId, this.baseClient.mapper) + .POST(subjectRequest) + .target(walletAddress.getAuthServer()) + .getRequest(this.baseClient.options); + + return this.baseClient.send(request, PendingGrant.class); + } + + /** + * Requests a non-interactive outgoing payment grant authorizing a card (EMV) payment. + * + * @param sender The card holder's wallet address. Must not be null. + * @param receiver The incoming payment URI the payment will be made to. Must not be null. + * @param receiveAmount The amount to be received. Must not be null. + * @param cardAuthorization The card credentials. Must not be null. + * @return the grant containing the access token and the card authorization result. + * @throws IllegalArgumentException if any of the input parameters are null or invalid. + */ + @Override + public Grant cardAuthorization( + WalletAddress sender, + URI receiver, + Amount receiveAmount, + CardAuthorization cardAuthorization + ) { + log.debug("grantCardAuthorization: ({},{},{})", sender, receiver, receiveAmount); + + ensureNotNull(sender, "WalletAddress sender cannot be null"); + ensureNotNullOrEmpty(sender.getAuthServer(), "AuthServer cannot be empty"); + ensureNotNull(receiver, "Receiver cannot be null"); + ensureNotNull(receiveAmount, "ReceiveAmount cannot be null"); + ensureNotNull(cardAuthorization, "CardAuthorization cannot be null"); + + var limits = Limits.receive(receiveAmount); + limits.setReceiver(receiver); + + var accessRequest = GrantRequest.outgoing( + this.baseClient.clientWallet, + Set.of(AccessAction.create, AccessAction.read), + sender.getIdAsURI(), + limits + ); + accessRequest.getAccessToken().getAccess().forEach(item -> item.setCardAuthorization(cardAuthorization)); + + var request = new SignatureRequestBuilder(this.baseClient.privateKey, this.baseClient.keyId, this.baseClient.mapper) + .POST(accessRequest) + .target(sender.getAuthServer()) + .getRequest(this.baseClient.options); + + return this.baseClient.send(request, Grant.class); + } + + /** + * Continues a pending grant after the user has completed the interaction. + * + * @param pendingGrant The pending grant returned by the initial grant request. Must not be null. + * @param interactRef The interaction reference received on the return URL. Must not be null or empty. + * @return the grant, containing an access token and/or subject information. + * @throws IllegalArgumentException If any of the parameters are null or invalid. + */ + @Override + public Grant finalize(PendingGrant pendingGrant, String interactRef) { + log.debug("grantFinalize: ({},{})", pendingGrant, interactRef); + + ensureNotNull(pendingGrant, "PendingGrant cannot be null"); + ensureNotNull(pendingGrant.getGrantContinue(), "PendingGrant.continue cannot be null"); + ensureNotNull(pendingGrant.getGrantContinue().getAccess(), "PendingGrant.continue.access_token cannot be null"); + ensureNotNullOrEmpty(interactRef, "Interact reference cannot be null or empty"); + + var request = new SignatureRequestBuilder(this.baseClient.privateKey, this.baseClient.keyId, this.baseClient.mapper) + .POST(InteractRef.build(interactRef)) + .target(pendingGrant.getGrantContinue().getUri()) + .accessToken(pendingGrant.getGrantContinue().getAccess().getToken()) + .getRequest(this.baseClient.options); + + return this.baseClient.send(request, Grant.class); + } + /** * Finalizes the creation of a grant by sending a signature request. * This method prepares and dispatches a request to finalize a grant using the provided outgoing payment and interaction reference. @@ -798,6 +949,7 @@ public IOpenPaymentsPaymentClient payment() { private static class ResourcePath { public static final String INCOMING_PAYMENTS = "/incoming-payments"; public static final String OUTGOING_PAYMENTS = "/outgoing-payments"; + public static final String OUTGOING_PAYMENT_GRANT = "/outgoing-payment-grant"; public static final String QUOTES = "/quotes"; } @@ -956,8 +1108,9 @@ private URI buildResourceUrl(URI baseUri, String path) { ensureNotNull(baseUri, "Base URI cannot be null"); ensureNotNull(path, "Path cannot be null"); var normalizedPath = path.startsWith("/") ? path : "/" + path; - var resolved = baseUri.toString().concat(normalizedPath); - return URI.create(resolved); + var base = baseUri.toString(); + if (base.endsWith("/")) base = base.substring(0, base.length() - 1); + return URI.create(base.concat(normalizedPath)); } /** diff --git a/src/main/java/org/interledger/openpayments/mapper/ClientSerializer.java b/src/main/java/org/interledger/openpayments/mapper/ClientSerializer.java index 234c8d7..94ae0e4 100644 --- a/src/main/java/org/interledger/openpayments/mapper/ClientSerializer.java +++ b/src/main/java/org/interledger/openpayments/mapper/ClientSerializer.java @@ -8,12 +8,13 @@ import java.io.IOException; /** - * A custom serializer for the {@link Client} class that serializes the {@code paymentPointer} - * property of the client object as a string. - * - *

This serializer is designed to be used with JSON serialization frameworks like Jackson. - * It converts the {@link java.net.URI} representation of the {@code paymentPointer} into its string form - * and writes it as a JSON string. + * A custom serializer for the {@link Client} class that writes the client in the format + * selected by {@link Client#getFormat()}: + *

* *

Note that this serializer only handles the serialization of the {@link Client} object; * deserialization logic, if needed, must be implemented separately. @@ -21,19 +22,46 @@ public class ClientSerializer extends JsonSerializer { /** - * Serializes a {@link Client} object by writing its {@code paymentPointer} property as a JSON string. + * Serializes a {@link Client} object according to its {@link Client.Format}. * - * @param client The {@link Client} object to be serialized. Must not be null. + * @param client The {@link Client} object to be serialized. * @param gen The {@link JsonGenerator} used to write the serialized JSON output. * @param serializers The {@link SerializerProvider} that can provide additional serializers if necessary. * @throws IOException If an I/O error occurs while writing the JSON output. */ @Override public void serialize(Client client, JsonGenerator gen, SerializerProvider serializers) throws IOException { - if (client == null || client.getPaymentPointer() == null) { + if (client == null || client.getFormat() == null) { gen.writeNull(); - } else { - gen.writeString(client.getPaymentPointer().toString()); + return; + } + + switch (client.getFormat()) { + case DIRECTED_IDENTITY -> { + if (client.getJwk() == null) { + gen.writeNull(); + return; + } + gen.writeStartObject(); + serializers.defaultSerializeField("jwk", client.getJwk(), gen); + gen.writeEndObject(); + } + case WALLET_ADDRESS_STRING -> { + if (client.getPaymentPointer() == null) { + gen.writeNull(); + return; + } + gen.writeString(client.getPaymentPointer().toString()); + } + default -> { + if (client.getPaymentPointer() == null) { + gen.writeNull(); + return; + } + gen.writeStartObject(); + gen.writeStringField("walletAddress", client.getPaymentPointer().toString()); + gen.writeEndObject(); + } } } } diff --git a/src/main/java/org/interledger/openpayments/model/grant/AccessItem.java b/src/main/java/org/interledger/openpayments/model/grant/AccessItem.java index 886c8b7..ce6eebf 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/AccessItem.java +++ b/src/main/java/org/interledger/openpayments/model/grant/AccessItem.java @@ -75,6 +75,19 @@ public class AccessItem { @JsonProperty("limits") private Limits limits; + /** + * Optional card (EMV) authorization data, only valid for outgoing payment access items + * (Open Payments v1.4.0+). + * + *

In a grant request it carries the card credentials; in a grant response it carries + * the authorization result. + * + * @see CardAuthorization + */ + @JsonProperty("cardAuthorization") + @JsonInclude(JsonInclude.Include.NON_NULL) + private CardAuthorization cardAuthorization; + /** * Configures this access item for outgoing payment operations. * @@ -90,9 +103,23 @@ public class AccessItem { public AccessItem accessOutgoing(URI identifier, Amount debitAmount) { ensureNotNull(identifier, "identifier cannot be null."); ensureNotNull(debitAmount, "debitAmount cannot be null."); + return accessOutgoing(identifier, Limits.debit(debitAmount)); + } + + /** + * Configures this access item for outgoing payment operations with the given limits. + * + *

Use this to request grants with a {@code receiveAmount} limit, a specific + * {@code receiver}, or a recurring {@code interval} (e.g. for future payments). + * + * @param identifier the sender wallet address the outgoing payments are made from + * @param limits the limits under which outgoing payments can be created, or null for none + * @return this AccessItem instance for method chaining + */ + public AccessItem accessOutgoing(URI identifier, Limits limits) { + ensureNotNull(identifier, "identifier cannot be null."); this.identifier = identifier; - limits = new Limits(); - limits.setDebitAmount(debitAmount); + this.limits = limits; return this; } } diff --git a/src/main/java/org/interledger/openpayments/model/grant/Client.java b/src/main/java/org/interledger/openpayments/model/grant/Client.java index 08728d8..11fd2af 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/Client.java +++ b/src/main/java/org/interledger/openpayments/model/grant/Client.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import lombok.Data; import org.interledger.openpayments.mapper.ClientSerializer; +import org.interledger.openpayments.model.key.Ed25519PublicKey; import java.net.URI; @@ -10,15 +11,24 @@ import static org.interledger.openpayments.util.UtilGlobal.ensureNotNullOrEmpty; /** - * Represents a wallet address for Interledger Open Payments operations. + * Identifies the client instance making a grant request to the authorization server. * - *

A wallet address is a URL that identifies a specific wallet or account - * within the Interledger network. It serves as the primary identifier for - * sending and receiving payments through the Open Payments protocol. + *

Open Payments (v1.3.0+) supports three forms of client identification: + *

* - *

Wallet addresses are typically hosted by Rafiki instances and follow - * the URI format. They are used to discover wallet capabilities, create - * payment resources, and identify parties in payment transactions. + *

When using a wallet address, a JSON Web Key Set containing the public key used to sign the + * request MUST be available at the wallet address + {@code /jwks.json}. * *

Example wallet addresses: *

* - *

This class is immutable and thread-safe. The wallet address is validated - * during construction to ensure it's a valid URI. + *

This class is immutable and thread-safe. */ @Data @JsonSerialize(using = ClientSerializer.class) public class Client { /** - * The URI representing the wallet address. - * - *

This is the actual wallet address URI that identifies the wallet - * within the Interledger network. It's used for payment operations, - * resource discovery, and wallet identification. + * The supported client identification formats. + */ + public enum Format { + /** + * Object form: {@code {"walletAddress": ""}}. + */ + WALLET_ADDRESS, + + /** + * Directed identity: {@code {"jwk": {...}}}. + */ + DIRECTED_IDENTITY, + + /** + * Deprecated plain string form: {@code ""}. + */ + WALLET_ADDRESS_STRING + } + + /** + * The URI representing the client wallet address. * - *

The URI must be valid and typically follows the HTTPS scheme - * for security purposes. + *

Present for {@link Format#WALLET_ADDRESS} and {@link Format#WALLET_ADDRESS_STRING}, + * {@code null} for {@link Format#DIRECTED_IDENTITY}. */ private final URI paymentPointer; /** - * Constructs a new WalletAddress from a string URI. - * - *

This constructor parses the provided address string into a URI - * and validates that it's not null. The string should be a valid - * URI format. + * The client's public key, used for {@link Format#DIRECTED_IDENTITY}. + */ + private final Ed25519PublicKey jwk; + + /** + * How this client is serialized in grant requests. + */ + private final Format format; + + /** + * Constructs a new client identified by its wallet address (object form) from a string URI. * * @param address the wallet address as a string (e.g., "https://ilp.interledger-test.dev/alice") - * @throws IllegalArgumentException if the address is null + * @throws IllegalArgumentException if the address is null or empty * @throws IllegalArgumentException if the address is not a valid URI format */ public Client(String address) { ensureNotNullOrEmpty(address, "paymentPointer cannot be null or empty."); this.paymentPointer = URI.create(address); + this.jwk = null; + this.format = Format.WALLET_ADDRESS; } /** - * Constructs a new WalletAddress from a URI object. - * - *

This constructor accepts a pre-parsed URI object and validates - * that it's not null. This is useful when you already have a URI - * object from other operations. + * Constructs a new client identified by its wallet address (object form) from a URI object. * * @param address the wallet address as a URI object * @throws IllegalArgumentException if the address is null */ public Client(URI address) { + this(address, Format.WALLET_ADDRESS); + } + + private Client(URI address, Format format) { ensureNotNull(address, "paymentPointer cannot be null."); this.paymentPointer = address; + this.jwk = null; + this.format = format; + } + + private Client(Ed25519PublicKey jwk) { + ensureNotNull(jwk, "jwk cannot be null."); + this.paymentPointer = null; + this.jwk = jwk; + this.format = Format.DIRECTED_IDENTITY; + } + + /** + * Creates a client identified by its wallet address, serialized as + * {@code {"walletAddress": ""}}. + * + * @param address the client wallet address + * @return a new client + */ + public static Client walletAddress(URI address) { + return new Client(address, Format.WALLET_ADDRESS); + } + + /** + * Creates a client using directed identity, serialized as {@code {"jwk": {...}}}. + * + *

The authorization server uses the supplied public key to verify the request signature, + * so the key MUST match the private key used to sign requests. Directed identity can only be + * used for non-interactive grant requests (e.g. incoming payments). + * + * @param jwk the client's Ed25519 public key + * @return a new client + */ + public static Client directedIdentity(Ed25519PublicKey jwk) { + return new Client(jwk); + } + + /** + * Creates a client identified by its wallet address, serialized as a plain string. + * + * @param address the client wallet address + * @return a new client + * @deprecated the plain string form is deprecated by the Open Payments specification. + * Use {@link #walletAddress(URI)} or {@link #directedIdentity(Ed25519PublicKey)}. + */ + @Deprecated + public static Client walletAddressString(URI address) { + return new Client(address, Format.WALLET_ADDRESS_STRING); } } diff --git a/src/main/java/org/interledger/openpayments/model/grant/Grant.java b/src/main/java/org/interledger/openpayments/model/grant/Grant.java index e37fe52..c25ac95 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/Grant.java +++ b/src/main/java/org/interledger/openpayments/model/grant/Grant.java @@ -51,4 +51,18 @@ public class Grant { @JsonProperty("continue") @JsonInclude(JsonInclude.Include.NON_NULL) private AccessContinue accessContinue; + + /** + * Optional subject information, returned by a grant continuation when the grant + * request contained a {@code subject} and the user has proved access to it. + * + *

When present, the authorization server has verified that the user has access + * to the wallet address(es) listed in {@link Subject#getSubIds()}. A subject-only + * grant does not include an access token. + * + * @see Subject + */ + @JsonProperty("subject") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Subject subject; } diff --git a/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java b/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java index 65944fc..96ff6dc 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java +++ b/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java @@ -68,8 +68,22 @@ public class GrantRequest { * @see AccessToken */ @JsonProperty("access_token") + @JsonInclude(JsonInclude.Include.NON_NULL) private AccessToken accessToken; + /** + * Optional subject the client is requesting information about. + * + *

Used to verify that the user has access to a wallet address. A grant request + * must contain an {@code access_token} request, a {@code subject} (together with + * {@code interact}), or both. + * + * @see Subject + */ + @JsonProperty("subject") + @JsonInclude(JsonInclude.Include.NON_NULL) + private Subject subject; + /** * Optional interaction methods for obtaining user consent. * @@ -161,6 +175,51 @@ public static GrantRequest outgoing( return request; } + /** + * Creates a grant access request for outgoing payment operations with the given limits. + * + *

Use this to request a grant with a {@code receiveAmount} limit, a specific + * {@code receiver}, or a recurring {@code interval} (e.g. for future payments). + * + * @param clientWallet the client wallet address making the request + * @param accessActions the set of actions to be performed (typically create and read) + * @param identifier the sender wallet address the outgoing payments are made from + * @param limits the limits under which outgoing payments can be created, or null for none + * @return a new GrantRequest configured for outgoing payments + */ + public static GrantRequest outgoing( + Client clientWallet, + Set accessActions, + URI identifier, + Limits limits + ) { + GrantRequest request = build(clientWallet, AccessItemType.outgoingPayment, accessActions); + + Optional found = request.accessToken.getAccess().stream().findFirst(); + found.ifPresent(accessItem -> accessItem.accessOutgoing(identifier, limits)); + + return request; + } + + /** + * Creates a grant request that asks the authorization server to verify that the user + * has access to the given subject (e.g. a wallet address). + * + *

No access token is requested. The request requires user interaction, so call + * {@link #redirectInteract(URI, String)} before sending it. + * + * @param clientWallet the client wallet address making the request + * @param subject the subject the user must prove access to + * @return a new GrantRequest containing only the client and subject + */ + public static GrantRequest subject(Client clientWallet, Subject subject) { + ensureNotNull(subject, "subject cannot be null."); + + GrantRequest request = new GrantRequest(clientWallet); + request.subject = subject; + return request; + } + /** * Configures this request to use redirect-based interaction. * diff --git a/src/main/java/org/interledger/openpayments/model/grant/Limits.java b/src/main/java/org/interledger/openpayments/model/grant/Limits.java index 5634734..bc84301 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/Limits.java +++ b/src/main/java/org/interledger/openpayments/model/grant/Limits.java @@ -7,6 +7,8 @@ import java.net.URI; +import static org.interledger.openpayments.util.UtilGlobal.ensureNotNull; + /** * Represents access limits for Interledger payment operations. * @@ -67,25 +69,71 @@ public class Limits { /** * ISO8601 repeating interval specification for recurring payment limits. * - *

This field defines the time interval for recurring payment operations. - * It follows the ISO8601 duration and repeating interval format, allowing - * for complex recurring payment schedules. - * - *

Note: This field is currently not fully implemented - * and is reserved for future use when repeating transaction functionality - * is added to the system. - * + *

This field defines the time interval for recurring payment operations + * (e.g. a grant for future payments). It follows the ISO8601 repeating + * interval format. Amount limits apply per interval, and the spent amounts + * returned for the grant reflect the current interval only. + * *

Example formats: *

- * - * @see ISO8601 Duration Format + * + * @see ISO8601 Repeating Intervals */ @JsonProperty("interval") @JsonInclude(JsonInclude.Include.NON_NULL) private String interval; + + /** + * Creates limits with a maximum debit amount. + * + * @param debitAmount the maximum amount that can be debited + * @return new limits + */ + public static Limits debit(Amount debitAmount) { + return debit(debitAmount, null); + } + + /** + * Creates limits with a maximum debit amount per interval. + * + * @param debitAmount the maximum amount that can be debited per interval + * @param interval the ISO8601 repeating interval, or null for a one-off limit + * @return new limits + */ + public static Limits debit(Amount debitAmount, String interval) { + ensureNotNull(debitAmount, "debitAmount cannot be null."); + var limits = new Limits(); + limits.debitAmount = debitAmount; + limits.interval = interval; + return limits; + } + + /** + * Creates limits with a maximum receive amount. + * + * @param receiveAmount the maximum amount that can be received + * @return new limits + */ + public static Limits receive(Amount receiveAmount) { + return receive(receiveAmount, null); + } + + /** + * Creates limits with a maximum receive amount per interval. + * + * @param receiveAmount the maximum amount that can be received per interval + * @param interval the ISO8601 repeating interval, or null for a one-off limit + * @return new limits + */ + public static Limits receive(Amount receiveAmount, String interval) { + ensureNotNull(receiveAmount, "receiveAmount cannot be null."); + var limits = new Limits(); + limits.receiveAmount = receiveAmount; + limits.interval = interval; + return limits; + } } diff --git a/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPayment.java b/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPayment.java index 7c1238f..16ac9f9 100644 --- a/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPayment.java +++ b/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPayment.java @@ -1,5 +1,6 @@ package org.interledger.openpayments.model.outgoingpayment; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import org.interledger.openpayments.model.common.Amount; @@ -8,6 +9,7 @@ import java.net.URI; import java.time.Instant; +import java.util.Map; /** * Represents the response from creating an outgoing payment request. @@ -104,13 +106,13 @@ public class OutgoingPayment { private URI receiver; /** - * The actual amount received so far for this payment. + * The amount that will be received by the receiver for this payment. * - *

This tracks the cumulative amount that has been successfully received - * for this outgoing payment. It starts at zero and increases as payments - * are received from senders. + *

Mapped from the {@code receiveAmount} property defined by the Open Payments + * specification. The legacy {@code receivedAmount} property name is still accepted. */ - @JsonProperty(value = "receivedAmount", required = false) + @JsonProperty(value = "receiveAmount", required = false) + @JsonAlias("receivedAmount") private Amount receivedAmount; /** @@ -157,6 +159,33 @@ public class OutgoingPayment { @JsonProperty(value = "sentAmount", required = false) private Amount sentAmount; + /** + * The total amount deducted from the sender's account under the outgoing payment grant + * used to create this payment, including this payment. + * + *

Only returned when the outgoing payment is created. For grants with an interval, + * the amount is for the current interval. + */ + @JsonProperty(value = "grantSpentDebitAmount", required = false) + private Amount grantSpentDebitAmount; + + /** + * The total amount received under the outgoing payment grant used to create this payment, + * including this payment. + * + *

Only returned when the outgoing payment is created. For grants with an interval, + * the amount is for the current interval. + */ + @JsonProperty(value = "grantSpentReceiveAmount", required = false) + private Amount grantSpentReceiveAmount; + + /** + * Optional free-form metadata associated with this outgoing payment, + * e.g. {@code {"description": "APlusVideo subscription", "externalRef": "customer: 847458475"}}. + */ + @JsonProperty(value = "metadata", required = false) + private Map metadata; + /** * Timestamp when this outgoing payment was created. * diff --git a/src/test/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClientTest.java b/src/test/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClientTest.java index b55a659..c105cab 100644 --- a/src/test/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClientTest.java +++ b/src/test/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClientTest.java @@ -5,9 +5,12 @@ import ch.qos.logback.core.read.ListAppender; import org.interledger.openpayments.exception.OpenPaymentsAPIException; import org.interledger.openpayments.exception.OpenPaymentsClientException; +import org.interledger.openpayments.mapper.OpenPaymentsObjectMapper; import org.interledger.openpayments.model.common.ApiError; +import org.interledger.openpayments.model.grant.CardAuthorization; import org.interledger.openpayments.model.grant.Client; import org.interledger.openpayments.model.grant.Grant; +import org.interledger.openpayments.model.grant.Limits; import org.interledger.openpayments.model.incomingpayment.IncomingPayment; import org.interledger.openpayments.model.key.Ed25519Keys; import org.interledger.openpayments.model.outgoingpayment.OutgoingPayment; @@ -32,8 +35,12 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; +import java.util.List; import java.util.Optional; +import java.util.concurrent.Flow; import java.util.function.Supplier; import java.util.stream.Stream; @@ -211,42 +218,72 @@ static Stream allClientMethods() { arg( "getWalletAddress", () -> opClient.walletAddress().get("https://wallet.example.com"), - walletAddress), + createWalletAddress()), arg( "getKeys", () -> opClient.walletAddress().keys("https://wallet.example.com"), - keys), + createKeys()), arg( "createGrantIncomingPayment", () -> opClient.auth().grant().incomingPayment(walletAddress), - grantForIncomingPayment), + createGrantForIncomingPayment()), arg( "createIncomingPayment", () -> opClient.payment().createIncoming( walletAddress, grantForIncomingPayment, BigDecimal.valueOf(100)), - incomingPayment), - arg("createGrantQuote", () -> opClient.auth().grant().quote(walletAddress), grantForQuote), + createIncomingPayment()), + arg("createGrantQuote", () -> opClient.auth().grant().quote(walletAddress), createGrantForQuote()), arg( "createQuote", () -> opClient.quote().create( "quote-token-123", walletAddress, incomingPayment, Optional.empty(), Optional.empty() ), - quote), + createQuote()), arg( "createGrantContinuation", () -> opClient.auth().grant().continuation( walletAddress, quote.getDebitAmount(), URI.create("https://app/cb"), "nonce123"), - outgoingPayment), + createOutgoingPayment()), arg( "createGrantFinalize", () -> opClient.auth().grant().finalize(outgoingPayment, "interact-123"), - grantForIncomingPayment), + createGrantForIncomingPayment()), arg( "createFinalizePayment", () -> opClient.payment().createOutgoing(grantForIncomingPayment, walletAddress, quote), - payment)); + createOutgoingPayment()), + arg( + "createGrantContinuationWithIntervalLimits", + () -> + opClient.auth().grant().continuation( + walletAddress, + Limits.debit(quote.getDebitAmount(), "R3/2025-05-20T13:00:00Z/P1M"), + URI.create("https://app/cb"), + "nonce123"), + outgoingPayment), + arg( + "createGrantVerifyOwnership", + () -> opClient.auth().grant().verifyOwnership(walletAddress, URI.create("https://app/cb"), "nonce123"), + createPendingGrant()), + arg( + "createGrantFinalizePendingGrant", + () -> opClient.auth().grant().finalize(createPendingGrant(), "interact-123"), + createGrantForSubject()), + arg( + "createGrantCardAuthorization", + () -> + opClient.auth().grant().cardAuthorization( + walletAddress, + URI.create(TEST_URL + "incoming-payments/1"), + quote.getDebitAmount(), + CardAuthorization.build("9F26089F27019F1002", "request-1")), + createGrantForOutgoingPayment()), + arg( + "getGrantSpentAmounts", + () -> opClient.payment().getGrantSpentAmounts(walletAddress, createGrantForOutgoingPayment()), + createGrantSpentAmounts())); } private static Arguments arg(String name, Supplier call, Object response) { @@ -254,6 +291,117 @@ private static Arguments arg(String name, Supplier call, Object response) } } + @DisplayName("Open Payments v1.4.0 – request targets and bodies") + @Nested + class SpecV140Requests { + private HttpRequest captureRequest(Object response, Runnable call) throws Exception { + var requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(any(HttpRequest.class), eq(HttpResponse.BodyHandlers.ofString()))) + .thenReturn(createResponse(response)); + call.run(); + verify(httpClient).send(requestCaptor.capture(), eq(HttpResponse.BodyHandlers.ofString())); + return requestCaptor.getValue(); + } + + private String body(HttpRequest request) throws Exception { + var publisher = request.bodyPublisher().orElseThrow(); + var subscriber = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); + publisher.subscribe(new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscriber.onSubscribe(subscription); + } + + @Override + public void onNext(ByteBuffer item) { + subscriber.onNext(List.of(item)); + } + + @Override + public void onError(Throwable throwable) { + subscriber.onError(throwable); + } + + @Override + public void onComplete() { + subscriber.onComplete(); + } + }); + return subscriber.getBody().toCompletableFuture().get(); + } + + @Test + void shouldGetGrantSpentAmountsFromResourceServer() throws Exception { + var request = captureRequest(createGrantSpentAmounts(), + () -> opClient.payment().getGrantSpentAmounts(walletAddress, createGrantForOutgoingPayment())); + + assertEquals("GET", request.method()); + assertEquals(URI.create(TEST_URL + "outgoing-payment-grant/"), request.uri()); + assertEquals("GNAP token", request.headers().firstValue("Authorization").orElseThrow()); + } + + @Test + void shouldRequestSubjectGrantForOwnershipVerification() throws Exception { + var request = captureRequest(createPendingGrant(), + () -> opClient.auth().grant().verifyOwnership(walletAddress, URI.create("https://app/cb"), "nonce123")); + + assertEquals("POST", request.method()); + assertEquals(URI.create(walletAddress.getAuthServer() + "/"), request.uri()); + + var json = OpenPaymentsObjectMapper.get().readTree(body(request)); + assertFalse(json.has("access_token")); + assertEquals(walletAddress.getId(), json.at("/subject/sub_ids/0/id").asText()); + assertEquals("uri", json.at("/subject/sub_ids/0/format").asText()); + assertEquals("redirect", json.at("/interact/finish/method").asText()); + assertEquals("nonce123", json.at("/interact/finish/nonce").asText()); + } + + @Test + void shouldContinuePendingGrantWithInteractRef() throws Exception { + var pendingGrant = createPendingGrant(); + var request = captureRequest(createGrantForSubject(), + () -> opClient.auth().grant().finalize(pendingGrant, "interact-123")); + + assertEquals("POST", request.method()); + assertEquals(URI.create(pendingGrant.getGrantContinue().getUri() + "/"), request.uri()); + assertEquals("GNAP continue-token", request.headers().firstValue("Authorization").orElseThrow()); + assertEquals("interact-123", + OpenPaymentsObjectMapper.get().readTree(body(request)).get("interact_ref").asText()); + } + + @Test + void shouldRequestOutgoingGrantWithInterval() throws Exception { + var request = captureRequest(outgoingPayment, + () -> opClient.auth().grant().continuation( + walletAddress, + Limits.debit(quote.getDebitAmount(), "R3/2025-05-20T13:00:00Z/P1M"), + URI.create("https://app/cb"), + "nonce123")); + + var item = OpenPaymentsObjectMapper.get().readTree(body(request)).at("/access_token/access/0"); + assertEquals("outgoing-payment", item.get("type").asText()); + assertEquals(walletAddress.getId(), item.get("identifier").asText()); + assertEquals("R3/2025-05-20T13:00:00Z/P1M", item.at("/limits/interval").asText()); + assertEquals(quote.getDebitAmount().getValue(), item.at("/limits/debitAmount/value").asText()); + } + + @Test + void shouldRequestCardAuthorizationGrantWithoutInteraction() throws Exception { + var receiver = URI.create(TEST_URL + "incoming-payments/1"); + var request = captureRequest(createGrantForOutgoingPayment(), + () -> opClient.auth().grant().cardAuthorization( + walletAddress, receiver, quote.getDebitAmount(), CardAuthorization.build("9F26089F27019F1002", "request-1"))); + + var json = OpenPaymentsObjectMapper.get().readTree(body(request)); + assertFalse(json.has("interact")); + var item = json.at("/access_token/access/0"); + assertEquals(receiver.toString(), item.at("/limits/receiver").asText()); + assertEquals(quote.getDebitAmount().getValue(), item.at("/limits/receiveAmount/value").asText()); + assertEquals("9F26089F27019F1002", item.at("/cardAuthorization/tlvData").asText()); + assertEquals("request-1", item.at("/cardAuthorization/requestId").asText()); + } + } + HttpResponse createApiErrorResponse(int code, String description) { ApiError error = new ApiError(); error.setCode(String.valueOf(code)); diff --git a/src/test/java/org/interledger/openpayments/util/testing/TestModels.java b/src/test/java/org/interledger/openpayments/util/testing/TestModels.java index d949bd3..623b88a 100644 --- a/src/test/java/org/interledger/openpayments/util/testing/TestModels.java +++ b/src/test/java/org/interledger/openpayments/util/testing/TestModels.java @@ -6,6 +6,7 @@ import org.interledger.openpayments.model.key.Ed25519Keys; import org.interledger.openpayments.model.key.Ed25519PublicKey; import org.interledger.openpayments.model.outgoingpayment.OutgoingPayment; +import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentGrantSpentAmounts; import org.interledger.openpayments.model.payment.Payment; import org.interledger.openpayments.model.quote.Quote; import org.interledger.openpayments.model.walletaddress.WalletAddress; @@ -100,6 +101,39 @@ public static Grant createGrant(AccessItemType type, Set actions) return grant; } + public static PendingGrant createPendingGrant() { + var pendingGrant = new PendingGrant(); + var accessContinue = new AccessContinue(); + accessContinue.setUri(URI.create(AUTH_URL + "/continue/1")); + var accessToken = new AccessToken(); + accessToken.setToken("continue-token"); + accessContinue.setAccess(accessToken); + pendingGrant.setGrantContinue(accessContinue); + var interact = new InteractContinue(); + interact.setRedirect(URI.create(AUTH_URL + "/interact/1")); + interact.setToken("finish"); + pendingGrant.setInteract(interact); + return pendingGrant; + } + + public static Grant createGrantForSubject() { + var grant = new Grant(); + grant.setSubject(Subject.walletAddress(URI.create(WALLET_ADDRESS_URL))); + grant.setAccessContinue(new AccessContinue()); + return grant; + } + + public static Grant createGrantForOutgoingPayment() { + return createGrant(AccessItemType.outgoingPayment, Set.of(AccessAction.read, AccessAction.create)); + } + + public static OutgoingPaymentGrantSpentAmounts createGrantSpentAmounts() { + var spent = new OutgoingPaymentGrantSpentAmounts(); + spent.setSpentDebitAmount(Amount.build(BigDecimal.valueOf(26), "USD")); + spent.setSpentReceiveAmount(Amount.build(BigDecimal.valueOf(25), "USD")); + return spent; + } + public static Payment createPayment() { var payment = new Payment(); payment.setCreatedAt(Instant.now()); From fe9dcc29c1ce4a5f5da49dafacef1539825eff14 Mon Sep 17 00:00:00 2001 From: koekiebox Date: Mon, 12 Oct 2026 14:45:09 +0200 Subject: [PATCH 2/3] feat(jav-15): New source files. --- .../model/grant/CardAuthorization.java | 91 ++++++ .../model/grant/PendingGrant.java | 49 ++++ .../model/grant/PinWorkingKey.java | 51 ++++ .../openpayments/model/grant/Subject.java | 83 ++++++ .../OutgoingPaymentGrantSpentAmounts.java | 31 +++ .../grant/GrantModelSerializationTest.java | 262 ++++++++++++++++++ 6 files changed, 567 insertions(+) create mode 100644 src/main/java/org/interledger/openpayments/model/grant/CardAuthorization.java create mode 100644 src/main/java/org/interledger/openpayments/model/grant/PendingGrant.java create mode 100644 src/main/java/org/interledger/openpayments/model/grant/PinWorkingKey.java create mode 100644 src/main/java/org/interledger/openpayments/model/grant/Subject.java create mode 100644 src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPaymentGrantSpentAmounts.java create mode 100644 src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java diff --git a/src/main/java/org/interledger/openpayments/model/grant/CardAuthorization.java b/src/main/java/org/interledger/openpayments/model/grant/CardAuthorization.java new file mode 100644 index 0000000..424f371 --- /dev/null +++ b/src/main/java/org/interledger/openpayments/model/grant/CardAuthorization.java @@ -0,0 +1,91 @@ +package org.interledger.openpayments.model.grant; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import static org.interledger.openpayments.util.UtilGlobal.ensureNotNull; +import static org.interledger.openpayments.util.UtilGlobal.ensureNotNullOrEmpty; + +/** + * Card (EMV) authorization data on an outgoing-payment access item (Open Payments v1.4.0+). + * + *

Its presence marks a card authorization. In a grant request it carries the card + * credentials; in a grant response it carries the authorization result. + * + *

Example (grant request with PIN): + *

+ * {
+ *   "tlvData": "9F26089F27019F1002...",
+ *   "pinBlock": "23e412341234",
+ *   "pwk": { "key": "A1B2...", "kvc": "A3F1C2", "iv": "0000..." },
+ *   "requestId": "018f8d2e-3c1a-7b6e-9f0a-2c4d6e8f0a1b"
+ * }
+ * 
+ */ +@Data +public class CardAuthorization { + /** + * Card data on a request, the authorization result on a response, as EMV TLV. + */ + @JsonProperty(value = "tlvData", required = true) + private String tlvData; + + /** + * The PIN block, encrypted under the PIN Working Key (PWK). Omitted when no PIN was captured. + * When present, {@link #pwk} is required. + */ + @JsonProperty("pinBlock") + @JsonInclude(JsonInclude.Include.NON_NULL) + private String pinBlock; + + /** + * The PIN Working Key used to encrypt the PIN block. + */ + @JsonProperty("pwk") + @JsonInclude(JsonInclude.Include.NON_NULL) + private PinWorkingKey pwk; + + /** + * Client-generated identifier for the request. Replaying it MUST return the same value + * as the one given in the original grant request. + */ + @JsonProperty(value = "requestId", required = true) + private String requestId; + + /** + * Creates card authorization data for a no-CVM (no PIN) card payment. + * + * @param tlvData the EMV TLV card data + * @param requestId a client-generated identifier for the request + * @return a new card authorization + */ + public static CardAuthorization build(String tlvData, String requestId) { + ensureNotNullOrEmpty(tlvData, "tlvData cannot be null or empty."); + ensureNotNullOrEmpty(requestId, "requestId cannot be null or empty."); + + var cardAuthorization = new CardAuthorization(); + cardAuthorization.tlvData = tlvData; + cardAuthorization.requestId = requestId; + return cardAuthorization; + } + + /** + * Creates card authorization data for a card payment with an encrypted PIN. + * + * @param tlvData the EMV TLV card data + * @param requestId a client-generated identifier for the request + * @param pinBlock the PIN block, encrypted under the PIN Working Key + * @param pwk the PIN Working Key used to encrypt the PIN block + * @return a new card authorization + */ + public static CardAuthorization build(String tlvData, String requestId, String pinBlock, PinWorkingKey pwk) { + ensureNotNullOrEmpty(pinBlock, "pinBlock cannot be null or empty."); + ensureNotNull(pwk, "pwk is required when pinBlock is present."); + + var cardAuthorization = build(tlvData, requestId); + cardAuthorization.pinBlock = pinBlock; + cardAuthorization.pwk = pwk; + return cardAuthorization; + } +} diff --git a/src/main/java/org/interledger/openpayments/model/grant/PendingGrant.java b/src/main/java/org/interledger/openpayments/model/grant/PendingGrant.java new file mode 100644 index 0000000..6f6376b --- /dev/null +++ b/src/main/java/org/interledger/openpayments/model/grant/PendingGrant.java @@ -0,0 +1,49 @@ +package org.interledger.openpayments.model.grant; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * A grant request that is pending user interaction. + * + *

Returned by the authorization server when a grant request requires the user to interact + * (e.g. to approve an outgoing payment or to prove access to a wallet address). The client + * redirects the user to {@code interact.redirect}. Once the interaction finishes, the client + * continues the grant using the {@code continue} details and the {@code interact_ref} it + * received. + * + *

Example: + *

+ * {
+ *   "interact": {
+ *     "redirect": "https://auth.interledger-test.dev/4CF492MLVMSW9MKMXKHQ",
+ *     "finish": "4105340a-05eb-4290-8739-f9e2b463bfa7"
+ *   },
+ *   "continue": {
+ *     "access_token": { "value": "33OMUKMKSKU80UPRY5NM" },
+ *     "uri": "https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ",
+ *     "wait": 30
+ *   }
+ * }
+ * 
+ */ +@Data +public class PendingGrant { + /** + * Interaction details: where to redirect the user, and the value that secures the callback. + * + * @see InteractContinue + */ + @JsonProperty(value = "interact", required = true) + private InteractContinue interact; + + /** + * Details for continuing the grant request once the interaction has finished. + * + * @see AccessContinue + */ + @JsonProperty(value = "continue", required = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + private AccessContinue grantContinue; +} diff --git a/src/main/java/org/interledger/openpayments/model/grant/PinWorkingKey.java b/src/main/java/org/interledger/openpayments/model/grant/PinWorkingKey.java new file mode 100644 index 0000000..47811eb --- /dev/null +++ b/src/main/java/org/interledger/openpayments/model/grant/PinWorkingKey.java @@ -0,0 +1,51 @@ +package org.interledger.openpayments.model.grant; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import static org.interledger.openpayments.util.UtilGlobal.ensureNotNullOrEmpty; + +/** + * The PIN Working Key (PWK) used to encrypt the PIN block of a {@link CardAuthorization} + * (Open Payments v1.4.0+). + */ +@Data +public class PinWorkingKey { + /** + * The encrypted key material. + */ + @JsonProperty(value = "key", required = true) + private String key; + + /** + * Key Check Value of the key. + */ + @JsonProperty(value = "kvc", required = true) + private String kvc; + + /** + * Initialization vector. + */ + @JsonProperty(value = "iv", required = true) + private String iv; + + /** + * Creates a new PIN Working Key. + * + * @param key the encrypted key material + * @param kvc the Key Check Value of the key + * @param iv the initialization vector + * @return a new PIN Working Key + */ + public static PinWorkingKey build(String key, String kvc, String iv) { + ensureNotNullOrEmpty(key, "key cannot be null or empty."); + ensureNotNullOrEmpty(kvc, "kvc cannot be null or empty."); + ensureNotNullOrEmpty(iv, "iv cannot be null or empty."); + + var pwk = new PinWorkingKey(); + pwk.key = key; + pwk.kvc = kvc; + pwk.iv = iv; + return pwk; + } +} diff --git a/src/main/java/org/interledger/openpayments/model/grant/Subject.java b/src/main/java/org/interledger/openpayments/model/grant/Subject.java new file mode 100644 index 0000000..8d1794b --- /dev/null +++ b/src/main/java/org/interledger/openpayments/model/grant/Subject.java @@ -0,0 +1,83 @@ +package org.interledger.openpayments.model.grant; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import static org.interledger.openpayments.util.UtilGlobal.ensureNotNull; + +/** + * Information about the subject for which the client is requesting information. + * + *

A subject is used in a grant request to verify that a user has access to a specific wallet + * address, without requesting an access token for payments. The authorization server interacts + * with the user through the identity provider (IdP) to confirm they control the wallet address + * listed in {@code sub_ids}. + * + *

The same structure is returned in a grant continuation response once the interaction has + * completed successfully. + * + *

Example: + *

+ * {
+ *   "sub_ids": [
+ *     { "id": "https://ilp.interledger-test.dev/alice", "format": "uri" }
+ *   ]
+ * }
+ * 
+ * + * @see Verify ownership of a wallet address + */ +@Data +public class Subject { + /** + * The only subject identifier format supported by Open Payments. + */ + public static final String FORMAT_URI = "uri"; + + /** + * A list of subject identifiers. The specification allows exactly one entry. + */ + @JsonProperty(value = "sub_ids", required = true) + private List subIds; + + /** + * Creates a subject for the given wallet address, using the {@code uri} format. + * + * @param walletAddress the wallet address the user must prove access to + * @return a new subject + */ + public static Subject walletAddress(URI walletAddress) { + ensureNotNull(walletAddress, "walletAddress cannot be null."); + + var subjectId = new SubjectId(); + subjectId.setId(walletAddress.toString()); + subjectId.setFormat(FORMAT_URI); + + var subject = new Subject(); + subject.subIds = new ArrayList<>(); + subject.subIds.add(subjectId); + return subject; + } + + /** + * A single subject identifier. + */ + @Data + public static class SubjectId { + /** + * Specific identifier for the subject, e.g. a wallet address URL. + */ + @JsonProperty(value = "id", required = true) + private String id; + + /** + * The format of the subject identifier. The only allowed value is {@code uri}. + */ + @JsonProperty(value = "format", required = true) + private String format; + } +} diff --git a/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPaymentGrantSpentAmounts.java b/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPaymentGrantSpentAmounts.java new file mode 100644 index 0000000..951289d --- /dev/null +++ b/src/main/java/org/interledger/openpayments/model/outgoingpayment/OutgoingPaymentGrantSpentAmounts.java @@ -0,0 +1,31 @@ +package org.interledger.openpayments.model.outgoingpayment; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import org.interledger.openpayments.model.common.Amount; + +/** + * The spent amounts for the current outgoing payment grant. + * + *

Returned by the {@code GET /outgoing-payment-grant} endpoint of the resource server. The + * grant is identified by the access token used for the request. For grants created with an + * {@code interval} (recurring), the amounts are for the current interval only. + * + *

Both amounts are {@code null} when nothing has been spent against the grant yet. + */ +@Data +public class OutgoingPaymentGrantSpentAmounts { + /** + * The total amount successfully received (by all receivers) using the current + * outgoing payment grant, or {@code null} if nothing has been received yet. + */ + @JsonProperty("spentReceiveAmount") + private Amount spentReceiveAmount; + + /** + * The total amount successfully deducted from the sender's account using the current + * outgoing payment grant, or {@code null} if nothing has been deducted yet. + */ + @JsonProperty("spentDebitAmount") + private Amount spentDebitAmount; +} diff --git a/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java b/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java new file mode 100644 index 0000000..40618ff --- /dev/null +++ b/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java @@ -0,0 +1,262 @@ +package org.interledger.openpayments.model.grant; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.interledger.openpayments.exception.OpenPaymentsClientException; +import org.interledger.openpayments.mapper.OpenPaymentsObjectMapper; +import org.interledger.openpayments.model.common.Amount; +import org.interledger.openpayments.model.key.Ed25519PublicKey; +import org.interledger.openpayments.model.outgoingpayment.OutgoingPayment; +import org.interledger.openpayments.model.outgoingpayment.OutgoingPaymentGrantSpentAmounts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.net.URI; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the (de)serialization of the grant and payment models against the + * Open Payments v1.4.0 specification. + * + *

Verifies that: + * + *

+ */ +@DisplayName("Grant models → Open Payments v1.4.0 (de)serialization") +public class GrantModelSerializationTest { + private static final ObjectMapper MAPPER = OpenPaymentsObjectMapper.get(); + private static final String WALLET = "https://ilp.interledger-test.dev/alice"; + private static final String RECEIVER = + "https://ilp.interledger-test.dev/incoming-payments/45a0d0ee-26dc-4c66-89e0-01fbf93156f7"; + + private static JsonNode toJson(Object value) { + return MAPPER.valueToTree(value); + } + + private static JsonNode readTree(String json) throws Exception { + return MAPPER.readTree(json); + } + + @DisplayName("Client – wallet address, directed identity and deprecated string forms") + @Nested + class ClientForms { + @Test + void shouldWriteWalletAddressObjectByDefault() throws Exception { + assertEquals(readTree("{\"walletAddress\":\"" + WALLET + "\"}"), toJson(new Client(WALLET))); + assertEquals(readTree("{\"walletAddress\":\"" + WALLET + "\"}"), toJson(new Client(URI.create(WALLET)))); + assertEquals(Client.Format.WALLET_ADDRESS, new Client(WALLET).getFormat()); + } + + @Test + void shouldWriteDirectedIdentity() throws Exception { + var jwk = new Ed25519PublicKey(); + jwk.setKid("key-1"); + jwk.setAlg("EdDSA"); + jwk.setKty("OKP"); + jwk.setCrv("Ed25519"); + jwk.setX("oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI"); + + var expected = readTree("{\"jwk\":{\"kid\":\"key-1\",\"alg\":\"EdDSA\",\"kty\":\"OKP\"," + + "\"crv\":\"Ed25519\",\"x\":\"oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI\"}}"); + var client = Client.directedIdentity(jwk); + + assertEquals(expected, toJson(client)); + assertNull(client.getPaymentPointer()); + } + + @Test + @SuppressWarnings("deprecation") + void shouldWriteDeprecatedString() { + var json = toJson(Client.walletAddressString(URI.create(WALLET))); + assertTrue(json.isTextual()); + assertEquals(WALLET, json.asText()); + } + + @Test + void shouldRejectMissingValues() { + assertThrows(OpenPaymentsClientException.class, () -> Client.directedIdentity(null)); + assertThrows(OpenPaymentsClientException.class, () -> Client.walletAddress(null)); + } + } + + @DisplayName("GrantRequest – subject, interval limits and card authorization") + @Nested + class GrantRequests { + @Test + void shouldWriteSubjectRequestWithoutAccessToken() throws Exception { + var request = GrantRequest.subject(new Client(WALLET), Subject.walletAddress(URI.create(WALLET))) + .redirectInteract(URI.create("https://paymentplatform.example/finish"), "nonce-1"); + + var expected = readTree("{" + + "\"client\":{\"walletAddress\":\"" + WALLET + "\"}," + + "\"subject\":{\"sub_ids\":[{\"id\":\"" + WALLET + "\",\"format\":\"uri\"}]}," + + "\"interact\":{\"start\":[\"redirect\"],\"finish\":{\"method\":\"redirect\"," + + "\"uri\":\"https://paymentplatform.example/finish\",\"nonce\":\"nonce-1\"}}" + + "}"); + + assertEquals(expected, toJson(request)); + } + + @Test + void shouldWriteOutgoingRequestWithInterval() throws Exception { + var debit = Amount.build(new BigDecimal("100.00"), "CAD"); + var request = GrantRequest.outgoing( + new Client(WALLET), + Set.of(AccessAction.create), + URI.create(WALLET), + Limits.debit(debit, "R3/2025-05-20T13:00:00Z/P1M")); + + var item = toJson(request).at("/access_token/access/0"); + assertEquals("outgoing-payment", item.get("type").asText()); + assertEquals(WALLET, item.get("identifier").asText()); + assertEquals("R3/2025-05-20T13:00:00Z/P1M", item.at("/limits/interval").asText()); + assertEquals("10000", item.at("/limits/debitAmount/value").asText()); + assertFalse(item.get("limits").has("receiveAmount")); + assertFalse(item.has("cardAuthorization")); + } + + @Test + void shouldKeepLegacyDebitAmountOutgoingRequest() { + var debit = Amount.build(new BigDecimal("5.00"), "USD"); + var request = GrantRequest.outgoing( + new Client(WALLET), AccessItemType.outgoingPayment, Set.of(AccessAction.create), URI.create(WALLET), debit); + + var limits = toJson(request).at("/access_token/access/0/limits"); + assertEquals("500", limits.at("/debitAmount/value").asText()); + assertFalse(limits.has("interval")); + } + + @Test + void shouldWriteCardAuthorizationWithPin() throws Exception { + var card = CardAuthorization.build( + "9F26089F27019F1002...", + "018f8d2e-3c1a-7b6e-9f0a-2c4d6e8f0a1b", + "23e412341234", + PinWorkingKey.build("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "A3F1C2", "00000000000000000000000000000000")); + + var expected = readTree("{\"tlvData\":\"9F26089F27019F1002...\",\"pinBlock\":\"23e412341234\"," + + "\"pwk\":{\"key\":\"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4\",\"kvc\":\"A3F1C2\"," + + "\"iv\":\"00000000000000000000000000000000\"}," + + "\"requestId\":\"018f8d2e-3c1a-7b6e-9f0a-2c4d6e8f0a1b\"}"); + + assertEquals(expected, toJson(card)); + } + + @Test + void shouldWriteNoCvmCardAuthorization() { + var json = toJson(CardAuthorization.build("9F26089F27019F1002...", "req-1")); + assertFalse(json.has("pinBlock")); + assertFalse(json.has("pwk")); + } + + @Test + void shouldRequirePwkWithPinBlock() { + assertThrows(OpenPaymentsClientException.class, + () -> CardAuthorization.build("tlv", "req-1", "23e412341234", null)); + } + } + + @DisplayName("Responses – spec examples deserialize into the models") + @Nested + class Responses { + @Test + void shouldReadSubjectContinuationResponse() throws Exception { + var json = "{\"subject\":{\"sub_ids\":[{\"id\":\"" + WALLET + "\",\"format\":\"uri\"}]}," + + "\"continue\":{\"access_token\":{\"value\":\"33OMUKMKSKU80UPRY5NM\"}," + + "\"uri\":\"https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ\",\"wait\":30}}"; + + var grant = MAPPER.readValue(json, Grant.class); + + assertNull(grant.getAccess()); + assertEquals(WALLET, grant.getSubject().getSubIds().getFirst().getId()); + assertEquals(Subject.FORMAT_URI, grant.getSubject().getSubIds().getFirst().getFormat()); + assertEquals("33OMUKMKSKU80UPRY5NM", grant.getAccessContinue().getAccess().getToken()); + } + + @Test + void shouldReadPendingGrant() throws Exception { + var json = "{\"interact\":{\"redirect\":\"https://auth.interledger-test.dev/4CF492MLVMSW9MKMXKHQ\"," + + "\"finish\":\"4105340a-05eb-4290-8739-f9e2b463bfa7\"}," + + "\"continue\":{\"access_token\":{\"value\":\"33OMUKMKSKU80UPRY5NM\"}," + + "\"uri\":\"https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ\",\"wait\":30}}"; + + var pending = MAPPER.readValue(json, PendingGrant.class); + + assertEquals(URI.create("https://auth.interledger-test.dev/4CF492MLVMSW9MKMXKHQ"), pending.getInteract().getRedirect()); + assertEquals("4105340a-05eb-4290-8739-f9e2b463bfa7", pending.getInteract().getToken()); + assertEquals("33OMUKMKSKU80UPRY5NM", pending.getGrantContinue().getAccess().getToken()); + assertEquals(30, pending.getGrantContinue().getWait()); + } + + @Test + void shouldReadCardAuthorizationGrant() throws Exception { + var json = "{\"access_token\":{\"value\":\"OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0\"," + + "\"manage\":\"https://auth.interledger-test.dev/token/dd17a202\",\"expires_in\":3600," + + "\"access\":[{\"type\":\"outgoing-payment\",\"actions\":[\"create\",\"read\"]," + + "\"identifier\":\"" + WALLET + "\"," + + "\"limits\":{\"receiver\":\"" + RECEIVER + "\"," + + "\"receiveAmount\":{\"value\":\"1000\",\"assetCode\":\"USD\",\"assetScale\":2}}," + + "\"cardAuthorization\":{\"tlvData\":\"8A023030...\"," + + "\"requestId\":\"018f8d2e-3c1a-7b6e-9f0a-2c4d6e8f0a1b\"}}]}," + + "\"continue\":{\"access_token\":{\"value\":\"33OMUKMKSKU80UPRY5NM\"}," + + "\"uri\":\"https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ\"}}"; + + var grant = MAPPER.readValue(json, Grant.class); + var item = grant.getAccess().getAccess().iterator().next(); + + assertEquals(URI.create(RECEIVER), item.getLimits().getReceiver()); + assertEquals("8A023030...", item.getCardAuthorization().getTlvData()); + assertEquals("018f8d2e-3c1a-7b6e-9f0a-2c4d6e8f0a1b", item.getCardAuthorization().getRequestId()); + } + + @Test + void shouldReadGrantSpentAmounts() throws Exception { + var json = "{\"spentReceiveAmount\":{\"value\":\"2500\",\"assetCode\":\"USD\",\"assetScale\":2}," + + "\"spentDebitAmount\":{\"value\":\"2600\",\"assetCode\":\"USD\",\"assetScale\":2}}"; + + var spent = MAPPER.readValue(json, OutgoingPaymentGrantSpentAmounts.class); + + assertEquals("2500", spent.getSpentReceiveAmount().getValue()); + assertEquals(new BigDecimal("26.00"), spent.getSpentDebitAmount().asBigDecimal()); + } + + @Test + void shouldReadNullGrantSpentAmounts() throws Exception { + var spent = MAPPER.readValue( + "{\"spentReceiveAmount\":null,\"spentDebitAmount\":null}", OutgoingPaymentGrantSpentAmounts.class); + + assertNull(spent.getSpentReceiveAmount()); + assertNull(spent.getSpentDebitAmount()); + } + + @Test + void shouldReadOutgoingPaymentWithSpentAmounts() throws Exception { + var json = "{\"id\":\"https://ilp.interledger-test.dev/outgoing-payments/8c68d3cc\"," + + "\"walletAddress\":\"https://ilp.interledger-test.dev/alice/\",\"failed\":false," + + "\"receiver\":\"" + RECEIVER + "\"," + + "\"receiveAmount\":{\"value\":\"2500\",\"assetCode\":\"USD\",\"assetScale\":2}," + + "\"debitAmount\":{\"value\":\"2600\",\"assetCode\":\"USD\",\"assetScale\":2}," + + "\"sentAmount\":{\"value\":\"2500\",\"assetCode\":\"USD\",\"assetScale\":2}," + + "\"grantSpentDebitAmount\":{\"value\":\"2600\",\"assetCode\":\"USD\",\"assetScale\":2}," + + "\"createdAt\":\"2022-03-12T23:20:50.52Z\"," + + "\"metadata\":{\"description\":\"APlusVideo subscription\",\"externalRef\":\"customer: 847458475\"}}"; + + var payment = MAPPER.readValue(json, OutgoingPayment.class); + + assertEquals("2500", payment.getReceivedAmount().getValue()); + assertEquals("2600", payment.getGrantSpentDebitAmount().getValue()); + assertNull(payment.getGrantSpentReceiveAmount()); + assertEquals("APlusVideo subscription", payment.getMetadata().get("description")); + } + } +} From 2ff25aeb72d07547b5495af763be1397029c32ff Mon Sep 17 00:00:00 2001 From: koekiebox Date: Mon, 12 Oct 2026 15:13:15 +0200 Subject: [PATCH 3/3] feat(jav-15): Modified for the existing gaps. --- .../InterledgerApiClientHttpImpl.java | 45 ++++++++++++ .../openpayments/TestConfiguration.java | 24 +++++++ .../resources/test-config.properties | 49 +++++++++---- .../httpclient/OpenPaymentsHttpClient.java | 6 +- .../openpayments/model/grant/Client.java | 29 +++++++- .../model/grant/GrantRequest.java | 13 ++-- .../grant/GrantModelSerializationTest.java | 71 +++++++++++++++++-- 7 files changed, 211 insertions(+), 26 deletions(-) diff --git a/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java b/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java index dcb5045..286101b 100644 --- a/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java +++ b/src/integrationTest/java/org/interledger/openpayments/InterledgerApiClientHttpImpl.java @@ -4,7 +4,9 @@ import lombok.extern.slf4j.Slf4j; import org.interledger.openpayments.httpclient.OpenPaymentsHttpClient; import org.interledger.openpayments.model.common.Amount; +import org.interledger.openpayments.model.grant.CardAuthorization; import org.interledger.openpayments.model.grant.Grant; +import org.interledger.openpayments.model.grant.PinWorkingKey; import org.interledger.openpayments.model.key.Ed25519PublicKey; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; @@ -24,10 +26,12 @@ import java.util.Arrays; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import static java.lang.Thread.sleep; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assumptions.assumeTrue; /** * Implementation of the Interledger API Client using HTTP for communication. @@ -231,6 +235,47 @@ public void getSenderWallet() { * Monitors the payment until its completion status is confirmed or a timeout is reached. * */ + /** + * Card (EMV) authorization grant (Open Payments v1.4.0). + * Skipped unless {@code card.tlv.data} is configured in test-config.properties. + */ + @Test + public void openPaymentsCardAuthorizationGrant() { + var tlvData = TestConfiguration.getCardTlvData(); + assumeTrue(tlvData != null, "card.tlv.data not configured - skipping card authorization test"); + + var requestId = Optional.ofNullable(TestConfiguration.getCardRequestId()) + .orElse(UUID.randomUUID().toString()); + var pinBlock = TestConfiguration.getCardPinBlock(); + var cardAuthorization = pinBlock == null + ? CardAuthorization.build(tlvData, requestId) + : CardAuthorization.build(tlvData, requestId, pinBlock, PinWorkingKey.build( + TestConfiguration.getCardPwkKey(), + TestConfiguration.getCardPwkKvc(), + TestConfiguration.getCardPwkIv())); + + // STEP 1: Create an incoming payment on the receiver's wallet (the merchant). + var receiverWallet = client.walletAddress().get(TestHelper.getReceiverWalletAddress()); + var ipGrant = this.client.auth().grant().incomingPayment(receiverWallet); + var incomingPayment = this.client.payment().createIncoming(receiverWallet, ipGrant, BigDecimal.valueOf(2.50)); + assertNotNull(incomingPayment); + + // STEP 2: Request a non-interactive outgoing payment grant authorized by the card. + var senderWallet = this.client.walletAddress().get(TestHelper.getSenderWalletAddress()); + var grant = this.client.auth().grant().cardAuthorization( + senderWallet, + incomingPayment.getId(), + incomingPayment.getIncomingAmount(), + cardAuthorization + ); + assertNotNull(grant); + assertNotNull(grant.getAccess()); + + var result = grant.getAccess().getAccess().iterator().next().getCardAuthorization(); + assertNotNull(result); + log.info("Card authorization result (TLV): " + result.getTlvData()); + } + @Test public void openPaymentsCreateOutgoingFromQuote() throws InterruptedException { // STEP 0: RECEIVER WALLET DISCOVERY diff --git a/src/integrationTest/java/org/interledger/openpayments/TestConfiguration.java b/src/integrationTest/java/org/interledger/openpayments/TestConfiguration.java index 5041892..525c053 100644 --- a/src/integrationTest/java/org/interledger/openpayments/TestConfiguration.java +++ b/src/integrationTest/java/org/interledger/openpayments/TestConfiguration.java @@ -111,6 +111,30 @@ public static String getTestWalletPassword() { * @return the property value * @throws RuntimeException if the property is not found or empty */ + public static String getCardTlvData() { + return getOptionalProperty("card.tlv.data", null); + } + + public static String getCardRequestId() { + return getOptionalProperty("card.request.id", null); + } + + public static String getCardPinBlock() { + return getOptionalProperty("card.pin.block", null); + } + + public static String getCardPwkKey() { + return getRequiredProperty("card.pwk.key"); + } + + public static String getCardPwkKvc() { + return getRequiredProperty("card.pwk.kvc"); + } + + public static String getCardPwkIv() { + return getRequiredProperty("card.pwk.iv"); + } + private static String getRequiredProperty(String key) { String value = properties.getProperty(key); if (value == null || value.trim().isEmpty()) { diff --git a/src/integrationTest/resources/test-config.properties b/src/integrationTest/resources/test-config.properties index b3d654b..45acf44 100644 --- a/src/integrationTest/resources/test-config.properties +++ b/src/integrationTest/resources/test-config.properties @@ -1,29 +1,24 @@ -# OpenPayments Integration Test Template -# +# OpenPayments Integration Test Template # This file contains configuration value examples for testing the OpenPayments API client. -# +# # IMPORTANT: Never commit the actual test-config.properties file to version control # as it contains sensitive information like private keys. - # ============================================================================= # CLIENT WALLET CONFIGURATION # ============================================================================= # This should be a valid Interledger wallet URL that can initiate payments -#TODO client.wallet.address=https://ilp.interledger-test.dev/fliqa-initiator -client.wallet.address=https://ilp.interledger-test.dev/56e321b2 +client.wallet.address=https://wallet-address/client # The private key for the client wallet in PEM format # This key is used to cryptographically sign requests to the Interledger network # Format: PEM-encoded Ed25519 private key -#TODO client.private.key=-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY----- -client.private.key=-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIBAEk4HmYYUaZKBtkrvkH8cukn+pVGBtK7vsGWGpxqg9\n-----END PRIVATE KEY----- +client.private.key=-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY_HERE\n-----END PRIVATE KEY----- # The key ID associated with the private key # This is used by the Interledger network to identify which public key to use for verification # Format: UUID string -#TODO client.key.id=164df9a0-46de-4f26-bde7-b0876fe30e9e -client.key.id=ed76e05a-d831-423f-ac2d-5b0a643cb4c2 +client.key.id=164df9a0-46de-4f26-bde7-b0876fe30e9e # ============================================================================= # TEST WALLET ADDRESSES @@ -31,13 +26,11 @@ client.key.id=ed76e05a-d831-423f-ac2d-5b0a643cb4c2 # Test wallet address for the payment sender # This represents the wallet that will send money in test scenarios -#TODO sender.wallet.address=https://ilp.interledger-test.dev/fliqa-sender -sender.wallet.address=https://ilp.interledger-test.dev/56e321b2 +sender.wallet.address=https://wallet-address/client/sender # Test wallet address for the payment receiver # This represents the wallet that will receive money in test scenarios -#TODO receiver.wallet.address=https://ilp.interledger-test.dev/fliqa-receiver -receiver.wallet.address=https://ilp.interledger-test.dev/140d27e0 +receiver.wallet.address=https://wallet-address/client/receiver # ============================================================================= # ADDITIONAL CONFIGURATION @@ -50,3 +43,31 @@ receiver.wallet.address=https://ilp.interledger-test.dev/140d27e0 # Timeout settings for tests (in seconds) # test.timeout.connect=10 # test.timeout.read=30 + +# ============================================================================= +# TEST WALLET LOGIN (used to approve interactive grants in the browser) +# ============================================================================= + +# Login for the sender's account on the test wallet (https://wallet.interledger-test.dev) +# Required by the interactive grant flows (e.g. outgoing payments) +test.wallet.email=user@example.com +test.wallet.password= + +# ============================================================================= +# CARD AUTHORIZATION (Open Payments v1.4.0) - OPTIONAL +# ============================================================================= + +# Card (EMV) data used by the card authorization integration test. +# The test is skipped when card.tlv.data is not set. +# Format: hex-encoded EMV TLV data +# card.tlv.data=9F26089F27019F1002... + +# Client-generated request identifier. A random UUID is used when not set. +# card.request.id=018f8d2e-3c1a-7b6e-9f0a-2c4d6e8f0a1b + +# PIN block, encrypted under the PIN Working Key. Leave unset for a no-CVM (no PIN) payment. +# When set, all card.pwk.* properties are required. +# card.pin.block= +# card.pwk.key= +# card.pwk.kvc= +# card.pwk.iv= diff --git a/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java b/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java index a45f317..e5f8c4d 100644 --- a/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java +++ b/src/main/java/org/interledger/openpayments/httpclient/OpenPaymentsHttpClient.java @@ -682,8 +682,10 @@ public PendingGrant verifyOwnership(WalletAddress walletAddress, URI returnUrl, var subjectRequest = GrantRequest.subject( this.baseClient.clientWallet, - Subject.walletAddress(walletAddress.getIdAsURI()) - ).redirectInteract(returnUrl, nonce); + Subject.walletAddress(walletAddress.getIdAsURI()), + returnUrl, + nonce + ); var request = new SignatureRequestBuilder(this.baseClient.privateKey, this.baseClient.keyId, this.baseClient.mapper) .POST(subjectRequest) diff --git a/src/main/java/org/interledger/openpayments/model/grant/Client.java b/src/main/java/org/interledger/openpayments/model/grant/Client.java index 11fd2af..cfa4cfb 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/Client.java +++ b/src/main/java/org/interledger/openpayments/model/grant/Client.java @@ -6,9 +6,11 @@ import org.interledger.openpayments.model.key.Ed25519PublicKey; import java.net.URI; +import java.util.regex.Pattern; import static org.interledger.openpayments.util.UtilGlobal.ensureNotNull; import static org.interledger.openpayments.util.UtilGlobal.ensureNotNullOrEmpty; +import static org.interledger.openpayments.util.UtilGlobal.ensureTrue; /** * Identifies the client instance making a grant request to the authorization server. @@ -42,6 +44,12 @@ @Data @JsonSerialize(using = ClientSerializer.class) public class Client { + private static final String JWK_ALG = "EdDSA"; + private static final String JWK_KTY = "OKP"; + private static final String JWK_CRV = "Ed25519"; + private static final String JWK_USE = "sig"; + private static final Pattern JWK_X_PATTERN = Pattern.compile("^[a-zA-Z0-9-_]+$"); + /** * The supported client identification formats. */ @@ -112,7 +120,7 @@ private Client(URI address, Format format) { } private Client(Ed25519PublicKey jwk) { - ensureNotNull(jwk, "jwk cannot be null."); + ensureValidJwk(jwk); this.paymentPointer = null; this.jwk = jwk; this.format = Format.DIRECTED_IDENTITY; @@ -138,6 +146,9 @@ public static Client walletAddress(URI address) { * * @param jwk the client's Ed25519 public key * @return a new client + * @throws org.interledger.openpayments.exception.OpenPaymentsClientException if the key is + * missing a required field ({@code kid}, {@code alg}, {@code kty}, {@code crv}, + * {@code x}) or is not an Ed25519 signing key */ public static Client directedIdentity(Ed25519PublicKey jwk) { return new Client(jwk); @@ -155,4 +166,20 @@ public static Client directedIdentity(Ed25519PublicKey jwk) { public static Client walletAddressString(URI address) { return new Client(address, Format.WALLET_ADDRESS_STRING); } + + /** + * Validates a JWK against the Open Payments {@code json-web-key} schema: {@code kid}, + * {@code alg}, {@code kty}, {@code crv} and {@code x} are required, and only Ed25519 + * signing keys are allowed. + */ + private static void ensureValidJwk(Ed25519PublicKey jwk) { + ensureNotNull(jwk, "jwk cannot be null."); + ensureNotNullOrEmpty(jwk.getKid(), "jwk.kid cannot be null or empty."); + ensureTrue(JWK_ALG.equals(jwk.getAlg()), String.format("jwk.alg must be '%s'.", JWK_ALG)); + ensureTrue(JWK_KTY.equals(jwk.getKty()), String.format("jwk.kty must be '%s'.", JWK_KTY)); + ensureTrue(JWK_CRV.equals(jwk.getCrv()), String.format("jwk.crv must be '%s'.", JWK_CRV)); + ensureTrue(jwk.getUse() == null || JWK_USE.equals(jwk.getUse()), String.format("jwk.use must be '%s'.", JWK_USE)); + ensureNotNullOrEmpty(jwk.getX(), "jwk.x cannot be null or empty."); + ensureTrue(JWK_X_PATTERN.matcher(jwk.getX()).matches(), "jwk.x must be a base64 url-encoded public key."); + } } diff --git a/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java b/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java index 96ff6dc..247a293 100644 --- a/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java +++ b/src/main/java/org/interledger/openpayments/model/grant/GrantRequest.java @@ -205,19 +205,22 @@ public static GrantRequest outgoing( * Creates a grant request that asks the authorization server to verify that the user * has access to the given subject (e.g. a wallet address). * - *

No access token is requested. The request requires user interaction, so call - * {@link #redirectInteract(URI, String)} before sending it. + *

No access token is requested. The specification requires {@code interact} for a + * subject request, so redirect interaction is always configured. * * @param clientWallet the client wallet address making the request * @param subject the subject the user must prove access to - * @return a new GrantRequest containing only the client and subject + * @param returnUrl the URL to return to when interaction is finished, or null to omit + * @param nonce a unique value to secure the interaction callback; required when returnUrl is set + * @return a new GrantRequest containing the client, subject and interact */ - public static GrantRequest subject(Client clientWallet, Subject subject) { + public static GrantRequest subject(Client clientWallet, Subject subject, URI returnUrl, String nonce) { ensureNotNull(subject, "subject cannot be null."); + ensureNotNullOrEmpty(subject.getSubIds(), "subject.sub_ids cannot be null or empty."); GrantRequest request = new GrantRequest(clientWallet); request.subject = subject; - return request; + return request.redirectInteract(returnUrl, nonce); } /** diff --git a/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java b/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java index 40618ff..0d86383 100644 --- a/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java +++ b/src/test/java/org/interledger/openpayments/model/grant/GrantModelSerializationTest.java @@ -11,6 +11,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.math.BigDecimal; import java.net.URI; @@ -57,14 +59,51 @@ void shouldWriteWalletAddressObjectByDefault() throws Exception { assertEquals(Client.Format.WALLET_ADDRESS, new Client(WALLET).getFormat()); } - @Test - void shouldWriteDirectedIdentity() throws Exception { + private Ed25519PublicKey validJwk() { var jwk = new Ed25519PublicKey(); jwk.setKid("key-1"); jwk.setAlg("EdDSA"); jwk.setKty("OKP"); jwk.setCrv("Ed25519"); jwk.setX("oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI"); + return jwk; + } + + @ParameterizedTest(name = "{0}") + @CsvSource(nullValues = "NULL", value = { + "missing kid, kid, NULL", + "empty kid, kid, ''", + "wrong alg, alg, Ed25519", + "missing alg, alg, NULL", + "wrong kty, kty, EC", + "wrong crv, crv, P-256", + "wrong use, use, enc", + "missing x, x, NULL", + "x not base64url, x, 'abc+/='" + }) + void shouldRejectInvalidDirectedIdentityJwk(String description, String field, String value) { + var jwk = validJwk(); + switch (field) { + case "kid" -> jwk.setKid(value); + case "alg" -> jwk.setAlg(value); + case "kty" -> jwk.setKty(value); + case "crv" -> jwk.setCrv(value); + case "use" -> jwk.setUse(value); + default -> jwk.setX(value); + } + assertThrows(OpenPaymentsClientException.class, () -> Client.directedIdentity(jwk)); + } + + @Test + void shouldAcceptJwkWithSigUse() { + var jwk = validJwk(); + jwk.setUse("sig"); + assertEquals(Client.Format.DIRECTED_IDENTITY, Client.directedIdentity(jwk).getFormat()); + } + + @Test + void shouldWriteDirectedIdentity() throws Exception { + var jwk = validJwk(); var expected = readTree("{\"jwk\":{\"kid\":\"key-1\",\"alg\":\"EdDSA\",\"kty\":\"OKP\"," + "\"crv\":\"Ed25519\",\"x\":\"oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI\"}}"); @@ -94,8 +133,11 @@ void shouldRejectMissingValues() { class GrantRequests { @Test void shouldWriteSubjectRequestWithoutAccessToken() throws Exception { - var request = GrantRequest.subject(new Client(WALLET), Subject.walletAddress(URI.create(WALLET))) - .redirectInteract(URI.create("https://paymentplatform.example/finish"), "nonce-1"); + var request = GrantRequest.subject( + new Client(WALLET), + Subject.walletAddress(URI.create(WALLET)), + URI.create("https://paymentplatform.example/finish"), + "nonce-1"); var expected = readTree("{" + "\"client\":{\"walletAddress\":\"" + WALLET + "\"}," @@ -107,6 +149,27 @@ void shouldWriteSubjectRequestWithoutAccessToken() throws Exception { assertEquals(expected, toJson(request)); } + @Test + void shouldAlwaysIncludeInteractInSubjectRequest() { + var request = GrantRequest.subject(new Client(WALLET), Subject.walletAddress(URI.create(WALLET)), null, null); + + var interact = toJson(request).get("interact"); + assertNotNull(interact); + assertEquals("redirect", interact.at("/start/0").asText()); + assertFalse(interact.has("finish")); + } + + @Test + void shouldRejectInvalidSubjectRequest() { + var client = new Client(WALLET); + var subject = Subject.walletAddress(URI.create(WALLET)); + var returnUrl = URI.create("https://paymentplatform.example/finish"); + + assertThrows(OpenPaymentsClientException.class, () -> GrantRequest.subject(client, null, returnUrl, "nonce-1")); + assertThrows(OpenPaymentsClientException.class, () -> GrantRequest.subject(client, new Subject(), returnUrl, "nonce-1")); + assertThrows(OpenPaymentsClientException.class, () -> GrantRequest.subject(client, subject, returnUrl, null)); + } + @Test void shouldWriteOutgoingRequestWithInterval() throws Exception { var debit = Amount.build(new BigDecimal("100.00"), "CAD");