diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/ledger/VaultObject.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/ledger/VaultObject.java index ae3dd5f20..d32da95c3 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/ledger/VaultObject.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/ledger/VaultObject.java @@ -1,10 +1,12 @@ package org.xrpl.xrpl4j.model.ledger; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.Beta; import com.google.common.primitives.UnsignedInteger; +import com.google.common.primitives.UnsignedLong; import org.immutables.value.Value; import org.xrpl.xrpl4j.model.flags.VaultFlags; import org.xrpl.xrpl4j.model.transactions.Address; @@ -13,6 +15,7 @@ import org.xrpl.xrpl4j.model.transactions.Hash256; import org.xrpl.xrpl4j.model.transactions.MpTokenIssuanceId; import org.xrpl.xrpl4j.model.transactions.VaultData; +import org.xrpl.xrpl4j.model.transactions.VaultKind; import org.xrpl.xrpl4j.model.transactions.WithdrawalPolicy; import java.util.Optional; @@ -195,6 +198,51 @@ default AssetScale scale() { return AssetScale.of(UnsignedInteger.ZERO); } + /** + * The ledger-entry version of this vault, which selects the accounting rules rippled applies to it. Vaults + * created before the {@code LendingProtocolV1_1} amendment activated carry no {@code LEVersion} field and are + * treated as version {@code 0} (legacy accounting); vaults created afterwards carry version {@code 1} + * (LoanBroker cash-basis accounting). + * + * @return An {@link UnsignedInteger}, defaulting to {@link UnsignedInteger#ZERO}. + */ + @JsonProperty("LEVersion") + @Value.Default + default UnsignedInteger leVersion() { + return UnsignedInteger.ZERO; + } + + /** + * Distinguishes a closed-ended vault from the default open-ended kind. Only a closed-ended vault may have a + * {@code LoanBroker} attached to it once the {@code LendingProtocolV1_1} amendment is enabled. + * + * @return A {@link VaultKind}, defaulting to {@link VaultKind#OPEN_ENDED}. + */ + @JsonProperty("VaultKind") + @Value.Default + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = VaultKind.OpenEndedFilter.class) + default VaultKind vaultKind() { + return VaultKind.OPEN_ENDED; + } + + /** + * The time, in seconds since the Ripple Epoch, after which this closed-ended vault's Subscription phase ends + * and its Investment phase begins. Only present when {@link #vaultKind()} is {@link VaultKind#CLOSED_ENDED}. + * + * @return An {@link Optional} of type {@link UnsignedLong} representing the subscription date. + */ + @JsonProperty("SubscriptionDate") + Optional subscriptionDate(); + + /** + * The time, in seconds since the Ripple Epoch, after which this closed-ended vault's Investment phase ends and + * its Redemption phase begins. Only present when {@link #vaultKind()} is {@link VaultKind#CLOSED_ENDED}. + * + * @return An {@link Optional} of type {@link UnsignedLong} representing the redemption date. + */ + @JsonProperty("RedemptionDate") + Optional redemptionDate(); + /** * The share MPTokenIssuance information for this vault. Only present in {@code vault_info} RPC responses. * diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/LoanBrokerCoverWithdraw.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/LoanBrokerCoverWithdraw.java index 6e7a62039..5ef3e4587 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/LoanBrokerCoverWithdraw.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/LoanBrokerCoverWithdraw.java @@ -10,6 +10,8 @@ import org.xrpl.xrpl4j.model.AddressConstants; import org.xrpl.xrpl4j.model.flags.TransactionFlags; +import java.util.HashSet; +import java.util.List; import java.util.Optional; /** @@ -76,6 +78,15 @@ default TransactionFlags flags() { @JsonProperty("DestinationTag") Optional destinationTag(); + /** + * Set of Credentials to authorize a deposit made by this transaction. Each member of the array must be the ledger + * entry ID of a Credential entry in the ledger. Requires the {@code LendingProtocolV1_1} amendment. + * + * @return A list of type {@link Hash256}. + */ + @JsonProperty("CredentialIDs") + List credentialIds(); + /** * Validates LoanBrokerCoverWithdraw data verification preconditions. */ @@ -95,5 +106,16 @@ default void check() { !dest.equals(AddressConstants.ACCOUNT_ZERO), "Destination must not be the zero account." )); + + if (!credentialIds().isEmpty()) { + Preconditions.checkArgument( + credentialIds().size() <= 8, + "CredentialIDs should have less than or equal to 8 items." + ); + Preconditions.checkArgument( + new HashSet<>(credentialIds()).size() == credentialIds().size(), + "CredentialIDs should have unique values." + ); + } } } diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultCreate.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultCreate.java index 4121c2f1f..fa9f092e3 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultCreate.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultCreate.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.Beta; +import com.google.common.primitives.UnsignedLong; import org.immutables.value.Value; import org.xrpl.xrpl4j.model.flags.VaultCreateFlags; import org.xrpl.xrpl4j.model.ledger.Issue; @@ -99,6 +100,48 @@ default VaultCreateFlags flags() { @JsonProperty("Scale") Optional scale(); + /** + * Distinguishes a closed-ended vault from the default open-ended kind. Requires the {@code LendingProtocolV1_1} + * amendment to be present on the transaction at all; only a closed-ended vault may later have a + * {@code LoanBroker} attached to it. + * + * @return An optionally-present {@link VaultKind}. + */ + @JsonProperty("VaultKind") + Optional vaultKind(); + + /** + * The time, in seconds since the Ripple Epoch, after which a closed-ended vault's Subscription phase ends and + * its Investment phase begins. Required, and only allowed, when {@link #vaultKind()} is + * {@link VaultKind#CLOSED_ENDED}. + * + * @return An {@link Optional} of type {@link UnsignedLong} representing the subscription date. + */ + @JsonProperty("SubscriptionDate") + Optional subscriptionDate(); + + /** + * The time, in seconds since the Ripple Epoch, after which a closed-ended vault's Investment phase ends and its + * Redemption phase begins. Required, and only allowed, when {@link #vaultKind()} is + * {@link VaultKind#CLOSED_ENDED}. + * + * @return An {@link Optional} of type {@link UnsignedLong} representing the redemption date. + */ + @JsonProperty("RedemptionDate") + Optional redemptionDate(); + + /** + * The minimum gap, in seconds, allowed between {@link #subscriptionDate()} and {@link #redemptionDate()} for a + * closed-ended vault, matching rippled's {@code kMinInvestmentPeriod}. + */ + UnsignedLong MIN_INVESTMENT_PERIOD_SECONDS = UnsignedLong.valueOf(180); + + /** + * The maximum gap, in seconds, allowed between {@link #subscriptionDate()} and {@link #redemptionDate()} for a + * closed-ended vault, matching rippled's {@code kMaxInvestmentPeriod} (30 x 365.2425-day years). + */ + UnsignedLong MAX_INVESTMENT_PERIOD_SECONDS = UnsignedLong.valueOf(946708560L); + /** * Validate VaultCreate preconditions. */ @@ -124,6 +167,32 @@ default void check() { throw new IllegalArgumentException("Scale is only allowed for IOU assets."); } )); + + boolean isClosedEnded = vaultKind().map(VaultKind.CLOSED_ENDED::equals).orElse(false); + if (!isClosedEnded) { + if (subscriptionDate().isPresent() || redemptionDate().isPresent()) { + throw new IllegalArgumentException( + "SubscriptionDate and RedemptionDate are only allowed when VaultKind is CLOSED_ENDED." + ); + } + } else { + if (!subscriptionDate().isPresent() || !redemptionDate().isPresent()) { + throw new IllegalArgumentException( + "SubscriptionDate and RedemptionDate are both required when VaultKind is CLOSED_ENDED." + ); + } + UnsignedLong subscription = subscriptionDate().get(); + UnsignedLong redemption = redemptionDate().get(); + if (redemption.compareTo(subscription) <= 0) { + throw new IllegalArgumentException("RedemptionDate must be strictly after SubscriptionDate."); + } + UnsignedLong gap = redemption.minus(subscription); + if (gap.compareTo(MIN_INVESTMENT_PERIOD_SECONDS) < 0 || gap.compareTo(MAX_INVESTMENT_PERIOD_SECONDS) > 0) { + throw new IllegalArgumentException( + "Investment period must be between MIN_INVESTMENT_PERIOD_SECONDS and MAX_INVESTMENT_PERIOD_SECONDS." + ); + } + } } } diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultDelete.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultDelete.java index 3cd8cc87a..9e3ca37da 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultDelete.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultDelete.java @@ -7,6 +7,8 @@ import org.immutables.value.Value; import org.xrpl.xrpl4j.model.flags.TransactionFlags; +import java.util.Optional; + /** * Delete a single asset vault. The vault must have no remaining assets or outstanding shares. * @@ -47,4 +49,16 @@ default TransactionFlags flags() { @JsonProperty("VaultID") Hash256 vaultId(); + /** + * Arbitrary metadata recording why the vault is being deleted, limited to 256 bytes, in hex format. This field + * requires the {@code LendingProtocolV1_1} amendment. + * + *

Despite the name, this is a top-level {@code MemoData} field (rippled's {@code sfMemoData}) and is unrelated to + * the standard {@link Transaction#memos()} field.

+ * + * @return An optionally-present {@link VaultData}. + */ + @JsonProperty("MemoData") + Optional memoData(); + } diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultKind.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultKind.java new file mode 100644 index 000000000..1d88b16be --- /dev/null +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultKind.java @@ -0,0 +1,76 @@ +package org.xrpl.xrpl4j.model.transactions; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.google.common.annotations.Beta; + +/** + * Distinguishes a closed-ended {@code Vault} from the default open-ended kind. Only a closed-ended vault may + * have a {@code LoanBroker} attached to it once the {@code LendingProtocolV1_1} amendment is enabled. + */ +@Beta +public enum VaultKind { + + /** + * The default vault kind. Deposits and withdrawals are allowed at any time, and the vault has no defined + * lifecycle. An open-ended vault cannot have a {@code LoanBroker} attached once {@code LendingProtocolV1_1} + * is enabled. + */ + OPEN_ENDED(0), + + /** + * A vault with a defined {@link VaultCreate#subscriptionDate()} and {@link VaultCreate#redemptionDate()}, + * required in order to attach a {@code LoanBroker} once {@code LendingProtocolV1_1} is enabled. + */ + CLOSED_ENDED(1); + + private final int value; + + VaultKind(int value) { + this.value = value; + } + + /** + * Get the integer value of this {@link VaultKind}. + * + * @return The integer value. + */ + @JsonValue + public int getValue() { + return value; + } + + /** + * Construct a {@link VaultKind} from an integer value. + * + * @param value The integer value. + * + * @return The {@link VaultKind} for the given value. + */ + @JsonCreator + public static VaultKind forValue(int value) { + for (VaultKind kind : VaultKind.values()) { + if (kind.value == value) { + return kind; + } + } + throw new IllegalArgumentException("No matching VaultKind for value " + value); + } + + /** + * Used with {@link com.fasterxml.jackson.annotation.JsonInclude.Include#CUSTOM} to omit {@link #OPEN_ENDED} from + * serialized JSON, matching how rippled omits {@code VaultKind} for open-ended vaults. + */ + public static class OpenEndedFilter { + + @Override + public boolean equals(Object obj) { + return obj == VaultKind.OPEN_ENDED; + } + + @Override + public int hashCode() { + return VaultKind.OPEN_ENDED.hashCode(); + } + } +} diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultWithdraw.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultWithdraw.java index 71b1e5bcc..c11b3340c 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultWithdraw.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/VaultWithdraw.java @@ -4,10 +4,13 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.Beta; +import com.google.common.base.Preconditions; import com.google.common.primitives.UnsignedInteger; import org.immutables.value.Value; import org.xrpl.xrpl4j.model.flags.TransactionFlags; +import java.util.HashSet; +import java.util.List; import java.util.Optional; /** @@ -75,4 +78,31 @@ default TransactionFlags flags() { @JsonProperty("DestinationTag") Optional destinationTag(); + /** + * Set of Credentials to authorize a deposit made by this transaction. Each member of the array must be the ledger + * entry ID of a Credential entry in the ledger. Requires the {@code LendingProtocolV1_1} amendment. + * + * @return A list of type {@link Hash256}. + */ + @JsonProperty("CredentialIDs") + List credentialIds(); + + /** + * Validate {@link VaultWithdraw#credentialIds} has less than or equal to 8 credentials, and that those + * credentials are unique. + */ + @Value.Check + default void validateCredentialIds() { + if (!credentialIds().isEmpty()) { + Preconditions.checkArgument( + credentialIds().size() <= 8, + "CredentialIDs should have less than or equal to 8 items." + ); + Preconditions.checkArgument( + new HashSet<>(credentialIds()).size() == credentialIds().size(), + "CredentialIDs should have unique values." + ); + } + } + } diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObject.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObject.java index a2b35acbf..9af19434c 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObject.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObject.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.google.common.annotations.Beta; import com.google.common.primitives.UnsignedInteger; +import com.google.common.primitives.UnsignedLong; import org.immutables.value.Value.Immutable; import org.xrpl.xrpl4j.model.flags.VaultFlags; import org.xrpl.xrpl4j.model.ledger.Issue; @@ -14,6 +15,7 @@ import org.xrpl.xrpl4j.model.transactions.Hash256; import org.xrpl.xrpl4j.model.transactions.MpTokenIssuanceId; import org.xrpl.xrpl4j.model.transactions.VaultData; +import org.xrpl.xrpl4j.model.transactions.VaultKind; import org.xrpl.xrpl4j.model.transactions.WithdrawalPolicy; import java.util.Optional; @@ -158,4 +160,38 @@ public interface MetaVaultObject extends MetaLedgerObject { @JsonProperty("Scale") Optional scale(); + /** + * The ledger-entry version of this vault, which selects the accounting rules rippled applies to it. + * + * @return An optionally-present {@link UnsignedInteger}. + */ + @JsonProperty("LEVersion") + Optional leVersion(); + + /** + * Distinguishes a closed-ended vault from the default open-ended kind. + * + * @return An optionally-present {@link VaultKind}. + */ + @JsonProperty("VaultKind") + Optional vaultKind(); + + /** + * The time, in seconds since the Ripple Epoch, after which this closed-ended vault's Subscription phase ends + * and its Investment phase begins. Only present when {@link #vaultKind()} is {@link VaultKind#CLOSED_ENDED}. + * + * @return An optionally-present {@link UnsignedLong}. + */ + @JsonProperty("SubscriptionDate") + Optional subscriptionDate(); + + /** + * The time, in seconds since the Ripple Epoch, after which this closed-ended vault's Investment phase ends and + * its Redemption phase begins. Only present when {@link #vaultKind()} is {@link VaultKind#CLOSED_ENDED}. + * + * @return An optionally-present {@link UnsignedLong}. + */ + @JsonProperty("RedemptionDate") + Optional redemptionDate(); + } diff --git a/xrpl4j-core/src/main/resources/definitions.json b/xrpl4j-core/src/main/resources/definitions.json index 672088aef..f7ec2bab5 100644 --- a/xrpl4j-core/src/main/resources/definitions.json +++ b/xrpl4j-core/src/main/resources/definitions.json @@ -928,6 +928,26 @@ "type": "UInt32" } ], + [ + "SubscriptionDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 75, + "type": "UInt32" + } + ], + [ + "RedemptionDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 76, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -3528,6 +3548,16 @@ "type": "UInt8" } ], + [ + "LEVersion", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 6, + "type": "UInt8" + } + ], [ "TickSize", { @@ -3578,6 +3608,16 @@ "type": "UInt8" } ], + [ + "VaultKind", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 22, + "type": "UInt8" + } + ], [ "TakerPaysCurrency", { @@ -5250,6 +5290,22 @@ { "name": "Scale", "optionality": 2 + }, + { + "name": "LEVersion", + "optionality": 2 + }, + { + "name": "VaultKind", + "optionality": 2 + }, + { + "name": "SubscriptionDate", + "optionality": 1 + }, + { + "name": "RedemptionDate", + "optionality": 1 } ], "XChainOwnedClaimID": [ @@ -6101,6 +6157,10 @@ { "name": "DestinationTag", "optionality": 1 + }, + { + "name": "CredentialIDs", + "optionality": 1 } ], "LoanBrokerDelete": [ @@ -6741,6 +6801,18 @@ { "name": "Scale", "optionality": 1 + }, + { + "name": "VaultKind", + "optionality": 1 + }, + { + "name": "SubscriptionDate", + "optionality": 1 + }, + { + "name": "RedemptionDate", + "optionality": 1 } ], "VaultDelete": [ @@ -6797,6 +6869,10 @@ { "name": "DestinationTag", "optionality": 1 + }, + { + "name": "CredentialIDs", + "optionality": 1 } ], "XChainAccountCreateCommit": [ diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtilsTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtilsTest.java index 25fa568c9..944b26216 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtilsTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtilsTest.java @@ -134,6 +134,7 @@ import org.xrpl.xrpl4j.model.transactions.TrustSet; import org.xrpl.xrpl4j.model.transactions.VaultClawback; import org.xrpl.xrpl4j.model.transactions.VaultCreate; +import org.xrpl.xrpl4j.model.transactions.VaultData; import org.xrpl.xrpl4j.model.transactions.VaultDelete; import org.xrpl.xrpl4j.model.transactions.VaultDeposit; import org.xrpl.xrpl4j.model.transactions.VaultSet; @@ -1514,6 +1515,7 @@ void addSignatureToVaultDelete() { VaultDelete transaction = VaultDelete.builder() .account(sourcePublicKey.deriveAddress()) .vaultId(Hash256.of("0123456789012345678901234567890123456789012345678901234567891234")) + .memoData(VaultData.of("48656C6C6F")) .fee(XrpCurrencyAmount.ofDrops(10)) .sequence(UnsignedInteger.valueOf(391)) .signingPublicKey(sourcePublicKey) @@ -1550,6 +1552,24 @@ void addSignatureToVaultWithdraw() { addSignatureToTransactionHelper(transaction); } + @Test + void addSignatureToVaultWithdrawWithCredentialIds() { + VaultWithdraw transaction = VaultWithdraw.builder() + .account(sourcePublicKey.deriveAddress()) + .vaultId(Hash256.of("0123456789012345678901234567890123456789012345678901234567891234")) + .amount(XrpCurrencyAmount.ofDrops(500)) + .addCredentialIds( + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A"), + Hash256.of("000000000000000000000000000000000000000000000000000000000000000B") + ) + .fee(XrpCurrencyAmount.ofDrops(10)) + .sequence(UnsignedInteger.valueOf(391)) + .signingPublicKey(sourcePublicKey) + .build(); + + addSignatureToTransactionHelper(transaction); + } + @Test void addSignatureToVaultClawback() { VaultClawback transaction = VaultClawback.builder() @@ -2083,6 +2103,7 @@ void addMultiSignatureToVaultDelete() { VaultDelete transaction = VaultDelete.builder() .account(sourcePublicKey.deriveAddress()) .vaultId(Hash256.of("0123456789012345678901234567890123456789012345678901234567891234")) + .memoData(VaultData.of("48656C6C6F")) .fee(XrpCurrencyAmount.ofDrops(10)) .sequence(UnsignedInteger.valueOf(391)) .build(); diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/ledger/LedgerEntryResultTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/ledger/LedgerEntryResultTest.java index 59b049110..3bddb9f05 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/ledger/LedgerEntryResultTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/ledger/LedgerEntryResultTest.java @@ -873,6 +873,7 @@ void testVaultResult() throws JSONException, JsonProcessingException { " \"ShareMPTID\": \"00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836\"," + " \"WithdrawalPolicy\": 1," + " \"Scale\": 8," + + " \"LEVersion\": 0," + " \"index\": \"6BCD7E451DDA015FB307DAD9208A98A2DC3AC4D1448E624B42C89246DCF08692\"" + " }," + " \"status\": \"success\"," + diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/vault/VaultInfoResultTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/vault/VaultInfoResultTest.java index 6da53de12..6ad0afc66 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/vault/VaultInfoResultTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/client/vault/VaultInfoResultTest.java @@ -79,6 +79,7 @@ void testJson() throws JSONException, JsonProcessingException { " \"ShareMPTID\" : \"00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836\",\n" + " \"WithdrawalPolicy\" : 1,\n" + " \"Scale\" : 6,\n" + + " \"LEVersion\" : 0,\n" + " \"shares\" : {\n" + " \"Flags\" : 0,\n" + " \"Issuer\" : \"rE54zDvgnghAoPopCgvtiqWNq3dU5y836S\",\n" + diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/ledger/VaultObjectTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/ledger/VaultObjectTest.java index a5a3f6c2d..7c02e85e7 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/ledger/VaultObjectTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/ledger/VaultObjectTest.java @@ -1,9 +1,11 @@ package org.xrpl.xrpl4j.model.ledger; +import static org.assertj.core.api.Assertions.assertThat; import static org.xrpl.xrpl4j.crypto.TestConstants.HASH_256; import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.UnsignedInteger; +import com.google.common.primitives.UnsignedLong; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.xrpl.xrpl4j.model.AbstractJsonTest; @@ -15,6 +17,7 @@ import org.xrpl.xrpl4j.model.transactions.Hash256; import org.xrpl.xrpl4j.model.transactions.MpTokenIssuanceId; import org.xrpl.xrpl4j.model.transactions.VaultData; +import org.xrpl.xrpl4j.model.transactions.VaultKind; import org.xrpl.xrpl4j.model.transactions.WithdrawalPolicy; class VaultObjectTest extends AbstractJsonTest { @@ -43,9 +46,16 @@ void testJsonWithAllFields() throws JSONException, JsonProcessingException { .withdrawalPolicy(WithdrawalPolicy.FIRST_COME_FIRST_SERVE) .scale(AssetScale.of(UnsignedInteger.valueOf(8))) .flags(VaultFlags.VAULT_PRIVATE) + .leVersion(UnsignedInteger.ONE) + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(2000)) .index(HASH_256) .build(); + assertThat(vault.vaultKind()).isEqualTo(VaultKind.CLOSED_ENDED); + assertThat(vault.leVersion()).isEqualTo(UnsignedInteger.ONE); + String json = String.format("{\n" + " \"LedgerEntryType\" : \"Vault\",\n" + " \"Flags\" : 65536,\n" + @@ -67,6 +77,10 @@ void testJsonWithAllFields() throws JSONException, JsonProcessingException { " \"ShareMPTID\" : \"00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836\",\n" + " \"WithdrawalPolicy\" : 1,\n" + " \"Scale\" : 8,\n" + + " \"LEVersion\" : 1,\n" + + " \"VaultKind\" : 1,\n" + + " \"SubscriptionDate\" : 1000,\n" + + " \"RedemptionDate\" : 2000,\n" + " \"index\" : %s\n" + "}", HASH_256); @@ -107,6 +121,50 @@ void testJsonWithDefaultFields() throws JSONException, JsonProcessingException { " \"ShareMPTID\" : \"00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836\",\n" + " \"WithdrawalPolicy\" : 1,\n" + " \"Scale\" : 0,\n" + + " \"LEVersion\" : 0,\n" + + " \"index\" : %s\n" + + "}", HASH_256); + + assertCanSerializeAndDeserialize(vault, json); + } + + @Test + void testJsonWithOmittedVaultKind() throws JSONException, JsonProcessingException { + VaultObject vault = VaultObject.builder() + .previousTransactionId(Hash256.of("0000000000000000000000000000000000000000000000000000000000000010")) + .previousTransactionLedgerSequence(UnsignedInteger.valueOf(100)) + .sequence(UnsignedInteger.valueOf(5)) + .ownerNode("0") + .owner(Address.of("rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm")) + .account(Address.of("rE54zDvgnghAoPopCgvtiqWNq3dU5y836S")) + .asset(Issue.XRP) + .shareMptId(MpTokenIssuanceId.of("00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836")) + .withdrawalPolicy(WithdrawalPolicy.FIRST_COME_FIRST_SERVE) + .index(HASH_256) + .build(); + + assertThat(vault.vaultKind()).isEqualTo(VaultKind.OPEN_ENDED); + + String json = String.format("{\n" + + " \"LedgerEntryType\" : \"Vault\",\n" + + " \"Flags\" : 0,\n" + + " \"PreviousTxnID\" : \"0000000000000000000000000000000000000000000000000000000000000010\",\n" + + " \"PreviousTxnLgrSeq\" : 100,\n" + + " \"Sequence\" : 5,\n" + + " \"OwnerNode\" : \"0\",\n" + + " \"Owner\" : \"rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm\",\n" + + " \"Account\" : \"rE54zDvgnghAoPopCgvtiqWNq3dU5y836S\",\n" + + " \"Asset\" : {\n" + + " \"currency\" : \"XRP\"\n" + + " },\n" + + " \"AssetsTotal\" : \"0\",\n" + + " \"AssetsAvailable\" : \"0\",\n" + + " \"AssetsMaximum\" : \"0\",\n" + + " \"LossUnrealized\" : \"0\",\n" + + " \"ShareMPTID\" : \"00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836\",\n" + + " \"WithdrawalPolicy\" : 1,\n" + + " \"Scale\" : 0,\n" + + " \"LEVersion\" : 0,\n" + " \"index\" : %s\n" + "}", HASH_256); diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultCreateTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultCreateTest.java index 96d5084b0..d0477f8fd 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultCreateTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultCreateTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.google.common.primitives.UnsignedInteger; +import com.google.common.primitives.UnsignedLong; import org.junit.jupiter.api.Test; import org.xrpl.xrpl4j.crypto.keys.PublicKey; import org.xrpl.xrpl4j.model.flags.VaultCreateFlags; @@ -104,6 +105,101 @@ void scaleZeroAllowedForIouAsset() { ); } + @Test + void closedEndedVaultWithValidDatesAllowed() { + assertDoesNotThrow(() -> baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(1000).plus(VaultCreate.MIN_INVESTMENT_PERIOD_SECONDS)) + .build() + ); + } + + @Test + void openEndedVaultWithSubscriptionDateNotAllowed() { + assertThatThrownBy(() -> baseBuilder() + .subscriptionDate(UnsignedLong.valueOf(1000)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("SubscriptionDate and RedemptionDate are only allowed when VaultKind is CLOSED_ENDED."); + } + + @Test + void openEndedVaultWithRedemptionDateNotAllowed() { + assertThatThrownBy(() -> baseBuilder() + .redemptionDate(UnsignedLong.valueOf(1000)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("SubscriptionDate and RedemptionDate are only allowed when VaultKind is CLOSED_ENDED."); + } + + @Test + void closedEndedVaultRequiresSubscriptionDate() { + assertThatThrownBy(() -> baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .redemptionDate(UnsignedLong.valueOf(1000)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("SubscriptionDate and RedemptionDate are both required when VaultKind is CLOSED_ENDED."); + } + + @Test + void closedEndedVaultRequiresRedemptionDate() { + assertThatThrownBy(() -> baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("SubscriptionDate and RedemptionDate are both required when VaultKind is CLOSED_ENDED."); + } + + @Test + void closedEndedVaultRedemptionDateMustBeAfterSubscriptionDate() { + assertThatThrownBy(() -> baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(1000)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("RedemptionDate must be strictly after SubscriptionDate."); + } + + @Test + void closedEndedVaultGapBelowMinimumNotAllowed() { + UnsignedLong tooSmallGap = VaultCreate.MIN_INVESTMENT_PERIOD_SECONDS.minus(UnsignedLong.ONE); + assertThatThrownBy(() -> baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(1000).plus(tooSmallGap)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Investment period must be between MIN_INVESTMENT_PERIOD_SECONDS and MAX_INVESTMENT_PERIOD_SECONDS."); + } + + @Test + void closedEndedVaultGapAtMaximumAllowed() { + VaultCreate vaultCreate = baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(1000).plus(VaultCreate.MAX_INVESTMENT_PERIOD_SECONDS)) + .build(); + + assertThat(vaultCreate.redemptionDate()) + .hasValue(UnsignedLong.valueOf(1000).plus(VaultCreate.MAX_INVESTMENT_PERIOD_SECONDS)); + } + + @Test + void closedEndedVaultGapAboveMaximumNotAllowed() { + UnsignedLong tooLargeGap = VaultCreate.MAX_INVESTMENT_PERIOD_SECONDS.plus(UnsignedLong.ONE); + assertThatThrownBy(() -> baseBuilder() + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(1000).plus(tooLargeGap)) + .build() + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Investment period must be between MIN_INVESTMENT_PERIOD_SECONDS and MAX_INVESTMENT_PERIOD_SECONDS."); + } + /** * Returns a builder pre-populated with required fields for VaultCreate. */ diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultKindTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultKindTest.java new file mode 100644 index 000000000..ce5169faa --- /dev/null +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/VaultKindTest.java @@ -0,0 +1,29 @@ +package org.xrpl.xrpl4j.model.transactions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VaultKind}. + */ +class VaultKindTest { + + @Test + void forValue() { + assertThat(VaultKind.forValue(0)).isEqualTo(VaultKind.OPEN_ENDED); + assertThat(VaultKind.forValue(1)).isEqualTo(VaultKind.CLOSED_ENDED); + assertThatThrownBy(() -> VaultKind.forValue(2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No matching VaultKind for value 2"); + } + + @Test + void openEndedFilter() { + VaultKind.OpenEndedFilter filter = new VaultKind.OpenEndedFilter(); + assertThat(filter.equals(VaultKind.OPEN_ENDED)).isTrue(); + assertThat(filter.equals(VaultKind.CLOSED_ENDED)).isFalse(); + assertThat(filter.hashCode()).isEqualTo(VaultKind.OPEN_ENDED.hashCode()); + } +} diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/LoanBrokerCoverWithdrawJsonTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/LoanBrokerCoverWithdrawJsonTest.java index ab72c1c4f..58b6421ed 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/LoanBrokerCoverWithdrawJsonTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/LoanBrokerCoverWithdrawJsonTest.java @@ -1,5 +1,7 @@ package org.xrpl.xrpl4j.model.transactions.json; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.UnsignedInteger; import org.json.JSONException; @@ -9,6 +11,7 @@ import org.xrpl.xrpl4j.model.flags.TransactionFlags; import org.xrpl.xrpl4j.model.transactions.Address; import org.xrpl.xrpl4j.model.transactions.Hash256; +import org.xrpl.xrpl4j.model.transactions.ImmutableLoanBrokerCoverWithdraw; import org.xrpl.xrpl4j.model.transactions.IssuedCurrencyAmount; import org.xrpl.xrpl4j.model.transactions.LoanBrokerCoverWithdraw; import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount; @@ -147,4 +150,75 @@ public void testLoanBrokerCoverWithdrawJsonWithNonZeroFlags() throws JsonProcess assertCanSerializeAndDeserialize(withdraw, json); } + + @Test + public void testLoanBrokerCoverWithdrawJsonWithCredentialIds() throws JsonProcessingException, JSONException { + LoanBrokerCoverWithdraw withdraw = LoanBrokerCoverWithdraw.builder() + .account(Address.of("rU1Cm8GymH5U1WuTcmMTUZ5XjwJbanQoA8")) + .fee(XrpCurrencyAmount.ofDrops(15)) + .sequence(UnsignedInteger.valueOf(192)) + .signingPublicKey( + PublicKey.fromBase16EncodedPublicKey( + "ED1F863E4E0957C6965B7B0563D56C77E4F68D571E8026251834F0ADBB0411D6FD" + ) + ) + .loanBrokerId(Hash256.of("79E25403E9FC010A277D80410EED5494FDD033A09FD4C1432335A1734A1D099D")) + .amount(XrpCurrencyAmount.ofDrops(25000)) + .addCredentialIds( + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A"), + Hash256.of("000000000000000000000000000000000000000000000000000000000000000B") + ) + .build(); + + String json = "{" + + " \"Account\": \"rU1Cm8GymH5U1WuTcmMTUZ5XjwJbanQoA8\"," + + " \"TransactionType\": \"LoanBrokerCoverWithdraw\"," + + " \"Fee\": \"15\"," + + " \"Sequence\": 192," + + " \"SigningPubKey\": \"ED1F863E4E0957C6965B7B0563D56C77E4F68D571E8026251834F0ADBB0411D6FD\"," + + " \"LoanBrokerID\": \"79E25403E9FC010A277D80410EED5494FDD033A09FD4C1432335A1734A1D099D\"," + + " \"Amount\": \"25000\"," + + " \"CredentialIDs\": [" + + " \"000000000000000000000000000000000000000000000000000000000000000A\"," + + " \"000000000000000000000000000000000000000000000000000000000000000B\"" + + " ]" + + "}"; + + assertCanSerializeAndDeserialize(withdraw, json); + } + + @Test + public void testLoanBrokerCoverWithdrawWithTooManyCredentialIds() { + ImmutableLoanBrokerCoverWithdraw.Builder builder = LoanBrokerCoverWithdraw.builder() + .account(Address.of("rU1Cm8GymH5U1WuTcmMTUZ5XjwJbanQoA8")) + .fee(XrpCurrencyAmount.ofDrops(15)) + .sequence(UnsignedInteger.valueOf(192)) + .loanBrokerId(Hash256.of("79E25403E9FC010A277D80410EED5494FDD033A09FD4C1432335A1734A1D099D")) + .amount(XrpCurrencyAmount.ofDrops(25000)); + + for (int i = 0; i < 9; i++) { + builder.addCredentialIds(Hash256.of(String.format("%064X", i))); + } + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("CredentialIDs should have less than or equal to 8 items."); + } + + @Test + public void testLoanBrokerCoverWithdrawWithDuplicateCredentialIds() { + assertThatThrownBy(() -> LoanBrokerCoverWithdraw.builder() + .account(Address.of("rU1Cm8GymH5U1WuTcmMTUZ5XjwJbanQoA8")) + .fee(XrpCurrencyAmount.ofDrops(15)) + .sequence(UnsignedInteger.valueOf(192)) + .loanBrokerId(Hash256.of("79E25403E9FC010A277D80410EED5494FDD033A09FD4C1432335A1734A1D099D")) + .amount(XrpCurrencyAmount.ofDrops(25000)) + .addCredentialIds( + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A"), + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A") + ) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("CredentialIDs should have unique values."); + } } diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultCreateJsonTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultCreateJsonTest.java index d230bd833..8fa6422cf 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultCreateJsonTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultCreateJsonTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.UnsignedInteger; +import com.google.common.primitives.UnsignedLong; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.xrpl.xrpl4j.crypto.keys.PublicKey; @@ -18,6 +19,7 @@ import org.xrpl.xrpl4j.model.transactions.MpTokenMetadata; import org.xrpl.xrpl4j.model.transactions.VaultCreate; import org.xrpl.xrpl4j.model.transactions.VaultData; +import org.xrpl.xrpl4j.model.transactions.VaultKind; import org.xrpl.xrpl4j.model.transactions.WithdrawalPolicy; import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount; @@ -155,6 +157,38 @@ public void testVaultCreateJsonWithUnknownFields() throws JsonProcessingExceptio assertCanSerializeAndDeserialize(vaultCreate, json); } + @Test + public void testVaultCreateJsonWithClosedEndedVaultKind() throws JsonProcessingException, JSONException { + VaultCreate vaultCreate = VaultCreate.builder() + .account(Address.of("rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm")) + .fee(XrpCurrencyAmount.ofDrops(10)) + .sequence(UnsignedInteger.valueOf(1)) + .asset(Issue.XRP) + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(1000).plus(VaultCreate.MIN_INVESTMENT_PERIOD_SECONDS)) + .signingPublicKey( + PublicKey.fromBase16EncodedPublicKey("02356E89059A75438887F9FEE2056A2890DB82A68353BE9C0C0C8F89C0018B37FC") + ) + .build(); + + String json = "{" + + " \"Account\": \"rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm\"," + + " \"Asset\": {" + + " \"currency\": \"XRP\"" + + " }," + + " \"VaultKind\": 1," + + " \"SubscriptionDate\": 1000," + + " \"RedemptionDate\": 1180," + + " \"Fee\": \"10\"," + + " \"Sequence\": 1," + + " \"SigningPubKey\": \"02356E89059A75438887F9FEE2056A2890DB82A68353BE9C0C0C8F89C0018B37FC\"," + + " \"TransactionType\": \"VaultCreate\"" + + "}"; + + assertCanSerializeAndDeserialize(vaultCreate, json); + } + @Test public void testVaultCreateJsonWithMptAsset() throws JsonProcessingException, JSONException { VaultCreate vaultCreate = VaultCreate.builder() diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultDeleteJsonTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultDeleteJsonTest.java index b086366f2..0b4e317ac 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultDeleteJsonTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultDeleteJsonTest.java @@ -8,6 +8,7 @@ import org.xrpl.xrpl4j.model.AbstractJsonTest; import org.xrpl.xrpl4j.model.transactions.Address; import org.xrpl.xrpl4j.model.transactions.Hash256; +import org.xrpl.xrpl4j.model.transactions.VaultData; import org.xrpl.xrpl4j.model.transactions.VaultDelete; import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount; @@ -39,5 +40,31 @@ public void testVaultDeleteJson() throws JsonProcessingException, JSONException assertCanSerializeAndDeserialize(vaultDelete, json); } + + @Test + public void testVaultDeleteJsonWithMemoData() throws JsonProcessingException, JSONException { + VaultDelete vaultDelete = VaultDelete.builder() + .account(Address.of("rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm")) + .fee(XrpCurrencyAmount.ofDrops(10)) + .sequence(UnsignedInteger.valueOf(1)) + .vaultId(Hash256.of("0000000000000000000000000000000000000000000000000000000000000001")) + .memoData(VaultData.of("48656C6C6F")) + .signingPublicKey( + PublicKey.fromBase16EncodedPublicKey("02356E89059A75438887F9FEE2056A2890DB82A68353BE9C0C0C8F89C0018B37FC") + ) + .build(); + + String json = "{" + + " \"Account\": \"rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm\"," + + " \"VaultID\": \"0000000000000000000000000000000000000000000000000000000000000001\"," + + " \"MemoData\": \"48656C6C6F\"," + + " \"Fee\": \"10\"," + + " \"Sequence\": 1," + + " \"SigningPubKey\": \"02356E89059A75438887F9FEE2056A2890DB82A68353BE9C0C0C8F89C0018B37FC\"," + + " \"TransactionType\": \"VaultDelete\"" + + "}"; + + assertCanSerializeAndDeserialize(vaultDelete, json); + } } diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultWithdrawJsonTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultWithdrawJsonTest.java index d4ba8f147..05c03b37b 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultWithdrawJsonTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/json/VaultWithdrawJsonTest.java @@ -1,5 +1,7 @@ package org.xrpl.xrpl4j.model.transactions.json; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.UnsignedInteger; import org.json.JSONException; @@ -8,6 +10,7 @@ import org.xrpl.xrpl4j.model.AbstractJsonTest; import org.xrpl.xrpl4j.model.transactions.Address; import org.xrpl.xrpl4j.model.transactions.Hash256; +import org.xrpl.xrpl4j.model.transactions.ImmutableVaultWithdraw; import org.xrpl.xrpl4j.model.transactions.IssuedCurrencyAmount; import org.xrpl.xrpl4j.model.transactions.MpTokenIssuanceId; import org.xrpl.xrpl4j.model.transactions.MptCurrencyAmount; @@ -146,5 +149,74 @@ public void testVaultWithdrawJsonWithMptAmount() throws JsonProcessingException, assertCanSerializeAndDeserialize(vaultWithdraw, json); } + + @Test + public void testVaultWithdrawJsonWithCredentialIds() throws JsonProcessingException, JSONException { + VaultWithdraw vaultWithdraw = VaultWithdraw.builder() + .account(Address.of("rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm")) + .fee(XrpCurrencyAmount.ofDrops(10)) + .sequence(UnsignedInteger.valueOf(1)) + .vaultId(Hash256.of("0000000000000000000000000000000000000000000000000000000000000001")) + .amount(XrpCurrencyAmount.ofDrops(500000)) + .addCredentialIds( + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A"), + Hash256.of("000000000000000000000000000000000000000000000000000000000000000B") + ) + .signingPublicKey( + PublicKey.fromBase16EncodedPublicKey("02356E89059A75438887F9FEE2056A2890DB82A68353BE9C0C0C8F89C0018B37FC") + ) + .build(); + + String json = "{" + + " \"Account\": \"rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm\"," + + " \"VaultID\": \"0000000000000000000000000000000000000000000000000000000000000001\"," + + " \"Amount\": \"500000\"," + + " \"CredentialIDs\": [" + + " \"000000000000000000000000000000000000000000000000000000000000000A\"," + + " \"000000000000000000000000000000000000000000000000000000000000000B\"" + + " ]," + + " \"Fee\": \"10\"," + + " \"Sequence\": 1," + + " \"SigningPubKey\": \"02356E89059A75438887F9FEE2056A2890DB82A68353BE9C0C0C8F89C0018B37FC\"," + + " \"TransactionType\": \"VaultWithdraw\"" + + "}"; + + assertCanSerializeAndDeserialize(vaultWithdraw, json); + } + + @Test + public void testVaultWithdrawWithTooManyCredentialIds() { + ImmutableVaultWithdraw.Builder builder = VaultWithdraw.builder() + .account(Address.of("rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm")) + .fee(XrpCurrencyAmount.ofDrops(10)) + .sequence(UnsignedInteger.valueOf(1)) + .vaultId(Hash256.of("0000000000000000000000000000000000000000000000000000000000000001")) + .amount(XrpCurrencyAmount.ofDrops(500000)); + + for (int i = 0; i < 9; i++) { + builder.addCredentialIds(Hash256.of(String.format("%064X", i))); + } + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("CredentialIDs should have less than or equal to 8 items."); + } + + @Test + public void testVaultWithdrawWithDuplicateCredentialIds() { + assertThatThrownBy(() -> VaultWithdraw.builder() + .account(Address.of("rJVUeRqDFNs2xqA7ncVE6ZoAhPUoaJJSQm")) + .fee(XrpCurrencyAmount.ofDrops(10)) + .sequence(UnsignedInteger.valueOf(1)) + .vaultId(Hash256.of("0000000000000000000000000000000000000000000000000000000000000001")) + .amount(XrpCurrencyAmount.ofDrops(500000)) + .addCredentialIds( + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A"), + Hash256.of("000000000000000000000000000000000000000000000000000000000000000A") + ) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("CredentialIDs should have unique values."); + } } diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObjectTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObjectTest.java index 796c53cb1..2bbe1d6f2 100644 --- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObjectTest.java +++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/metadata/MetaVaultObjectTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.google.common.primitives.UnsignedInteger; +import com.google.common.primitives.UnsignedLong; import org.json.JSONException; import org.junit.jupiter.api.Test; import org.xrpl.xrpl4j.model.AbstractJsonTest; @@ -13,6 +14,7 @@ import org.xrpl.xrpl4j.model.transactions.Hash256; import org.xrpl.xrpl4j.model.transactions.MpTokenIssuanceId; import org.xrpl.xrpl4j.model.transactions.VaultData; +import org.xrpl.xrpl4j.model.transactions.VaultKind; import org.xrpl.xrpl4j.model.transactions.WithdrawalPolicy; class MetaVaultObjectTest extends AbstractJsonTest { @@ -41,6 +43,10 @@ void testMetaVaultObjectWithAllFields() throws JsonProcessingException, JSONExce .shareMptId(MpTokenIssuanceId.of("00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836")) .withdrawalPolicy(WithdrawalPolicy.FIRST_COME_FIRST_SERVE) .scale(AssetScale.of(UnsignedInteger.valueOf(8))) + .leVersion(UnsignedInteger.ONE) + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(UnsignedLong.valueOf(1000)) + .redemptionDate(UnsignedLong.valueOf(2000)) .build(); String json = "{" + @@ -62,7 +68,11 @@ void testMetaVaultObjectWithAllFields() throws JsonProcessingException, JSONExce " \"LossUnrealized\": \"100\"," + " \"ShareMPTID\": \"00000005E54ZDVGNGHAOPOPCGVTIQWNQ3DU5Y836\"," + " \"WithdrawalPolicy\": 1," + - " \"Scale\": 8" + + " \"Scale\": 8," + + " \"LEVersion\": 1," + + " \"VaultKind\": 1," + + " \"SubscriptionDate\": 1000," + + " \"RedemptionDate\": 2000" + "}"; assertCanSerializeAndDeserialize(metaVaultObject, json, MetaVaultObject.class); diff --git a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java index fc03b5b31..4dd14e375 100644 --- a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java +++ b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/LendingProtocolIT.java @@ -32,6 +32,7 @@ import org.xrpl.xrpl4j.model.client.transactions.TransactionResult; import org.xrpl.xrpl4j.model.flags.LoanManageFlags; import org.xrpl.xrpl4j.model.flags.LoanPayFlags; +import org.xrpl.xrpl4j.model.immutables.FluentCompareTo; import org.xrpl.xrpl4j.model.ledger.IouIssue; import org.xrpl.xrpl4j.model.ledger.Issue; import org.xrpl.xrpl4j.model.ledger.LedgerObject; @@ -64,6 +65,7 @@ import org.xrpl.xrpl4j.model.transactions.SignerListSet; import org.xrpl.xrpl4j.model.transactions.VaultCreate; import org.xrpl.xrpl4j.model.transactions.VaultDeposit; +import org.xrpl.xrpl4j.model.transactions.VaultKind; import org.xrpl.xrpl4j.model.transactions.WithdrawalPolicy; import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount; @@ -117,6 +119,7 @@ void loanSetWithSingleSignBrokerAndSingleSignCounterparty() .principalRequested(Amount.of("1000000")) .counterparty(borrowerKeyPair.publicKey().deriveAddress()) .paymentTotal(UnsignedInteger.valueOf(3)) + .paymentInterval(UnsignedInteger.valueOf(60)) .signingPublicKey(brokerKeyPair.publicKey()) .build(); @@ -208,6 +211,7 @@ void loanSetWithSingleSignBrokerAndRegularKeyCounterparty() .principalRequested(Amount.of("1000000")) .counterparty(borrowerKeyPair.publicKey().deriveAddress()) .paymentTotal(UnsignedInteger.valueOf(3)) + .paymentInterval(UnsignedInteger.valueOf(60)) .signingPublicKey(brokerKeyPair.publicKey()) .build(); @@ -277,6 +281,7 @@ void loanSetWithSingleSignBrokerAndMultiSignCounterparty() .principalRequested(Amount.of("1000000")) .counterparty(borrowerKeyPair.publicKey().deriveAddress()) .paymentTotal(UnsignedInteger.valueOf(3)) + .paymentInterval(UnsignedInteger.valueOf(60)) .signingPublicKey(brokerKeyPair.publicKey()) .build(); @@ -350,6 +355,7 @@ void loanSetWithMultiSignBrokerAndSingleSignCounterparty() .principalRequested(Amount.of("1000000")) .counterparty(borrowerKeyPair.publicKey().deriveAddress()) .paymentTotal(UnsignedInteger.valueOf(3)) + .paymentInterval(UnsignedInteger.valueOf(60)) .build(); final LoanSet unsignedLoanSet = ImmutableLoanSet.copyOf(unpricedLoanSet).withFee( @@ -421,6 +427,7 @@ void loanSetWithMultiSignBrokerAndMultiSignCounterparty() .principalRequested(Amount.of("1000000")) .counterparty(borrowerKeyPair.publicKey().deriveAddress()) .paymentTotal(UnsignedInteger.valueOf(3)) + .paymentInterval(UnsignedInteger.valueOf(60)) .build(); final LoanSet unsignedLoanSet = ImmutableLoanSet.copyOf(unpricedLoanSet).withFee( @@ -543,6 +550,15 @@ void lendingProtocolLifecycle() throws Exception { () -> this.getValidatedAccountInfo(loanBrokerKeyPair.publicKey().deriveAddress()) ); + // LoanBrokerSet requires a closed-ended Vault once LendingProtocolV1_1 is enabled, so this Vault must define a + // SubscriptionDate/RedemptionDate pair, and this method must wait for the ledger to pass SubscriptionDate before + // attaching a LoanBroker to it, below. See setupLoanBrokerAndXrpVault() for why SubscriptionDate and + // RedemptionDate are padded well past their minimums. + final UnsignedLong subscriptionDate = instantToXrpTimestamp(getMinExpirationTime().plus(Duration.ofSeconds(60))); + final UnsignedLong redemptionDate = subscriptionDate.plus( + VaultCreate.MIN_INVESTMENT_PERIOD_SECONDS.plus(UnsignedLong.valueOf(3600)) + ); + VaultCreate vaultCreate = VaultCreate.builder() .account(loanBrokerKeyPair.publicKey().deriveAddress()) .fee(fee) @@ -550,6 +566,9 @@ void lendingProtocolLifecycle() throws Exception { .asset(usdIssue) .assetsMaximum(Amount.of("500000")) .withdrawalPolicy(WithdrawalPolicy.FIRST_COME_FIRST_SERVE) + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(subscriptionDate) + .redemptionDate(redemptionDate) .signingPublicKey(loanBrokerKeyPair.publicKey()) .build(); @@ -572,6 +591,10 @@ void lendingProtocolLifecycle() throws Exception { LedgerSpecifier.VALIDATED ) ); + assertThat(vaultLedgerEntry.node().vaultKind()).isEqualTo(VaultKind.CLOSED_ENDED); + assertThat(vaultLedgerEntry.node().subscriptionDate()).hasValue(subscriptionDate); + assertThat(vaultLedgerEntry.node().redemptionDate()).hasValue(redemptionDate); + assertThat(vaultLedgerEntry.node().leVersion()).isEqualTo(UnsignedInteger.ONE); Hash256 vaultId = vaultLedgerEntry.node().index(); logger.info("Vault created: {}", vaultId); @@ -598,6 +621,9 @@ void lendingProtocolLifecycle() throws Exception { () -> this.getValidatedTransaction(signedDeposit.hash(), VaultDeposit.class) ); + // LoanBrokerSet requires the Vault to be past its SubscriptionDate once it is closed-ended. + awaitSubscriptionDatePassed(subscriptionDate); + // ========== LOAN BROKER SET ========== loanBrokerAccountInfo = this.scanForResult( () -> this.getValidatedAccountInfo(loanBrokerKeyPair.publicKey().deriveAddress()) @@ -728,6 +754,7 @@ void lendingProtocolLifecycle() throws Exception { .principalRequested(Amount.of("50000")) .counterparty(borrowerKeyPair.publicKey().deriveAddress()) .paymentTotal(UnsignedInteger.valueOf(3)) + .paymentInterval(UnsignedInteger.valueOf(60)) .data(LoanData.of("AABBCC")) .signingPublicKey(loanBrokerKeyPair.publicKey()) .build(); @@ -1108,9 +1135,11 @@ void loanManageImpairAndUnimpair() throws JsonRpcClientErrorException, JsonProce * strictly past the loan's {@code NextPaymentDueDate}. * *

Under the {@code fixCleanup3_4_0} amendment, rippled only permits impairing a loan whose payment is actually - * late (see rippled's {@code LoanManage::impairLoan}, which otherwise returns {@code tecTOO_SOON}). A standalone - * rippled's close time advances much faster than wall-clock time, so pinning the loan to the 60-second minimum - * {@code PaymentInterval} keeps this wait well under a second.

+ * late (see rippled's {@code LoanManage::impairLoan}, which otherwise returns {@code tecTOO_SOON}). A standalone, + * local rippled's close time can advance much faster than wall-clock time, so pinning the loan to the 60-second + * minimum {@code PaymentInterval} keeps this wait well under a second there. Against a real network (e.g. Devnet), + * though, ledger close time tracks wall-clock time roughly 1:1, so this genuinely takes close to a full + * {@code PaymentInterval} of real time to resolve; the timeout below is sized for that case.

* *

Note that rippled evaluates lateness against the applying ledger's {@code parentCloseTime}, whereas * this polls the validated ledger's {@code closeTime}. Waiting on the latter is sufficient because close @@ -1133,7 +1162,7 @@ private void awaitLoanPaymentLate(final Hash256 loanId) throws JsonRpcClientErro given() .pollInterval(Durations.ONE_HUNDRED_MILLISECONDS) - .atMost(Duration.of(1, ChronoUnit.MINUTES)) + .atMost(Duration.of(3, ChronoUnit.MINUTES)) .await() .until(() -> getValidatedLedger().ledger().closeTime() .orElseThrow(() -> new IllegalStateException("Validated ledger must have a closeTime.")) @@ -1292,6 +1321,27 @@ private Hash256 setupLoanBrokerAndXrpVault(KeyPair brokerKeyPair, XrpCurrencyAmo () -> this.getValidatedAccountInfo(brokerKeyPair.publicKey().deriveAddress()) ); + // LoanBrokerSet requires a closed-ended Vault once LendingProtocolV1_1 is enabled, so this Vault must + // define a SubscriptionDate/RedemptionDate pair and this method must wait for the ledger to pass + // SubscriptionDate before attaching a LoanBroker to it, below. + // + // SubscriptionDate must also leave enough real time for the VaultDeposit below to land before it -- deposits + // into a closed-ended Vault are only accepted before SubscriptionDate. A 5-second buffer is enough on a local + // rippled node with near-instant round trips, but against a real network (e.g. Devnet, ~4-5s ledger closes plus + // RPC latency for every call between VaultCreate and VaultDeposit) that window routinely closes before the + // deposit is submitted, and rippled rejects it with tecEXPIRED. + // + // RedemptionDate must also clear the Loan's own finalPayment date (StartDate + PaymentTotal * PaymentInterval, + // plus rippled's kLoanRedemptionBuffer), or the LoanSet submitted after this setup returns tecNO_PERMISSION. + // StartDate is whenever LoanSet actually validates, which is after this method already waited past + // SubscriptionDate and closed ledgers for LoanBrokerSet and LoanBrokerCoverDeposit, so a window sized to just + // the loan term leaves no slack for that setup latency. Pad it generously; VaultCreate allows up to + // MAX_INVESTMENT_PERIOD_SECONDS (~30 years), so there is ample room. + final UnsignedLong subscriptionDate = instantToXrpTimestamp(getMinExpirationTime().plus(Duration.ofSeconds(60))); + final UnsignedLong redemptionDate = subscriptionDate.plus( + VaultCreate.MIN_INVESTMENT_PERIOD_SECONDS.plus(UnsignedLong.valueOf(3600)) + ); + final VaultCreate vaultCreate = VaultCreate.builder() .account(brokerKeyPair.publicKey().deriveAddress()) .fee(fee) @@ -1299,6 +1349,9 @@ private Hash256 setupLoanBrokerAndXrpVault(KeyPair brokerKeyPair, XrpCurrencyAmo .asset(Issue.XRP) .assetsMaximum(Amount.of("10000000000")) .withdrawalPolicy(WithdrawalPolicy.FIRST_COME_FIRST_SERVE) + .vaultKind(VaultKind.CLOSED_ENDED) + .subscriptionDate(subscriptionDate) + .redemptionDate(redemptionDate) .signingPublicKey(brokerKeyPair.publicKey()) .build(); @@ -1318,7 +1371,12 @@ private Hash256 setupLoanBrokerAndXrpVault(KeyPair brokerKeyPair, XrpCurrencyAmo LedgerSpecifier.VALIDATED ) ); - final Hash256 vaultId = vaultEntry.node().index(); + final VaultObject vault = vaultEntry.node(); + assertThat(vault.vaultKind()).isEqualTo(VaultKind.CLOSED_ENDED); + assertThat(vault.subscriptionDate()).hasValue(subscriptionDate); + assertThat(vault.redemptionDate()).hasValue(redemptionDate); + assertThat(vault.leVersion()).isEqualTo(UnsignedInteger.ONE); + final Hash256 vaultId = vault.index(); final AccountInfoResult depositorAccountInfo = this.scanForResult( () -> this.getValidatedAccountInfo(depositorKeyPair.publicKey().deriveAddress()) @@ -1340,6 +1398,9 @@ private Hash256 setupLoanBrokerAndXrpVault(KeyPair brokerKeyPair, XrpCurrencyAmo assertThat(depositResult.engineResult()).isEqualTo(SUCCESS_STATUS); this.scanForResult(() -> this.getValidatedTransaction(signedDeposit.hash(), VaultDeposit.class)); + // LoanBrokerSet requires the Vault to be past its SubscriptionDate once it is closed-ended. + awaitSubscriptionDatePassed(subscriptionDate); + brokerAccountInfo = this.scanForResult( () -> this.getValidatedAccountInfo(brokerKeyPair.publicKey().deriveAddress()) ); @@ -1394,6 +1455,24 @@ private Hash256 setupLoanBrokerAndXrpVault(KeyPair brokerKeyPair, XrpCurrencyAmo return loanBrokerId; } + /** + * Waits for the ledger's close time to pass {@code subscriptionDate}. {@link AbstractIT#scanForResult} caps its + * wait at {@link AbstractIT#AT_MOST_INTERVAL} (30 seconds), which is too short once SubscriptionDate is padded out + * far enough to survive real network latency (see setupLoanBrokerAndXrpVault()), so this polls with its own, + * longer timeout instead. + */ + private void awaitSubscriptionDatePassed(UnsignedLong subscriptionDate) { + given() + .pollInterval(POLL_INTERVAL) + .atMost(Duration.ofSeconds(120)) + .await() + .until( + this::getValidatedLedger, + ledgerResult -> FluentCompareTo.is(ledgerResult.ledger().closeTime().orElse(UnsignedLong.ZERO)) + .greaterThan(subscriptionDate) + ); + } + /** * Sets up a signer list with two signers on the given account (quorum=2, each weight=1). */ diff --git a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SingleAssetVaultIT.java b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SingleAssetVaultIT.java index 64c10cba0..602468795 100644 --- a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SingleAssetVaultIT.java +++ b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SingleAssetVaultIT.java @@ -1228,6 +1228,11 @@ void vaultWithIouAssetAndDomainId() throws Exception { .build() ) .destination(authorizedDepositorKeyPair.publicKey().deriveAddress()) + .credentialIds( + getCredentialObjectIds( + credentialIssuerKeyPair, authorizedDepositorKeyPair, new CredentialType[]{credentialType} + ) + ) .signingPublicKey(authorizedDepositorKeyPair.publicKey()) .build(); @@ -1360,6 +1365,7 @@ void vaultWithIouAssetAndDomainId() throws Exception { .fee(FeeUtils.computeNetworkFees(feeResult).recommendedFee()) .sequence(vaultOwnerAccountInfo.accountData().sequence()) .vaultId(vaultId) + .memoData(VaultData.ofPlainText("closing vault")) .signingPublicKey(vaultOwnerKeyPair.publicKey()) .build(); @@ -1371,9 +1377,10 @@ void vaultWithIouAssetAndDomainId() throws Exception { assertThat(vaultDeleteSubmitResult.engineResult()).isEqualTo(SUCCESS_STATUS); // Wait for transaction to be validated - this.scanForResult( + VaultDelete validatedVaultDelete = this.scanForResult( () -> this.getValidatedTransaction(signedVaultDelete.hash(), VaultDelete.class) - ); + ).transaction(); + assertThat(validatedVaultDelete.memoData()).hasValue(VaultData.ofPlainText("closing vault")); } /** diff --git a/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg b/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg index 85d1e72f0..99eb33cb6 100644 --- a/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg +++ b/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg @@ -243,6 +243,7 @@ fixTokenEscrowV1 # 3.1.0 Amendments SingleAssetVault LendingProtocol +LendingProtocolV1_1 # 3.1.3 Amendments fixCleanup3_1_3 # 3.2.0 Amendments