diff --git a/.gitignore b/.gitignore index 93add13..cbacb9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .gradle/ build/ *.class +.agents/ diff --git a/src/integrationTest/java/com/stripe/mpp/integration/TempoIntegrationTest.java b/src/integrationTest/java/com/stripe/mpp/integration/TempoIntegrationTest.java index b9ab66a..7d59861 100644 --- a/src/integrationTest/java/com/stripe/mpp/integration/TempoIntegrationTest.java +++ b/src/integrationTest/java/com/stripe/mpp/integration/TempoIntegrationTest.java @@ -5,6 +5,7 @@ import com.stripe.mpp.Credential; import com.stripe.mpp.Mpp; import com.stripe.mpp.Receipt; +import com.stripe.mpp.methods.tempo.Attribution; import com.stripe.mpp.methods.tempo.Tempo; import com.stripe.mpp.methods.tempo.TempoChargeIntent; import com.stripe.mpp.methods.tempo.TempoMethod; @@ -16,6 +17,7 @@ import org.web3j.abi.FunctionEncoder; import org.web3j.abi.datatypes.Address; import org.web3j.abi.datatypes.Function; +import org.web3j.abi.datatypes.generated.Bytes32; import org.web3j.abi.datatypes.generated.Uint256; import org.web3j.crypto.Credentials; import org.web3j.crypto.Sign; @@ -84,7 +86,7 @@ static void connect() throws Exception { /** Client signs a transaction and passes it raw — server broadcasts and verifies. */ @Test void transactionCredentialVerifies() throws Exception { - String rawTx = buildSignedTx(nextNonce(), BigInteger.valueOf(1_000L)); + String rawTx = buildSignedTx(nextNonce(), BigInteger.valueOf(1_000L), boundMemo()); TempoChargeIntent intent = Tempo.chargeIntent(rpcUrl); Credential credential = txCredential(rawTx); @@ -99,7 +101,7 @@ void transactionCredentialVerifies() throws Exception { /** Client broadcasts first and passes only the hash — server polls for the receipt. */ @Test void hashCredentialVerifies() throws Exception { - String rawTx = buildSignedTx(nextNonce(), BigInteger.valueOf(1_000L)); + String rawTx = buildSignedTx(nextNonce(), BigInteger.valueOf(1_000L), boundMemo()); String txHash = rpc("eth_sendRawTransaction", List.of(rawTx)); TempoChargeIntent intent = Tempo.chargeIntent(rpcUrl); @@ -126,8 +128,9 @@ void fullMppRoundTrip() throws Exception { assertThat(r1).isInstanceOf(VerifyResult.Challenged.class); Challenge challenge = ((VerifyResult.Challenged) r1).challenge(); - // Step 2: client builds a transaction and wraps it in a credential - String rawTx = buildSignedTx(nextNonce(), tokenAmount); + // Step 2: client builds a transaction bound to this challenge and wraps it in a credential + String rawTx = buildSignedTx(nextNonce(), tokenAmount, + Attribution.encode(challenge.realm(), challenge.id())); Credential credential = new Credential(challenge.toEcho(), Map.of("type", "transaction", "signature", rawTx), null); // Step 3: retry with the credential @@ -192,9 +195,13 @@ private static ChallengeEcho stubEcho() { ); } + private static String boundMemo() { + return Attribution.encode("localhost", "test-id"); + } + /** - * Build a signed Tempo 0x76 transaction that calls transfer(address,uint256) - * on the TIP-20 token contract. + * Build a signed Tempo 0x76 transaction that calls transferWithMemo(address,uint256,bytes32) + * on the TIP-20 token contract with an MPP attribution memo bound to the credential's challenge. * *

Tempo uses a custom transaction type (0x76) distinct from legacy EVM transactions. * RLP field order (tempo-primitives TempoTransaction): @@ -202,11 +209,11 @@ private static ChallengeEcho stubEcho() { * nonce_key, nonce, valid_before, valid_after, fee_token, fee_payer_signature, * tempo_auth_list — followed by the secp256k1 signature bytes (r||s||v, 65 bytes). */ - private String buildSignedTx(BigInteger nonce, BigInteger tokenAmount) throws Exception { - // ABI-encode transfer(address, uint256) call data + private String buildSignedTx(BigInteger nonce, BigInteger tokenAmount, String memoHex) throws Exception { + byte[] memoBytes = Numeric.hexStringToByteArray(memoHex); Function function = new Function( - "transfer", - Arrays.asList(new Address(RECIPIENT), new Uint256(tokenAmount)), + "transferWithMemo", + Arrays.asList(new Address(RECIPIENT), new Uint256(tokenAmount), new Bytes32(memoBytes)), Collections.emptyList() ); byte[] callData = Numeric.hexStringToByteArray(FunctionEncoder.encode(function)); diff --git a/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java b/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java new file mode 100644 index 0000000..b3fa1b2 --- /dev/null +++ b/src/main/java/com/stripe/mpp/methods/tempo/Attribution.java @@ -0,0 +1,150 @@ +package com.stripe.mpp.methods.tempo; + +import org.bouncycastle.jcajce.provider.digest.Keccak; +import org.bouncycastle.util.encoders.Hex; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; + +/** + * MPP attribution memo encoding for TIP-20 {@code transferWithMemo}. + * + *

When the merchant does not set an explicit memo, Tempo clients write this + * 32-byte value so a payment can be bound to a specific challenge. Layout: + * + *

+ *  0..3   TAG = keccak256("mpp")[0..3]
+ *  4      version (0x01)
+ *  5..14  serverId = keccak256(serverId)[0..9]
+ *  15..24 clientId = keccak256(clientId)[0..9] or zeros
+ *  25..31 nonce    = keccak256(challengeId)[0..6]
+ * 
+ * + *

The encoding matches the mppx and mpp-rb SDKs so a memo produced by one + * verifies on the others. + */ +public final class Attribution { + private static final int VERSION = 0x01; + private static final byte[] TAG = Arrays.copyOf(keccak256(bytes("mpp")), 4); + + /** First 4 bytes of {@code keccak256("mpp")}, as {@code 0x}-prefixed hex. */ + public static final String TAG_HEX = "0x" + Hex.toHexString(TAG); + + private Attribution() {} + + /** + * Encodes a 32-byte attribution memo bound to {@code serverId} and {@code challengeId}. + * + * @return a {@code 0x}-prefixed 64-character hex string + */ + public static String encode(String serverId, String challengeId) { + return encode(serverId, challengeId, null); + } + + /** + * Encodes a 32-byte attribution memo, optionally including a client fingerprint. + */ + public static String encode(String serverId, String challengeId, String clientId) { + Objects.requireNonNull(serverId, "serverId"); + Objects.requireNonNull(challengeId, "challengeId"); + byte[] buf = new byte[32]; + System.arraycopy(TAG, 0, buf, 0, 4); + buf[4] = VERSION; + System.arraycopy(fingerprint(serverId), 0, buf, 5, 10); + if (clientId != null) { + System.arraycopy(fingerprint(clientId), 0, buf, 15, 10); + } + System.arraycopy(challengeNonce(challengeId), 0, buf, 25, 7); + return "0x" + Hex.toHexString(buf); + } + + /** Returns {@code true} if {@code memo} has the MPP tag and version byte. */ + public static boolean isMppMemo(String memo) { + if (memo == null || memo.length() != 66) return false; + if (!memo.startsWith("0x") && !memo.startsWith("0X")) return false; + String lower = memo.toLowerCase(Locale.ROOT); + if (!lower.startsWith(TAG_HEX)) return false; + try { + return Integer.parseInt(lower.substring(10, 12), 16) == VERSION; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Returns {@code true} if {@code memo} is an MPP memo whose server fingerprint + * matches {@code serverId}. + */ + public static boolean verifyServer(String memo, String serverId) { + if (serverId == null || !isMppMemo(memo)) return false; + String memoServer = memo.substring(12, 32).toLowerCase(Locale.ROOT); + return Hex.toHexString(fingerprint(serverId)).equals(memoServer); + } + + /** + * Returns {@code true} if {@code memo} is an MPP memo whose nonce equals + * {@code keccak256(challengeId)[0..6]}. + */ + public static boolean verifyChallengeBinding(String memo, String challengeId) { + if (challengeId == null) return false; + Decoded decoded = decode(memo); + if (decoded == null) return false; + String expected = "0x" + Hex.toHexString(challengeNonce(challengeId)); + return decoded.nonce().equalsIgnoreCase(expected); + } + + /** + * Decodes an MPP attribution memo, or {@code null} if it is not one. + */ + public static Decoded decode(String memo) { + if (!isMppMemo(memo)) return null; + String lower = memo.toLowerCase(Locale.ROOT); + int version = Integer.parseInt(lower.substring(10, 12), 16); + String serverFingerprint = "0x" + lower.substring(12, 32); + String clientHex = "0x" + lower.substring(32, 52); + String nonce = "0x" + lower.substring(52); + String clientFingerprint = "0x00000000000000000000".equals(clientHex) ? null : clientHex; + return new Decoded(version, serverFingerprint, clientFingerprint, nonce); + } + + /** Decoded fields of an MPP attribution memo. */ + public static final class Decoded { + private final int version; + private final String serverFingerprint; + private final String clientFingerprint; + private final String nonce; + + Decoded(int version, String serverFingerprint, String clientFingerprint, String nonce) { + this.version = version; + this.serverFingerprint = serverFingerprint; + this.clientFingerprint = clientFingerprint; + this.nonce = nonce; + } + + public int version() { return version; } + /** 10-byte server fingerprint ({@code 0x} + 20 hex chars). */ + public String serverFingerprint() { return serverFingerprint; } + /** 10-byte client fingerprint, or {@code null} if anonymous. */ + public String clientFingerprint() { return clientFingerprint; } + /** 7-byte challenge-bound nonce ({@code 0x} + 14 hex chars). */ + public String nonce() { return nonce; } + } + + private static byte[] fingerprint(String value) { + return Arrays.copyOf(keccak256(bytes(value)), 10); + } + + private static byte[] challengeNonce(String challengeId) { + return Arrays.copyOf(keccak256(bytes(challengeId)), 7); + } + + private static byte[] keccak256(byte[] data) { + return new Keccak.Digest256().digest(data); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} 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 2fa54f7..f5ab16c 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java @@ -9,10 +9,13 @@ import com.stripe.mpp.store.Store; import java.math.BigInteger; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Server-side intent that verifies Tempo payments. @@ -25,6 +28,13 @@ * the server polls for the receipt directly. * * + *

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 + * 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 + * presenting someone else's settled transaction as their own payment. + * *

Create the intent once and reuse it so its replay store is shared across requests: * *

{@code
@@ -46,6 +56,10 @@ public class TempoChargeIntent implements Intent {
     static final String TRANSFER_WITH_MEMO_TOPIC =
         "0x57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0";
     private static final String REPLAY_KEY_PREFIX = "tempo:hash:";
+    private static final Pattern PKH_SOURCE =
+        Pattern.compile("^did:pkh:eip155:(0|[1-9]\\d*):([^:]+)$");
+    private static final Pattern ADDRESS =
+        Pattern.compile("^0x[a-fA-F0-9]{40}$");
 
     private final String rpcUrl;
     private final int maxRetries;
@@ -96,22 +110,25 @@ public Receipt verify(Credential credential, Map request) {
         String type = (String) payload.get("type");
         if ("transaction".equals(type)) {
             // Pull: client signed the tx, server broadcasts it.
-            return verifyTransaction((String) payload.get("signature"), request);
+            return verifyTransaction((String) payload.get("signature"), request, credential);
         }
         if ("hash".equals(type)) {
             // Push: client already broadcast, server just verifies the receipt.
-            return verifyHash((String) payload.get("hash"), request);
+            return verifyHash((String) payload.get("hash"), request, credential);
         }
         throw new VerificationFailedException("unrecognized payload type: " + type);
     }
 
-    private Receipt verifyTransaction(String rawTx, Map request) {
+    private Receipt verifyTransaction(String rawTx, Map request, Credential credential) {
         String txHash = rpc.sendRawTransaction(rpcUrl, rawTx);
-        return claimOnce(awaitReceipt(txHash, request));
+        return claimOnce(awaitReceipt(txHash, request, credential, null));
     }
 
-    private Receipt verifyHash(String txHash, Map request) {
-        return claimOnce(awaitReceipt(txHash, request));
+    private Receipt verifyHash(String txHash, Map request, Credential credential) {
+        // Validate the declared payer before reserving the hash so a malformed
+        // source cannot burn an otherwise valid payment.
+        String sourceAddress = parseHashCredentialSource(credential.source(), chainIdFrom(request));
+        return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress));
     }
 
     /** Records first use of the settled transaction, rejecting a hash that was already claimed. */
@@ -123,18 +140,28 @@ private Receipt claimOnce(Receipt receipt) {
         return receipt;
     }
 
-    private Receipt awaitReceipt(String txHash, Map request) {
+    private Receipt awaitReceipt(
+        String txHash,
+        Map request,
+        Credential credential,
+        String sourceAddress
+    ) {
         for (int i = 0; i < maxRetries; i++) {
             Map receipt = rpc.getTransactionReceipt(rpcUrl, txHash);
             if (receipt != null) {
                 if (!"0x1".equals(receipt.get("status"))) {
                     throw new VerificationFailedException("transaction reverted");
                 }
-                if (!matchTransferLogs(receipt, request)) {
+                String expectedSender = sourceAddress != null ? sourceAddress : (String) receipt.get("from");
+                List matched = matchTransferLogs(receipt, request, expectedSender);
+                if (matched.isEmpty()) {
                     throw new VerificationFailedException(
                         "transaction logs contain no Transfer matching the request currency, recipient, and amount"
                     );
                 }
+                if (memoFrom(request) == null) {
+                    assertChallengeBoundMemo(matched, credential);
+                }
                 return Receipt.success(txHash, "tempo");
             }
             if (i < maxRetries - 1) {
@@ -150,30 +177,36 @@ private Receipt awaitReceipt(String txHash, Map request) {
     }
 
     /**
-     * Returns true if the receipt contains at least one ERC-20 Transfer (or TransferWithMemo)
-     * log that matches the request's currency (token contract), recipient, sender, and amount.
+     * Collects ERC-20 Transfer / TransferWithMemo logs that match the request's
+     * currency, recipient, amount, expected sender, and (when set) merchant memo.
      *
-     * The request amount must already be in atomic units (i.e. after transformRequest has run).
+     * 

The request amount must already be in atomic units (i.e. after + * transformRequest has run). */ @SuppressWarnings("unchecked") - private boolean matchTransferLogs(Map receipt, Map request) { + private List matchTransferLogs( + Map receipt, + Map request, + String expectedSender + ) { String currency = (String) request.get("currency"); String recipient = (String) request.get("recipient"); String amountStr = (String) request.get("amount"); - String sender = (String) receipt.get("from"); + String expectedMemo = normalizeMemo(memoFrom(request)); - if (currency == null || recipient == null || amountStr == null) return false; + if (currency == null || recipient == null || amountStr == null) return List.of(); BigInteger expectedAmount; try { expectedAmount = new BigInteger(amountStr); } catch (NumberFormatException e) { - return false; + return List.of(); } List logs = (List) receipt.get("logs"); - if (logs == null) return false; + if (logs == null) return List.of(); + List matched = new ArrayList<>(); for (Object logObj : logs) { Map log = (Map) logObj; @@ -188,12 +221,18 @@ private boolean matchTransferLogs(Map receipt, Map receipt, Map matched, Credential credential) { + String realm = credential.challenge().realm(); + String challengeId = credential.challenge().id(); + for (MatchedLog log : matched) { + if (!log.memo) continue; + if (Attribution.verifyServer(log.memoValue, realm) + && Attribution.verifyChallengeBinding(log.memoValue, challengeId)) { + return; + } + } + throw new VerificationFailedException("memo is not bound to this challenge"); + } + + /** + * Parses a hash-credential source. {@code null} or empty if absent; the + * address for a {@code did:pkh:eip155} DID matching {@code expectedChainId}; + * otherwise raises. + */ + static String parseHashCredentialSource(String source, Object expectedChainId) { + if (source == null || source.isEmpty()) return null; + ParsedPkh parsed = parsePkhSource(source); + Integer expected = parseChainIdValue(expectedChainId); + if (parsed == null || (expected != null && parsed.chainId != expected)) { + throw new VerificationFailedException("Hash credential source is invalid"); + } + return parsed.address; + } + + static Object chainIdFrom(Map request) { + Object details = request.get("methodDetails"); + if (!(details instanceof Map)) return null; + return ((Map) details).get("chainId"); + } + + static Integer parseChainIdValue(Object raw) { + if (raw == null) return null; + if (raw instanceof Number) return ((Number) raw).intValue(); + if (raw instanceof String) { + try { + return Integer.valueOf((String) raw); + } catch (NumberFormatException e) { + throw new VerificationFailedException("Hash credential source is invalid"); + } + } + throw new VerificationFailedException("Hash credential source is invalid"); + } + + static ParsedPkh parsePkhSource(String source) { + Matcher match = PKH_SOURCE.matcher(source); + if (!match.matches()) return null; + if (!ADDRESS.matcher(match.group(2)).matches()) return null; + return new ParsedPkh(match.group(2), Integer.parseInt(match.group(1))); + } + + static String memoFrom(Map request) { + Object top = request.get("memo"); + if (top instanceof String && !((String) top).isEmpty()) return (String) top; + Object details = request.get("methodDetails"); + if (details instanceof Map) { + Object nested = ((Map) details).get("memo"); + if (nested instanceof String && !((String) nested).isEmpty()) return (String) nested; + } + 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; + + ParsedPkh(String address, int chainId) { + this.address = address; + this.chainId = chainId; + } + } + + private static final class MatchedLog { + final boolean memo; + final String memoValue; + + MatchedLog(boolean memo, String memoValue) { + this.memo = memo; + this.memoValue = memoValue; + } } } 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 217d990..ee0a8f9 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java @@ -26,20 +26,26 @@ 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); + this(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, null, null, null); } TempoMethod(String rpcUrl, int chainId, int decimals, TempoRelay relay) { - this(rpcUrl, chainId, decimals, relay, null); + this(rpcUrl, chainId, decimals, relay, null, 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); @@ -60,6 +66,7 @@ public static final class Builder { private int chainId = TempoDefaults.MAINNET_CHAIN_ID; private TempoRelay relay; private Store store; + private String memo; private Builder() {} @@ -96,13 +103,28 @@ 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); + return new TempoMethod(rpcUrl, chainId, TempoDefaults.DEFAULT_DECIMALS, relay, store, memo); } } @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. */ @@ -132,7 +154,11 @@ public Map transformRequest(Map request) { .toString(); Map result = new LinkedHashMap<>(request); result.put("amount", atomic); - result.put("methodDetails", Map.of("chainId", chainId)); + Map methodDetails = new LinkedHashMap<>(); + methodDetails.put("chainId", chainId); + Object requestMemo = result.get("memo"); + if (requestMemo != null) methodDetails.put("memo", requestMemo); + result.put("methodDetails", methodDetails); return result; } catch (Exception e) { throw new IllegalArgumentException("invalid amount: " + amount, e); diff --git a/src/test/java/com/stripe/mpp/methods/tempo/AttributionTest.java b/src/test/java/com/stripe/mpp/methods/tempo/AttributionTest.java new file mode 100644 index 0000000..300ae69 --- /dev/null +++ b/src/test/java/com/stripe/mpp/methods/tempo/AttributionTest.java @@ -0,0 +1,105 @@ +package com.stripe.mpp.methods.tempo; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AttributionTest { + + @Test + void tagMatchesKeccakVector() { + assertThat(Attribution.TAG_HEX).isEqualTo("0xef1ed712"); + } + + @Test + void encodeReturns32ByteHex() { + String memo = Attribution.encode("api.example.com", "chal-id"); + assertThat(memo).matches("0x[0-9a-f]{64}"); + assertThat(memo.length()).isEqualTo(66); + } + + @Test + void encodeStartsWithTagAndVersion() { + String memo = Attribution.encode("api.example.com", "chal-id"); + assertThat(memo.substring(0, 10)).isEqualTo(Attribution.TAG_HEX); + assertThat(memo.substring(10, 12)).isEqualTo("01"); + } + + @Test + void encodeIsDeterministicForTheSameChallenge() { + String a = Attribution.encode("api.example.com", "challenge-a"); + String b = Attribution.encode("api.example.com", "challenge-a"); + String c = Attribution.encode("api.example.com", "challenge-b"); + assertThat(a).isEqualTo(b); + assertThat(a).isNotEqualTo(c); + } + + @Test + void isMppMemo() { + assertThat(Attribution.isMppMemo(Attribution.encode("test-server", "chal-123"))).isTrue(); + assertThat(Attribution.isMppMemo("0x" + "00".repeat(32))).isFalse(); + assertThat(Attribution.isMppMemo("0x1234")).isFalse(); + assertThat(Attribution.isMppMemo("")).isFalse(); + assertThat(Attribution.isMppMemo(null)).isFalse(); + } + + @Test + void verifyServer() { + String memo = Attribution.encode("test-server", "chal-123"); + assertThat(Attribution.verifyServer(memo, "test-server")).isTrue(); + assertThat(Attribution.verifyServer(memo, "other-server")).isFalse(); + } + + @Test + void verifyChallengeBinding() { + String memo = Attribution.encode("test-server", "chal-123"); + assertThat(Attribution.verifyChallengeBinding(memo, "chal-123")).isTrue(); + assertThat(Attribution.verifyChallengeBinding(memo, "chal-b")).isFalse(); + assertThat(Attribution.verifyChallengeBinding(memo, null)).isFalse(); + } + + @Test + void decodeRoundtripWithClientId() { + String memo = Attribution.encode("test-server", "chal-123", "test-client"); + Attribution.Decoded decoded = Attribution.decode(memo); + + assertThat(decoded).isNotNull(); + assertThat(decoded.version()).isEqualTo(1); + assertThat(decoded.serverFingerprint()).startsWith("0x").hasSize(22); + assertThat(decoded.clientFingerprint()).isNotNull().startsWith("0x").hasSize(22); + assertThat(decoded.nonce()).startsWith("0x").hasSize(16); + } + + @Test + void decodeWithoutClientIsAnonymous() { + Attribution.Decoded decoded = Attribution.decode(Attribution.encode("test-server", "chal-123")); + assertThat(decoded).isNotNull(); + assertThat(decoded.clientFingerprint()).isNull(); + } + + @Test + void decodeInvalidMemo() { + assertThat(Attribution.decode("0x" + "00".repeat(32))).isNull(); + } + + @Test + void differentServerIdsProduceDifferentFingerprints() { + Attribution.Decoded a = Attribution.decode(Attribution.encode("server-a", "chal")); + Attribution.Decoded b = Attribution.decode(Attribution.encode("server-b", "chal")); + assertThat(a.serverFingerprint()).isNotEqualTo(b.serverFingerprint()); + } + + @Test + void fingerprintMatchesKeccakVector() { + Attribution.Decoded decoded = Attribution.decode(Attribution.encode("test-server", "chal-123")); + assertThat(decoded.serverFingerprint()).isEqualTo("0x3c224683515d3cb375bf"); + } + + @Test + void acceptsMemoProducedByOtherSdks() { + String memo = "0xef1ed712013c224683515d3cb375bf000000000000000000003b2941df281c7c"; + assertThat(Attribution.isMppMemo(memo)).isTrue(); + assertThat(Attribution.verifyServer(memo, "test-server")).isTrue(); + assertThat(Attribution.verifyChallengeBinding(memo, "chal-123")).isTrue(); + } +} 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 6540ee4..c61adf9 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 java.util.HashMap; import java.util.List; import java.util.Map; @@ -18,53 +19,88 @@ class TempoChargeIntentTest { static final String RPC_URL = "https://rpc.example.com"; - // Realistic addresses used across all tests static final String TOKEN_CONTRACT = TempoDefaults.TESTNET_PATH_USD; static final String SENDER = "0x1234567890123456789012345678901234567890"; static final String RECIPIENT = "0xabcdef1234567890abcdef1234567890abcdef12"; - static final long AMOUNT_ATOMIC = 1_000_000L; // 1 token with 6 decimals + static final long AMOUNT_ATOMIC = 1_000_000L; + static final int CHAIN_ID = TempoDefaults.TESTNET_CHAIN_ID; - // Request as it arrives at verify() — amount already in atomic units, currency = contract address static final Map REQUEST = Map.of( "amount", String.valueOf(AMOUNT_ATOMIC), "currency", TOKEN_CONTRACT, "recipient", RECIPIENT ); + static final Map REQUEST_WITH_CHAIN = Map.of( + "amount", String.valueOf(AMOUNT_ATOMIC), + "currency", TOKEN_CONTRACT, + "recipient", RECIPIENT, + "methodDetails", Map.of("chainId", CHAIN_ID) + ); + static final ChallengeEcho ECHO = new ChallengeEcho( "chal-id", "api.example.com", "tempo", "charge", "e30", "2099-01-01T00:00:00Z", null, null ); + static final String BOUND_MEMO = Attribution.encode(ECHO.realm(), ECHO.id()); + static Credential txCredential(String rawTx) { return new Credential(ECHO, Map.of("type", "transaction", "signature", rawTx), null); } static Credential hashCredential(String txHash) { - return new Credential(ECHO, Map.of("type", "hash", "hash", txHash), null); + return hashCredential(txHash, null); + } + + static Credential hashCredential(String txHash, String source) { + return new Credential(ECHO, Map.of("type", "hash", "hash", txHash), source); + } + + static String didPkh(int chainId, String address) { + return "did:pkh:eip155:" + chainId + ":" + address; } - /** Build a receipt whose Transfer log exactly matches REQUEST. */ + /** Bound TransferWithMemo log matching REQUEST and ECHO. */ static Map successReceipt() { - return receiptWithLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC); + return receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, BOUND_MEMO); } - /** Build a receipt containing one ERC-20 Transfer log with the given parameters. */ static Map receiptWithLog(String contract, String from, String to, long amount) { - String senderTopic = "0x000000000000000000000000" + from.substring(2); - String recipientTopic = "0x000000000000000000000000" + to.substring(2); - String amountData = "0x" + String.format("%064x", amount); + return receiptWithTopics( + contract, from, to, amount, + List.of(TempoChargeIntent.TRANSFER_TOPIC, topic(from), topic(to)), + from + ); + } + + static Map receiptWithMemoLog( + String contract, String from, String to, long amount, String memo + ) { + return receiptWithTopics( + contract, from, to, amount, + List.of(TempoChargeIntent.TRANSFER_WITH_MEMO_TOPIC, topic(from), topic(to), memo), + from + ); + } + + static Map receiptWithTopics( + String contract, String from, String to, long amount, List topics, String receiptFrom + ) { + String amountData = "0x" + String.format("%064x", amount); return Map.of( "status", "0x1", - "from", from, + "from", receiptFrom, "logs", List.of(Map.of( "address", contract, - "topics", List.of(TempoChargeIntent.TRANSFER_TOPIC, senderTopic, recipientTopic), + "topics", topics, "data", amountData )) ); } - // --- Stub RPC --- + static String topic(String address) { + return "0x000000000000000000000000" + address.substring(2); + } static class StubRpc extends TempoRpc { private final String txHashOnSend; @@ -93,8 +129,6 @@ static TempoChargeIntent intent(TempoRpc rpc, Store store) { return new TempoChargeIntent(RPC_URL, 5, 0, rpc, store); } - // --- Existing transport / broadcast tests --- - @Test void pullPaymentBroadcastsAndReturnsReceipt() { StubRpc rpc = new StubRpc("0xdeadbeef", successReceipt(), 0); @@ -202,8 +236,6 @@ void missingPayloadThrows() { .hasMessageContaining("missing or invalid payload"); } - // --- Transfer log validation tests --- - @Test void wrongTokenContractThrows() { String otherContract = "0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead"; @@ -235,16 +267,11 @@ void wrongAmountThrows() { @Test void wrongSenderThrows() { - String otherSender = "0x8888888888888888888888888888888888888888"; - // log.from matches SENDER but receipt.from is otherSender — mismatch - StubRpc rpc = new StubRpc("0xtx", receiptWithLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC), 0); - Map baseReceipt = receiptWithLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC); - // Rebuild receipt with a different "from" field - Map tampered = new java.util.HashMap<>(baseReceipt); - tampered.put("from", otherSender); - StubRpc rpc2 = new StubRpc("0xtx", tampered, 0); - - assertThatThrownBy(() -> intent(rpc2).verify(txCredential("0xsignedtx"), REQUEST)) + Map tampered = new HashMap<>(successReceipt()); + tampered.put("from", RECIPIENT); + StubRpc rpc = new StubRpc("0xtx", tampered, 0); + + assertThatThrownBy(() -> intent(rpc).verify(txCredential("0xsignedtx"), REQUEST)) .isInstanceOf(VerificationFailedException.class) .hasMessageContaining("Transfer"); } @@ -260,35 +287,259 @@ void noLogsThrows() { } @Test - void transferWithMemoTopicAccepted() { - // TransferWithMemo has amount in data and memo in topics[3]; should still verify. - String senderTopic = "0x000000000000000000000000" + SENDER.substring(2); - String recipientTopic = "0x000000000000000000000000" + RECIPIENT.substring(2); - String memoTopic = "0x" + String.format("%064x", 42); // arbitrary memo - String amountData = "0x" + String.format("%064x", AMOUNT_ATOMIC); + void pushRejectsPlainTransferWithoutChallengeBoundMemo() { + StubRpc rpc = new StubRpc(null, receiptWithLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC), 0); + + assertThatThrownBy(() -> intent(rpc).verify(hashCredential("0xstolen"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("memo is not bound to this challenge"); + } + + @Test + void pushRejectsMemoBoundToADifferentChallenge() { + String stolenMemo = Attribution.encode(ECHO.realm(), "other-challenge"); + StubRpc rpc = new StubRpc(null, + receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, stolenMemo), 0); + + assertThatThrownBy(() -> intent(rpc).verify(hashCredential("0xstolen"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("memo is not bound to this challenge"); + } + + @Test + void pushRejectsMemoBoundToADifferentRealm() { + String stolenMemo = Attribution.encode("other.example.com", ECHO.id()); + StubRpc rpc = new StubRpc(null, + receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, stolenMemo), 0); + + assertThatThrownBy(() -> intent(rpc).verify(hashCredential("0xstolen"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("memo is not bound to this challenge"); + } + + @Test + void pushRejectsArbitraryNonMppMemo() { + String arbitrary = "0x" + String.format("%064x", 42); + StubRpc rpc = new StubRpc(null, + receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, arbitrary), 0); + + assertThatThrownBy(() -> intent(rpc).verify(hashCredential("0xpushedtx"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("memo is not bound to this challenge"); + } + + @Test + void unboundMemoDoesNotConsumeReplayClaim() { + Store store = new MemoryStore(); + StubRpc plain = new StubRpc(null, receiptWithLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC), 0); + + assertThatThrownBy(() -> intent(plain, store).verify(hashCredential("0xunrelated"), REQUEST)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("memo is not bound"); + + Receipt receipt = intent(new StubRpc(null, successReceipt(), 0), store) + .verify(hashCredential("0xunrelated"), REQUEST); + assertThat(receipt.reference()).isEqualTo("0xunrelated"); + } + + @Test + void pushAcceptsChallengeBoundMemoAlongsideAPlainTransfer() { + String senderTopic = topic(SENDER); + String recipientTopic = topic(RECIPIENT); + String amountData = "0x" + String.format("%064x", AMOUNT_ATOMIC); Map receipt = Map.of( "status", "0x1", "from", SENDER, - "logs", List.of(Map.of( - "address", TOKEN_CONTRACT, - "topics", List.of(TempoChargeIntent.TRANSFER_WITH_MEMO_TOPIC, - senderTopic, recipientTopic, memoTopic), - "data", amountData - )) + "logs", List.of( + Map.of( + "address", TOKEN_CONTRACT, + "topics", List.of(TempoChargeIntent.TRANSFER_TOPIC, senderTopic, recipientTopic), + "data", amountData + ), + Map.of( + "address", TOKEN_CONTRACT, + "topics", List.of(TempoChargeIntent.TRANSFER_WITH_MEMO_TOPIC, + senderTopic, recipientTopic, BOUND_MEMO), + "data", amountData + ) + ) ); - StubRpc rpc = new StubRpc("0xtx", receipt, 0); + Receipt result = intent(new StubRpc(null, receipt, 0)).verify(hashCredential("0xpushedtx"), REQUEST); + assertThat(result.status()).isEqualTo("success"); + } - Receipt result = intent(rpc).verify(txCredential("0xsignedtx"), REQUEST); + @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() { + String merchantMemo = "0x" + "ab".repeat(32); + String otherMemo = "0x" + "cd".repeat(32); + Map request = new HashMap<>(REQUEST); + request.put("memo", merchantMemo); + + assertThatThrownBy(() -> intent(new StubRpc(null, + receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, otherMemo), 0)) + .verify(hashCredential("0xpushedtx"), 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"); } @Test void contractAddressMatchIsCaseInsensitive() { - // Vary the casing of the contract address in the log - Map receipt = receiptWithLog(TOKEN_CONTRACT.toUpperCase(), SENDER, RECIPIENT, AMOUNT_ATOMIC); + Map receipt = receiptWithMemoLog( + TOKEN_CONTRACT.toUpperCase(), SENDER, RECIPIENT, AMOUNT_ATOMIC, BOUND_MEMO); StubRpc rpc = new StubRpc("0xtx", receipt, 0); Receipt result = intent(rpc).verify(txCredential("0xsignedtx"), REQUEST); assertThat(result.status()).isEqualTo("success"); } + + @Test + void parseHashCredentialSourceAbsentIsNull() { + assertThat(TempoChargeIntent.parseHashCredentialSource(null, CHAIN_ID)).isNull(); + assertThat(TempoChargeIntent.parseHashCredentialSource("", CHAIN_ID)).isNull(); + } + + @Test + void parseHashCredentialSourceValidReturnsAddress() { + assertThat(TempoChargeIntent.parseHashCredentialSource(didPkh(CHAIN_ID, SENDER), CHAIN_ID)) + .isEqualTo(SENDER); + } + + @Test + void parseHashCredentialSourceChainMismatchRejected() { + assertThatThrownBy(() -> + TempoChargeIntent.parseHashCredentialSource(didPkh(1, SENDER), CHAIN_ID)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Hash credential source is invalid"); + } + + @Test + void parseHashCredentialSourceAcceptsStringChainId() { + assertThat(TempoChargeIntent.parseHashCredentialSource(didPkh(CHAIN_ID, SENDER), String.valueOf(CHAIN_ID))) + .isEqualTo(SENDER); + } + + @Test + void parseHashCredentialSourceRejectsNonNumericChainId() { + assertThatThrownBy(() -> + TempoChargeIntent.parseHashCredentialSource(didPkh(CHAIN_ID, SENDER), "not-a-number")) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Hash credential source is invalid"); + } + + @Test + void parseHashCredentialSourceRejectsMalformedVariants() { + List malformed = List.of( + "not-a-valid-did", + "did:pkh:solana:" + CHAIN_ID + ":" + SENDER, + "did:pkh:eip155:04217:" + SENDER, + "did:pkh:eip155:not-a-number:" + SENDER, + "did:pkh:eip155:" + CHAIN_ID + ":extra:" + SENDER, + "did:pkh:eip155:" + CHAIN_ID + ":not-an-address" + ); + for (String source : malformed) { + assertThatThrownBy(() -> TempoChargeIntent.parseHashCredentialSource(source, CHAIN_ID)) + .as("case: %s", source) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Hash credential source is invalid"); + } + } + + @Test + void hashAcceptsSourceMatchingTransferSender() { + Map receipt = receiptWithMemoLog( + TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, BOUND_MEMO); + Receipt result = intent(new StubRpc(null, receipt, 0)) + .verify(hashCredential("0xpushedtx", didPkh(CHAIN_ID, SENDER)), REQUEST_WITH_CHAIN); + assertThat(result.reference()).isEqualTo("0xpushedtx"); + } + + @Test + void hashAcceptsSourceWhenReceiptSenderDiffers() { + Map receipt = new HashMap<>(receiptWithMemoLog( + TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, BOUND_MEMO)); + receipt.put("from", RECIPIENT); + + Receipt result = intent(new StubRpc(null, receipt, 0)) + .verify(hashCredential("0xpushedtx", didPkh(CHAIN_ID, SENDER)), REQUEST_WITH_CHAIN); + assertThat(result.reference()).isEqualTo("0xpushedtx"); + } + + @Test + void hashRejectsSourceDifferingFromTransferSender() { + Map receipt = receiptWithMemoLog( + TOKEN_CONTRACT, RECIPIENT, RECIPIENT, AMOUNT_ATOMIC, BOUND_MEMO); + + assertThatThrownBy(() -> intent(new StubRpc(null, receipt, 0)) + .verify(hashCredential("0xpushedtx", didPkh(CHAIN_ID, SENDER)), REQUEST_WITH_CHAIN)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Transfer"); + } + + @Test + void malformedSourceDoesNotConsumeReplayClaim() { + Store store = new MemoryStore(); + + assertThatThrownBy(() -> intent(new StubRpc(null, successReceipt(), 0), store) + .verify(hashCredential("0xpushedtx", "not-a-did"), REQUEST_WITH_CHAIN)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Hash credential source is invalid"); + + Receipt receipt = intent(new StubRpc(null, successReceipt(), 0), store) + .verify(hashCredential("0xpushedtx"), REQUEST); + assertThat(receipt.reference()).isEqualTo("0xpushedtx"); + } + + @Test + void wrongChainSourceDoesNotConsumeReplayClaim() { + Store store = new MemoryStore(); + + assertThatThrownBy(() -> intent(new StubRpc(null, successReceipt(), 0), store) + .verify(hashCredential("0xpushedtx", didPkh(1, SENDER)), REQUEST_WITH_CHAIN)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Hash credential source is invalid"); + + Receipt receipt = intent(new StubRpc(null, successReceipt(), 0), store) + .verify(hashCredential("0xpushedtx"), REQUEST); + assertThat(receipt.reference()).isEqualTo("0xpushedtx"); + } } 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 6b887da..824c5a0 100644 --- a/src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java +++ b/src/test/java/com/stripe/mpp/methods/tempo/TempoMethodTest.java @@ -1,5 +1,8 @@ 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; @@ -44,4 +47,38 @@ void rejectsAmountWithTooManyDecimalPlaces() { Map.of("amount", "0.0000001", "currency", "USDC", "recipient", "0xRecipient") )).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); + } }