From 6f9eb1856fa271a9e7c71913d85b201ed0ae517d Mon Sep 17 00:00:00 2001 From: Karan Narula Date: Tue, 15 Sep 2026 13:16:37 -0400 Subject: [PATCH] fix(tempo): validate transaction credential source Committed-By-Agent: codex Co-authored-by: codex --- .../mpp/methods/tempo/TempoChargeIntent.java | 15 ++-- .../methods/tempo/TempoChargeIntentTest.java | 78 ++++++++++++++----- 2 files changed, 67 insertions(+), 26 deletions(-) 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 f5ab16c..5e4ee9c 100644 --- a/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java +++ b/src/main/java/com/stripe/mpp/methods/tempo/TempoChargeIntent.java @@ -120,14 +120,15 @@ public Receipt verify(Credential credential, Map request) { } private Receipt verifyTransaction(String rawTx, Map request, Credential credential) { + String sourceAddress = parseCredentialSource(credential.source(), chainIdFrom(request)); String txHash = rpc.sendRawTransaction(rpcUrl, rawTx); - return claimOnce(awaitReceipt(txHash, request, credential, null)); + return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress)); } 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)); + String sourceAddress = parseCredentialSource(credential.source(), chainIdFrom(request)); return claimOnce(awaitReceipt(txHash, request, credential, sourceAddress)); } @@ -265,16 +266,16 @@ private static void assertChallengeBoundMemo(List matched, Credentia } /** - * Parses a hash-credential source. {@code null} or empty if absent; the + * Parses a 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) { + static String parseCredentialSource(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"); + throw new VerificationFailedException("Credential source is invalid"); } return parsed.address; } @@ -292,10 +293,10 @@ static Integer parseChainIdValue(Object raw) { try { return Integer.valueOf((String) raw); } catch (NumberFormatException e) { - throw new VerificationFailedException("Hash credential source is invalid"); + throw new VerificationFailedException("Credential source is invalid"); } } - throw new VerificationFailedException("Hash credential source is invalid"); + throw new VerificationFailedException("Credential source is invalid"); } static ParsedPkh parsePkhSource(String source) { 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 c61adf9..edbee7c 100644 --- a/src/test/java/com/stripe/mpp/methods/tempo/TempoChargeIntentTest.java +++ b/src/test/java/com/stripe/mpp/methods/tempo/TempoChargeIntentTest.java @@ -7,6 +7,8 @@ import com.stripe.mpp.store.MemoryStore; import com.stripe.mpp.store.Store; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.util.HashMap; import java.util.List; @@ -45,7 +47,11 @@ class TempoChargeIntentTest { 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); + return txCredential(rawTx, null); + } + + static Credential txCredential(String rawTx, String source) { + return new Credential(ECHO, Map.of("type", "transaction", "signature", rawTx), source); } static Credential hashCredential(String txHash) { @@ -139,6 +145,40 @@ void pullPaymentBroadcastsAndReturnsReceipt() { assertThat(receipt.method()).isEqualTo("tempo"); } + @ParameterizedTest + @ValueSource(strings = {SENDER, RECIPIENT}) + void transactionAcceptsSourceMatchingTransferSender(String receiptSender) { + Map receipt = new HashMap<>(successReceipt()); + receipt.put("from", receiptSender); + + Receipt result = intent(new StubRpc("0xdeadbeef", receipt, 0)) + .verify(txCredential("0xsignedtx", didPkh(CHAIN_ID, SENDER)), REQUEST_WITH_CHAIN); + + assertThat(result.reference()).isEqualTo("0xdeadbeef"); + } + + @Test + void transactionRejectsSourceDifferingFromTransferSender() { + assertThatThrownBy(() -> intent(new StubRpc("0xdeadbeef", successReceipt(), 0)) + .verify(txCredential("0xsignedtx", didPkh(CHAIN_ID, RECIPIENT)), REQUEST_WITH_CHAIN)) + .isInstanceOf(VerificationFailedException.class) + .hasMessageContaining("Transfer"); + } + + @Test + void transactionRejectsInvalidSourceBeforeBroadcast() { + StubRpc rpc = new StubRpc("0xdeadbeef", successReceipt(), 0) { + @Override String sendRawTransaction(String rpcUrl, String rawTx) { + throw new AssertionError("invalid source must not be broadcast"); + } + }; + for (String source : List.of("not-a-did", didPkh(1, SENDER))) { + assertThatThrownBy(() -> intent(rpc) + .verify(txCredential("0xsignedtx", source), REQUEST_WITH_CHAIN)) + .isInstanceOf(VerificationFailedException.class); + } + } + @Test void pullPaymentWaitsForReceiptToMine() { StubRpc rpc = new StubRpc("0xdeadbeef", successReceipt(), 2); @@ -433,41 +473,41 @@ void contractAddressMatchIsCaseInsensitive() { } @Test - void parseHashCredentialSourceAbsentIsNull() { - assertThat(TempoChargeIntent.parseHashCredentialSource(null, CHAIN_ID)).isNull(); - assertThat(TempoChargeIntent.parseHashCredentialSource("", CHAIN_ID)).isNull(); + void parseCredentialSourceAbsentIsNull() { + assertThat(TempoChargeIntent.parseCredentialSource(null, CHAIN_ID)).isNull(); + assertThat(TempoChargeIntent.parseCredentialSource("", CHAIN_ID)).isNull(); } @Test - void parseHashCredentialSourceValidReturnsAddress() { - assertThat(TempoChargeIntent.parseHashCredentialSource(didPkh(CHAIN_ID, SENDER), CHAIN_ID)) + void parseCredentialSourceValidReturnsAddress() { + assertThat(TempoChargeIntent.parseCredentialSource(didPkh(CHAIN_ID, SENDER), CHAIN_ID)) .isEqualTo(SENDER); } @Test - void parseHashCredentialSourceChainMismatchRejected() { + void parseCredentialSourceChainMismatchRejected() { assertThatThrownBy(() -> - TempoChargeIntent.parseHashCredentialSource(didPkh(1, SENDER), CHAIN_ID)) + TempoChargeIntent.parseCredentialSource(didPkh(1, SENDER), CHAIN_ID)) .isInstanceOf(VerificationFailedException.class) - .hasMessageContaining("Hash credential source is invalid"); + .hasMessageContaining("Credential source is invalid"); } @Test - void parseHashCredentialSourceAcceptsStringChainId() { - assertThat(TempoChargeIntent.parseHashCredentialSource(didPkh(CHAIN_ID, SENDER), String.valueOf(CHAIN_ID))) + void parseCredentialSourceAcceptsStringChainId() { + assertThat(TempoChargeIntent.parseCredentialSource(didPkh(CHAIN_ID, SENDER), String.valueOf(CHAIN_ID))) .isEqualTo(SENDER); } @Test - void parseHashCredentialSourceRejectsNonNumericChainId() { + void parseCredentialSourceRejectsNonNumericChainId() { assertThatThrownBy(() -> - TempoChargeIntent.parseHashCredentialSource(didPkh(CHAIN_ID, SENDER), "not-a-number")) + TempoChargeIntent.parseCredentialSource(didPkh(CHAIN_ID, SENDER), "not-a-number")) .isInstanceOf(VerificationFailedException.class) - .hasMessageContaining("Hash credential source is invalid"); + .hasMessageContaining("Credential source is invalid"); } @Test - void parseHashCredentialSourceRejectsMalformedVariants() { + void parseCredentialSourceRejectsMalformedVariants() { List malformed = List.of( "not-a-valid-did", "did:pkh:solana:" + CHAIN_ID + ":" + SENDER, @@ -477,10 +517,10 @@ void parseHashCredentialSourceRejectsMalformedVariants() { "did:pkh:eip155:" + CHAIN_ID + ":not-an-address" ); for (String source : malformed) { - assertThatThrownBy(() -> TempoChargeIntent.parseHashCredentialSource(source, CHAIN_ID)) + assertThatThrownBy(() -> TempoChargeIntent.parseCredentialSource(source, CHAIN_ID)) .as("case: %s", source) .isInstanceOf(VerificationFailedException.class) - .hasMessageContaining("Hash credential source is invalid"); + .hasMessageContaining("Credential source is invalid"); } } @@ -522,7 +562,7 @@ void malformedSourceDoesNotConsumeReplayClaim() { 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"); + .hasMessageContaining("Credential source is invalid"); Receipt receipt = intent(new StubRpc(null, successReceipt(), 0), store) .verify(hashCredential("0xpushedtx"), REQUEST); @@ -536,7 +576,7 @@ void wrongChainSourceDoesNotConsumeReplayClaim() { 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"); + .hasMessageContaining("Credential source is invalid"); Receipt receipt = intent(new StubRpc(null, successReceipt(), 0), store) .verify(hashCredential("0xpushedtx"), REQUEST);