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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ implementation 'com.stripe:mpp-java:0.1.4'
</dependency>
```

## Breaking change: custom Tempo memos

`TempoMethod.Builder.memo(...)` and `Method.memo()` have been removed. Remove these
configurations and overrides; clients use automatic challenge-bound attribution
memos. Direct Tempo verification rejects explicit memos in payment requests.

## Usage

### Testnet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/**
* MPP attribution memo encoding for TIP-20 {@code transferWithMemo}.
*
* <p>When the merchant does not set an explicit memo, Tempo clients write this
* <p>Tempo clients write this
* 32-byte value so a payment can be bound to a specific challenge. Layout:
*
* <pre>
Expand Down
26 changes: 6 additions & 20 deletions src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
* </ul>
*
* <p>A qualifying Transfer of the requested token, recipient and amount is not
* enough. Unless the merchant set an explicit memo, the matched logs must include
* enough. The matched logs must include
* a {@code TransferWithMemo} whose memo is bound to this challenge (MPP attribution
* tag, server fingerprint of the challenge realm, and nonce
* {@code keccak256(challengeId)[0..6]}). That is what stops a third party from
Expand Down Expand Up @@ -102,6 +102,9 @@ public TempoChargeIntent(String rpcUrl, Store store) {
@Override
@SuppressWarnings("unchecked")
public Receipt verify(Credential credential, Map<String, Object> request) {
if (memoFrom(request) != null) {
throw new VerificationFailedException("explicit memos are not supported for direct Tempo charge verification");
}
if (!(credential.payload() instanceof Map<?, ?>)) {
throw new VerificationFailedException("missing or invalid payload");
}
Expand Down Expand Up @@ -160,9 +163,7 @@ private Receipt awaitReceipt(
"transaction logs contain no Transfer matching the request currency, recipient, and amount"
);
}
if (memoFrom(request) == null) {
assertChallengeBoundMemo(matched, credential);
}
assertChallengeBoundMemo(matched, credential);
return Receipt.success(txHash, "tempo");
}
if (i < maxRetries - 1) {
Expand All @@ -179,7 +180,7 @@ private Receipt awaitReceipt(

/**
* Collects ERC-20 Transfer / TransferWithMemo logs that match the request's
* currency, recipient, amount, expected sender, and (when set) merchant memo.
* currency, recipient, amount, and expected sender.
*
* <p>The request amount must already be in atomic units (i.e. after
* transformRequest has run).
Expand All @@ -193,7 +194,6 @@ private List<MatchedLog> matchTransferLogs(
String currency = (String) request.get("currency");
String recipient = (String) request.get("recipient");
String amountStr = (String) request.get("amount");
String expectedMemo = normalizeMemo(memoFrom(request));

if (currency == null || recipient == null || amountStr == null) return List.of();

Expand Down Expand Up @@ -222,19 +222,13 @@ private List<MatchedLog> matchTransferLogs(
boolean isTransferWithMemo = TRANSFER_WITH_MEMO_TOPIC.equalsIgnoreCase(topic0);
if (!isTransfer && !isTransferWithMemo) continue;
if (isTransferWithMemo && topics.size() < 4) continue;
if (expectedMemo != null && !isTransferWithMemo) continue;

String fromAddress = "0x" + topics.get(1).substring(topics.get(1).length() - 40);
String toAddress = "0x" + topics.get(2).substring(topics.get(2).length() - 40);

if (!toAddress.equalsIgnoreCase(recipient)) continue;
if (expectedSender != null && !fromAddress.equalsIgnoreCase(expectedSender)) continue;

if (expectedMemo != null) {
String logMemo = normalizeMemo(topics.get(3));
if (logMemo == null || !logMemo.equals(expectedMemo)) continue;
}

String data = (String) log.get("data");
if (data == null || data.length() < 66) continue;

Expand Down Expand Up @@ -317,14 +311,6 @@ static String memoFrom(Map<String, Object> request) {
return null;
}

static String normalizeMemo(String memo) {
if (memo == null) return null;
String value = memo.trim();
if (value.isEmpty()) return null;
if (!value.startsWith("0x") && !value.startsWith("0X")) value = "0x" + value;
return value.toLowerCase(Locale.ROOT);
}

static final class ParsedPkh {
final String address;
final int chainId;
Expand Down
34 changes: 4 additions & 30 deletions src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,20 @@ public class TempoMethod implements Method {
private final String rpcUrl;
private final int chainId;
private final int decimals;
private final String memo;
private final TempoChargeIntent chargeIntent;

TempoMethod(String rpcUrl, int chainId) {
this(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, null, null, null);
this(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, null, null);
}

TempoMethod(String rpcUrl, int chainId, int decimals, TempoRelay relay) {
this(rpcUrl, chainId, decimals, relay, null, null);
this(rpcUrl, chainId, decimals, relay, null);
}

TempoMethod(String rpcUrl, int chainId, int decimals, TempoRelay relay, Store store) {
this(rpcUrl, chainId, decimals, relay, store, null);
}

TempoMethod(String rpcUrl, int chainId, int decimals, TempoRelay relay, Store store, String memo) {
this.rpcUrl = rpcUrl;
this.chainId = chainId;
this.decimals = decimals;
this.memo = memo;
this.chargeIntent = relay == null
? new TempoChargeIntent(rpcUrl, store != null ? store : new MemoryStore())
: new TempoRelayChargeIntent(rpcUrl, relay);
Expand All @@ -66,7 +60,6 @@ public static final class Builder {
private int chainId = TempoDefaults.MAINNET_CHAIN_ID;
private TempoRelay relay;
private Store store;
private String memo;

private Builder() {}

Expand Down Expand Up @@ -103,28 +96,13 @@ public Builder store(Store store) {
return this;
}

/**
* Sets an explicit TIP-20 memo that payments must match.
*
* <p>When omitted, clients write an MPP attribution memo bound to the
* challenge and the server requires that binding. An explicit memo is
* matched exactly and is not challenge-bound — the caller must make it
* unique per challenge if hash reuse across challenges should be rejected.
*/
public Builder memo(String memo) {
this.memo = Objects.requireNonNull(memo, "memo");
return this;
}

public TempoMethod build() {
return new TempoMethod(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, relay, store, memo);
return new TempoMethod(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, relay, store);
}
}

@Override public String name() { return "tempo"; }

@Override public String memo() { return memo; }

public int chainId() { return chainId; }
public String rpcUrl() { return rpcUrl; }
/** Returns the CAIP-2 network string (e.g. {@code "eip155:4217"}) for display purposes. */
Expand Down Expand Up @@ -154,11 +132,7 @@ public Map<String, Object> transformRequest(Map<String, Object> request) {
.toString();
Map<String, Object> result = new LinkedHashMap<>(request);
result.put("amount", atomic);
Map<String, Object> methodDetails = new LinkedHashMap<>();
methodDetails.put("chainId", chainId);
Object requestMemo = result.get("memo");
if (requestMemo != null) methodDetails.put("memo", requestMemo);
result.put("methodDetails", methodDetails);
result.put("methodDetails", Map.of("chainId", chainId));
return result;
} catch (Exception e) {
throw new IllegalArgumentException("invalid amount: " + amount, e);
Expand Down
3 changes: 0 additions & 3 deletions src/main/java/com/stripe/mpp/server/Method.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ public interface Method {
*/
List<Class<? extends Intent>> intents();

/** Optional: blockchain/network memo field. */
default String memo() { return null; }

/** Optional: fee payer address. */
default String feePayer() { return null; }

Expand Down
2 changes: 0 additions & 2 deletions src/main/java/com/stripe/mpp/server/MppHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ public VerifyResult charge(
request.put("amount", resolvedAmount);
request.put("currency", resolvedCurrency);
request.put("recipient", resolvedRecipient);
if (method.memo() != null) request.put("memo", method.memo());
if (method.feePayer() != null) request.put("fee_payer", method.feePayer());
if (method.chain() != null) request.put("chain", method.chain());

Expand Down Expand Up @@ -203,7 +202,6 @@ Map<String, Object> buildRequest(ChargeDescriptor d) {
request.put("amount", resolvedAmount);
request.put("currency", resolvedCurrency);
request.put("recipient", resolvedRecipient);
if (method.memo() != null) request.put("memo", method.memo());
if (method.feePayer() != null) request.put("fee_payer", method.feePayer());
if (method.chain() != null) request.put("chain", method.chain());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.stripe.mpp.store.Store;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import java.util.HashMap;
Expand Down Expand Up @@ -113,14 +114,18 @@ static class StubRpc extends TempoRpc {
private final Map<String, Object> receipt;
private final int nullReceiptsBeforeResult;
private int receiptCalls = 0;
private int sendCalls = 0;

StubRpc(String txHashOnSend, Map<String, Object> receipt, int nullReceiptsBeforeResult) {
this.txHashOnSend = txHashOnSend;
this.receipt = receipt;
this.nullReceiptsBeforeResult = nullReceiptsBeforeResult;
}

@Override String sendRawTransaction(String rpcUrl, String rawTx) { return txHashOnSend; }
@Override String sendRawTransaction(String rpcUrl, String rawTx) {
sendCalls++;
return txHashOnSend;
}

@Override Map<String, Object> getTransactionReceipt(String rpcUrl, String txHash) {
return receiptCalls++ < nullReceiptsBeforeResult ? null : receipt;
Expand Down Expand Up @@ -408,58 +413,35 @@ void pushAcceptsChallengeBoundMemoAlongsideAPlainTransfer() {
assertThat(result.status()).isEqualTo("success");
}

@Test
void explicitMemoMustMatchExactly() {
String merchantMemo = "0x" + "ab".repeat(32);
Map<String, Object> request = new HashMap<>(REQUEST);
request.put("memo", merchantMemo);

Receipt result = intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0))
.verify(hashCredential("0xpushedtx"), request);
assertThat(result.status()).isEqualTo("success");
}

@Test
void explicitMemoMismatchIsRejected() {
@ParameterizedTest
@CsvSource({
"hash, false, false", "hash, false, true",
"hash, true, false", "hash, true, true",
"transaction, false, false", "transaction, false, true",
"transaction, true, false", "transaction, true, true"
})
void explicitMemoIsRejectedBeforeRpc(String type, boolean nested, boolean declaredPayer) {
String merchantMemo = "0x" + "ab".repeat(32);
String otherMemo = "0x" + "cd".repeat(32);
Map<String, Object> request = new HashMap<>(REQUEST);
request.put("memo", merchantMemo);
if (nested) {
request.put("methodDetails", Map.of("chainId", CHAIN_ID, "memo", merchantMemo));
} else {
request.put("memo", merchantMemo);
}
Credential credential = new Credential(ECHO,
"hash".equals(type) ? Map.of("type", type, "hash", "0xstolen")
: Map.of("type", type, "signature", "0xsignedtx"),
declaredPayer ? didPkh(CHAIN_ID, SENDER) : null);
StubRpc rpc = new StubRpc("0xstolen",
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0);
Store store = new MemoryStore();

assertThatThrownBy(() -> intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, otherMemo), 0))
.verify(hashCredential("0xpushedtx"), request))
assertThatThrownBy(() -> intent(rpc, store).verify(credential, request))
.isInstanceOf(VerificationFailedException.class)
.hasMessageContaining("Transfer");
}

@Test
void explicitMemoDoesNotRequireChallengeBinding() {
String merchantMemo = "0x" + "ab".repeat(32);
Map<String, Object> request = new HashMap<>(REQUEST);
request.put("memo", merchantMemo);

Receipt result = intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0))
.verify(hashCredential("0xpushedtx"), request);
assertThat(result.status()).isEqualTo("success");
}

@Test
void explicitMemoInMethodDetailsIsHonored() {
String merchantMemo = "0x" + "ab".repeat(32);
Map<String, Object> request = Map.of(
"amount", String.valueOf(AMOUNT_ATOMIC),
"currency", TOKEN_CONTRACT,
"recipient", RECIPIENT,
"methodDetails", Map.of("chainId", CHAIN_ID, "memo", merchantMemo)
);

Receipt result = intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0))
.verify(hashCredential("0xpushedtx"), request);
assertThat(result.status()).isEqualTo("success");
.hasMessageContaining("explicit memos are not supported");
assertThat(rpc.sendCalls).isZero();
assertThat(rpc.receiptCalls).isZero();
assertThat(store.tryClaim("tempo:hash:0xstolen")).isTrue();
}

@Test
Expand Down
36 changes: 0 additions & 36 deletions src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package com.stripe.mpp.methods.tempo;

import com.stripe.mpp.Mpp;
import com.stripe.mpp.server.MppHandler;
import com.stripe.mpp.server.VerifyResult;
import org.junit.jupiter.api.Test;

import java.util.Map;
Expand Down Expand Up @@ -48,37 +45,4 @@ void rejectsAmountWithTooManyDecimalPlaces() {
)).isInstanceOf(IllegalArgumentException.class);
}

@Test
void copiesMemoIntoMethodDetails() {
Map<String, Object> result = METHOD.transformRequest(Map.of(
"amount", "1.000000",
"currency", "USDC",
"recipient", "0xABC",
"memo", "0x" + "ab".repeat(32)
));
assertThat(((Map<?, ?>) result.get("methodDetails")).get("chainId")).isEqualTo(1);
assertThat(((Map<?, ?>) result.get("methodDetails")).get("memo"))
.isEqualTo("0x" + "ab".repeat(32));
assertThat(result.get("memo")).isEqualTo("0x" + "ab".repeat(32));
}

@Test
void builderMemoIsAdvertisedOnTheMethod() {
String memo = "0x" + "cd".repeat(32);
TempoMethod method = TempoMethod.custom("http://rpc.example.com", 1).memo(memo).build();
assertThat(method.memo()).isEqualTo(memo);
}

@Test
void challengeIncludesConfiguredMemo() {
String memo = "0x" + "ab".repeat(32);
TempoMethod tempo = TempoMethod.custom("http://rpc.example.com", 1).memo(memo).build();
MppHandler mpp = Mpp.create(tempo, "api.example.com", "secret");

VerifyResult result = mpp.charge(null, tempo.chargeIntent(), "1.000000", "USDC", "0xABC");

Map<String, Object> request = ((VerifyResult.Challenged) result).challenge().request();
assertThat(request.get("memo")).isEqualTo(memo);
assertThat(((Map<?, ?>) request.get("methodDetails")).get("memo")).isEqualTo(memo);
}
}
Loading