diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 000000000..062416ae8 --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,42 @@ +name: Java CI +on: + pull_request: + paths: + - 'java/**' + push: + branches: [ main ] + paths: + - 'java/**' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Maven dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Run tests with coverage + working-directory: ./java + run: mvn clean test -Pcoverage + + - name: Run checkstyle + working-directory: ./java + run: mvn checkstyle:check + + - name: Run SpotBugs + working-directory: ./java + run: mvn spotbugs:check \ No newline at end of file diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 000000000..a90a89128 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,62 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Eclipse +.classpath +.project +.settings/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +/build/ + +# VS Code +.vscode/ + +# Mac +.DS_Store + +# Compile files +*.class + +# Log files +*.log +logs/ + +# BlueJ files +*.ctxt + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Virtual Machine crash logs +hs_err_pid* + +# Local config overrides +application-local.properties +application-local.yml \ No newline at end of file diff --git a/java/README.md b/java/README.md new file mode 100644 index 000000000..20a69c077 --- /dev/null +++ b/java/README.md @@ -0,0 +1,286 @@ +# x402 Java + +[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg)](https://github.com/coinbase/x402/java) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/coinbase/x402/blob/main/LICENSE) +[![Java Version](https://img.shields.io/badge/java-17%2B-orange)](https://github.com/coinbase/x402/java) + +Java implementation of [x402](https://github.com/coinbase/x402) + +## Quick Start + +```bash +# Build and test +mvn clean install + +# Run tests +mvn test + +# Check code coverage +mvn jacoco:report +mvn -P coverage verify # Enforces 90% coverage + +# Check code quality +mvn checkstyle:check +mvn spotbugs:check +``` + +## Overview + +x402 is a system for decentralized payments for API calls, web content, and other HTTP resources. The `402` stands for the HTTP status code `Payment Required`. + +This library provides a Java implementation of the `x402` protocol, with the following core components: + +- `**PaymentFilter**`: A servlet filter that authenticates payments and rejects unauthorized requests +- `**FacilitatorClient**`: A client for verifying and settling payments with a facilitator service +- `**X402HttpClient**`: A convenience HTTP client for making payment-enabled requests + +## Compatibility + +- Java 17+ +- Jakarta Servlet API or `javax.servlet` +- Works with any servlet container (Tomcat, Jetty, etc.) +- Compatible with Spring Boot, Quarkus, and other Java frameworks + +## Installation + +To use this library, you need to build and install it locally: + +```bash +# Clone the repository +git clone https://github.com/coinbase/x402.git +cd x402/java + +# Build and install to your local Maven repository +mvn clean install +``` + +Then add the dependency to your Maven project: + +```xml + + com.coinbase + x402 + 0.1.0-SNAPSHOT + +``` + +## Usage + +### Server Side - Requiring Payments for Access + +Integrate the `x402` filter into your servlet-based application to require payment for specific paths: + +```java +import com.coinbase.x402.server.PaymentFilter; +import com.coinbase.x402.client.HttpFacilitatorClient; + +import java.math.BigInteger; +import java.util.Map; + +// 1. Define paths that require payment and their prices +Map priceTable = Map.of( + "/api/premium", BigInteger.valueOf(1000), // 1000 wei + "/content/exclusive", BigInteger.valueOf(500) +); + +// 2. Create a facilitator client +String facilitatorUrl = "https://x402.org/faciliator"; +HttpFacilitatorClient facilitator = new HttpFacilitatorClient(facilitatorUrl); + +// 3. Create and register the filter +String payToAddress = "0xYourReceiverAddress"; +PaymentFilter paymentFilter = new PaymentFilter(payToAddress, priceTable, facilitator); + +// 4. Register the filter with your servlet container +// In a standard servlet app: +FilterRegistration.Dynamic registration = servletContext.addFilter("paymentFilter", paymentFilter); +registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*"); + +// Or in Spring Boot: +@Bean +public FilterRegistration paymentFilter(ServletContext servletContext) { + FilterRegistration.Dynamic registration = servletContext.addFilter( + "paymentFilter", + new PaymentFilter(payToAddress, priceTable, new HttpFacilitatorClient(facilitatorUrl)) + ); + registration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*"); + return registration; +} +``` + +### Client Side - Making Requests with Payment + +To make HTTP requests that include payment proofs: + +```java +import com.coinbase.x402.client.X402HttpClient; +import com.coinbase.x402.crypto.CryptoSigner; + +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpResponse; +import java.util.Map; + +// 1. Implement the CryptoSigner interface with your crypto library +// This example uses a stub - you'd integrate with web3j, Solana-J, etc. +CryptoSigner signer = new CryptoSigner() { + @Override + public String sign(Map payload) { + // Sign the payload with your private key + return "0xYourSignatureHere"; + } +}; + +// 2. Create the client +X402HttpClient client = new X402HttpClient(signer); + +// 3. Make a GET request with payment +BigInteger amount = BigInteger.valueOf(1000); +String asset = "0xTokenContractAddress"; // Or "USDC", etc. +String payTo = "0xReceiverAddress"; +URI uri = URI.create("https://api.example.com/premium"); + +HttpResponse response = client.get(uri, amount, asset, payTo); +System.out.println("Response: " + response.body()); +``` + +## Complete Example + +Here's a complete example of a Spring Boot application with a paid joke API: + +```java +import com.coinbase.x402.server.PaymentFilter; +import com.coinbase.x402.client.HttpFacilitatorClient; +import javax.servlet.DispatcherType; +import javax.servlet.FilterRegistration; +import javax.servlet.ServletContext; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.ServletContextInitializer; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.math.BigInteger; +import java.util.EnumSet; +import java.util.Map; + +@SpringBootApplication +public class PaidJokeApplication implements ServletContextInitializer { + + public static void main(String[] args) { + SpringApplication.run(PaidJokeApplication.class, args); + } + + @Override + public void onStartup(ServletContext servletContext) { + // Set up the payment filter + String facilitatorUrl = "https://x402.org/facilitator"; + HttpFacilitatorClient facilitator = new HttpFacilitatorClient(facilitatorUrl); + + // Define which URLs require payment and their prices + Map priceTable = Map.of( + "/api/joke", BigInteger.valueOf(1000) // 1000 wei for a premium joke + ); + + // Create and register the filter + String payToAddress = "0xYourReceiverAddress"; + PaymentFilter paymentFilter = new PaymentFilter(payToAddress, priceTable, facilitator); + + FilterRegistration.Dynamic registration = + servletContext.addFilter("paymentFilter", paymentFilter); + registration.addMappingForUrlPatterns( + EnumSet.of(DispatcherType.REQUEST), true, "/*"); + } + + @RestController + static class JokeController { + @GetMapping("/api/joke") + public Map getPremiumJoke() { + // If this code runs, payment was already verified by the filter + return Map.of("joke", "Why do programmers prefer dark mode? Because light attracts bugs!"); + } + } +} +``` + +## How It Works + +1. The server defines endpoints that require payment and their prices +2. When a request comes in, the `PaymentFilter` checks if the path requires payment +3. If payment is required, it looks for the `X-PAYMENT` header +4. The facilitator verifies the payment and either approves or rejects the request +5. If approved, the request continues; if rejected, a 402 Payment Required response is returned +6. After serving the request, the filter calls the facilitator to settle the payment + +## Payment Flow + +```mermaid +sequenceDiagram + participant Client + participant Server + participant Facilitator + + Client->>Server: GET /resource + Server->>Client: 402 Payment Required + + Client->>Server: GET /resource (with X-PAYMENT header) + Server->>Facilitator: Verify payment + Facilitator->>Server: Payment verified + + Server->>Client: 200 OK + content + Server->>Facilitator: Settle payment (async) +``` + +## Error Handling + +The PaymentFilter handles different types of errors with appropriate HTTP status codes: + +### Payment Required (402) +When a payment is required but not provided or is invalid, the filter returns a `402 Payment Required` response with a JSON body: + +```json +{ + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base-sepolia", + "maxAmountRequired": "1000", + "asset": "USDC", + "resource": "/api/premium", + "mimeType": "application/json", + "payTo": "0xReceiverAddress", + "maxTimeoutSeconds": 30 + } + ], + "error": "missing payment header" +} +``` + +### Server Errors (500) +When the facilitator service is unavailable or other unexpected errors occur during payment verification: + +```json +{ + "error": "Payment verification failed: Connection timeout" +} +``` + +Or for unexpected internal errors: + +```json +{ + "error": "Internal server error during payment verification" +} +``` + +### Settlement Errors +When payment settlement fails after successful verification, the filter returns a 402 status to prevent users from receiving content without proper payment completion. This matches the behavior of the Go and TypeScript implementations: + +```json +{ + "x402Version": 1, + "accepts": [...], + "error": "settlement failed: insufficient balance" +} +``` diff --git a/java/checkstyle-suppressions.xml b/java/checkstyle-suppressions.xml new file mode 100644 index 000000000..f8b7c9873 --- /dev/null +++ b/java/checkstyle-suppressions.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/java/checkstyle.xml b/java/checkstyle.xml new file mode 100644 index 000000000..4fb746528 --- /dev/null +++ b/java/checkstyle.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 000000000..799d43869 --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,249 @@ + + + 4.0.0 + + com.coinbase + x402 + 0.1.0-SNAPSHOT + jar + + + 17 + 17 + 9.4.51.v20230217 + UTF-8 + + + + + + com.fasterxml.jackson.core + jackson-databind + 2.17.0 + + + + jakarta.servlet + jakarta.servlet-api + 4.0.4 + provided + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + org.mockito + mockito-junit-jupiter + 5.11.0 + test + + + + com.github.tomakehurst + wiremock-jre8 + 2.35.1 + test + + + + org.eclipse.jetty + jetty-server + ${jetty.version} + test + + + + org.eclipse.jetty + jetty-servlet + ${jetty.version} + test + + + + javax.servlet + javax.servlet-api + 3.1.0 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.4.0 + + checkstyle.xml + true + true + false + + + + com.puppycrawl.tools + checkstyle + 10.12.4 + + + + + validate + validate + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.2.0 + + spotbugs-exclude.xml + Max + High + + + + com.github.spotbugs + spotbugs + 4.8.3 + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + + + + lint + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + lint + validate + + check + + + + + + com.github.spotbugs + spotbugs-maven-plugin + + + lint + verify + + check + + + + + + + + + + coverage + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + prepare-agent + + prepare-agent + + + + report + + report + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.85 + + + BRANCH + COVEREDRATIO + 0.75 + + + + + + + + + + + + + diff --git a/java/spotbugs-exclude.xml b/java/spotbugs-exclude.xml new file mode 100644 index 000000000..a5700e7c9 --- /dev/null +++ b/java/spotbugs-exclude.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/java/src/main/java/com/coinbase/x402/client/FacilitatorClient.java b/java/src/main/java/com/coinbase/x402/client/FacilitatorClient.java new file mode 100644 index 000000000..320b8434f --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/client/FacilitatorClient.java @@ -0,0 +1,44 @@ +package com.coinbase.x402.client; + +import com.coinbase.x402.model.PaymentRequirements; + +import java.io.IOException; +import java.util.Set; + +/** Contract for calling an x402 facilitator (HTTP, gRPC, mock, etc.). */ +public interface FacilitatorClient { + /** + * Verifies a payment header against the given requirements. + * + * @param paymentHeader the X-402 payment header to verify + * @param req the payment requirements to validate against + * @return verification response indicating if payment is valid + * @throws IOException if HTTP request fails or returns non-200 status + * @throws InterruptedException if the request is interrupted + */ + VerificationResponse verify(String paymentHeader, + PaymentRequirements req) + throws IOException, InterruptedException; + + /** + * Settles a verified payment on the blockchain. + * + * @param paymentHeader the X-402 payment header to settle + * @param req the payment requirements for settlement + * @return settlement response with transaction details if successful + * @throws IOException if HTTP request fails or returns non-200 status + * @throws InterruptedException if the request is interrupted + */ + SettlementResponse settle(String paymentHeader, + PaymentRequirements req) + throws IOException, InterruptedException; + + /** + * Retrieves the set of payment kinds supported by this facilitator. + * + * @return set of supported payment kinds (scheme/network combinations) + * @throws IOException if HTTP request fails or returns non-200 status + * @throws InterruptedException if the request is interrupted + */ + Set supported() throws IOException, InterruptedException; +} diff --git a/java/src/main/java/com/coinbase/x402/client/HttpFacilitatorClient.java b/java/src/main/java/com/coinbase/x402/client/HttpFacilitatorClient.java new file mode 100644 index 000000000..5d5638ad9 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/client/HttpFacilitatorClient.java @@ -0,0 +1,118 @@ +package com.coinbase.x402.client; + +import com.coinbase.x402.model.PaymentRequirements; +import com.coinbase.x402.util.Json; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Synchronous facilitator client using Java 17 HttpClient. */ +public class HttpFacilitatorClient implements FacilitatorClient { + + private final HttpClient http = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + private final String baseUrl; // without trailing “/” + + /** + * Creates a new HTTP facilitator client. + * + * @param baseUrl the base URL of the facilitator service (trailing slash will be removed) + */ + public HttpFacilitatorClient(String baseUrl) { + this.baseUrl = baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) + : baseUrl; + } + + /* ------------------------------------------------ verify ------------- */ + + @Override + public VerificationResponse verify(String paymentHeader, + PaymentRequirements req) + throws IOException, InterruptedException { + + Map body = Map.of( + "x402Version", 1, + "paymentHeader", paymentHeader, + "paymentRequirements", req + ); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/verify")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + Json.MAPPER.writeValueAsString(body))) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + return Json.MAPPER.readValue(response.body(), VerificationResponse.class); + } + + /* ------------------------------------------------ settle ------------- */ + + @Override + public SettlementResponse settle(String paymentHeader, + PaymentRequirements req) + throws IOException, InterruptedException { + + Map body = Map.of( + "x402Version", 1, + "paymentHeader", paymentHeader, + "paymentRequirements", req + ); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/settle")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString( + Json.MAPPER.writeValueAsString(body))) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + return Json.MAPPER.readValue(response.body(), SettlementResponse.class); + } + + /* ------------------------------------------------ supported ---------- */ + + @Override + public Set supported() throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/supported")) + .GET() + .build(); + + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("HTTP " + response.statusCode() + ": " + response.body()); + } + + @SuppressWarnings("unchecked") + Map map = Json.MAPPER.readValue(response.body(), Map.class); + List kinds = (List) map.getOrDefault("kinds", List.of()); + + + Set out = new HashSet<>(); + for (Object k : kinds) { + out.add(Json.MAPPER.convertValue(k, Kind.class)); + } + return out; + } +} diff --git a/java/src/main/java/com/coinbase/x402/client/Kind.java b/java/src/main/java/com/coinbase/x402/client/Kind.java new file mode 100644 index 000000000..49bc16f91 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/client/Kind.java @@ -0,0 +1,27 @@ +package com.coinbase.x402.client; + +/** Identifies a payment scheme+network pair that a facilitator supports. */ +public class Kind { + /** Payment scheme identifier (e.g. "exact"). */ + public final String scheme; + + /** Network identifier (e.g. "base-sepolia"). */ + public final String network; + + /** Default constructor for Jackson deserialization. */ + public Kind() { + this.scheme = null; + this.network = null; + } + + /** + * Creates a new Kind with the specified scheme and network. + * + * @param scheme the payment scheme identifier + * @param network the network identifier + */ + public Kind(String scheme, String network) { + this.scheme = scheme; + this.network = network; + } +} diff --git a/java/src/main/java/com/coinbase/x402/client/SettlementResponse.java b/java/src/main/java/com/coinbase/x402/client/SettlementResponse.java new file mode 100644 index 000000000..636e761fe --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/client/SettlementResponse.java @@ -0,0 +1,16 @@ +package com.coinbase.x402.client; + +/** JSON returned by POST /settle on the facilitator. */ +public class SettlementResponse { + /** Whether the payment settlement succeeded. */ + public boolean success; + + /** Error message if settlement failed. */ + public String error; + + /** Transaction hash of the settled payment. */ + public String txHash; + + /** Network ID where the settlement occurred. */ + public String networkId; +} diff --git a/java/src/main/java/com/coinbase/x402/client/VerificationResponse.java b/java/src/main/java/com/coinbase/x402/client/VerificationResponse.java new file mode 100644 index 000000000..e34c843c9 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/client/VerificationResponse.java @@ -0,0 +1,11 @@ +package com.coinbase.x402.client; + +/** JSON returned by POST /verify on the facilitator. */ +public class VerificationResponse { + /** Whether the payment verification succeeded. */ + public boolean isValid; + + /** Reason for verification failure (if isValid is false). */ + public String invalidReason; +} + diff --git a/java/src/main/java/com/coinbase/x402/client/X402HttpClient.java b/java/src/main/java/com/coinbase/x402/client/X402HttpClient.java new file mode 100644 index 000000000..d646ee29c --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/client/X402HttpClient.java @@ -0,0 +1,98 @@ +package com.coinbase.x402.client; + +import com.coinbase.x402.crypto.CryptoSigner; +import com.coinbase.x402.crypto.CryptoSignException; +import com.coinbase.x402.model.PaymentPayload; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Convenience wrapper that builds an HTTP request with a properly-formed + * X-PAYMENT header for the “exact” EVM scheme on Base Sepolia. + * + * You provide a {@link CryptoSigner} implementation to actually sign the + * payment payload (e.g. using web3j). Everything else is generic JSON + Base64. + */ +public class X402HttpClient { + + private final HttpClient http = HttpClient.newHttpClient(); + private final int x402Version = 1; + private final String scheme = "exact"; + private final String network = "base-sepolia"; + + private final CryptoSigner signer; + + /** + * Creates a new X402 HTTP client with the specified crypto signer. + * + * @param signer the crypto signer for signing payment headers + */ + public X402HttpClient(CryptoSigner signer) { + this.signer = signer; + } + + /** + * Protected method that can be overridden in tests to mock HTTP responses. + * + * @param request the HTTP request to send + * @return the HTTP response + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the request is interrupted + */ + protected HttpResponse sendRequest(HttpRequest request) throws IOException, InterruptedException { + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } + + /** + * Build and execute a GET request that includes an X-PAYMENT + * header proving the caller intends to pay {@code amount} of {@code assetContract} + * to {@code payTo}. + * + * @param uri destination (must include path) + * @param amount amount in atomic units (wei, lamports, etc.) + * @param assetContract token contract address (incl. 0x prefix) or symbol + * @param payTo receiver address (same chain as asset) + */ + public HttpResponse get(URI uri, + BigInteger amount, + String assetContract, + String payTo) + throws IOException, InterruptedException { + + /* ---------- Build scheme-specific payload map ------------------- */ + Map pl = new LinkedHashMap<>(); + pl.put("amount", amount.toString()); + pl.put("asset", assetContract); + pl.put("payTo", payTo); + pl.put("resource", uri.getPath()); + pl.put("nonce", UUID.randomUUID().toString()); + try { + pl.put("signature", signer.sign(pl)); // <-- signer injected + } catch (CryptoSignException e) { + throw new RuntimeException("Failed to sign payment payload", e); + } + /* ---------------------------------------------------------------- */ + + PaymentPayload p = new PaymentPayload(); + p.x402Version = x402Version; + p.scheme = scheme; + p.network = network; + p.payload = pl; + + HttpRequest req = HttpRequest.newBuilder() + .uri(uri) + .header("X-PAYMENT", p.toHeader()) + .GET() + .build(); + + return sendRequest(req); + } +} diff --git a/java/src/main/java/com/coinbase/x402/crypto/CryptoSignException.java b/java/src/main/java/com/coinbase/x402/crypto/CryptoSignException.java new file mode 100644 index 000000000..4d92d9013 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/crypto/CryptoSignException.java @@ -0,0 +1,26 @@ +package com.coinbase.x402.crypto; + +/** + * Exception thrown when cryptographic signing operations fail. + */ +public class CryptoSignException extends Exception { + + /** + * Constructs a new CryptoSignException with the specified detail message. + * + * @param message the detail message + */ + public CryptoSignException(String message) { + super(message); + } + + /** + * Constructs a new CryptoSignException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause + */ + public CryptoSignException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/java/src/main/java/com/coinbase/x402/crypto/CryptoSigner.java b/java/src/main/java/com/coinbase/x402/crypto/CryptoSigner.java new file mode 100644 index 000000000..52980bbde --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/crypto/CryptoSigner.java @@ -0,0 +1,37 @@ +package com.coinbase.x402.crypto; + +import java.util.Map; + +/** + * Produces a protocol-specific signature for an x402 payment-authorization payload. + * + *

