Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,37 @@ Maven dependency (Please see [Maven Central](https://central.sonatype.com/artifa
</dependency>
```

### 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -491,6 +536,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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
28 changes: 28 additions & 0 deletions src/integrationTest/resources/test-config.properties
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,31 @@ receiver.wallet.address=https://wallet-address/client/receiver
# 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)
[email protected]
test.wallet.password=<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=<encrypted-pin-block>
# card.pwk.key=<encrypted-pin-working-key>
# card.pwk.kvc=<key-check-value>
# card.pwk.iv=<initialization-vector>
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -68,6 +71,73 @@ OutgoingPayment continuation(
String nonce
);

/**
* Requests an interactive outgoing payment grant with the given limits.
*
* <p>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.:
* <pre>
* 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);
* </pre>
*
* @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.
*
* <p>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+).
*
* <p>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,4 +141,16 @@ OutgoingPaymentsResult getOutgoingPayments(
URI walletAddressUri,
Grant grantRequest
);

/**
* Retrieves the spent amounts for the current outgoing payment grant.
*
* <p>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);
}
Loading
Loading