Skip to content

Commit c2da494

Browse files
raubrey2014claude
andcommitted
Verify ERC-20 Transfer logs in TempoChargeIntent; add token contract constants
Previously any successful transaction was accepted as payment. Now verify() checks that the receipt contains a Transfer (or TransferWithMemo) log whose token contract matches the request currency, recipient matches, sender matches, and atomic amount matches — bringing parity with the Ruby SDK. Adds MAINNET_USDC and TESTNET_PATH_USD constants to TempoDefaults so callers pass the correct contract address as currency. Updates README, integration test, and unit tests accordingly; adds 7 new unit tests for log validation cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 55a111e commit c2da494

5 files changed

Lines changed: 285 additions & 226 deletions

File tree

README.md

Lines changed: 59 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -2,230 +2,86 @@
22

33
Java 11 proof-of-concept SDK for the [Machine Payments Protocol](https://mpp.dev).
44

5-
This library currently supports:
6-
7-
- Core MPP types: `Challenge`, `ChallengeEcho`, `Credential`, and `Receipt`
8-
- Server-side charge verification with `MppHandler`
9-
- Tempo charge challenges via `Tempo.method()` and `Tempo.chargeIntent()`
10-
- Tempo credential verification for raw transaction payloads (`transaction`) and already-broadcast transaction hashes (`hash`)
11-
- Manual client-side challenge parsing and credential serialization
12-
13-
It does not yet include a high-level automatic client transport like Ruby's
14-
`Mpp::Client::Transport` from the [Go and Ruby SDK announcement](https://mpp.dev/blog/go-and-ruby-sdks).
15-
165
## Install
176

18-
The package is configured as a Gradle Java library.
19-
207
```groovy
21-
repositories {
22-
mavenCentral()
23-
}
24-
258
dependencies {
269
implementation "io.github.raubrey2014:mpp-java-poc:0.1.0"
2710
}
2811
```
2912

30-
For local development:
13+
## Tempo
3114

32-
```sh
33-
./gradlew test
34-
```
35-
36-
## Charge For A Route
37-
38-
This mirrors the Ruby server flow from the blog post: create an MPP server with a
39-
payment method, call `charge`, then either return a `402` challenge or continue
40-
with the verified credential and receipt.
15+
The `currency` parameter must be the token contract address — the server verifies it against
16+
the ERC-20 Transfer logs in the transaction receipt. On testnet (Moderato) this is PATH_USD;
17+
on mainnet it is USDC.
4118

4219
```java
43-
import com.stripe.mpp.Mpp;
44-
import com.stripe.mpp.methods.tempo.Tempo;
45-
import com.stripe.mpp.methods.tempo.TempoMethod;
46-
import com.stripe.mpp.server.MppHandler;
47-
import com.stripe.mpp.server.VerifyResult;
48-
49-
import com.sun.net.httpserver.HttpServer;
50-
51-
import java.io.IOException;
52-
import java.net.InetSocketAddress;
53-
import java.nio.charset.StandardCharsets;
54-
55-
public class ServerExample {
56-
public static void main(String[] args) throws IOException {
57-
String secretKey = System.getenv("MPP_SECRET_KEY");
58-
59-
TempoMethod tempo = Tempo.method(true); // Moderato testnet
60-
MppHandler payment = Mpp.create(tempo, "api.example.com", secretKey);
61-
62-
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
63-
server.createContext("/paid", exchange -> {
64-
VerifyResult result = payment.charge(
65-
exchange.getRequestHeaders().getFirst("Authorization"),
66-
tempo.chargeIntent(),
67-
"0.50",
68-
"USDC",
69-
"0x742d35Cc6634c0532925a3b844bC9e7595F8fE00",
70-
"Paid endpoint",
71-
null,
72-
null
73-
);
74-
75-
if (result instanceof VerifyResult.Challenged) {
76-
VerifyResult.Challenged challenged = (VerifyResult.Challenged) result;
77-
exchange.getResponseHeaders().add(
78-
"WWW-Authenticate",
79-
challenged.challenge().toWwwAuthenticate()
80-
);
81-
exchange.sendResponseHeaders(402, -1);
82-
exchange.close();
83-
return;
84-
}
85-
86-
VerifyResult.Verified verified = (VerifyResult.Verified) result;
87-
String body = "{\"data\":\"paid content\",\"payer\":\""
88-
+ verified.credential().source()
89-
+ "\"}";
90-
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
91-
92-
exchange.getResponseHeaders().add(
93-
"Payment-Receipt",
94-
verified.receipt().toPaymentReceipt()
95-
);
96-
exchange.getResponseHeaders().add("Content-Type", "application/json");
97-
exchange.sendResponseHeaders(200, bytes.length);
98-
exchange.getResponseBody().write(bytes);
99-
exchange.close();
100-
});
101-
server.start();
102-
}
20+
// Testnet: TempoDefaults.TESTNET_PATH_USD = "0x20c0000000000000000000000000000000000000"
21+
// Mainnet: TempoDefaults.MAINNET_USDC = "0x20C000000000000000000000b9537d11c60E8b50"
22+
String currency = TempoDefaults.TESTNET_PATH_USD;
23+
24+
TempoMethod tempo = Tempo.method(true); // true = Moderato testnet
25+
MppHandler payment = Mpp.create(tempo, "api.example.com", System.getenv("MPP_SECRET_KEY"));
26+
27+
// In your HTTP handler:
28+
VerifyResult result = payment.charge(
29+
request.getHeader("Authorization"),
30+
tempo.chargeIntent(),
31+
"0.50", currency, "0xYourWalletAddress",
32+
"Paid endpoint", null, null
33+
);
34+
35+
if (result instanceof VerifyResult.Challenged) {
36+
response.setHeader("WWW-Authenticate",
37+
((VerifyResult.Challenged) result).challenge().toWwwAuthenticate());
38+
response.setStatus(402);
39+
} else {
40+
VerifyResult.Verified verified = (VerifyResult.Verified) result;
41+
response.setHeader("Payment-Receipt", verified.receipt().toPaymentReceipt());
42+
// serve your content
10343
}
10444
```
10545

106-
The first request without a valid `Authorization` header returns:
46+
The client presents a Tempo credential in `Authorization`:
10747

108-
```http
109-
HTTP/1.1 402 Payment Required
110-
WWW-Authenticate: Payment id="...", realm="api.example.com", method="tempo", intent="charge", request="...", expires="...", description="Paid endpoint"
48+
```json
49+
{"transaction": "0x..."}
11150
```
11251

113-
The next request presents a payment credential in `Authorization`. If the
114-
credential is valid and the Tempo transaction verifies, the handler returns a
115-
`VerifyResult.Verified` containing both the credential and receipt.
116-
117-
## Make A Paid Request
52+
or, if already broadcast:
11853

119-
The Ruby SDK example uses `Mpp::Client::Transport` to automatically retry after
120-
a `402`. This Java POC exposes the lower-level pieces today: parse the challenge,
121-
build a `Credential`, and send it in a second request.
122-
123-
```java
124-
import com.stripe.mpp.Challenge;
125-
import com.stripe.mpp.Credential;
126-
import com.stripe.mpp.Receipt;
127-
128-
import java.net.URI;
129-
import java.net.http.HttpClient;
130-
import java.net.http.HttpRequest;
131-
import java.net.http.HttpResponse;
132-
import java.util.List;
133-
import java.util.Map;
134-
135-
public class ClientExample {
136-
public static void main(String[] args) throws Exception {
137-
HttpClient http = HttpClient.newHttpClient();
138-
URI uri = URI.create("https://api.example.com/paid");
139-
140-
HttpResponse<String> first = http.send(
141-
HttpRequest.newBuilder(uri).GET().build(),
142-
HttpResponse.BodyHandlers.ofString()
143-
);
144-
145-
if (first.statusCode() != 402) {
146-
System.out.println(first.statusCode());
147-
return;
148-
}
149-
150-
List<String> headers = first.headers().allValues("WWW-Authenticate");
151-
Challenge challenge = Challenge.fromWwwAuthenticate(headers).get(0);
152-
153-
// For Tempo, payload must contain either:
154-
// - "transaction": a signed raw EVM transaction for server broadcast, or
155-
// - "hash": a transaction hash already broadcast by the client.
156-
Map<String, Object> payload = Map.of(
157-
"hash",
158-
"0x..."
159-
);
160-
Credential credential = new Credential(challenge.toEcho(), payload, null);
161-
162-
HttpResponse<String> paid = http.send(
163-
HttpRequest.newBuilder(uri)
164-
.header("Authorization", credential.toAuthorization())
165-
.GET()
166-
.build(),
167-
HttpResponse.BodyHandlers.ofString()
168-
);
169-
170-
paid.headers().firstValue("Payment-Receipt").ifPresent(header -> {
171-
Receipt receipt = Receipt.fromPaymentReceipt(header);
172-
System.out.println(receipt.status());
173-
});
174-
System.out.println(paid.statusCode());
175-
}
176-
}
54+
```json
55+
{"hash": "0x..."}
17756
```
17857

179-
## Tempo Support
180-
181-
Use `Tempo.method()` and `Tempo.chargeIntent()` for mainnet, or pass `true` for
182-
Moderato testnet.
183-
184-
```java
185-
TempoMethod mainnet = Tempo.method();
186-
TempoMethod testnet = Tempo.method(true);
187-
```
188-
189-
`TempoChargeIntent` accepts the credential payload shapes produced by Tempo
190-
clients:
191-
192-
- `{"transaction": "0x..."}` broadcasts the raw signed transaction through the configured Tempo RPC.
193-
- `{"hash": "0x..."}` verifies an already-broadcast transaction receipt.
194-
195-
## Custom Payment Methods
196-
197-
Implement `Method` and `Intent` to add another payment method.
58+
## Tempo + Stripe
19859

19960
```java
200-
import com.stripe.mpp.Credential;
201-
import com.stripe.mpp.Receipt;
202-
import com.stripe.mpp.server.Intent;
203-
import com.stripe.mpp.server.Method;
204-
205-
import java.util.List;
206-
import java.util.Map;
207-
208-
class ChargeIntent implements Intent {
209-
@Override
210-
public String name() {
211-
return "charge";
212-
}
213-
214-
@Override
215-
public Receipt verify(Credential credential, Map<String, Object> request) {
216-
return Receipt.success("payment-reference", "custom");
217-
}
218-
}
219-
220-
class CustomMethod implements Method {
221-
@Override
222-
public String name() {
223-
return "custom";
224-
}
225-
226-
@Override
227-
public List<Class<? extends Intent>> intents() {
228-
return List.of(ChargeIntent.class);
229-
}
61+
TempoMethod tempo = Tempo.method(true);
62+
MppHandler tempoHandler = Mpp.create(tempo, "api.example.com", System.getenv("MPP_SECRET_KEY"));
63+
64+
StripeMethod stripe = Stripe.method(System.getenv("STRIPE_SECRET_KEY"), "us-east-1");
65+
MppHandler stripeHandler = Mpp.create(stripe, "api.example.com", System.getenv("MPP_SECRET_KEY"));
66+
67+
ComposedHandler payment = Mpp.compose(
68+
tempoHandler.chargeDescriptor(tempo.chargeIntent(), "0.50", TempoDefaults.TESTNET_PATH_USD, "0xYourWalletAddress"),
69+
stripeHandler.chargeDescriptor(stripe.chargeIntent(), "0.50", "usd", null)
70+
);
71+
72+
// In your HTTP handler:
73+
VerifyResult result = payment.charge(request.getHeader("Authorization"));
74+
75+
if (result instanceof VerifyResult.Challenged) {
76+
List<String> headers = Challenge.toWwwAuthenticate(
77+
((VerifyResult.Challenged) result).challenges());
78+
headers.forEach(h -> response.addHeader("WWW-Authenticate", h));
79+
response.setStatus(402);
80+
} else {
81+
VerifyResult.Verified verified = (VerifyResult.Verified) result;
82+
response.setHeader("Payment-Receipt", verified.receipt().toPaymentReceipt());
83+
// serve your content
23084
}
23185
```
86+
87+
The `402` response includes one `WWW-Authenticate` header per method; the client picks one and retries with the matching credential.

src/integrationTest/java/com/stripe/mpp/integration/TempoIntegrationTest.java

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,24 @@ void fullMppRoundTrip() throws Exception {
117117
TempoMethod tempo = Tempo.method(rpcUrl, (int) chainId);
118118
MppHandler server = Mpp.create(tempo, "localhost", "test-secret");
119119

120+
// Token amount matches what buildSignedTx sends (1_000 atomic units = 0.001000 tokens)
121+
BigInteger tokenAmount = BigInteger.valueOf(1_000L);
122+
String chargeAmount = "0.001000";
123+
120124
// Step 1: no auth → server issues challenge
121-
VerifyResult r1 = server.charge(null, tempo.chargeIntent(), "0.010000", "USDC", RECIPIENT);
125+
VerifyResult r1 = server.charge(null, tempo.chargeIntent(), chargeAmount, TOKEN_CONTRACT, RECIPIENT);
122126
assertThat(r1).isInstanceOf(VerifyResult.Challenged.class);
123127
Challenge challenge = ((VerifyResult.Challenged) r1).challenge();
124128

125129
// Step 2: client builds a transaction and wraps it in a credential
126-
String rawTx = buildSignedTx(nextNonce(), BigInteger.valueOf(1_000L));
130+
String rawTx = buildSignedTx(nextNonce(), tokenAmount);
127131
Credential credential = new Credential(challenge.toEcho(), Map.of("type", "transaction", "signature", rawTx), null);
128132

129133
// Step 3: retry with the credential
130134
VerifyResult r2 = server.charge(
131135
credential.toAuthorization(),
132136
tempo.chargeIntent(),
133-
"0.010000", "USDC", RECIPIENT
137+
chargeAmount, TOKEN_CONTRACT, RECIPIENT
134138
);
135139

136140
assertThat(r2).isInstanceOf(VerifyResult.Verified.class);
@@ -145,7 +149,7 @@ void tamperedChallengeIsRejected() throws Exception {
145149
TempoMethod tempo = Tempo.method(rpcUrl, (int) chainId);
146150
MppHandler server = Mpp.create(tempo, "localhost", "test-secret");
147151

148-
VerifyResult r1 = server.charge(null, tempo.chargeIntent(), "0.010000", "USDC", RECIPIENT);
152+
VerifyResult r1 = server.charge(null, tempo.chargeIntent(), "0.001000", TOKEN_CONTRACT, RECIPIENT);
149153
Challenge challenge = ((VerifyResult.Challenged) r1).challenge();
150154

151155
// Swap in a different payload that has never been broadcast.
@@ -160,7 +164,7 @@ void tamperedChallengeIsRejected() throws Exception {
160164
VerifyResult r2 = server.charge(
161165
credential.toAuthorization(),
162166
tempo.chargeIntent(),
163-
"0.010000", "USDC", RECIPIENT
167+
"0.001000", TOKEN_CONTRACT, RECIPIENT
164168
);
165169
assertThat(r2).isInstanceOf(VerifyResult.Challenged.class);
166170
}
@@ -170,7 +174,7 @@ void tamperedChallengeIsRejected() throws Exception {
170174
// -------------------------------------------------------------------------
171175

172176
private static Map<String, Object> baseRequest() {
173-
return Map.of("amount", "0.010000", "currency", "USDC", "recipient", RECIPIENT);
177+
return Map.of("amount", "1000", "currency", TOKEN_CONTRACT, "recipient", RECIPIENT);
174178
}
175179

176180
private static Credential txCredential(String rawTx) {

0 commit comments

Comments
 (0)