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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ Test the endpoint using the [Tempo CLI](https://mpp.dev):
tempo request https://your-api.com/your-endpoint
```

### Tempo API relay

Delegate Moderato charge validation and broadcast to Tempo API by adding the server-side API key
to the method builder:

```java
TempoMethod tempo = TempoMethod.of()
.testnet()
.relay(System.getenv("TEMPO_API_KEY"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I like this

.build();
```

`MppHandler.charge(...)` validates immediately before broadcast. Applications that expose those
phases separately can use `validateCredential(...)` for a non-mutating pre-check and
`broadcastCredential(...)` when accepting the payment. The latter always re-validates first.

See the runnable [Tempo relay example](examples/tempo-relay).

### Mainnet

```java
Expand Down
24 changes: 23 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@ repositories {
dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.stripe:stripe-java:25.3.0'
implementation 'org.bouncycastle:bcprov-jdk18on:1.84'

testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3'
testImplementation 'org.assertj:assertj-core:3.26.0'
testImplementation 'org.web3j:crypto:4.9.8'
testImplementation('org.web3j:crypto:4.9.8') {
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
}
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Expand All @@ -40,6 +43,12 @@ test {
// --- Integration test source set ---

sourceSets {
example {
java.srcDir 'examples/tempo-relay/src/main/java'
compileClasspath += sourceSets.main.output + configurations.runtimeClasspath
runtimeClasspath += output + compileClasspath
}

integrationTest {
java.srcDir 'src/integrationTest/java'
compileClasspath += sourceSets.main.output + configurations.testRuntimeClasspath
Expand All @@ -48,10 +57,23 @@ sourceSets {
}

configurations {
exampleImplementation.extendsFrom implementation
exampleRuntimeOnly.extendsFrom runtimeOnly
integrationTestImplementation.extendsFrom testImplementation
integrationTestRuntimeOnly.extendsFrom testRuntimeOnly
}

tasks.register('runTempoRelayExample', JavaExec) {
description = "Runs the Tempo API relay-backed charge example."
group = 'application'
classpath = sourceSets.example.runtimeClasspath
mainClass = 'com.stripe.mpp.examples.TempoRelayServer'
}

tasks.named('test') {
dependsOn tasks.named('compileExampleJava')
}

tasks.register('integrationTest', Test) {
description = 'Runs integration tests against a live Tempo node (requires docker compose up).'
group = 'verification'
Expand Down
25 changes: 25 additions & 0 deletions examples/tempo-relay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Tempo relay charge

This example runs a Java HTTP endpoint that issues a route-bound Moderato pathUSD charge,
validates the credential through Tempo API, and asks the relay to finalize it.

```sh
export TEMPO_API_KEY=tempo:sk:...
export MPP_RECIPIENT=0xYourRecipientAddress
export MPP_SECRET_KEY=$(openssl rand -base64 32)
./gradlew runTempoRelayExample
```

The API key stays in the server process and needs the `mpp:write` scope.

| Route | Description |
| --- | --- |
| `GET /api/health` | Free health check |
| `GET /api/photo` | `0.01` pathUSD relay-backed charge |

The paid flow is:

1. The server returns a `tempo/charge` challenge.
2. The payer signs a pull transaction and retries with an MPP credential.
3. The Java SDK calls `POST /v1/mpp/validate`, then `POST /v1/mpp/broadcast`.
4. The relay receipt is returned in `Payment-Receipt`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.stripe.mpp.examples;

import com.stripe.mpp.Challenge;
import com.stripe.mpp.Json;
import com.stripe.mpp.Mpp;
import com.stripe.mpp.error.PaymentException;
import com.stripe.mpp.methods.tempo.TempoDefaults;
import com.stripe.mpp.methods.tempo.TempoMethod;
import com.stripe.mpp.server.ChargeRequest;
import com.stripe.mpp.server.MppHandler;
import com.stripe.mpp.server.VerifyResult;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;

/** Minimal HTTP server that accepts a Moderato pathUSD charge through Tempo API's relay. */
public final class TempoRelayServer {
private TempoRelayServer() {}

public static void main(String[] args) throws IOException {
String apiKey = required("TEMPO_API_KEY");
String recipient = required("MPP_RECIPIENT");
String secretKey = required("MPP_SECRET_KEY");
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));

TempoMethod tempo = TempoMethod.of().testnet().relay(apiKey).build();
MppHandler payments = Mpp.create(tempo, "localhost:" + port, secretKey);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);

server.createContext("/api/health", exchange -> {
if (!"GET".equals(exchange.getRequestMethod())) {
send(exchange, 405, Map.of("error", "method not allowed"));
return;
}
send(exchange, 200, Map.of("status", "ok"));
});
server.createContext("/api/photo", exchange -> {
if (!"GET".equals(exchange.getRequestMethod())) {
send(exchange, 405, Map.of("error", "method not allowed"));
return;
}

ChargeRequest charge = ChargeRequest.of(
tempo.chargeIntent(), "0.01", TempoDefaults.TESTNET_PATH_USD, recipient
).description("Relay-backed Java example")
.meta(Map.of("route", "/api/photo"));
try {
VerifyResult result = payments.charge(
exchange.getRequestHeaders().getFirst("Authorization"), charge
);
if (result instanceof VerifyResult.Challenged) {
exchange.getResponseHeaders().add(
"WWW-Authenticate",
Comment thread
parvahuja marked this conversation as resolved.
((VerifyResult.Challenged) result).challenge().toWwwAuthenticate()
);
send(exchange, 402, Map.of("error", "payment required"));
return;
}

VerifyResult.Verified verified = (VerifyResult.Verified) result;
exchange.getResponseHeaders().set(
"Payment-Receipt", verified.receipt().toPaymentReceipt()
);
send(exchange, 200, Map.of("ok", true, "message", "relay payment accepted"));
} catch (PaymentException error) {
Challenge retry = payments.challenge(charge);
exchange.getResponseHeaders().add("WWW-Authenticate", retry.toWwwAuthenticate());
exchange.getResponseHeaders().set("Content-Type", "application/problem+json");
send(exchange, error.getHttpStatus(), error.toProblemDetails(retry.id()));
}
});

server.start();
System.out.println("Tempo relay example listening on http://127.0.0.1:" + port);
}

private static String required(String name) {
String value = System.getenv(name);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Set " + name);
return value;
}

private static void send(HttpExchange exchange, int status, Map<String, Object> body)
throws IOException {
byte[] bytes = Json.compact(body).getBytes(StandardCharsets.UTF_8);
if (exchange.getResponseHeaders().getFirst("Content-Type") == null) {
exchange.getResponseHeaders().set("Content-Type", "application/json");
}
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
}
52 changes: 41 additions & 11 deletions src/main/java/com/stripe/mpp/Challenge.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public final class Challenge {
private final String expires;
private final String description;
private final Map<String, Object> opaque;
private final String opaqueRaw;

public Challenge(
String id,
Expand All @@ -32,6 +33,25 @@ public Challenge(
String expires,
String description,
Map<String, Object> opaque
) {
this(
id, method, intent, request, realm, requestB64, digest, expires, description,
opaque, ChallengeId.encodeOpaque(opaque)
);
}

private Challenge(
String id,
String method,
String intent,
Map<String, Object> request,
String realm,
String requestB64,
String digest,
String expires,
String description,
Map<String, Object> opaque,
String opaqueRaw
) {
this.id = id;
this.method = method;
Expand All @@ -43,6 +63,7 @@ public Challenge(
this.expires = expires;
this.description = description;
this.opaque = opaque;
this.opaqueRaw = opaqueRaw;
}

public String id() { return id; }
Expand All @@ -54,7 +75,10 @@ public Challenge(
public String digest() { return digest; }
public String expires() { return expires; }
public String description() { return description; }
/** Decoded metadata when the opaque value contains a JSON object. */
public Map<String, Object> opaque() { return opaque; }
/** Exact base64url opaque value carried in the header and bound by the challenge ID. */
public String opaqueRaw() { return opaqueRaw; }

/**
* Create a new challenge with a cryptographically bound ID.
Expand All @@ -70,8 +94,9 @@ public static Challenge create(
Map<String, Object> meta
) {
String requestB64 = ChallengeId.b64urlEncode(Json.compact(request));
String id = ChallengeId.generate(secretKey, realm, method, intent, request, expires, null, meta);
return new Challenge(id, method, intent, request, realm, requestB64, null, expires, description, meta);
String opaqueRaw = ChallengeId.encodeOpaque(meta);
String id = ChallengeId.generateWithOpaque(secretKey, realm, method, intent, request, expires, null, opaqueRaw);
return new Challenge(id, method, intent, request, realm, requestB64, null, expires, description, meta, opaqueRaw);
}

public static Challenge create(
Expand Down Expand Up @@ -100,11 +125,8 @@ public static List<Challenge> fromWwwAuthenticate(List<String> wwwAuthenticateHe
);
}
Map<String, Object> request = ChallengeId.b64urlDecodeToMap(requestB64);
Map<String, Object> opaque = null;
String opaqueVal = params.get("opaque");
if (opaqueVal != null && !opaqueVal.isEmpty()) {
opaque = ChallengeId.b64urlDecodeToMap(opaqueVal);
}
String opaqueRaw = params.get("opaque");
Map<String, Object> opaque = Parsing.decodeOpaque(opaqueRaw);
challenges.add(new Challenge(
id,
method,
Expand All @@ -115,7 +137,8 @@ public static List<Challenge> fromWwwAuthenticate(List<String> wwwAuthenticateHe
params.get("digest"),
params.get("expires"),
params.get("description"),
opaque
opaque,
opaqueRaw
));
}
}
Expand Down Expand Up @@ -247,7 +270,9 @@ public static List<String> toWwwAuthenticate(List<Challenge> challenges) {
* Convert to the echo form included inside a Credential.
*/
public ChallengeEcho toEcho() {
return new ChallengeEcho(id, realm, method, intent, requestB64, expires, digest, opaque);
return ChallengeEcho.fromWire(
id, realm, method, intent, requestB64, expires, digest, opaqueRaw, opaque
);
}

@Override
Expand All @@ -264,12 +289,16 @@ public boolean equals(Object o) {
&& Objects.equals(digest, challenge.digest)
&& Objects.equals(expires, challenge.expires)
&& Objects.equals(description, challenge.description)
&& Objects.equals(opaque, challenge.opaque);
&& Objects.equals(opaque, challenge.opaque)
&& Objects.equals(opaqueRaw, challenge.opaqueRaw);
}

@Override
public int hashCode() {
return Objects.hash(id, method, intent, request, realm, requestB64, digest, expires, description, opaque);
return Objects.hash(
id, method, intent, request, realm, requestB64, digest, expires,
description, opaque, opaqueRaw
);
}

@Override
Expand All @@ -285,6 +314,7 @@ public String toString() {
+ ", expires=" + expires
+ ", description=" + description
+ ", opaque=" + opaque
+ ", opaqueRaw=" + opaqueRaw
+ "]";
}
}
Loading
Loading