diff --git a/README.md b/README.md index 493f319..b835ef5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ implementation 'com.stripe:mpp-java:0.1.4' ``` +## 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 diff --git a/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java b/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java index b3fa1b2..d25d0c0 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java @@ -11,7 +11,7 @@ /** * MPP attribution memo encoding for TIP-20 {@code transferWithMemo}. * - *

When the merchant does not set an explicit memo, Tempo clients write this + *

Tempo clients write this * 32-byte value so a payment can be bound to a specific challenge. Layout: * *

diff --git a/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java b/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java
index 5e4ee9c..db86c9f 100644
--- a/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java
+++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java
@@ -29,7 +29,7 @@
  * 
  *
  * 

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 @@ -102,6 +102,9 @@ public TempoChargeIntent(String rpcUrl, Store store) { @Override @SuppressWarnings("unchecked") public Receipt verify(Credential credential, Map 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"); } @@ -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) { @@ -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. * *

The request amount must already be in atomic units (i.e. after * transformRequest has run). @@ -193,7 +194,6 @@ private List 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(); @@ -222,7 +222,6 @@ private List 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); @@ -230,11 +229,6 @@ private List matchTransferLogs( 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; @@ -317,14 +311,6 @@ static String memoFrom(Map 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; diff --git a/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java b/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java index ee0a8f9..217d990 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java @@ -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); @@ -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() {} @@ -103,28 +96,13 @@ public Builder store(Store store) { return this; } - /** - * Sets an explicit TIP-20 memo that payments must match. - * - *

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. */ @@ -154,11 +132,7 @@ public Map transformRequest(Map request) { .toString(); Map result = new LinkedHashMap<>(request); result.put("amount", atomic); - Map 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); diff --git a/src/main/java/com/stripe/mpp/server/Method.java b/src/main/java/com/stripe/mpp/server/Method.java index 8fe1319..2868dc1 100644 --- a/src/main/java/com/stripe/mpp/server/Method.java +++ b/src/main/java/com/stripe/mpp/server/Method.java @@ -18,9 +18,6 @@ public interface Method { */ List> intents(); - /** Optional: blockchain/network memo field. */ - default String memo() { return null; } - /** Optional: fee payer address. */ default String feePayer() { return null; } diff --git a/src/main/java/com/stripe/mpp/server/MppHandler.java b/src/main/java/com/stripe/mpp/server/MppHandler.java index 7893862..807b537 100644 --- a/src/main/java/com/stripe/mpp/server/MppHandler.java +++ b/src/main/java/com/stripe/mpp/server/MppHandler.java @@ -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()); @@ -203,7 +202,6 @@ Map 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()); diff --git a/src/test/java/com/stripe/mpp/methods/tempo/TempoChargeIntentTest.java b/src/test/java/com/stripe/mpp/methods/tempo/TempoChargeIntentTest.java index edbee7c..ecca9f2 100644 --- a/src/test/java/com/stripe/mpp/methods/tempo/TempoChargeIntentTest.java +++ b/src/test/java/com/stripe/mpp/methods/tempo/TempoChargeIntentTest.java @@ -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; @@ -113,6 +114,7 @@ static class StubRpc extends TempoRpc { private final Map receipt; private final int nullReceiptsBeforeResult; private int receiptCalls = 0; + private int sendCalls = 0; StubRpc(String txHashOnSend, Map receipt, int nullReceiptsBeforeResult) { this.txHashOnSend = txHashOnSend; @@ -120,7 +122,10 @@ static class StubRpc extends TempoRpc { this.nullReceiptsBeforeResult = nullReceiptsBeforeResult; } - @Override String sendRawTransaction(String rpcUrl, String rawTx) { return txHashOnSend; } + @Override String sendRawTransaction(String rpcUrl, String rawTx) { + sendCalls++; + return txHashOnSend; + } @Override Map getTransactionReceipt(String rpcUrl, String txHash) { return receiptCalls++ < nullReceiptsBeforeResult ? null : receipt; @@ -408,58 +413,35 @@ void pushAcceptsChallengeBoundMemoAlongsideAPlainTransfer() { assertThat(result.status()).isEqualTo("success"); } - @Test - void explicitMemoMustMatchExactly() { - String merchantMemo = "0x" + "ab".repeat(32); - Map 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 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 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 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 diff --git a/src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java b/src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java index 824c5a0..57df0c6 100644 --- a/src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java +++ b/src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java @@ -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; @@ -48,37 +45,4 @@ void rejectsAmountWithTooManyDecimalPlaces() { )).isInstanceOf(IllegalArgumentException.class); } - @Test - void copiesMemoIntoMethodDetails() { - Map 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 request = ((VerifyResult.Challenged) result).challenge().request(); - assertThat(request.get("memo")).isEqualTo(memo); - assertThat(((Map) request.get("methodDetails")).get("memo")).isEqualTo(memo); - } }