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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.gradle/
build/
*.class
.agents/
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -192,21 +195,25 @@ 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.
*
* <p>Tempo uses a custom transaction type (0x76) distinct from legacy EVM transactions.
* RLP field order (tempo-primitives TempoTransaction):
* chain_id, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, calls, access_list,
* 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));
Expand Down
150 changes: 150 additions & 0 deletions src/main/java/com/stripe/mpp/methods/tempo/Attribution.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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:
*
* <pre>
* 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]
* </pre>
*
* <p>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);
}
}
Loading
Loading