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();
+ }
+}