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:
+ *
+ *
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