Skip to content

Commit

Permalink
Fix EscrowFinish condition/fulfillment deserialization (#483)
Browse files Browse the repository at this point in the history
* add conditionRawValue and fulfillmentRawValue to EscrowFinish to prevent deserialization failing for EscrowFinishes with malformed conditions/fulfillments

* add conditionRawValue to EscrowCreate to prevent deserialization failing for EscrowCreates with malformed conditions

* log warning

* Revert "add conditionRawValue to EscrowCreate to prevent deserialization failing for EscrowCreates with malformed conditions"

This reverts commit 3262f39.

* add comment
  • Loading branch information
nkramer44 authored Aug 24, 2023
1 parent 96650dd commit c0efe11
Show file tree
Hide file tree
Showing 3 changed files with 468 additions and 96 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Expand All @@ -20,18 +20,28 @@
* =========================LICENSE_END==================================
*/

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.google.common.base.Preconditions;
import com.google.common.io.BaseEncoding;
import com.google.common.primitives.UnsignedInteger;
import com.google.common.primitives.UnsignedLong;
import com.ripple.cryptoconditions.Condition;
import com.ripple.cryptoconditions.CryptoConditionReader;
import com.ripple.cryptoconditions.CryptoConditionWriter;
import com.ripple.cryptoconditions.Fulfillment;
import com.ripple.cryptoconditions.der.DerEncodingException;
import org.immutables.value.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xrpl.xrpl4j.model.flags.TransactionFlags;
import org.xrpl.xrpl4j.model.immutables.FluentCompareTo;
import org.xrpl.xrpl4j.model.transactions.AccountSet.AccountSetFlag;

import java.util.Arrays;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;

Expand All @@ -43,6 +53,8 @@
@JsonDeserialize(as = ImmutableEscrowFinish.class)
public interface EscrowFinish extends Transaction {

Logger logger = LoggerFactory.getLogger(EscrowFinish.class);

/**
* Construct a builder for this class.
*
Expand All @@ -62,6 +74,7 @@ static ImmutableEscrowFinish.Builder builder() {
* purposes.
*
* @return An {@link XrpCurrencyAmount} representing the computed fee.
*
* @see "https://xrpl.org/escrowfinish.html"
*/
static XrpCurrencyAmount computeFee(final XrpCurrencyAmount currentLedgerFeeDrops, final Fulfillment fulfillment) {
Expand All @@ -78,8 +91,8 @@ static XrpCurrencyAmount computeFee(final XrpCurrencyAmount currentLedgerFeeDrop
}

/**
* Set of {@link TransactionFlags}s for this {@link EscrowFinish}, which only allows the
* {@code tfFullyCanonicalSig} flag, which is deprecated.
* Set of {@link TransactionFlags}s for this {@link EscrowFinish}, which only allows the {@code tfFullyCanonicalSig}
* flag, which is deprecated.
*
* <p>The value of the flags cannot be set manually, but exists for JSON serialization/deserialization only and for
* proper signature computation in rippled.
Expand Down Expand Up @@ -111,34 +124,204 @@ default TransactionFlags flags() {
/**
* Hex value matching the previously-supplied PREIMAGE-SHA-256 crypto-condition of the held payment.
*
* <p>If this field is empty, developers should check if {@link #conditionRawValue()} is also empty. If
* {@link #conditionRawValue()} is present, it means that the {@code "Condition"} field of the transaction was not a
* well-formed crypto-condition but was still present in a transaction on ledger.</p>
*
* @return An {@link Optional} of type {@link Condition} containing the escrow condition.
*/
@JsonProperty("Condition")
@JsonIgnore
Optional<Condition> condition();

/**
* Hex value of the PREIMAGE-SHA-256 crypto-condition fulfillment matching the held payment's {@code condition}.
* The raw, hex-encoded PREIMAGE-SHA-256 crypto-condition of the escrow.
*
* <p>Developers should prefer setting {@link #condition()} and leaving this field empty when constructing a new
* {@link EscrowFinish}. This field is used to serialize and deserialize the {@code "Condition"} field in JSON, the
* XRPL will sometimes include an {@link EscrowFinish} in its ledger even if the crypto condition is malformed.
* Without this field, xrpl4j would fail to deserialize those transactions, as {@link #condition()} is typed as a
* {@link Condition}, which tries to decode the condition from DER.</p>
*
* <p>Note that a similar field does not exist on {@link EscrowCreate},
* {@link org.xrpl.xrpl4j.model.ledger.EscrowObject}, or
* {@link org.xrpl.xrpl4j.model.transactions.metadata.MetaEscrowObject} because {@link EscrowCreate}s with
* malformed conditions will never be included in a ledger by the XRPL. Because of this fact, an
* {@link org.xrpl.xrpl4j.model.ledger.EscrowObject} and
* {@link org.xrpl.xrpl4j.model.transactions.metadata.MetaEscrowObject} will also never contain a malformed
* crypto condition.</p>
*
* @return An {@link Optional} {@link String} containing the hex-encoded PREIMAGE-SHA-256 condition.
*/
@JsonProperty("Condition")
Optional<String> conditionRawValue();

/**
* Hex value of the PREIMAGE-SHA-256 crypto-condition fulfillment matching the held payment's {@link #condition()}.
*
* <p>If this field is empty, developers should check if {@link #fulfillmentRawValue()} is also empty. If
* {@link #fulfillmentRawValue()} is present, it means that the {@code "Fulfillment"} field of the transaction was not
* a well-formed crypto-condition fulfillment but was still present in a transaction on ledger.</p>
*
* @return An {@link Optional} of type {@link Fulfillment} containing the fulfillment for the escrow's condition.
*/
@JsonProperty("Fulfillment")
@JsonIgnore
Optional<Fulfillment<?>> fulfillment();

/**
* Validate fields.
* The raw, hex-encoded value of the PREIMAGE-SHA-256 crypto-condition fulfillment matching the held payment's
* {@link #condition()}.
*
* <p>Developers should prefer setting {@link #fulfillment()} and leaving this field empty when constructing a new
* {@link EscrowFinish}. This field is used to serialize and deserialize the {@code "Fulfillment"} field in JSON, the
* XRPL will sometimes include an {@link EscrowFinish} in its ledger even if the crypto fulfillment is malformed.
* Without this field, xrpl4j would fail to deserialize those transactions, as {@link #fulfillment()} is typed as a
* {@link Fulfillment}, which tries to decode the fulfillment from DER.</p>
*
* @return An {@link Optional} {@link String} containing the hex-encoded PREIMAGE-SHA-256 fulfillment.
*/
@JsonProperty("Fulfillment")
Optional<String> fulfillmentRawValue();

/**
* Normalization method to try to get {@link #condition()} and {@link #conditionRawValue()} to match.
*
* <p>If neither field is present, there is nothing to do.</p>
* <p>If both fields are present, there is nothing to do, but we will check that {@link #condition()}'s
* underlying value equals {@link #conditionRawValue()}.</p>
* <p>If {@link #condition()} is present but {@link #conditionRawValue()} is empty, we set
* {@link #conditionRawValue()} to the underlying value of {@link #condition()}.</p>
* <p>If {@link #condition()} is empty and {@link #conditionRawValue()} is present, we will set
* {@link #condition()} to the {@link Condition} representing the raw condition value, or leave
* {@link #condition()} empty if {@link #conditionRawValue()} is a malformed {@link Condition}.</p>
*
* @return A normalized {@link EscrowFinish}.
*/
@Value.Check
default void check() {
fulfillment().ifPresent(f -> {
UnsignedLong feeInDrops = fee().value();
Preconditions.checkState(condition().isPresent(),
"If a fulfillment is specified, the corresponding condition must also be specified.");
Preconditions.checkState(FluentCompareTo.is(feeInDrops).greaterThanEqualTo(UnsignedLong.valueOf(330)),
"If a fulfillment is specified, the fee must be set to 330 or greater.");
default EscrowFinish normalizeCondition() {
try {
if (!condition().isPresent() && !conditionRawValue().isPresent()) {
// If both are empty, nothing to do.
return this;
} else if (condition().isPresent() && conditionRawValue().isPresent()) {
// Both will be present if:
// 1. A developer set them both manually (in the builder)
// 2. This method has already been called.

// We should check that the condition()'s value matches the raw value.
Preconditions.checkState(
Arrays.equals(CryptoConditionWriter.writeCondition(condition().get()),
BaseEncoding.base16().decode(conditionRawValue().get())),
"condition and conditionRawValue should be equivalent if both are present."
);
return this;
} else if (condition().isPresent() && !conditionRawValue().isPresent()) {
// This can only happen if the developer only set condition() because condition() will never be set
// after deserializing from JSON. In this case, we need to set conditionRawValue to match setFlag.
return EscrowFinish.builder().from(this)
.conditionRawValue(BaseEncoding.base16().encode(CryptoConditionWriter.writeCondition(condition().get())))
.build();
} else { // condition is empty and conditionRawValue is present
// This can happen if:
// 1. A developer sets conditionRawValue manually in the builder
// 2. JSON has Condition and Jackson sets conditionRawValue

// In this case, we should try to read conditionRawValue to a Condition. If that fails, condition()
// will remain empty, otherwise we will set condition().
try {
Condition condition = CryptoConditionReader.readCondition(
BaseEncoding.base16().decode(conditionRawValue().get().toUpperCase(Locale.US))
);
return EscrowFinish.builder().from(this)
.condition(condition)
.build();
} catch (DerEncodingException | IllegalArgumentException e) {
logger.warn(
"EscrowFinish Condition was malformed. conditionRawValue() will contain the condition value, but " +
"condition() will be empty: {}",
e.getMessage(),
e
);
return this;
}
}
);
condition().ifPresent($ -> Preconditions.checkState(fulfillment().isPresent(),
"If a condition is specified, the corresponding fulfillment must also be specified."));

} catch (DerEncodingException e) {
// This should never happen. CryptoconditionWriter.writeCondition errantly declares that it can throw
// a DerEncodingException, but nowhere in its implementation does it throw.
throw new RuntimeException(e);
}
}

/**
* Normalization method to try to get {@link #fulfillment()} and {@link #fulfillmentRawValue()} to match.
*
* <p>If neither field is present, there is nothing to do.</p>
* <p>If both fields are present, there is nothing to do, but we will check that {@link #fulfillment()}'s
* underlying value equals {@link #fulfillmentRawValue()}.</p>
* <p>If {@link #fulfillment()} is present but {@link #fulfillmentRawValue()} is empty, we set
* {@link #fulfillmentRawValue()} to the underlying value of {@link #fulfillment()}.</p>
* <p>If {@link #fulfillment()} is empty and {@link #fulfillmentRawValue()} is present, we will set
* {@link #fulfillment()} to the {@link Fulfillment} representing the raw fulfillment value, or leave
* {@link #fulfillment()} empty if {@link #fulfillmentRawValue()} is a malformed {@link Fulfillment}.</p>
*
* @return A normalized {@link EscrowFinish}.
*/
@Value.Check
default EscrowFinish normalizeFulfillment() {
try {
if (!fulfillment().isPresent() && !fulfillmentRawValue().isPresent()) {
// If both are empty, nothing to do.
return this;
} else if (fulfillment().isPresent() && fulfillmentRawValue().isPresent()) {
// Both will be present if:
// 1. A developer set them both manually (in the builder)
// 2. This method has already been called.

// We should check that the fulfillment()'s value matches the raw value.
Preconditions.checkState(
Arrays.equals(CryptoConditionWriter.writeFulfillment(fulfillment().get()),
BaseEncoding.base16().decode(fulfillmentRawValue().get())),
"fulfillment and fulfillmentRawValue should be equivalent if both are present."
);
return this;
} else if (fulfillment().isPresent() && !fulfillmentRawValue().isPresent()) {
// This can only happen if the developer only set fulfillment() because fulfillment() will never be set
// after deserializing from JSON. In this case, we need to set fulfillmentRawValue to match setFlag.
return EscrowFinish.builder().from(this)
.fulfillmentRawValue(
BaseEncoding.base16().encode(CryptoConditionWriter.writeFulfillment(fulfillment().get()))
)
.build();
} else { // fulfillment is empty and fulfillmentRawValue is present
// This can happen if:
// 1. A developer sets fulfillmentRawValue manually in the builder
// 2. JSON has Condition and Jackson sets fulfillmentRawValue

// In this case, we should try to read fulfillmentRawValue to a Condition. If that fails, fulfillment()
// will remain empty, otherwise we will set fulfillment().
try {
Fulfillment<?> fulfillment = CryptoConditionReader.readFulfillment(
BaseEncoding.base16().decode(fulfillmentRawValue().get().toUpperCase(Locale.US))
);
return EscrowFinish.builder().from(this)
.fulfillment(fulfillment)
.build();
} catch (DerEncodingException | IllegalArgumentException e) {
logger.warn(
"EscrowFinish Fulfillment was malformed. fulfillmentRawValue() will contain the fulfillment value, " +
"but fulfillment() will be empty: {}",
e.getMessage(),
e
);
return this;
}
}

} catch (DerEncodingException e) {
// This should never happen. CryptoconditionWriter.writeCondition errantly declares that it can throw
// a DerEncodingException, but nowhere in its implementation does it throw.
throw new RuntimeException(e);
}
}

}
Loading

0 comments on commit c0efe11

Please sign in to comment.