The caller decides which concrete signer to use (e.g. by scheme ID or DI). + * Each implementation MUST interpret the {@code payload} keys as defined by its + * payment scheme and return the scheme’s canonical encoding:

+ * + *
    + *
  • exact-evm – ERC-3009 transferWithAuthorization
    + * keys: {@code from,to,value,validAfter,validBefore,nonce}
    + * return: 0x-prefixed 65-byte hex string {@code r∥s∥v} (v = 27 or 28)
  • + * + *
  • exact-solana – Ed25519 over the canonical JSON payload
    + * return: Base58-encoded 64-byte signature
  • + *
+ * + *

Implementations should throw {@link IllegalArgumentException} for missing + * or mistyped fields and {@link CryptoSignException} for low-level crypto + * errors.

+ */ +public interface CryptoSigner { + + /** + * Signs the supplied payload and returns the signature. + * + * @param payload scheme-specific authorization fields + * @return encoded signature string (see scheme rules above) + * @throws IllegalArgumentException for malformed payloads + * @throws CryptoSignException for cryptographic failures + */ + String sign(Map payload) throws CryptoSignException; +} + diff --git a/java/src/main/java/com/coinbase/x402/model/Authorization.java b/java/src/main/java/com/coinbase/x402/model/Authorization.java new file mode 100644 index 000000000..8f6f93c68 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/model/Authorization.java @@ -0,0 +1,28 @@ +package com.coinbase.x402.model; + +/** + * ERC-3009 authorization information within a payment payload. + * Matches the TypeScript ExactEvmPayloadAuthorization and Go ExactEvmPayloadAuthorization structures. + */ +public class Authorization { + /** Wallet address of the person making the payment (sender). */ + public String from; + + /** Wallet address receiving the payment. */ + public String to; + + /** Payment amount in atomic units. */ + public String value; + + /** Timestamp after which the authorization is valid. */ + public String validAfter; + + /** Timestamp before which the authorization is valid. */ + public String validBefore; + + /** Unique hex-encoded nonce to prevent replay attacks. */ + public String nonce; + + /** Default constructor for Jackson. */ + public Authorization() {} +} diff --git a/java/src/main/java/com/coinbase/x402/model/ExactSchemePayload.java b/java/src/main/java/com/coinbase/x402/model/ExactSchemePayload.java new file mode 100644 index 000000000..0f592bf21 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/model/ExactSchemePayload.java @@ -0,0 +1,16 @@ +package com.coinbase.x402.model; + +/** + * Payload structure for the "exact" EVM payment scheme. + * Matches the TypeScript ExactEvmPayload and Go ExactEvmPayload structures. + */ +public class ExactSchemePayload { + /** The cryptographic signature for the payment. */ + public String signature; + + /** Authorization information including ERC-3009 transfer authorization. */ + public Authorization authorization; + + /** Default constructor for Jackson. */ + public ExactSchemePayload() {} +} diff --git a/java/src/main/java/com/coinbase/x402/model/PaymentPayload.java b/java/src/main/java/com/coinbase/x402/model/PaymentPayload.java new file mode 100644 index 000000000..3a31c8246 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/model/PaymentPayload.java @@ -0,0 +1,32 @@ +package com.coinbase.x402.model; + +import com.coinbase.x402.util.Json; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +/** Base header object encoded into X-PAYMENT. */ +public class PaymentPayload { + public int x402Version; + public String scheme; + public String network; + public Map payload; // scheme‑specific map + + /** Serialise and base64‑encode for the X‑PAYMENT header. */ + public String toHeader() { + try { + String json = Json.MAPPER.writeValueAsString(this); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalStateException("Unable to encode payment header", e); + } + } + + /** Decode from the header. */ + public static PaymentPayload fromHeader(String header) throws IOException { + byte[] decoded = Base64.getDecoder().decode(header); + return Json.MAPPER.readValue(decoded, PaymentPayload.class); + } +} diff --git a/java/src/main/java/com/coinbase/x402/model/PaymentRequiredResponse.java b/java/src/main/java/com/coinbase/x402/model/PaymentRequiredResponse.java new file mode 100644 index 000000000..b332aca13 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/model/PaymentRequiredResponse.java @@ -0,0 +1,11 @@ +package com.coinbase.x402.model; + +import java.util.ArrayList; +import java.util.List; + +/** HTTP 402 response body returned by an x402-enabled server. */ +public class PaymentRequiredResponse { + public int x402Version; + public List accepts = new ArrayList<>(); + public String error; +} diff --git a/java/src/main/java/com/coinbase/x402/model/PaymentRequirements.java b/java/src/main/java/com/coinbase/x402/model/PaymentRequirements.java new file mode 100644 index 000000000..34d6b39b4 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/model/PaymentRequirements.java @@ -0,0 +1,19 @@ +package com.coinbase.x402.model; + +import java.util.Map; + +/** Defines one acceptable way to pay for a resource. */ +public class PaymentRequirements { + public String scheme; // e.g. "exact" + public String network; // e.g. "base-sepolia" + public String maxAmountRequired; // uint256 in wei / atomic units + public String resource; // URL path the client is paying for + public String description; + public String mimeType; // expected response MIME + public Map outputSchema; // optional JSON schema + public String payTo; // address (EVM / Solana etc.) + public int maxTimeoutSeconds; + public String asset; // token contract address / symbol + public Map extra; // scheme‑specific +} + diff --git a/java/src/main/java/com/coinbase/x402/model/SettlementResponseHeader.java b/java/src/main/java/com/coinbase/x402/model/SettlementResponseHeader.java new file mode 100644 index 000000000..a41c970f8 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/model/SettlementResponseHeader.java @@ -0,0 +1,33 @@ +package com.coinbase.x402.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Settlement response header that gets base64-encoded into X-PAYMENT-RESPONSE. + * Matches the structure of Go SettleResponse and TypeScript SettleResponse. + */ +@JsonInclude(JsonInclude.Include.ALWAYS) // Always include all fields, even nulls +public class SettlementResponseHeader { + /** Whether the settlement was successful. */ + public boolean success; + + /** Transaction hash of the settled payment. */ + public String transaction; + + /** Network ID where the settlement occurred. */ + public String network; + + /** Wallet address of the person who made the payment (can be null). */ + public String payer; + + /** Default constructor for Jackson. */ + public SettlementResponseHeader() {} + + /** Constructor with all fields. */ + public SettlementResponseHeader(boolean success, String transaction, String network, String payer) { + this.success = success; + this.transaction = transaction; + this.network = network; + this.payer = payer; + } +} diff --git a/java/src/main/java/com/coinbase/x402/server/PaymentFilter.java b/java/src/main/java/com/coinbase/x402/server/PaymentFilter.java new file mode 100644 index 000000000..4b8d881af --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/server/PaymentFilter.java @@ -0,0 +1,258 @@ +package com.coinbase.x402.server; + +import com.coinbase.x402.client.FacilitatorClient; +import com.coinbase.x402.client.SettlementResponse; +import com.coinbase.x402.client.VerificationResponse; +import com.coinbase.x402.model.ExactSchemePayload; +import com.coinbase.x402.model.PaymentPayload; +import com.coinbase.x402.model.PaymentRequirements; +import com.coinbase.x402.model.PaymentRequiredResponse; +import com.coinbase.x402.model.SettlementResponseHeader; +import com.coinbase.x402.util.Json; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.math.BigInteger; +import java.util.Map; +import java.util.Objects; + +/** Servlet/Spring filter that enforces x402 payments on selected paths. */ +public class PaymentFilter implements Filter { + + private final String payTo; + private final Map priceTable; // path → amount + private final FacilitatorClient facilitator; + + /** + * Creates a payment filter that enforces X-402 payments on configured paths. + * + * @param payTo wallet address for payments + * @param priceTable maps request paths to required payment amounts in atomic units. + * Uses exact, case-sensitive matching against {@code HttpServletRequest#getRequestURI()}. + * Query parameters are included in matching, HTTP method is ignored. + * Paths not present in the map allow free access. Values are atomic units + * assuming 6-decimal tokens (10000 = 0.01 USDC, 1000000 = 1.00 USDC). + * @param facilitator client for payment verification and settlement + * @apiNote + *

Path matching

+ *
    + *
  • Exact, case-sensitive compare of {@code HttpServletRequest#getRequestURI()}
  • + *
  • Query string included; HTTP method ignored
  • + *
  • URIs not present in the map are free
  • + *
+ * + *

Price units — amounts assume a 6-decimal token (e.g. USDC). + * Multiply by 1012 for 18-decimal tokens.

+ * + *

Examples

+ *
{@code
+     * Map priceTable = Map.of(
+     *     "/api/premium", BigInteger.valueOf( 10000),   // 0.01 USDC
+     *     "/api/report",  BigInteger.valueOf(1000000)   // 1.00 USDC
+     * );
+     * }
+ */ + public PaymentFilter(String payTo, + Map priceTable, + FacilitatorClient facilitator) { + this.payTo = Objects.requireNonNull(payTo); + this.priceTable = Objects.requireNonNull(priceTable); + this.facilitator = Objects.requireNonNull(facilitator); + } + + /* ------------------------------------------------ core -------------- */ + + @Override + public void doFilter(ServletRequest req, + ServletResponse res, + FilterChain chain) + throws IOException, ServletException { + + if (!(req instanceof HttpServletRequest) || + !(res instanceof HttpServletResponse)) { + chain.doFilter(req, res); // non-HTTP + return; + } + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + String path = request.getRequestURI(); + + /* -------- path is free? skip check ----------------------------- */ + if (!priceTable.containsKey(path)) { + chain.doFilter(req, res); + return; + } + + String header = request.getHeader("X-PAYMENT"); + if (header == null || header.isEmpty()) { + respond402(response, path, null); + return; + } + + VerificationResponse vr; + PaymentPayload payload; + try { + payload = PaymentPayload.fromHeader(header); + + // simple sanity: resource must match the URL path + if (!Objects.equals(payload.payload.get("resource"), path)) { + respond402(response, path, "resource mismatch"); + return; + } + + vr = facilitator.verify(header, buildRequirements(path)); + } catch (IllegalArgumentException ex) { + // Malformed payment header - client error + respond402(response, path, "malformed X-PAYMENT header"); + return; + } catch (IOException ex) { + // Network/communication error with facilitator - server error + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + try { + response.getWriter().write("{\"error\":\"Payment verification failed: " + ex.getMessage() + "\"}"); + } catch (IOException writeEx) { + // If we can't write the response, at least set the status + System.err.println("Failed to write error response: " + writeEx.getMessage()); + } + return; + } catch (Exception ex) { + // Other unexpected errors - server error + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + try { + response.getWriter().write("{\"error\":\"Internal server error during payment verification\"}"); + } catch (IOException writeEx) { + System.err.println("Failed to write error response: " + writeEx.getMessage()); + } + return; + } + + if (!vr.isValid) { + respond402(response, path, vr.invalidReason); + return; + } + + /* -------- payment verified → continue business logic ----------- */ + chain.doFilter(req, res); + + /* -------- settlement (return errors to user) ------------- */ + try { + SettlementResponse sr = facilitator.settle(header, buildRequirements(path)); + if (sr == null || !sr.success) { + // Settlement failed - return 402 if headers not sent yet + if (!response.isCommitted()) { + String errorMsg = sr != null && sr.error != null ? sr.error : "settlement failed"; + respond402(response, path, errorMsg); + } + return; + } + + // Settlement succeeded - add settlement response header (base64-encoded JSON) + try { + // Extract payer from payment payload (wallet address of person making payment) + String payer = extractPayerFromPayload(payload); + + String base64Header = createPaymentResponseHeader(sr, payer); + response.setHeader("X-PAYMENT-RESPONSE", base64Header); + + // Set CORS header to expose X-PAYMENT-RESPONSE to browser clients + response.setHeader("Access-Control-Expose-Headers", "X-PAYMENT-RESPONSE"); + } catch (Exception ex) { + // If header creation fails, return 500 + if (!response.isCommitted()) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType("application/json"); + try { + response.getWriter().write("{\"error\":\"Failed to create settlement response header\"}"); + } catch (IOException writeEx) { + System.err.println("Failed to write error response: " + writeEx.getMessage()); + } + } + return; + } + } catch (Exception ex) { + // Network/communication errors during settlement - return 402 + if (!response.isCommitted()) { + respond402(response, path, "settlement error: " + ex.getMessage()); + } + return; + } + } + + /* ------------------------------------------------ helpers ---------- */ + + /** Build a PaymentRequirements object for the given path and price. */ + private PaymentRequirements buildRequirements(String path) { + PaymentRequirements pr = new PaymentRequirements(); + pr.scheme = "exact"; + pr.network = "base-sepolia"; + pr.maxAmountRequired = priceTable.get(path).toString(); + pr.asset = "USDC"; // adjust for your token + pr.resource = path; + pr.mimeType = "application/json"; + pr.payTo = payTo; + pr.maxTimeoutSeconds = 30; + return pr; + } + + /** Create a base64-encoded payment response header. */ + private String createPaymentResponseHeader(SettlementResponse sr, String payer) throws Exception { + SettlementResponseHeader settlementHeader = new SettlementResponseHeader( + true, + sr.txHash != null ? sr.txHash : "", + sr.networkId != null ? sr.networkId : "", + payer + ); + + String jsonString = Json.MAPPER.writeValueAsString(settlementHeader); + return Base64.getEncoder().encodeToString(jsonString.getBytes(StandardCharsets.UTF_8)); + } + + /** Extract the payer wallet address from payment payload. */ + private String extractPayerFromPayload(PaymentPayload payload) { + try { + // Convert the generic payload map to a typed ExactSchemePayload + ExactSchemePayload exactPayload = Json.MAPPER.convertValue(payload.payload, ExactSchemePayload.class); + return exactPayload.authorization != null ? exactPayload.authorization.from : null; + } catch (Exception ex) { + // If conversion fails, fall back to manual extraction for compatibility + try { + Object authorization = payload.payload.get("authorization"); + if (authorization instanceof Map) { + Object from = ((Map) authorization).get("from"); + return from instanceof String ? (String) from : null; + } + } catch (Exception ignored) { + // Ignore any extraction errors + } + return null; + } + } + + /** Write a JSON 402 response. */ + private void respond402(HttpServletResponse resp, + String path, + String error) + throws IOException { + + resp.setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + resp.setContentType("application/json"); + + PaymentRequiredResponse prr = new PaymentRequiredResponse(); + prr.x402Version = 1; + prr.accepts.add(buildRequirements(path)); + prr.error = error; + + resp.getWriter().write(Json.MAPPER.writeValueAsString(prr)); + } +} diff --git a/java/src/main/java/com/coinbase/x402/util/Json.java b/java/src/main/java/com/coinbase/x402/util/Json.java new file mode 100644 index 000000000..1a2554490 --- /dev/null +++ b/java/src/main/java/com/coinbase/x402/util/Json.java @@ -0,0 +1,16 @@ +package com.coinbase.x402.util; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** Utility holder for a configured Jackson ObjectMapper singleton. */ +public final class Json { + public static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + + private Json() {} +} diff --git a/java/src/test/java/com/coinbase/x402/client/HttpFacilitatorClientTest.java b/java/src/test/java/com/coinbase/x402/client/HttpFacilitatorClientTest.java new file mode 100644 index 000000000..79ee17572 --- /dev/null +++ b/java/src/test/java/com/coinbase/x402/client/HttpFacilitatorClientTest.java @@ -0,0 +1,227 @@ +package com.coinbase.x402.client; + +import com.coinbase.x402.model.PaymentRequirements; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.*; +import static com.github.tomakehurst.wiremock.client.WireMock.*; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class HttpFacilitatorClientTest { + + static WireMockServer wm; + HttpFacilitatorClient client; + + @BeforeAll + static void startServer() { + wm = new WireMockServer(0); // random port + wm.start(); + } + + @AfterAll + static void stopServer() { wm.stop(); } + + @BeforeEach + void setUp() { + wm.resetAll(); + client = new HttpFacilitatorClient("http://localhost:" + wm.port()); + } + + @Test + void constructorHandlesTrailingSlash() { + // Create client with trailing slash + HttpFacilitatorClient clientWithTrailingSlash = + new HttpFacilitatorClient("http://localhost:" + wm.port() + "/"); + + // Stub a simple request to verify the URL is formatted correctly + wm.stubFor(get(urlEqualTo("/supported")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"kinds\":[]}"))); + + // This would fail with a 404 if the URL was not correctly handled + assertDoesNotThrow(() -> clientWithTrailingSlash.supported()); + } + + @Test + void verifyAndSettleHappyPath() throws Exception { + // stub /verify + wm.stubFor(post(urlEqualTo("/verify")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"isValid\":true}"))); + + // stub /settle + wm.stubFor(post(urlEqualTo("/settle")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"success\":true,\"txHash\":\"0xabc\",\"networkId\":\"1\"}"))); + + PaymentRequirements req = new PaymentRequirements(); + VerificationResponse vr = client.verify("header", req); + assertTrue(vr.isValid); + + SettlementResponse sr = client.settle("header", req); + assertTrue(sr.success); + assertEquals("0xabc", sr.txHash); + } + + @Test + void supportedEndpoint() throws Exception { + wm.stubFor(get(urlEqualTo("/supported")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"kinds\":[{\"scheme\":\"exact\",\"network\":\"base-sepolia\"}]}"))); + + Set kinds = client.supported(); + assertEquals(1, kinds.size()); + Kind k = kinds.iterator().next(); + assertEquals("exact", k.scheme); + assertEquals("base-sepolia", k.network); + } + + @Test + void supportedEndpointWithEmptyKinds() throws Exception { + // Test when the 'kinds' list is empty + wm.stubFor(get(urlEqualTo("/supported")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"kinds\":[]}"))); + + Set kinds = client.supported(); + assertTrue(kinds.isEmpty()); + } + + @Test + void supportedEndpointWithMissingKinds() throws Exception { + // Test when the 'kinds' field is missing entirely + wm.stubFor(get(urlEqualTo("/supported")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"otherField\":123}"))); + + Set kinds = client.supported(); + assertTrue(kinds.isEmpty()); + } + + @Test + void verifyWithInvalidResponse() throws Exception { + // Test handling of invalid JSON in the verify response + wm.stubFor(post(urlEqualTo("/verify")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"isValid\":false,\"invalidReason\":\"insufficient balance\"}"))); + + PaymentRequirements req = new PaymentRequirements(); + VerificationResponse response = client.verify("header", req); + + assertFalse(response.isValid); + assertEquals("insufficient balance", response.invalidReason); + } + + @Test + void settleWithPartialResponse() throws Exception { + // Test when settlement response only has some fields + wm.stubFor(post(urlEqualTo("/settle")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"success\":true}"))); // Missing txHash and networkId + + PaymentRequirements req = new PaymentRequirements(); + SettlementResponse response = client.settle("header", req); + + assertTrue(response.success); + assertNull(response.txHash); // Should be null since it wasn't in the response + assertNull(response.networkId); + } + + @Test + void settleWithError() throws Exception { + // Test settlement with error response + wm.stubFor(post(urlEqualTo("/settle")) + .willReturn(aResponse() + .withHeader("Content-Type","application/json") + .withBody("{\"success\":false,\"error\":\"payment timed out\"}"))); + + PaymentRequirements req = new PaymentRequirements(); + SettlementResponse response = client.settle("header", req); + + assertFalse(response.success); + assertEquals("payment timed out", response.error); + } + + @Test + void testNetworkTimeout() { + // Test with a non-existent server to simulate network issues + HttpFacilitatorClient badClient = new HttpFacilitatorClient("http://localhost:1"); // Port 1 should not be listening + + PaymentRequirements req = new PaymentRequirements(); + + // Both methods should throw an exception + assertThrows(Exception.class, () -> badClient.verify("header", req)); + assertThrows(Exception.class, () -> badClient.settle("header", req)); + assertThrows(Exception.class, () -> badClient.supported()); + } + + @Test + void verifyRejectsNon200Status() { + PaymentRequirements req = new PaymentRequirements(); + + // Test HTTP 201 - should be rejected even though it's successful + wm.stubFor(post(urlEqualTo("/verify")) + .willReturn(aResponse() + .withStatus(201) + .withHeader("Content-Type", "application/json") + .withBody("{\"isValid\":true}"))); + + Exception ex = assertThrows(Exception.class, () -> client.verify("header", req)); + assertTrue(ex.getMessage().contains("HTTP 201")); + } + + @Test + void settleRejectsNon200Status() { + PaymentRequirements req = new PaymentRequirements(); + + // Test HTTP 404 + wm.stubFor(post(urlEqualTo("/settle")) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"not found\"}"))); + + Exception ex = assertThrows(Exception.class, () -> client.settle("header", req)); + assertTrue(ex.getMessage().contains("HTTP 404")); + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + void supportedRejectsNon200Status() { + // Test HTTP 500 + wm.stubFor(get(urlEqualTo("/supported")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"internal server error\"}"))); + + Exception ex = assertThrows(Exception.class, () -> client.supported()); + assertTrue(ex.getMessage().contains("HTTP 500")); + assertTrue(ex.getMessage().contains("internal server error")); + } + + @Test + void verifyHandles400BadRequest() { + PaymentRequirements req = new PaymentRequirements(); + + wm.stubFor(post(urlEqualTo("/verify")) + .willReturn(aResponse() + .withStatus(400) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\":\"invalid payment header\"}"))); + + Exception ex = assertThrows(Exception.class, () -> client.verify("header", req)); + assertTrue(ex.getMessage().contains("HTTP 400")); + assertTrue(ex.getMessage().contains("invalid payment header")); + } +} diff --git a/java/src/test/java/com/coinbase/x402/client/X402HttpClientTest.java b/java/src/test/java/com/coinbase/x402/client/X402HttpClientTest.java new file mode 100644 index 000000000..4e67dad10 --- /dev/null +++ b/java/src/test/java/com/coinbase/x402/client/X402HttpClientTest.java @@ -0,0 +1,86 @@ +package com.coinbase.x402.client; + +import com.coinbase.x402.crypto.CryptoSigner; +import com.coinbase.x402.crypto.CryptoSignException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class X402HttpClientTest { + + @Mock + private CryptoSigner mockSigner; + + private X402HttpClient client; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + // Create client with mock signer + client = new X402HttpClient(mockSigner) { + // Override the internal HttpClient to avoid actual network calls + @Override + protected HttpResponse sendRequest(HttpRequest request) throws IOException, InterruptedException { + // Capture and verify the request, then return a mock response + @SuppressWarnings("unchecked") + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"ok\":true}"); + return mockResponse; + } + }; + } + + @Test + void testGet() throws IOException, InterruptedException, CryptoSignException { + // Setup + URI uri = URI.create("https://example.com/private"); + BigInteger amount = BigInteger.valueOf(1000); + String assetContract = "0xTokenContract"; + String payTo = "0xReceiverAddress"; + + // Mock the signer to return a fixed signature + when(mockSigner.sign(any())).thenReturn("0xMockSignature"); + + // Execute + HttpResponse response = client.get(uri, amount, assetContract, payTo); + + // Verify + assertNotNull(response); + assertEquals(200, response.statusCode()); + assertEquals("{\"ok\":true}", response.body()); + + // Verify signer was called with proper payload + try { + verify(mockSigner).sign(argThat(payload -> { + assertEquals(amount.toString(), payload.get("amount")); + assertEquals(assetContract, payload.get("asset")); + assertEquals(payTo, payload.get("payTo")); + assertEquals("/private", payload.get("resource")); + assertNotNull(payload.get("nonce")); + return true; + })); + } catch (CryptoSignException e) { + fail("Unexpected CryptoSignException: " + e.getMessage()); + } + } + + @Test + void testConstructor() { + // Simply verify the constructor sets up the client properly + X402HttpClient testClient = new X402HttpClient(mockSigner); + assertNotNull(testClient); + } +} \ No newline at end of file diff --git a/java/src/test/java/com/coinbase/x402/integration/FilterIntegrationTest.java b/java/src/test/java/com/coinbase/x402/integration/FilterIntegrationTest.java new file mode 100644 index 000000000..ec71ac28b --- /dev/null +++ b/java/src/test/java/com/coinbase/x402/integration/FilterIntegrationTest.java @@ -0,0 +1,116 @@ +package com.coinbase.x402.integration; + +import com.coinbase.x402.client.FacilitatorClient; +import com.coinbase.x402.client.VerificationResponse; +import com.coinbase.x402.model.PaymentPayload; +import com.coinbase.x402.server.PaymentFilter; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.FilterHolder; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.io.PrintWriter; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Embedded-Jetty integration: real PaymentFilter + stub FacilitatorClient + * + simple business servlet. + */ +class FilterIntegrationTest { + + static Server jetty; + static int port; + static HttpClient http = HttpClient.newHttpClient(); + + @BeforeAll + static void startJetty() throws Exception { + // ----- stub facilitator ----------------------------------------- + FacilitatorClient stubFac = new FacilitatorClient() { + @Override public VerificationResponse verify(String hdr, com.coinbase.x402.model.PaymentRequirements r) { + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; // always accept + return vr; + } + @Override public com.coinbase.x402.client.SettlementResponse settle(String h, com.coinbase.x402.model.PaymentRequirements r) { return new com.coinbase.x402.client.SettlementResponse(); } + @Override public java.util.Set supported() { return java.util.Set.of(); } + }; + + // price-table: /private costs 1 (value irrelevant here) + Map priced = Map.of("/private", java.math.BigInteger.ONE); + + // ----- Jetty context -------------------------------------------- + jetty = new Server(0); // auto-choose port + ServletContextHandler ctx = new ServletContextHandler(); + ctx.setContextPath("/"); + + // business servlet at /private – returns 200 + JSON + ctx.addServlet(new ServletHolder(new HttpServlet() { + @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("application/json"); + try (PrintWriter w = resp.getWriter()) { + w.write("{\"ok\":true}"); + } + } + }), "/private"); + + // register PaymentFilter + ctx.addFilter( + new FilterHolder(new PaymentFilter("0xReceiver", priced, stubFac)), + "/*", + null + ); + + jetty.setHandler(ctx); + jetty.start(); + port = jetty.getURI().getPort(); + } + + @AfterAll + static void stopJetty() throws Exception { jetty.stop(); } + + /* ---------- test: missing header -> 402 --------------------------- */ + @Test + void missingHeaderGets402() throws Exception { + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/private")) + .GET() + .build(); + + HttpResponse rsp = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(402, rsp.statusCode()); + assertTrue(rsp.body().contains("\"x402Version\":")); + } + + /* ---------- test: valid header -> 200 ----------------------------- */ + @Test + void validHeaderGets200() throws Exception { + // build minimal payment header with matching resource + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String hdr = p.toHeader(); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/private")) + .header("X-PAYMENT", hdr) + .GET() + .build(); + + HttpResponse rsp = http.send(req, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, rsp.statusCode()); + assertEquals("{\"ok\":true}", rsp.body()); + } +} diff --git a/java/src/test/java/com/coinbase/x402/model/PaymentPayloadTest.java b/java/src/test/java/com/coinbase/x402/model/PaymentPayloadTest.java new file mode 100644 index 000000000..f7c94b1ea --- /dev/null +++ b/java/src/test/java/com/coinbase/x402/model/PaymentPayloadTest.java @@ -0,0 +1,31 @@ +package com.coinbase.x402.model; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class PaymentPayloadTest { + + @Test + void headerRoundTripMaintainsFields() throws Exception { + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of( + "amount", "123", + "resource", "/weather", + "nonce", "abc" + ); + + String header = p.toHeader(); + PaymentPayload decoded = PaymentPayload.fromHeader(header); + + assertEquals(p.x402Version, decoded.x402Version); + assertEquals(p.scheme, decoded.scheme); + assertEquals(p.network, decoded.network); + assertEquals(p.payload, decoded.payload); + } +} diff --git a/java/src/test/java/com/coinbase/x402/server/PaymentFilterTest.java b/java/src/test/java/com/coinbase/x402/server/PaymentFilterTest.java new file mode 100644 index 000000000..b5a2b6163 --- /dev/null +++ b/java/src/test/java/com/coinbase/x402/server/PaymentFilterTest.java @@ -0,0 +1,517 @@ +package com.coinbase.x402.server; + +import com.coinbase.x402.client.FacilitatorClient; +import com.coinbase.x402.client.SettlementResponse; +import com.coinbase.x402.client.VerificationResponse; +import com.coinbase.x402.model.Authorization; +import com.coinbase.x402.model.ExactSchemePayload; +import com.coinbase.x402.model.PaymentPayload; +import javax.servlet.FilterChain; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.math.BigInteger; +import java.util.Base64; +import java.util.Map; + +import static org.mockito.Mockito.*; + +class PaymentFilterTest { + + @Mock HttpServletRequest req; + @Mock HttpServletResponse resp; + @Mock FilterChain chain; + @Mock FacilitatorClient fac; + + private PaymentFilter filter; + + @BeforeEach + void init() throws Exception { + MockitoAnnotations.openMocks(this); + + // writer stub + when(resp.getWriter()).thenReturn(new PrintWriter(new ByteArrayOutputStream(), true)); + + filter = new PaymentFilter( + "0xReceiver", + Map.of("/private", BigInteger.TEN), + fac + ); + } + + /* ------------ free endpoint passes straight through --------------- */ + @Test + void freeEndpoint() throws Exception { + when(req.getRequestURI()).thenReturn("/public"); + + filter.doFilter(req, resp, chain); + + verify(chain).doFilter(req, resp); + verify(resp, never()).setStatus(anyInt()); + } + + /* ------------ missing header => 402 -------------------------------- */ + @Test + void missingHeader() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + when(req.getHeader("X-PAYMENT")).thenReturn(null); + + filter.doFilter(req, resp, chain); + + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + verify(chain, never()).doFilter(any(), any()); + } + + /* ------------ valid header => OK ----------------------------------- */ + @Test + void validHeader() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // build a syntactically correct header whose resource matches the path + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // facilitator says it's valid + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; + when(fac.verify(eq(header), any())).thenReturn(vr); + + // settlement succeeds + SettlementResponse sr = new SettlementResponse(); + sr.success = true; + sr.txHash = "0xabcdef1234567890"; + sr.networkId = "base-sepolia"; + when(fac.settle(eq(header), any())).thenReturn(sr); + + filter.doFilter(req, resp, chain); + + verify(chain).doFilter(req, resp); + verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + verify(fac).verify(eq(header), any()); + verify(fac).settle(eq(header), any()); + } + + /* ------------ facilitator rejects payment → 402 ------------------- */ + @Test + void facilitatorRejection() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // well-formed header for /private + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // facilitator response: invalid + VerificationResponse vr = new VerificationResponse(); + vr.isValid = false; + vr.invalidReason = "insufficient funds"; + when(fac.verify(eq(header), any())).thenReturn(vr); + + filter.doFilter(req, resp, chain); + + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + verify(chain, never()).doFilter(any(), any()); + // settle must NOT be called + verify(fac, never()).settle(any(), any()); + } + + /* ------------ resource mismatch in header → 402 ------------------- */ + @Test + void resourceMismatch() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // header says resource is /other + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/other"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + filter.doFilter(req, resp, chain); + + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + verify(chain, never()).doFilter(any(), any()); + // facilitator should NOT have been called + verify(fac, never()).verify(any(), any()); + } + + /* ------------ empty header (vs null) → 402 ---------------------------- */ + @Test + void emptyHeader() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + when(req.getHeader("X-PAYMENT")).thenReturn(""); // Empty string + + filter.doFilter(req, resp, chain); + + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + verify(chain, never()).doFilter(any(), any()); + } + + /* ------------ non-HTTP request passes through without checks ---------- */ + @Test + void nonHttpRequest() throws Exception { + // Create non-HTTP servlet request and response + ServletRequest nonHttpReq = mock(ServletRequest.class); + ServletResponse nonHttpRes = mock(ServletResponse.class); + + filter.doFilter(nonHttpReq, nonHttpRes, chain); + + // Should pass through without any checks + verify(chain).doFilter(nonHttpReq, nonHttpRes); + verifyNoInteractions(fac); // No facilitator interactions + } + + /* ------------ exception parsing header → 402 -------------------------- */ + @Test + void malformedHeader() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + when(req.getHeader("X-PAYMENT")).thenReturn("invalid-json-format"); + + filter.doFilter(req, resp, chain); + + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + verify(chain, never()).doFilter(any(), any()); + } + + /* ------------ exception during verification → 402 --------------------- */ + @Test + void verificationException() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create a valid header + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Make facilitator throw exception during verify + when(fac.verify(any(), any())).thenThrow(new IOException("Network error")); + + filter.doFilter(req, resp, chain); + + // IOException should return 500 status, not 402 + verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + verify(resp).setContentType("application/json"); + verify(chain, never()).doFilter(any(), any()); + } + + /* ------------ exception during settlement returns 402 */ + @Test + void settlementException() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create a valid header + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Verification succeeds + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; + when(fac.verify(eq(header), any())).thenReturn(vr); + + // But settlement throws exception (should return 402) + doThrow(new IOException("Network error")).when(fac).settle(any(), any()); + + filter.doFilter(req, resp, chain); + + // Request should be processed, but then settlement failure should return 402 + verify(chain).doFilter(req, resp); + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + + // Verify and settle were both called + verify(fac).verify(eq(header), any()); + verify(fac).settle(eq(header), any()); + } + + /* ------------ settlement failure returns 402 */ + @Test + void settlementFailure() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create a valid header + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Verification succeeds + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; + when(fac.verify(eq(header), any())).thenReturn(vr); + + // Settlement fails (facilitator returns success=false) + SettlementResponse sr = new SettlementResponse(); + sr.success = false; + sr.error = "insufficient balance"; + when(fac.settle(eq(header), any())).thenReturn(sr); + + filter.doFilter(req, resp, chain); + + // Request should be processed, but then settlement failure should return 402 + verify(chain).doFilter(req, resp); + verify(resp).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + + // Verify and settle were both called + verify(fac).verify(eq(header), any()); + verify(fac).settle(eq(header), any()); + } + + /* ------------ payer extraction from payment payload ---------------- */ + @Test + void payerExtractedFromPaymentPayload() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create payment payload with proper authorization structure + String payerAddress = "0x1234567890abcdef1234567890abcdef12345678"; + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + + // Create the exact EVM payload structure + ExactSchemePayload exactPayload = new ExactSchemePayload(); + exactPayload.signature = "0x1234567890abcdef"; + exactPayload.authorization = new Authorization(); + exactPayload.authorization.from = payerAddress; + exactPayload.authorization.to = "0xReceiver"; + exactPayload.authorization.value = "1000000"; + exactPayload.authorization.validAfter = "0"; + exactPayload.authorization.validBefore = "999999999999"; + exactPayload.authorization.nonce = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + + // Since PaymentPayload.payload is Map, we need to set it as a Map + p.payload = Map.of( + "resource", "/private", + "signature", exactPayload.signature, + "authorization", Map.of( + "from", exactPayload.authorization.from, + "to", exactPayload.authorization.to, + "value", exactPayload.authorization.value, + "validAfter", exactPayload.authorization.validAfter, + "validBefore", exactPayload.authorization.validBefore, + "nonce", exactPayload.authorization.nonce + ) + ); + + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Verification succeeds + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; + when(fac.verify(eq(header), any())).thenReturn(vr); + + // Settlement succeeds + SettlementResponse sr = new SettlementResponse(); + sr.success = true; + sr.txHash = "0xabcdef1234567890"; + sr.networkId = "base-sepolia"; + when(fac.settle(eq(header), any())).thenReturn(sr); + + filter.doFilter(req, resp, chain); + + // Verify request was processed successfully + verify(chain).doFilter(req, resp); + verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + + // Verify X-PAYMENT-RESPONSE header was set + verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), any()); + verify(resp).setHeader(eq("Access-Control-Expose-Headers"), eq("X-PAYMENT-RESPONSE")); + + // Capture the settlement response header to verify payer was included + org.mockito.ArgumentCaptor headerCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), headerCaptor.capture()); + + // Decode and verify the settlement response contains the correct payer + String base64Header = headerCaptor.getValue(); + String jsonString = new String(Base64.getDecoder().decode(base64Header)); + + // Verify the JSON contains the expected payer address + org.junit.jupiter.api.Assertions.assertTrue(jsonString.contains("\"payer\":\"" + payerAddress + "\""), + "Settlement response should contain payer address: " + jsonString); + org.junit.jupiter.api.Assertions.assertTrue(jsonString.contains("\"success\":true"), + "Settlement response should indicate success: " + jsonString); + } + + /* ------------ payer extraction with missing authorization ----------- */ + @Test + void payerExtractionWithMissingAuthorization() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create payment payload without authorization + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Verification succeeds + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; + when(fac.verify(eq(header), any())).thenReturn(vr); + + // Settlement succeeds + SettlementResponse sr = new SettlementResponse(); + sr.success = true; + sr.txHash = "0xabcdef1234567890"; + sr.networkId = "base-sepolia"; + when(fac.settle(eq(header), any())).thenReturn(sr); + + filter.doFilter(req, resp, chain); + + // Verify request was processed successfully + verify(chain).doFilter(req, resp); + verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + + // Capture the settlement response header + org.mockito.ArgumentCaptor headerCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), headerCaptor.capture()); + + // Decode and verify the settlement response has null payer + String base64Header = headerCaptor.getValue(); + String jsonString = new String(Base64.getDecoder().decode(base64Header)); + + // Verify the JSON contains null payer when authorization is missing + org.junit.jupiter.api.Assertions.assertTrue(jsonString.contains("\"payer\":null"), + "Settlement response should contain null payer when authorization missing: " + jsonString); + } + + /* ------------ payer extraction with malformed authorization --------- */ + @Test + void payerExtractionWithMalformedAuthorization() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create payment payload with malformed authorization (string instead of object) + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of( + "resource", "/private", + "authorization", "malformed-string" // Should be an object, not a string + ); + + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Verification succeeds + VerificationResponse vr = new VerificationResponse(); + vr.isValid = true; + when(fac.verify(eq(header), any())).thenReturn(vr); + + // Settlement succeeds + SettlementResponse sr = new SettlementResponse(); + sr.success = true; + sr.txHash = "0xabcdef1234567890"; + sr.networkId = "base-sepolia"; + when(fac.settle(eq(header), any())).thenReturn(sr); + + filter.doFilter(req, resp, chain); + + // Verify request was processed successfully (payer extraction failure should not break processing) + verify(chain).doFilter(req, resp); + verify(resp, never()).setStatus(HttpServletResponse.SC_PAYMENT_REQUIRED); + + // Capture the settlement response header + org.mockito.ArgumentCaptor headerCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(resp).setHeader(eq("X-PAYMENT-RESPONSE"), headerCaptor.capture()); + + // Decode and verify the settlement response has null payer for malformed data + String base64Header = headerCaptor.getValue(); + String jsonString = new String(Base64.getDecoder().decode(base64Header)); + + // Verify the JSON contains null payer when authorization is malformed + org.junit.jupiter.api.Assertions.assertTrue(jsonString.contains("\"payer\":null"), + "Settlement response should contain null payer when authorization malformed: " + jsonString); + } + + /* ------------ facilitator IOException returns 500 --------------------- */ + @Test + void facilitatorIOExceptionReturns500() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create a valid header + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Make facilitator throw IOException during verify + when(fac.verify(any(), any())).thenThrow(new IOException("Network timeout")); + + filter.doFilter(req, resp, chain); + + // Should return 500 status for network errors + verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + verify(resp).setContentType("application/json"); + verify(chain, never()).doFilter(any(), any()); + + // Verify error message is written to response + verify(resp).getWriter(); + } + + /* ------------ facilitator unexpected exception returns 500 ------------ */ + @Test + void facilitatorUnexpectedExceptionReturns500() throws Exception { + when(req.getRequestURI()).thenReturn("/private"); + + // Create a valid header + PaymentPayload p = new PaymentPayload(); + p.x402Version = 1; + p.scheme = "exact"; + p.network = "base-sepolia"; + p.payload = Map.of("resource", "/private"); + String header = p.toHeader(); + when(req.getHeader("X-PAYMENT")).thenReturn(header); + + // Make facilitator throw unexpected exception during verify + when(fac.verify(any(), any())).thenThrow(new RuntimeException("Unexpected error")); + + filter.doFilter(req, resp, chain); + + // Should return 500 status for unexpected errors + verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + verify(resp).setContentType("application/json"); + verify(chain, never()).doFilter(any(), any()); + + // Verify error message is written to response + verify(resp).getWriter(); + } +}