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
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,15 @@ public Receipt verify(Credential credential, Map<String, Object> request) {
}

private Receipt verifyTransaction(String rawTx, Map<String, Object> 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<String, Object> 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));
}

Expand Down Expand Up @@ -265,16 +266,16 @@ private static void assertChallengeBoundMemo(List<MatchedLog> 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;
}
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -139,6 +145,40 @@ void pullPaymentBroadcastsAndReturnsReceipt() {
assertThat(receipt.method()).isEqualTo("tempo");
}

@ParameterizedTest
@ValueSource(strings = {SENDER, RECIPIENT})
void transactionAcceptsSourceMatchingTransferSender(String receiptSender) {
Map<String, Object> 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);
Expand Down Expand Up @@ -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<String> malformed = List.of(
"not-a-valid-did",
"did:pkh:solana:" + CHAIN_ID + ":" + SENDER,
Expand All @@ -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");
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading