Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@
* </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
* 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.
* 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]}). A merchant
* memo, when present, is an additional exact-match constraint — it does not
* skip that binding. That is what stops a third party from presenting someone
* else's settled transaction as their own payment.
*
* <p>Create the intent once and reuse it so its replay store is shared across requests:
*
Expand Down Expand Up @@ -159,9 +160,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 Down
11 changes: 6 additions & 5 deletions src/main/java/com/stripe/mpp/methods/tempo/TempoMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,13 @@ public Builder store(Store store) {
}

/**
* Sets an explicit TIP-20 memo that payments must match.
* Sets an explicit TIP-20 memo that payments must match in addition to
* the challenge-bound MPP attribution memo.
*
* <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.
* <p>Verification always requires an on-chain memo bound to the
* challenge id and realm. A method-wide static value cannot satisfy
* that binding across distinct challenges, so omit this unless the
* advertised memo is itself unique per challenge.
*/
public Builder memo(String memo) {
this.memo = Objects.requireNonNull(memo, "memo");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ static Credential txCredential(String rawTx) {
}

static Credential hashCredential(String txHash) {
return hashCredential(txHash, null);
return hashCredential(txHash, (String) null);
}

static Credential hashCredential(String txHash, String source) {
return new Credential(ECHO, Map.of("type", "hash", "hash", txHash), source);
}

static Credential hashCredential(String txHash, ChallengeEcho echo) {
return new Credential(echo, Map.of("type", "hash", "hash", txHash), null);
}

static String didPkh(int chainId, String address) {
return "did:pkh:eip155:" + chainId + ":" + address;
}
Expand Down Expand Up @@ -370,44 +374,43 @@ void pushAcceptsChallengeBoundMemoAlongsideAPlainTransfer() {

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

Receipt result = intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0))
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, BOUND_MEMO), 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<String, Object> request = new HashMap<>(REQUEST);
request.put("memo", merchantMemo);

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

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

Receipt result = intent(new StubRpc(null,
assertThatThrownBy(() -> intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0))
.verify(hashCredential("0xpushedtx"), request);
assertThat(result.status()).isEqualTo("success");
.verify(hashCredential("0xpushedtx"), request))
.isInstanceOf(VerificationFailedException.class)
.hasMessageContaining("memo is not bound to this challenge");
}

@Test
void explicitMemoInMethodDetailsIsHonored() {
void explicitMemoInMethodDetailsStillRequiresChallengeBinding() {
String merchantMemo = "0x" + "ab".repeat(32);
Map<String, Object> request = Map.of(
"amount", String.valueOf(AMOUNT_ATOMIC),
Expand All @@ -416,10 +419,60 @@ void explicitMemoInMethodDetailsIsHonored() {
"methodDetails", Map.of("chainId", CHAIN_ID, "memo", merchantMemo)
);

Receipt result = intent(new StubRpc(null,
assertThatThrownBy(() -> intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo), 0))
.verify(hashCredential("0xpushedtx"), request);
assertThat(result.status()).isEqualTo("success");
.verify(hashCredential("0xpushedtx"), request))
.isInstanceOf(VerificationFailedException.class)
.hasMessageContaining("memo is not bound to this challenge");
}

@Test
void sharedExplicitMemoCannotSatisfyADifferentChallenge() {
ChallengeEcho echoA = new ChallengeEcho(
"chal-a", "api.example.com", "tempo", "charge", "e30", "2099-01-01T00:00:00Z", null, null
);
ChallengeEcho echoB = new ChallengeEcho(
"chal-b", "api.example.com", "tempo", "charge", "e30", "2099-01-01T00:00:00Z", null, null
);
String merchantMemo = "0x" + "ab".repeat(32);
Map<String, Object> request = new HashMap<>(REQUEST);
request.put("memo", merchantMemo);
Map<String, Object> paidForB = receiptWithMemoLog(
TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, merchantMemo);

Store store = new MemoryStore();
assertThatThrownBy(() -> intent(new StubRpc(null, paidForB, 0), store)
.verify(hashCredential("0xvictimtx", echoA), request))
.isInstanceOf(VerificationFailedException.class)
.hasMessageContaining("memo is not bound to this challenge");

assertThatThrownBy(() -> intent(new StubRpc(null, paidForB, 0), store)
.verify(hashCredential("0xvictimtx", echoB), request))
.isInstanceOf(VerificationFailedException.class)
.hasMessageContaining("memo is not bound to this challenge");
}

@Test
void stolenBoundPaymentCannotSatisfyADifferentChallenge() {
ChallengeEcho echoA = new ChallengeEcho(
"chal-a", "api.example.com", "tempo", "charge", "e30", "2099-01-01T00:00:00Z", null, null
);
ChallengeEcho echoB = new ChallengeEcho(
"chal-b", "api.example.com", "tempo", "charge", "e30", "2099-01-01T00:00:00Z", null, null
);
String memoForB = Attribution.encode(echoB.realm(), echoB.id());
Store store = new MemoryStore();

assertThatThrownBy(() -> intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, memoForB), 0), store)
.verify(hashCredential("0xvictimtx", echoA), REQUEST))
.isInstanceOf(VerificationFailedException.class)
.hasMessageContaining("memo is not bound to this challenge");

Receipt result = intent(new StubRpc(null,
receiptWithMemoLog(TOKEN_CONTRACT, SENDER, RECIPIENT, AMOUNT_ATOMIC, memoForB), 0), store)
.verify(hashCredential("0xvictimtx", echoB), REQUEST);
assertThat(result.reference()).isEqualTo("0xvictimtx");
}

@Test
Expand Down
Loading