Signature multiSign(final P privateKeyable, final
}
@Override
- public Signature multiSignInner(final P privateKeyable, final Batch batchTransaction) {
+ public Signature multiSignInner(final P privateKeyable, final Batch batchTransaction,
+ final Address batchSignerAddress) {
Objects.requireNonNull(privateKeyable);
Objects.requireNonNull(batchTransaction);
+ Objects.requireNonNull(batchSignerAddress);
- final Address address = derivePublicKey(privateKeyable).deriveAddress();
- final UnsignedByteArray signableBytes = this.signatureUtils.toMultiSignableInnerBytes(batchTransaction, address);
+ final Address nestedSignerAddress = derivePublicKey(privateKeyable).deriveAddress();
+ final UnsignedByteArray signableBytes = this.signatureUtils.toMultiSignableInnerBytes(
+ batchTransaction, batchSignerAddress, nestedSignerAddress
+ );
return this.signatureHelper(privateKeyable, signableBytes);
}
diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtils.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtils.java
index c71761fe8..a6625de53 100644
--- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtils.java
+++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/SignatureUtils.java
@@ -153,21 +153,20 @@ public UnsignedByteArray toMultiSignableBytes(final Transaction transaction, fin
/**
- * Convert a {@link Batch} transaction to bytes that can be signed by a Batch inner transaction signer. Per XLS-0056,
- * BatchSigners sign the inner transaction in a specific format: HashPrefix::batch + flags + count + inner tx IDs.
+ * Convert a {@link Batch} transaction to bytes that can be signed by a Batch inner transaction signer (single-sign
+ * path). Per XLS-0056 V1_1, the payload is: {@code HashPrefix::Batch} + outer {@code Account} + sequence +
+ * {@code Flags} + count + inner tx IDs + {@code batchSignerAddress}.
*
- * @param batch A {@link Batch} transaction.
+ * @param batch A {@link Batch} transaction.
+ * @param batchSignerAddress The {@link Address} of the BatchSigner entry signing this batch.
*
* @return An {@link UnsignedByteArray} containing the bytes to be signed.
*/
@Beta
- public UnsignedByteArray toSignableInnerBytes(final Batch batch) {
+ public UnsignedByteArray toSignableInnerBytes(final Batch batch, final Address batchSignerAddress) {
Objects.requireNonNull(batch);
- try {
- return binaryCodec.encodeForBatchInnerSigning(batch);
- } catch (JsonProcessingException e) {
- throw new RuntimeException(e.getMessage(), e);
- }
+ Objects.requireNonNull(batchSignerAddress);
+ return binaryCodec.encodeForBatchInnerSigning(batch, batchSignerAddress);
}
/**
@@ -200,22 +199,22 @@ public UnsignedByteArray toCounterpartyMultiSignableBytes(final LoanSet transact
/**
* Converts a {@link Batch} to multi-signable bytes for a specific signer. This is used when a multi-sig account acts
- * as a BatchSigner with nested Signers. Per rippled's checkBatchMultiSign, this uses batch serialization followed by
- * appending the signer's account ID.
+ * as a BatchSigner with nested Signers. Per XLS-0056 V1_1 / rippled's {@code checkBatchMultiSign}, the payload is the
+ * base batch serialization followed by {@code batchSignerAddress} then {@code nestedSignerAddress}.
*
- * @param batch The {@link Batch} to convert.
- * @param signerAddress The {@link Address} of the signer.
+ * @param batch The {@link Batch} to convert.
+ * @param batchSignerAddress The {@link Address} of the BatchSigner entry (outer multi-sig account).
+ * @param nestedSignerAddress The {@link Address} of the individual signer within the BatchSigner's Signers list.
*
- * @return An {@link UnsignedByteArray} containing the batch serialization with account ID suffix.
+ * @return An {@link UnsignedByteArray} containing the batch serialization with both account ID suffixes.
*/
@Beta
- public UnsignedByteArray toMultiSignableInnerBytes(final Batch batch, final Address signerAddress) {
+ public UnsignedByteArray toMultiSignableInnerBytes(
+ final Batch batch, final Address batchSignerAddress, final Address nestedSignerAddress
+ ) {
Objects.requireNonNull(batch);
- Objects.requireNonNull(signerAddress);
- try {
- return binaryCodec.encodeForBatchInnerMultiSigning(batch, signerAddress);
- } catch (JsonProcessingException e) {
- throw new RuntimeException(e.getMessage(), e);
- }
+ Objects.requireNonNull(batchSignerAddress);
+ Objects.requireNonNull(nestedSignerAddress);
+ return binaryCodec.encodeForBatchInnerMultiSigning(batch, batchSignerAddress, nestedSignerAddress);
}
}
diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/TransactionSigner.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/TransactionSigner.java
index d31feb967..2c891e2c1 100644
--- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/TransactionSigner.java
+++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/TransactionSigner.java
@@ -25,7 +25,9 @@
import org.xrpl.xrpl4j.crypto.keys.PublicKey;
import org.xrpl.xrpl4j.model.client.channels.UnsignedClaim;
import org.xrpl.xrpl4j.model.ledger.Attestation;
+import org.xrpl.xrpl4j.model.transactions.Address;
import org.xrpl.xrpl4j.model.transactions.Batch;
+import org.xrpl.xrpl4j.model.transactions.BatchSigner;
import org.xrpl.xrpl4j.model.transactions.LoanSet;
import org.xrpl.xrpl4j.model.transactions.Signer;
import org.xrpl.xrpl4j.model.transactions.Transaction;
@@ -86,19 +88,24 @@ public interface TransactionSigner {
/**
* Get a signature for a batch transaction using the supplied {@link P}.
*
- *
Per XLS-0056, BatchSigners sign a specific format: HashPrefix::batch + flags + count + inner tx IDs.
- * This differs from both single-signing and multi-signing.
+ * Per XLS-0056 V1_1, the payload is: {@code HashPrefix::Batch} + outer {@code Account} + sequence +
+ * {@code Flags} + count + inner tx IDs, followed by {@code batchSignerAddress} (the {@link BatchSigner}'s own
+ * account) as a per-signer suffix. Note that {@code batchSignerAddress} is not necessarily the address derived
+ * from {@code privateKeyable} — e.g. when a {@link BatchSigner} is authorized via a regular key, the signing key
+ * derives to the regular key's own address, but {@code batchSignerAddress} must be the underlying account's master
+ * address.
*
* This method will be marked {@link Beta} until the featureBatch amendment is enabled on mainnet.
* Its API is subject to change.
*
- * @param privateKeyable The {@link P} used to sign {@code batchTransaction}.
- * @param batchTransaction The {@link Batch} transaction to sign.
+ * @param privateKeyable The {@link P} used to sign {@code batchTransaction}.
+ * @param batchTransaction The {@link Batch} transaction to sign.
+ * @param batchSignerAddress The {@link Address} of the {@link BatchSigner} entry that this signature is for.
*
* @return A {@link Signature} for the batch transaction.
*/
@Beta
- Signature signInner(P privateKeyable, Batch batchTransaction);
+ Signature signInner(P privateKeyable, Batch batchTransaction, Address batchSignerAddress);
/**
* Get a signature for the supplied unsigned transaction using the supplied {@link P}. The primary reason this
@@ -120,25 +127,28 @@ public interface TransactionSigner {
/**
* Obtain a multi-signature for a batch transaction using the supplied {@link P}.
*
- *
This is used when a multi-sig account acts as a BatchSigner with nested Signers.
- * Per rippled's checkBatchMultiSign, this uses batch serialization (HashPrefix::batch + flags + count + tx IDs)
- * followed by appending the signer's account ID.
+ * This is used when a multi-sig account acts as a BatchSigner with nested Signers. Per XLS-0056 V1_1 /
+ * rippled's {@code checkBatchMultiSign}, the payload is the base batch serialization followed by
+ * {@code batchSignerAddress} (the outer multi-sig account) then the address derived from {@code privateKeyable} (the
+ * individual nested signer).
*
* This method will be marked {@link Beta} until the featureBatch amendment is enabled on mainnet.
* Its API is subject to change.
*
- * @param privateKeyable The {@link P} used to sign {@code batchTransaction}.
- * @param batchTransaction The {@link Batch} transaction to sign.
+ * @param privateKeyable The {@link P} used to sign {@code batchTransaction}.
+ * @param batchTransaction The {@link Batch} transaction to sign.
+ * @param batchSignerAddress The {@link Address} of the BatchSigner entry (the outer multi-sig account that contains
+ * the individual signer in its Signers list).
*
* @return A {@link Signature} for the batch transaction with multi-sig format.
*/
- Signature multiSignInner(P privateKeyable, Batch batchTransaction);
+ Signature multiSignInner(P privateKeyable, Batch batchTransaction, Address batchSignerAddress);
/**
* Obtain a counterparty single-signature for the supplied {@link LoanSet} transaction. The counterparty signs the
- * same bytes as the first-party signer, but this method returns only the raw
- * {@link Signature} rather than a {@link SingleSignedTransaction} wrapper, since the counterparty's signature is
- * placed into the {@link org.xrpl.xrpl4j.model.transactions.CounterpartySignature} field, not the transaction's
+ * same bytes as the first-party signer, but this method returns only the raw {@link Signature} rather than a
+ * {@link SingleSignedTransaction} wrapper, since the counterparty's signature is placed into the
+ * {@link org.xrpl.xrpl4j.model.transactions.CounterpartySignature} field, not the transaction's
* {@code TxnSignature}.
*
* This method will be marked {@link Beta} until the LendingProtocol amendment is enabled on mainnet. Its API
@@ -154,9 +164,9 @@ public interface TransactionSigner
{
/**
* Obtain a counterparty multi-signature for the supplied {@link LoanSet} transaction. Unlike
- * {@link #multiSign(PrivateKeyable, Transaction)}, this method does not clear the {@code SigningPubKey}
- * field, preserving the first-party signer's public key in the signed data. The resulting bytes use the same
- * multi-signing prefix ({@code SMT\0}) and the counterparty signer's account ID suffix.
+ * {@link #multiSign(PrivateKeyable, Transaction)}, this method does not clear the {@code SigningPubKey} field,
+ * preserving the first-party signer's public key in the signed data. The resulting bytes use the same multi-signing
+ * prefix ({@code SMT\0}) and the counterparty signer's account ID suffix.
*
*
This method will be marked {@link Beta} until the LendingProtocol amendment is enabled on mainnet. Its API
* is subject to change.
diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureService.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureService.java
index 54a9707d2..dd8011926 100644
--- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureService.java
+++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureService.java
@@ -47,6 +47,7 @@
import org.xrpl.xrpl4j.crypto.signing.SingleSignedTransaction;
import org.xrpl.xrpl4j.model.client.channels.UnsignedClaim;
import org.xrpl.xrpl4j.model.ledger.Attestation;
+import org.xrpl.xrpl4j.model.transactions.Address;
import org.xrpl.xrpl4j.model.transactions.Batch;
import org.xrpl.xrpl4j.model.transactions.LoanSet;
import org.xrpl.xrpl4j.model.transactions.Signer;
@@ -145,17 +146,22 @@ public Signature multiSign(
}
@Override
- public Signature signInner(final PrivateKeyReference privateKeyable, final Batch batchTransaction) {
+ public Signature signInner(
+ final PrivateKeyReference privateKeyable, final Batch batchTransaction, final Address batchSignerAddress
+ ) {
Objects.requireNonNull(privateKeyable);
Objects.requireNonNull(batchTransaction);
- return getTransactionSigner(privateKeyable).signInner(batchTransaction);
+ Objects.requireNonNull(batchSignerAddress);
+ return getTransactionSigner(privateKeyable).signInner(batchTransaction, batchSignerAddress);
}
@Override
- public Signature multiSignInner(final PrivateKeyReference privateKeyable, final Batch batchTransaction) {
+ public Signature multiSignInner(final PrivateKeyReference privateKeyable, final Batch batchTransaction,
+ final Address batchSignerAddress) {
Objects.requireNonNull(privateKeyable);
Objects.requireNonNull(batchTransaction);
- return getTransactionSigner(privateKeyable).multiSignInner(batchTransaction);
+ Objects.requireNonNull(batchSignerAddress);
+ return getTransactionSigner(privateKeyable).multiSignInner(batchTransaction, batchSignerAddress);
}
@Override
@@ -350,12 +356,12 @@ public Signer multiSignToSigner(T transaction) {
return bcSignatureService.multiSignToSigner(this.privateKey, transaction);
}
- public final Signature signInner(final Batch transaction) {
- return bcSignatureService.signInner(this.privateKey, transaction);
+ public final Signature signInner(final Batch transaction, final Address batchSignerAddress) {
+ return bcSignatureService.signInner(this.privateKey, transaction, batchSignerAddress);
}
- public final Signature multiSignInner(final Batch transaction) {
- return bcSignatureService.multiSignInner(this.privateKey, transaction);
+ public final Signature multiSignInner(final Batch transaction, final Address batchSignerAddress) {
+ return bcSignatureService.multiSignInner(this.privateKey, transaction, batchSignerAddress);
}
public final Signature counterpartySign(final LoanSet transaction) {
diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/Batch.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/Batch.java
index 377aa6eb8..d2c75f1ac 100644
--- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/Batch.java
+++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/Batch.java
@@ -20,20 +20,25 @@
* =========================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.annotations.Beta;
import com.google.common.base.Preconditions;
import org.immutables.value.Value;
+import org.xrpl.xrpl4j.codec.addresses.AddressCodec;
import org.xrpl.xrpl4j.codec.addresses.UnsignedByteArray;
import org.xrpl.xrpl4j.crypto.keys.PublicKey;
import org.xrpl.xrpl4j.model.flags.BatchFlags;
+import java.math.BigInteger;
+import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
/**
* A Batch transaction allows multiple transactions to be grouped together and executed atomically according to the
@@ -81,12 +86,25 @@ default BatchFlags flags() {
/**
* The list of inner transactions to be executed as part of this batch.
*
- * Must contain between 2 and 8 transactions (inclusive). Inner transactions must also have, among other rules:
+ *
Must contain between 2 and 8 transactions (inclusive). The following rules apply to every inner transaction
+ * (enforced by xrpl4j where noted; otherwise enforced server-side):
+ *
*
- * - Have the {@code tfInnerBatchTxn} flag set
- * - Have a fee of "0" (fees are paid by the outer Batch transaction)
- * - Have an empty SigningPubKey and no TxnSignature
- * - Not be Batch transactions themselves (no nesting)
+ * - Flag: {@code tfInnerBatchTxn} ({@code 0x40000000}) must be set. A standalone transaction that carries
+ * this flag is rejected by rippled with {@code temINVALID_INNER_BATCH} to prevent submission of unsigned inner
+ * transactions as normal transactions. (Enforced by {@link RawTransactionWrapper} at construction
+ * time.)
+ * - Fee: must be exactly {@code 0} XRP drops; the fee is paid by the outer {@link Batch} transaction.
+ * A non-zero or non-XRP fee yields {@code temBAD_FEE}. (Enforced by xrpl4j.)
+ * - TxnSignature: must be absent. Presence yields {@code temBAD_SIGNATURE}.
+ * (Enforced by xrpl4j.)
+ * - SigningPubKey: must be empty / zero-bytes. A non-empty value yields {@code temBAD_REGKEY}
+ * (note: not {@code temBAD_SIGNATURE} as one might expect). (Enforced by xrpl4j.)
+ * - Signers: must be absent. Presence yields {@code temBAD_SIGNER}. (Enforced by xrpl4j.)
+ * - Sequence / TicketSequence: exactly one of the two must be set; having both or neither yields
+ * {@code temSEQ_AND_TICKET}. (Enforced server-side.)
+ * - No nesting: an inner transaction must not itself be a {@link Batch}. (Enforced by
+ * xrpl4j.)
*
*
* @return A {@link List} of {@link RawTransactionWrapper} containing the inner transactions.
@@ -99,17 +117,43 @@ default BatchFlags flags() {
/**
* Optional list of batch signers for multi-account batch transactions.
*
- * When inner transactions come from multiple accounts, each account must sign the batch
- * and provide their signature in this array.
+ *
A {@link BatchSigner} entry is required for every account that has at least one inner transaction, or
+ * that would ordinarily have to sign at least one of the inner transactions (e.g., a co-signer or delegate). When
+ * multiple such accounts exist and no {@code BatchSigners} array is provided, rippled rejects the outer transaction
+ * with {@code tefBAD_AUTH}.
+ *
+ *
Pseudo-accounts (e.g., vault or MPT-issuance accounts) cannot sign batch entries. A {@link BatchSigner} entry
+ * that references a pseudo-account is rejected by rippled with {@code tefBAD_AUTH}. xrpl4j does not enforce this
+ * at construction time because pseudo-account status is ledger state that is unknowable from an address alone.
+ *
+ *
Notes on the current ({@code V1_1}) design:
+ *
+ * - Entries are auto-sorted ascending by {@code Account} address (required by the wire format).
+ * - There is a hard cap of 24 entries, independent of the number of inner transactions.
+ * - Extra signers are allowed: an account may sign without having its own inner transaction (e.g.,
+ * co-signer or delegate).
+ *
*
* @return A {@link List} of {@link BatchSignerWrapper} containing the batch signers.
*/
@JsonProperty("BatchSigners")
List batchSigners();
+ /**
+ * Internal flag used to prevent infinite recursion when auto-sorting {@link #batchSigners()}.
+ *
+ * @return {@code true} if {@link #batchSigners()} has already been sorted.
+ */
+ @JsonIgnore
+ @Value.Default
+ default boolean sortedBatchSigners() {
+ return false;
+ }
+
/**
* Validates all properties of inner transactions in a single pass for efficiency. This combines multiple validations
- * to avoid iterating over rawTransactions multiple times.
+ * to avoid iterating over rawTransactions multiple times. Note: the {@code tfInnerBatchTxn} flag is enforced earlier,
+ * by {@link RawTransactionWrapper#check()}, and is not re-checked here.
*/
@Value.Check
default void checkRawTransactions() {
@@ -211,12 +255,27 @@ default void checkBatchModeFlag() {
}
/**
- * Validates all BatchSigners-related constraints in a single pass for efficiency. This combines multiple validations
- * to avoid iterating over batchSigners and computing account sets multiple times.
+ * Validates BatchSigners constraints and auto-sorts entries by {@code Account} in ascending order (required by
+ * V1_1). Follows the same normalizing {@link Value.Check} pattern as {@link BatchSigner#checkAndNormalize()}.
+ *
+ * Notes on the current ({@code V1_1}) design:
+ *
+ * - BatchSigners are auto-sorted ascending by Account (temBAD_SIGNER if out of order on-chain).
+ * - There is a hard cap of 24 BatchSigners, independent of the number of inner transactions.
+ * - No "extra signers" check is performed: co-signers/delegates are allowed without their own inner
+ * transaction.
+ *
*/
@Value.Check
- default void checkBatchSigners() {
- // Check 1: Validate no duplicate batch signers
+ default Batch checkBatchSigners() {
+ // Check 1: Hard cap of 24 BatchSigners (V1_1)
+ Preconditions.checkArgument(
+ this.batchSigners().size() <= 24,
+ "BatchSigners must not exceed 24 entries, but contained %s.",
+ this.batchSigners().size()
+ );
+
+ // Check 2: No duplicate signers by account
final long uniqueSignerCount = this.batchSigners().stream()
.map(wrapper -> wrapper.batchSigner().account())
.distinct()
@@ -229,16 +288,13 @@ default void checkBatchSigners() {
this.batchSigners().size()
);
- // Check 2: Validate that the outer account is not included as a signer in BatchSigners
- // This applies to both single-sig and multi-sig scenarios
+ // Check 3: Outer account must not appear in BatchSigners (directly or via nested Signers)
final Optional firstSignerMatchingOuterAccount = this.batchSigners().stream()
.map(BatchSignerWrapper::batchSigner)
.filter(batchSigner -> {
- // Check single-sig: BatchSigner.Account matches outer account
if (batchSigner.account().equals(this.account())) {
return true;
}
- // Check multi-sig: Any nested Signer.Account matches outer account
return batchSigner.signers().stream()
.anyMatch(signerWrapper -> signerWrapper.signer().account().equals(this.account()));
})
@@ -251,12 +307,24 @@ default void checkBatchSigners() {
firstSignerMatchingOuterAccount.orElse(null)
);
- // Checks 3-4: Validate BatchSigners completeness (no missing, no extra)
- // Only perform these checks if BatchSigners is non-empty
+ // Check 4: When BatchSigners is non-empty, every account required to sign an inner transaction (excluding
+ // the outer account) must have a corresponding BatchSigner entry.
if (!this.batchSigners().isEmpty()) {
- // Compute the set of accounts that require signatures (all inner transaction accounts except outer account)
+ // Compute the set of accounts that require signatures (excluding the outer account, which signs the Batch
+ // itself). For each inner transaction, the required signer is its Delegate if present (the delegate signs
+ // on behalf of the account holder), otherwise the transaction's Account. A LoanSet's Counterparty must also
+ // sign, if present. Note: rippled also requires a Sponsor to sign when SponsorSignature is present, but
+ // xrpl4j does not yet model sfSponsor/sfSponsorSignature, so that case cannot be checked here.
final Set requiredSignerAccounts = this.rawTransactions().stream()
- .map(wrapper -> wrapper.rawTransaction().account())
+ .flatMap(wrapper -> {
+ final Transaction innerTransaction = wrapper.rawTransaction();
+ final Stream.Builder requiredSigners = Stream.builder();
+ requiredSigners.add(innerTransaction.delegate().orElseGet(innerTransaction::account));
+ if (innerTransaction instanceof LoanSet) {
+ ((LoanSet) innerTransaction).counterparty().ifPresent(requiredSigners::add);
+ }
+ return requiredSigners.build();
+ })
.filter(account -> !account.equals(this.account()))
.collect(Collectors.toSet());
@@ -265,32 +333,40 @@ default void checkBatchSigners() {
.map(wrapper -> wrapper.batchSigner().account())
.collect(Collectors.toSet());
- // Check 3: Validate no missing BatchSigners
- // Every required account must have a corresponding BatchSigner
+ // Find the first inner-transaction account (excluding the outer account) that has no BatchSigner entry.
final Optional missingSignerAccount = requiredSignerAccounts.stream()
.filter(account -> !actualSignerAccounts.contains(account))
.findFirst();
+ // Note: we intentionally do NOT check for extra BatchSigners (accounts with no inner transaction).
+ // V1_1 allows co-signers and delegates who sign on behalf of an inner-transaction account without
+ // having their own inner transaction. Whether an extra signer is legitimate requires ledger state
+ // (e.g. SignerList entries), so that check belongs on the server, not here.
Preconditions.checkArgument(
!missingSignerAccount.isPresent(),
"BatchSigners must contain signatures from all accounts with inner transactions " +
"(excluding outer signer). Missing BatchSigner for account: %s",
missingSignerAccount.orElse(null)
);
+ }
- // Check 4: Validate no extra BatchSigners
- // Every BatchSigner must correspond to an inner transaction account
- final Optional extraSignerAccount = actualSignerAccounts.stream()
- .filter(signerAccount -> !requiredSignerAccounts.contains(signerAccount))
- .findFirst();
-
- Preconditions.checkArgument(
- !extraSignerAccount.isPresent(),
- "BatchSigners must only contain signatures from accounts that have inner transactions. " +
- "Found BatchSigner with no inner transactions: %s",
- extraSignerAccount.orElse(null)
- );
+ // Auto-sort BatchSigners ascending by Account (V1_1 requirement). Uses the same normalizing
+ // @Value.Check pattern as BatchSigner#checkAndNormalize to avoid infinite recursion.
+ if (!this.batchSigners().isEmpty() && !sortedBatchSigners()) {
+ final List sorted = this.batchSigners().stream()
+ .sorted(Comparator.comparing(wrapper -> new BigInteger(
+ AddressCodec.getInstance().decodeAccountId(wrapper.batchSigner().account()).hexValue(), 16
+ )))
+ .collect(Collectors.toList());
+
+ return ImmutableBatch.builder()
+ .from(this)
+ .batchSigners(sorted)
+ .sortedBatchSigners(true)
+ .build();
}
+
+ return this;
}
}
diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/BatchSigner.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/BatchSigner.java
index 1d5726f84..c571a8abf 100644
--- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/BatchSigner.java
+++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/model/transactions/BatchSigner.java
@@ -64,8 +64,10 @@ static ImmutableBatchSigner.Builder builder() {
}
/**
- * The account address of the signer. This must match either the outer transaction's Account or one of the inner
- * transaction accounts.
+ * The account address of the signer. Must be an account that has at least one inner transaction in the enclosing
+ * {@link Batch}, or an account that would ordinarily have to sign at least one of the inner transactions
+ * (e.g., a co-signer or delegate). Pseudo-accounts (e.g., vault or MPT-issuance accounts) cannot appear here;
+ * rippled rejects such entries with {@code tefBAD_AUTH}.
*
* @return An {@link Address}.
*/
diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java
index 55085a9b0..20c9778a2 100644
--- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java
+++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java
@@ -508,6 +508,7 @@ void transactionFixtureTests(CodecFixture codecFixture) throws JsonProcessingExc
}
private static final Address BATCH_ACCOUNT = Address.of("r45dBj4S3VvMMYXxr9vHX4Z4Ma6ifPMCkK");
+ private static final Address BATCH_SIGNER_ADDRESS = Address.of("rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN");
private static final PublicKey BATCH_ACCOUNT_SIGNING_PUB_KEY =
PublicKey.fromBase16EncodedPublicKey("0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020");
@@ -530,22 +531,26 @@ void encodeForBatchInnerSigningWithTwoTransactions() throws JsonProcessingExcept
)
.build();
- UnsignedByteArray result = encoder.encodeForBatchInnerSigning(batch);
+ UnsignedByteArray result = encoder.encodeForBatchInnerSigning(batch, BATCH_SIGNER_ADDRESS);
// Verify the result starts with the batch signature prefix "BCH\0" = 0x42434800
assertThat(result.hexValue()).startsWith("42434800");
- // Verify the structure: prefix (4 bytes) + flags (4 bytes) + count (4 bytes) + tx IDs (32 bytes each)
- // Total: 4 + 4 + 4 + (32 * 2) = 76 bytes = 152 hex chars
- assertThat(result.hexValue()).hasSize(152);
+ // Verify the structure: prefix (4 bytes) + account (20 bytes) + sequence (4 bytes) + flags (4 bytes) +
+ // count (4 bytes) + tx IDs (32 bytes each) + batchSignerAddress (20 bytes)
+ // Total: 4 + 20 + 4 + 4 + 4 + (32 * 2) + 20 = 120 bytes = 240 hex chars
+ assertThat(result.hexValue()).hasSize(240);
// Verify flags are encoded correctly (ALL_OR_NOTHING = 0x00010000)
- String flagsHex = result.hexValue().substring(8, 16);
+ String flagsHex = result.hexValue().substring(56, 64);
assertThat(flagsHex).isEqualTo("00010000");
// Verify count is 2
- String countHex = result.hexValue().substring(16, 24);
+ String countHex = result.hexValue().substring(64, 72);
assertThat(countHex).isEqualTo("00000002");
+
+ // Verify batchSignerAddress suffix is appended (last 40 hex chars = 20 bytes)
+ assertThat(result.hexValue().substring(200)).hasSize(40);
}
@Test
@@ -554,14 +559,15 @@ void encodeForBatchInnerSigningWithMaxTransactions() throws JsonProcessingExcept
List innerTransactions = createInnerPayments(8);
Batch batch = createBatch(BatchFlags.ALL_OR_NOTHING, innerTransactions);
- UnsignedByteArray result = encoder.encodeForBatchInnerSigning(batch);
+ UnsignedByteArray result = encoder.encodeForBatchInnerSigning(batch, BATCH_SIGNER_ADDRESS);
- // Verify the structure: prefix (4 bytes) + flags (4 bytes) + count (4 bytes) + tx IDs (32 bytes * 8)
- // Total: 4 + 4 + 4 + (32 * 8) = 268 bytes = 536 hex chars
- assertThat(result.hexValue()).hasSize(536);
+ // Verify the structure: prefix (4 bytes) + account (20 bytes) + sequence (4 bytes) + flags (4 bytes) +
+ // count (4 bytes) + tx IDs (32 bytes * 8) + batchSignerAddress (20 bytes)
+ // Total: 4 + 20 + 4 + 4 + 4 + (32 * 8) + 20 = 312 bytes = 624 hex chars
+ assertThat(result.hexValue()).hasSize(624);
// Verify count is 8
- String countHex = result.hexValue().substring(16, 24);
+ String countHex = result.hexValue().substring(64, 72);
assertThat(countHex).isEqualTo("00000008");
}
@@ -571,23 +577,23 @@ void encodeForBatchInnerSigningWithDifferentFlags() throws JsonProcessingExcepti
// Test ALL_OR_NOTHING flag (0x00010000)
Batch batchAllOrNothing = createBatch(BatchFlags.ALL_OR_NOTHING, innerTransactions);
- UnsignedByteArray resultAllOrNothing = encoder.encodeForBatchInnerSigning(batchAllOrNothing);
- assertThat(resultAllOrNothing.hexValue().substring(8, 16)).isEqualTo("00010000");
+ UnsignedByteArray resultAllOrNothing = encoder.encodeForBatchInnerSigning(batchAllOrNothing, BATCH_SIGNER_ADDRESS);
+ assertThat(resultAllOrNothing.hexValue().substring(56, 64)).isEqualTo("00010000");
// Test ONLY_ONE flag (0x00020000)
Batch batchOnlyOne = createBatch(BatchFlags.ONLY_ONE, innerTransactions);
- UnsignedByteArray resultOnlyOne = encoder.encodeForBatchInnerSigning(batchOnlyOne);
- assertThat(resultOnlyOne.hexValue().substring(8, 16)).isEqualTo("00020000");
+ UnsignedByteArray resultOnlyOne = encoder.encodeForBatchInnerSigning(batchOnlyOne, BATCH_SIGNER_ADDRESS);
+ assertThat(resultOnlyOne.hexValue().substring(56, 64)).isEqualTo("00020000");
// Test UNTIL_FAILURE flag (0x00040000)
Batch batchUntilFailure = createBatch(BatchFlags.UNTIL_FAILURE, innerTransactions);
- UnsignedByteArray resultUntilFailure = encoder.encodeForBatchInnerSigning(batchUntilFailure);
- assertThat(resultUntilFailure.hexValue().substring(8, 16)).isEqualTo("00040000");
+ UnsignedByteArray resultUntilFailure = encoder.encodeForBatchInnerSigning(batchUntilFailure, BATCH_SIGNER_ADDRESS);
+ assertThat(resultUntilFailure.hexValue().substring(56, 64)).isEqualTo("00040000");
// Test INDEPENDENT flag (0x00080000)
Batch batchIndependent = createBatch(BatchFlags.INDEPENDENT, innerTransactions);
- UnsignedByteArray resultIndependent = encoder.encodeForBatchInnerSigning(batchIndependent);
- assertThat(resultIndependent.hexValue().substring(8, 16)).isEqualTo("00080000");
+ UnsignedByteArray resultIndependent = encoder.encodeForBatchInnerSigning(batchIndependent, BATCH_SIGNER_ADDRESS);
+ assertThat(resultIndependent.hexValue().substring(56, 64)).isEqualTo("00080000");
}
@Test
@@ -606,13 +612,13 @@ void encodeForBatchInnerSigningVerifyTransactionIds() throws JsonProcessingExcep
.addRawTransactions(RawTransactionWrapper.of(innerPayment2))
.build();
- UnsignedByteArray result = encoder.encodeForBatchInnerSigning(batch);
+ UnsignedByteArray result = encoder.encodeForBatchInnerSigning(batch, BATCH_SIGNER_ADDRESS);
// Extract the two transaction IDs (each is 32 bytes = 64 hex chars)
// TODO: Once https://github.com/XRPLF/xrpl4j/issues/692 is merged, use the new method to compute the transaction
// id and assert against these byte indexed values.
- String txId1 = result.hexValue().substring(24, 88);
- String txId2 = result.hexValue().substring(88, 152);
+ String txId1 = result.hexValue().substring(72, 136);
+ String txId2 = result.hexValue().substring(136, 200);
// Since both inner transactions are identical, their IDs should be the same
assertThat(txId1).isNotEqualTo(txId2);
@@ -624,7 +630,15 @@ void encodeForBatchInnerSigningVerifyTransactionIds() throws JsonProcessingExcep
@Test
void encodeForBatchSigningNullBatchInnerThrowsException() {
- Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerSigning(null))
+ Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerSigning(null, BATCH_SIGNER_ADDRESS))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void encodeForBatchSigningNullBatchSignerAddressThrowsException() {
+ List innerTransactions = createInnerPayments(2);
+ Batch batch = createBatch(BatchFlags.ALL_OR_NOTHING, innerTransactions);
+ Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerSigning(batch, null))
.isInstanceOf(NullPointerException.class);
}
@@ -634,17 +648,29 @@ void encodeForBatchSigningNullBatchInnerThrowsException() {
@Test
void encodeForBatchInnerMultiSigningWithNullBatch() {
- Address signerAddress = Address.of("rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN");
- Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerMultiSigning(null, signerAddress))
+ Address batchSignerAddress = Address.of("rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN");
+ Address nestedSignerAddress = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
+ Assertions.assertThatThrownBy(
+ () -> encoder.encodeForBatchInnerMultiSigning(null, batchSignerAddress, nestedSignerAddress))
.isInstanceOf(NullPointerException.class);
}
@Test
- void encodeForBatchInnerMultiSigningWithNullSignerAddress() throws JsonProcessingException {
+ void encodeForBatchInnerMultiSigningWithNullBatchSignerAddress() throws JsonProcessingException {
List innerTransactions = createInnerPayments(2);
Batch batch = createBatch(BatchFlags.ALL_OR_NOTHING, innerTransactions);
+ Address nestedSignerAddress = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
- Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerMultiSigning(batch, null))
+ Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerMultiSigning(batch, null, nestedSignerAddress))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void encodeForBatchInnerMultiSigningWithNullNestedSignerAddress() throws JsonProcessingException {
+ List innerTransactions = createInnerPayments(2);
+ Batch batch = createBatch(BatchFlags.ALL_OR_NOTHING, innerTransactions);
+
+ Assertions.assertThatThrownBy(() -> encoder.encodeForBatchInnerMultiSigning(batch, BATCH_SIGNER_ADDRESS, null))
.isInstanceOf(NullPointerException.class);
}
@@ -667,28 +693,37 @@ void encodeForBatchInnerMultiSigningWithValidBatch() throws JsonProcessingExcept
)
.build();
- Address signerAddress = Address.of("rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN");
- UnsignedByteArray result = encoder.encodeForBatchInnerMultiSigning(batch, signerAddress);
+ Address nestedSignerAddress = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
+ UnsignedByteArray result = encoder.encodeForBatchInnerMultiSigning(batch, BATCH_SIGNER_ADDRESS,
+ nestedSignerAddress);
// Verify the result starts with the batch signature prefix "BCH\0" = 0x42434800
assertThat(result.hexValue()).startsWith("42434800");
// Verify the structure:
- // prefix (4 bytes) + flags (4 bytes) + count (4 bytes) + tx IDs (32 bytes * 2) + account ID (20 bytes)
- // Total: 4 + 4 + 4 + (32 * 2) + 20 = 96 bytes = 192 hex chars
- assertThat(result.hexValue()).hasSize(192);
+ // prefix (4 bytes) + account (20 bytes) + sequence (4 bytes) + flags (4 bytes) +
+ // count (4 bytes) + tx IDs (32 bytes * 2) + batchSignerAddress (20 bytes) + nestedSignerAddress (20 bytes)
+ // Total: 4 + 20 + 4 + 4 + 4 + (32 * 2) + 20 + 20 = 140 bytes = 280 hex chars
+ assertThat(result.hexValue()).hasSize(280);
// Verify flags are encoded correctly (ALL_OR_NOTHING = 0x00010000)
- String flagsHex = result.hexValue().substring(8, 16);
+ String flagsHex = result.hexValue().substring(56, 64);
assertThat(flagsHex).isEqualTo("00010000");
// Verify count is 2
- String countHex = result.hexValue().substring(16, 24);
+ String countHex = result.hexValue().substring(64, 72);
assertThat(countHex).isEqualTo("00000002");
- // Verify the account ID suffix is appended (last 40 hex chars = 20 bytes)
- String accountIdSuffix = result.hexValue().substring(152, 192);
- assertThat(accountIdSuffix).hasSize(40);
+ // Verify batchSignerAddress is at bytes 100-120 (hex 200-240)
+ String batchSignerHex = result.hexValue().substring(200, 240);
+ assertThat(batchSignerHex).hasSize(40);
+
+ // Verify nestedSignerAddress is appended last (hex 240-280)
+ String nestedSignerHex = result.hexValue().substring(240, 280);
+ assertThat(nestedSignerHex).hasSize(40);
+
+ // The two suffixes should be different (different accounts)
+ assertThat(batchSignerHex).isNotEqualTo(nestedSignerHex);
}
@Test
@@ -696,17 +731,21 @@ void encodeForBatchInnerMultiSigningWithDifferentSigners() throws JsonProcessing
List innerTransactions = createInnerPayments(2);
Batch batch = createBatch(BatchFlags.ALL_OR_NOTHING, innerTransactions);
- Address signer1 = Address.of("rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN");
- Address signer2 = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
+ Address batchSigner1 = Address.of("rJZdUusLDtY9NEsGea7ijqhVrXv98rYBYN");
+ Address batchSigner2 = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
+ Address nestedSigner = Address.of("r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59");
+
+ UnsignedByteArray result1 = encoder.encodeForBatchInnerMultiSigning(batch, batchSigner1, nestedSigner);
+ UnsignedByteArray result2 = encoder.encodeForBatchInnerMultiSigning(batch, batchSigner2, nestedSigner);
- UnsignedByteArray result1 = encoder.encodeForBatchInnerMultiSigning(batch, signer1);
- UnsignedByteArray result2 = encoder.encodeForBatchInnerMultiSigning(batch, signer2);
+ // The base batch payload should be the same (first 200 hex chars)
+ assertThat(result1.hexValue().substring(0, 200)).isEqualTo(result2.hexValue().substring(0, 200));
- // The batch serialization part should be the same (first 152 hex chars)
- assertThat(result1.hexValue().substring(0, 152)).isEqualTo(result2.hexValue().substring(0, 152));
+ // But the batchSigner suffix differs (hex 200-240)
+ assertThat(result1.hexValue().substring(200, 240)).isNotEqualTo(result2.hexValue().substring(200, 240));
- // But the account ID suffix should be different (last 40 hex chars)
- assertThat(result1.hexValue().substring(152)).isNotEqualTo(result2.hexValue().substring(152));
+ // The nestedSigner suffix is the same for both (hex 240-280)
+ assertThat(result1.hexValue().substring(240)).isEqualTo(result2.hexValue().substring(240));
}
// /////////////////
diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureServiceTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureServiceTest.java
index 68ec7d0fa..f8448941c 100644
--- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureServiceTest.java
+++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureServiceTest.java
@@ -44,6 +44,7 @@
import org.xrpl.xrpl4j.crypto.keys.PublicKey;
import org.xrpl.xrpl4j.model.client.channels.UnsignedClaim;
import org.xrpl.xrpl4j.model.ledger.Attestation;
+import org.xrpl.xrpl4j.model.transactions.Address;
import org.xrpl.xrpl4j.model.transactions.Batch;
import org.xrpl.xrpl4j.model.transactions.LoanSet;
import org.xrpl.xrpl4j.model.transactions.Payment;
@@ -92,8 +93,8 @@ public void setUp() throws Exception {
when(signedTransactionMock.unsignedTransaction()).thenReturn(transactionMock);
when(signatureUtilsMock.toSignableBytes(Mockito.any())).thenReturn(UnsignedByteArray.empty());
when(signatureUtilsMock.toMultiSignableBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
- when(signatureUtilsMock.toSignableInnerBytes(any())).thenReturn(UnsignedByteArray.empty());
- when(signatureUtilsMock.toMultiSignableInnerBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
+ when(signatureUtilsMock.toSignableInnerBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
+ when(signatureUtilsMock.toMultiSignableInnerBytes(any(), any(), any())).thenReturn(UnsignedByteArray.empty());
when(signatureUtilsMock.toCounterpartyMultiSignableBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
// Mock signatures need to return valid values for serialization
@@ -483,32 +484,39 @@ public void ecDsaVerify() {
@Test
public void signInnerWithNullPrivateKey() {
- assertThrows(NullPointerException.class, () -> signatureService.signInner(null, batchMock));
+ assertThrows(
+ NullPointerException.class,
+ () -> signatureService.signInner(null, batchMock, TestConstants.ED_ADDRESS)
+ );
}
@Test
public void signInnerWithNullBatch() {
assertThrows(NullPointerException.class,
- () -> signatureService.signInner(TestConstants.getEdPrivateKey(), null));
+ () -> signatureService.signInner(TestConstants.getEdPrivateKey(), null, TestConstants.ED_ADDRESS));
}
@Test
public void signInnerEd25519() {
- Signature actualSignature = signatureService.signInner(TestConstants.getEdPrivateKey(), batchMock);
+ Signature actualSignature = signatureService.signInner(
+ TestConstants.getEdPrivateKey(), batchMock, TestConstants.ED_ADDRESS
+ );
assertThat(actualSignature).isEqualTo(ed25519SignatureMock);
- verify(signatureUtilsMock).toSignableInnerBytes(batchMock);
- verify(signatureUtilsMock, times(0)).toMultiSignableInnerBytes(any(), any());
+ verify(signatureUtilsMock).toSignableInnerBytes(batchMock, TestConstants.ED_ADDRESS);
+ verify(signatureUtilsMock, times(0)).toMultiSignableInnerBytes(any(), any(), any());
verifyNoMoreInteractions(signatureUtilsMock);
}
@Test
public void signInnerSecp256k1() {
- Signature actualSignature = signatureService.signInner(TestConstants.getEcPrivateKey(), batchMock);
+ Signature actualSignature = signatureService.signInner(
+ TestConstants.getEcPrivateKey(), batchMock, TestConstants.EC_ADDRESS
+ );
assertThat(actualSignature).isEqualTo(secp256k1SignatureMock);
- verify(signatureUtilsMock).toSignableInnerBytes(batchMock);
- verify(signatureUtilsMock, times(0)).toMultiSignableInnerBytes(any(), any());
+ verify(signatureUtilsMock).toSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS);
+ verify(signatureUtilsMock, times(0)).toMultiSignableInnerBytes(any(), any(), any());
verifyNoMoreInteractions(signatureUtilsMock);
}
@@ -518,32 +526,41 @@ public void signInnerSecp256k1() {
@Test
public void multiSignInnerWithNullPrivateKey() {
- assertThrows(NullPointerException.class, () -> signatureService.multiSignInner(null, batchMock));
+ Address batchSignerAddress = Address.of("r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59");
+ assertThrows(NullPointerException.class, () -> signatureService.multiSignInner(null, batchMock,
+ batchSignerAddress));
}
@Test
public void multiSignInnerWithNullBatch() {
+ Address batchSignerAddress = Address.of("r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59");
assertThrows(NullPointerException.class,
- () -> signatureService.multiSignInner(TestConstants.getEdPrivateKey(), null));
+ () -> signatureService.multiSignInner(TestConstants.getEdPrivateKey(), null, batchSignerAddress));
}
@Test
public void multiSignInnerEd25519() {
- Signature actualSignature = signatureService.multiSignInner(TestConstants.getEdPrivateKey(), batchMock);
+ Signature actualSignature = signatureService.multiSignInner(
+ TestConstants.getEdPrivateKey(), batchMock, TestConstants.EC_ADDRESS
+ );
assertThat(actualSignature).isEqualTo(ed25519SignatureMock);
- verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.ED_ADDRESS);
- verify(signatureUtilsMock, times(0)).toSignableInnerBytes(any());
+ verify(signatureUtilsMock).toMultiSignableInnerBytes(
+ batchMock, TestConstants.EC_ADDRESS, TestConstants.ED_ADDRESS
+ );
+ verify(signatureUtilsMock, times(0)).toSignableInnerBytes(any(), any());
verifyNoMoreInteractions(signatureUtilsMock);
}
@Test
public void multiSignInnerSecp256k1() {
- Signature actualSignature = signatureService.multiSignInner(TestConstants.getEcPrivateKey(), batchMock);
+ Signature actualSignature = signatureService.multiSignInner(
+ TestConstants.getEcPrivateKey(), batchMock, TestConstants.EC_ADDRESS
+ );
assertThat(actualSignature).isEqualTo(secp256k1SignatureMock);
- verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS);
- verify(signatureUtilsMock, times(0)).toSignableInnerBytes(any());
+ verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS, TestConstants.EC_ADDRESS);
+ verify(signatureUtilsMock, times(0)).toSignableInnerBytes(any(), any());
verifyNoMoreInteractions(signatureUtilsMock);
}
diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSignerTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSignerTest.java
index 4adbec296..f30147190 100644
--- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSignerTest.java
+++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSignerTest.java
@@ -102,8 +102,8 @@ void setUp() {
when(signatureUtilsMock.toSignableBytes(Mockito.any())).thenReturn(UnsignedByteArray.empty());
when(signatureUtilsMock.toMultiSignableBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
- when(signatureUtilsMock.toSignableInnerBytes(any())).thenReturn(UnsignedByteArray.empty());
- when(signatureUtilsMock.toMultiSignableInnerBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
+ when(signatureUtilsMock.toSignableInnerBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
+ when(signatureUtilsMock.toMultiSignableInnerBytes(any(), any(), any())).thenReturn(UnsignedByteArray.empty());
when(signatureUtilsMock.toCounterpartyMultiSignableBytes(any(), any())).thenReturn(UnsignedByteArray.empty());
when(signerMock.signingPublicKey()).thenReturn(publicKeyMock);
@@ -357,22 +357,32 @@ void multiSignToSignerSecp256k1() {
@Test
void signInnerWithNullMetadata() {
- assertThrows(NullPointerException.class, () -> transactionSigner.signInner(null, batchMock));
+ assertThrows(
+ NullPointerException.class,
+ () -> transactionSigner.signInner(null, batchMock, TestConstants.EC_ADDRESS)
+ );
}
@Test
void signInnerWithNullBatch() {
- assertThrows(NullPointerException.class, () -> transactionSigner.signInner(privateKeyableMock, null));
+ assertThrows(NullPointerException.class,
+ () -> transactionSigner.signInner(privateKeyableMock, null, TestConstants.EC_ADDRESS));
+ }
+
+ @Test
+ void signInnerWithNullBatchSignerAddress() {
+ assertThrows(NullPointerException.class,
+ () -> transactionSigner.signInner(privateKeyableMock, batchMock, null));
}
@Test
void signInnerEd25519() {
keyType = KeyType.ED25519;
- Signature signature = transactionSigner.signInner(privateKeyableMock, batchMock);
+ Signature signature = transactionSigner.signInner(privateKeyableMock, batchMock, TestConstants.ED_ADDRESS);
assertThat(signature).isEqualTo(fauxEd25519Signature);
- verify(signatureUtilsMock).toSignableInnerBytes(batchMock);
+ verify(signatureUtilsMock).toSignableInnerBytes(batchMock, TestConstants.ED_ADDRESS);
verifyNoMoreInteractions(signatureUtilsMock);
}
@@ -380,10 +390,10 @@ void signInnerEd25519() {
void signInnerSecp256k1() {
keyType = KeyType.SECP256K1;
- Signature signature = transactionSigner.signInner(privateKeyableMock, batchMock);
+ Signature signature = transactionSigner.signInner(privateKeyableMock, batchMock, TestConstants.EC_ADDRESS);
assertThat(signature).isEqualTo(fauxSecp256k1Signature);
- verify(signatureUtilsMock).toSignableInnerBytes(batchMock);
+ verify(signatureUtilsMock).toSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS);
verifyNoMoreInteractions(signatureUtilsMock);
}
@@ -393,22 +403,30 @@ void signInnerSecp256k1() {
@Test
void multiSignInnerWithNullMetadata() {
- assertThrows(NullPointerException.class, () -> transactionSigner.multiSignInner(null, batchMock));
+ assertThrows(NullPointerException.class,
+ () -> transactionSigner.multiSignInner(null, batchMock, TestConstants.EC_ADDRESS));
}
@Test
void multiSignInnerWithNullBatch() {
- assertThrows(NullPointerException.class, () -> transactionSigner.multiSignInner(privateKeyableMock, null));
+ assertThrows(NullPointerException.class,
+ () -> transactionSigner.multiSignInner(privateKeyableMock, null, TestConstants.EC_ADDRESS));
+ }
+
+ @Test
+ void multiSignInnerWithNullBatchSignerAddress() {
+ assertThrows(NullPointerException.class,
+ () -> transactionSigner.multiSignInner(privateKeyableMock, batchMock, null));
}
@Test
void multiSignInnerEd25519() {
keyType = KeyType.ED25519;
- Signature signature = transactionSigner.multiSignInner(privateKeyableMock, batchMock);
+ Signature signature = transactionSigner.multiSignInner(privateKeyableMock, batchMock, TestConstants.EC_ADDRESS);
assertThat(signature).isEqualTo(fauxEd25519Signature);
- verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.ED_ADDRESS);
+ verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS, TestConstants.ED_ADDRESS);
verifyNoMoreInteractions(signatureUtilsMock);
}
@@ -416,10 +434,10 @@ void multiSignInnerEd25519() {
void multiSignInnerSecp256k1() {
keyType = KeyType.SECP256K1;
- Signature signature = transactionSigner.multiSignInner(privateKeyableMock, batchMock);
+ Signature signature = transactionSigner.multiSignInner(privateKeyableMock, batchMock, TestConstants.EC_ADDRESS);
assertThat(signature).isEqualTo(fauxSecp256k1Signature);
- verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS);
+ verify(signatureUtilsMock).toMultiSignableInnerBytes(batchMock, TestConstants.EC_ADDRESS, TestConstants.EC_ADDRESS);
verifyNoMoreInteractions(signatureUtilsMock);
}
diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionVerifierTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionVerifierTest.java
index aa48e6209..1fe7d0f24 100644
--- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionVerifierTest.java
+++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionVerifierTest.java
@@ -105,9 +105,9 @@ protected boolean ecDsaVerify(
};
}
- ///////////////////
+ // /////////////////
// verify (signatureWithKeyMetadata, transaction)
- ///////////////////
+ // /////////////////
@Test
void verifyWithNullMetadata() {
@@ -145,9 +145,9 @@ void verifySecp256k1() {
verifyNoMoreInteractions(signatureUtilsMock);
}
- ///////////////////
+ // /////////////////
// verify (signatureWithKeyMetadata, transaction)
- ///////////////////
+ // /////////////////
@Test
void verifyMultiWithNullSet() {
@@ -197,9 +197,9 @@ void verifyMultiSecp256k1() {
verifyNoMoreInteractions(signatureUtilsMock);
}
- ///////////////////
+ // /////////////////
// edDsaVerify
- ///////////////////
+ // /////////////////
@Test
void edDsaVerify() {
@@ -211,9 +211,9 @@ void edDsaVerify() {
verifyNoMoreInteractions(signatureUtilsMock);
}
- ///////////////////
+ // /////////////////
// ecDsaVerify
- ///////////////////
+ // /////////////////
@Test
void ecDsaVerify() {
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 89d4a96b7..3a2b22db8 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
@@ -436,19 +436,20 @@ void batchToSignableBytes() throws JsonProcessingException {
Payment payment2 = createPayment2();
Batch batch = createBatchTransaction(payment1, payment2);
+ Address batchSignerAddress = sourcePublicKey.deriveAddress();
- when(xrplBinaryCodecMock.encodeForBatchInnerSigning(any(Batch.class))).thenReturn(
+ when(xrplBinaryCodecMock.encodeForBatchInnerSigning(any(Batch.class), any(Address.class))).thenReturn(
UnsignedByteArray.fromHex("ABCD1234"));
when(xrplBinaryCodecMock.encodeForSigning(anyString())).thenReturn("4321DCBA");
- UnsignedByteArray innerBytes = signatureUtils.toSignableInnerBytes(batch);
+ UnsignedByteArray innerBytes = signatureUtils.toSignableInnerBytes(batch, batchSignerAddress);
assertThat(innerBytes.hexValue()).isEqualTo("ABCD1234");
UnsignedByteArray outerBytes = signatureUtils.toSignableBytes(batch);
assertThat(outerBytes.hexValue()).isEqualTo("4321DCBA");
- verify(xrplBinaryCodecMock).encodeForBatchInnerSigning(batch);
+ verify(xrplBinaryCodecMock).encodeForBatchInnerSigning(batch, batchSignerAddress);
verify(xrplBinaryCodecMock).encodeForSigning(anyString());
verifyNoMoreInteractions(xrplBinaryCodecMock);
verify(objectMapperMock).writeValueAsString(batch);
@@ -456,41 +457,56 @@ void batchToSignableBytes() throws JsonProcessingException {
}
@Test
- void batchToSignableBytesWithJsonException() throws JsonProcessingException {
+ void batchToSignableBytesWithRuntimeException() {
Payment payment1 = createPayment1();
Payment payment2 = createPayment2();
Batch batch = createBatchTransaction(payment1, payment2);
+ Address batchSignerAddress = sourcePublicKey.deriveAddress();
+ final Address nestedSignerAddress = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
- when(xrplBinaryCodecMock.encodeForBatchInnerSigning(any(Batch.class))).thenThrow(
- new JsonParseException(mock(JsonParser.class), "", mock(JsonLocation.class)));
- assertThrows(RuntimeException.class, () -> signatureUtils.toSignableInnerBytes(batch));
+ when(xrplBinaryCodecMock.encodeForBatchInnerSigning(any(Batch.class), any(Address.class))).thenThrow(
+ new RuntimeException("codec error"));
+ assertThrows(RuntimeException.class, () -> signatureUtils.toSignableInnerBytes(batch, batchSignerAddress));
- when(xrplBinaryCodecMock.encodeForBatchInnerMultiSigning(any(Batch.class), any(Address.class))).thenThrow(
- new JsonParseException(mock(JsonParser.class), "", mock(JsonLocation.class)));
+ when(xrplBinaryCodecMock.encodeForBatchInnerMultiSigning(any(Batch.class), any(Address.class), any(Address.class)))
+ .thenThrow(new RuntimeException("codec error"));
assertThrows(RuntimeException.class,
- () -> signatureUtils.toMultiSignableInnerBytes(batch, sourcePublicKey.deriveAddress()));
-
- when(xrplBinaryCodecMock.encodeForSigning(anyString())).thenThrow(
- new JsonParseException(mock(JsonParser.class), "", mock(JsonLocation.class)));
- assertThrows(RuntimeException.class, () -> signatureUtils.toSignableBytes(batch));
+ () -> signatureUtils.toMultiSignableInnerBytes(batch, batchSignerAddress, nestedSignerAddress));
}
@Test
void toSignableInnerBytesWithNullBatch() {
- assertThrows(NullPointerException.class, () -> signatureUtils.toSignableInnerBytes((Batch) null));
+ assertThrows(NullPointerException.class,
+ () -> signatureUtils.toSignableInnerBytes((Batch) null, sourcePublicKey.deriveAddress()));
+ }
+
+ @Test
+ void toSignableInnerBytesWithNullBatchSignerAddress() {
+ Batch batch = createBatchTransaction(createPayment1(), createPayment2());
+ assertThrows(NullPointerException.class, () -> signatureUtils.toSignableInnerBytes(batch, null));
}
@Test
void toMultiSignableInnerBytesWithNullBatch() {
+ Address nestedSignerAddress = sourcePublicKey.deriveAddress();
assertThrows(NullPointerException.class,
- () -> signatureUtils.toMultiSignableInnerBytes(null, sourcePublicKey.deriveAddress()));
+ () -> signatureUtils.toMultiSignableInnerBytes(null, sourcePublicKey.deriveAddress(), nestedSignerAddress));
}
@Test
- void toMultiSignableInnerBytesWithNullAddress() {
+ void toMultiSignableInnerBytesWithNullBatchSignerAddress() {
Batch batch = createBatchTransaction(createPayment1(), createPayment2());
- assertThrows(NullPointerException.class, () -> signatureUtils.toMultiSignableInnerBytes(batch, null));
+ Address nestedSignerAddress = sourcePublicKey.deriveAddress();
+ assertThrows(NullPointerException.class,
+ () -> signatureUtils.toMultiSignableInnerBytes(batch, null, nestedSignerAddress));
+ }
+
+ @Test
+ void toMultiSignableInnerBytesWithNullNestedSignerAddress() {
+ Batch batch = createBatchTransaction(createPayment1(), createPayment2());
+ assertThrows(NullPointerException.class,
+ () -> signatureUtils.toMultiSignableInnerBytes(batch, sourcePublicKey.deriveAddress(), null));
}
@Test
@@ -499,14 +515,18 @@ void batchToMultiSignableInnerBytes() throws JsonProcessingException {
Payment payment2 = createPayment2();
Batch batch = createBatchTransaction(payment1, payment2);
+ Address batchSignerAddress = sourcePublicKey.deriveAddress();
+ Address nestedSignerAddress = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
- when(xrplBinaryCodecMock.encodeForBatchInnerMultiSigning(any(Batch.class), any(Address.class))).thenReturn(
+ when(xrplBinaryCodecMock.encodeForBatchInnerMultiSigning(
+ any(Batch.class), any(Address.class), any(Address.class))).thenReturn(
UnsignedByteArray.fromHex("DEADBEEF"));
- UnsignedByteArray innerBytes = signatureUtils.toMultiSignableInnerBytes(batch, sourcePublicKey.deriveAddress());
+ UnsignedByteArray innerBytes = signatureUtils.toMultiSignableInnerBytes(batch, batchSignerAddress,
+ nestedSignerAddress);
assertThat(innerBytes.hexValue()).isEqualTo("DEADBEEF");
- verify(xrplBinaryCodecMock).encodeForBatchInnerMultiSigning(batch, sourcePublicKey.deriveAddress());
+ verify(xrplBinaryCodecMock).encodeForBatchInnerMultiSigning(batch, batchSignerAddress, nestedSignerAddress);
verifyNoMoreInteractions(xrplBinaryCodecMock);
}
@@ -534,7 +554,8 @@ void toMultiSignableBytesActual() {
@Test
void batchToSignableInnerBytesActual() {
Batch batch = createBatchTransaction(createPayment1(), createPayment2());
- UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch);
+ UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch,
+ sourcePublicKey.deriveAddress());
assertThat(bytes).isNotNull();
assertThat(bytes.hexValue()).isNotEmpty();
}
@@ -542,8 +563,10 @@ void batchToSignableInnerBytesActual() {
@Test
void batchToMultiSignableInnerBytesActual() {
Batch batch = createBatchTransaction(createPayment1(), createPayment2());
+ Address batchSignerAddress = sourcePublicKey.deriveAddress();
+ Address nestedSignerAddress = Address.of("rDgZZ3wyprx4ZqrGQUkquE9Fs2Xs8XBcdw");
UnsignedByteArray bytes = SignatureUtils.getInstance()
- .toMultiSignableInnerBytes(batch, sourcePublicKey.deriveAddress());
+ .toMultiSignableInnerBytes(batch, batchSignerAddress, nestedSignerAddress);
assertThat(bytes).isNotNull();
assertThat(bytes.hexValue()).isNotEmpty();
}
@@ -637,19 +660,22 @@ void batchToSignableBytesWithManyWellKnownInnerTransactions() {
}
Batch batch = batchBuilder.signingPublicKey(sourcePublicKey).build();
- UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch);
+ Address batchSignerAddress = sourcePublicKey.deriveAddress();
+ UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch, batchSignerAddress);
assertThat(bytes).isNotNull();
assertThat(bytes.hexValue()).isNotEmpty();
// Regression Guard: This value reflects current stable behavior captured from a known-good run. It ensures that
// future refactors do not inadvertently change the output of this established implementation.
+ // Note: the last 40 hex chars are the batchSignerAddress account ID (sourcePublicKey.deriveAddress()).
assertThat(bytes.hexValue()).isEqualTo(
- "4243480000010000000000086AE6957F40DE369AD007F1C7DCABE1B80E415857E317990E0116A9381649E91BC05EF8A5D" +
- "B6C5767176E74B2436782FE806CF27BA5889BE8885964EC53DC5EA4CA771E2F9EC90459E4FD192069270CF1CA111CECB2E576BC51" +
- "EA9BE8DEEF21F68877A84706E711E8929EE53008E7A684BB94FAF4038179375DF7DA2800465671E00067040F059E0ED0260A27350" +
- "5948A101188DD35DEABC476A7ED403B5DE587649A49BAAC5A101938AB3AF6546C0BBF65AA9EC33EF30E4EF9B5D116568077407E10" +
- "EFAC96EABBC2ED1A14759617E6881942EC8C137B4974B1D83E07B47C2EB82358840E4B6B237493029D7297933216EA16BC3EDEF3F" +
- "10478577A1BE98055BE");
+ "424348008C51B130E125AC3269E279D675691B09E57C7CED0000000600010000000000086AE6957F40DE369AD007F1C7D" +
+ "CABE1B80E415857E317990E0116A9381649E91BC05EF8A5DB6C5767176E74B2436782FE806CF27BA5889BE8885964EC53DC5EA4C" +
+ "A771E2F9EC90459E4FD192069270CF1CA111CECB2E576BC51EA9BE8DEEF21F68877A84706E711E8929EE53008E7A684BB94FAF40" +
+ "38179375DF7DA2800465671E00067040F059E0ED0260A273505948A101188DD35DEABC476A7ED403B5DE587649A49BAAC5A101938" +
+ "AB3AF6546C0BBF65AA9EC33EF30E4EF9B5D116568077407E10EFAC96EABBC2ED1A14759617E6881942EC8C137B4974B1D83E07B4" +
+ "7C2EB82358840E4B6B237493029D7297933216EA16BC3EDEF3F10478577A1BE98055BE8C51B130E1" +
+ "25AC3269E279D675691B09E57C7CED");
}
@Test
@@ -665,7 +691,8 @@ void batchToSignableBytesWithManyRandomInnerTransactions() {
}
Batch batch = batchBuilder.signingPublicKey(sourcePublicKey).build();
- UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch);
+ UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch,
+ sourcePublicKey.deriveAddress());
assertThat(bytes).isNotNull();
assertThat(bytes.hexValue()).isNotEmpty();
}
@@ -688,13 +715,16 @@ void batchToSignableBytesWithWellKnownMixedTransactionTypes() {
.addRawTransactions(RawTransactionWrapper.of(payment), RawTransactionWrapper.of(accountSet))
.signingPublicKey(sourcePublicKey).build();
- UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch);
+ UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch,
+ sourcePublicKey.deriveAddress());
assertThat(bytes).isNotNull();
assertThat(bytes.hexValue()).isNotEmpty();
+ // Note: the last 40 hex chars are the batchSignerAddress account ID (sourcePublicKey.deriveAddress()).
assertThat(bytes.hexValue()).isEqualTo(
- "4243480000010000000000022ECA1C69512AB1846736340F2622CF324660B09F3D6C1724B74326E917C7207F2E0010DA9D" +
- "B4F815BC8743C63D3B9BFC0A1E548B08A624B617A22B1B39112ABB");
+ "424348006AEFACB89787F7E3B6B3CB68FBDBF9F1658DE8E20000000600010000000000022ECA1C69512AB1846736340F2" +
+ "622CF324660B09F3D6C1724B74326E917C7207F2E0010DA9DB4F815BC8743C63D3B9BFC0A1E548B08A624B617A22B1B39112ABB" +
+ "8C51B130E125AC3269E279D675691B09E57C7CED");
}
@Test
@@ -708,7 +738,9 @@ void batchToSignableBytesWithRandomMixedTransactionTypes() {
.sequence(UnsignedInteger.valueOf(6)).flags(BatchFlags.ALL_OR_NOTHING)
.addRawTransactions(RawTransactionWrapper.of(payment), RawTransactionWrapper.of(accountSet))
.signingPublicKey(sourcePublicKey).build();
- UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(batch);
+ UnsignedByteArray bytes = SignatureUtils.getInstance().toSignableInnerBytes(
+ batch, sourcePublicKey.deriveAddress()
+ );
assertThat(bytes).isNotNull();
assertThat(bytes.hexValue()).isNotEmpty();
}
diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureServiceTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureServiceTest.java
index 96b8297ab..1d2d5ee5b 100644
--- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureServiceTest.java
+++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/crypto/signing/bc/BcDerivedKeySignatureServiceTest.java
@@ -797,11 +797,15 @@ void signInnerEd() {
final ExecutorService pool = Executors.newFixedThreadPool(5);
final Callable signedBatchCallable = () -> {
- Signature signature = this.derivedKeySignatureService.signInner(privateKeyReference, batchTransaction);
+ Signature signature = this.derivedKeySignatureService.signInner(
+ privateKeyReference, batchTransaction, publicKey.deriveAddress()
+ );
assertThat(signature).isNotNull();
assertThat(signature.base16Value()).isNotEmpty();
// Verify signature is deterministic
- Signature signature2 = this.derivedKeySignatureService.signInner(privateKeyReference, batchTransaction);
+ Signature signature2 = this.derivedKeySignatureService.signInner(
+ privateKeyReference, batchTransaction, publicKey.deriveAddress()
+ );
assertThat(signature.base16Value()).isEqualTo(signature2.base16Value());
return true;
};
@@ -831,11 +835,15 @@ void signInnerEc() {
final ExecutorService pool = Executors.newFixedThreadPool(5);
final Callable signedBatchCallable = () -> {
- Signature signature = this.derivedKeySignatureService.signInner(privateKeyReference, batchTransaction);
+ Signature signature = this.derivedKeySignatureService.signInner(
+ privateKeyReference, batchTransaction, publicKey.deriveAddress()
+ );
assertThat(signature).isNotNull();
assertThat(signature.base16Value()).isNotEmpty();
// Verify signature is deterministic for SECP256K1
- Signature signature2 = this.derivedKeySignatureService.signInner(privateKeyReference, batchTransaction);
+ Signature signature2 = this.derivedKeySignatureService.signInner(
+ privateKeyReference, batchTransaction, publicKey.deriveAddress()
+ );
assertThat(signature.base16Value()).isEqualTo(signature2.base16Value());
return true;
};
@@ -862,14 +870,19 @@ void multiSignInnerEd() {
final PublicKey publicKey = this.derivedKeySignatureService.derivePublicKey(privateKeyReference);
final Batch batchTransaction = createBatchTransaction(publicKey);
+ final Address batchSignerAddress = Address.of(sourceClassicAddressEd);
final ExecutorService pool = Executors.newFixedThreadPool(5);
final Callable signedBatchCallable = () -> {
- Signature signature = this.derivedKeySignatureService.multiSignInner(privateKeyReference, batchTransaction);
+ Signature signature = this.derivedKeySignatureService.multiSignInner(
+ privateKeyReference, batchTransaction, batchSignerAddress
+ );
assertThat(signature).isNotNull();
assertThat(signature.base16Value()).isNotEmpty();
// Verify signature is deterministic
- Signature signature2 = this.derivedKeySignatureService.multiSignInner(privateKeyReference, batchTransaction);
+ Signature signature2 = this.derivedKeySignatureService.multiSignInner(
+ privateKeyReference, batchTransaction, batchSignerAddress
+ );
assertThat(signature.base16Value()).isEqualTo(signature2.base16Value());
return true;
};
@@ -896,14 +909,19 @@ void multiSignInnerEc() {
final PublicKey publicKey = this.derivedKeySignatureService.derivePublicKey(privateKeyReference);
final Batch batchTransaction = createBatchTransaction(publicKey);
+ final Address batchSignerAddress = Address.of(sourceClassicAddressEc);
final ExecutorService pool = Executors.newFixedThreadPool(5);
final Callable signedBatchCallable = () -> {
- Signature signature = this.derivedKeySignatureService.multiSignInner(privateKeyReference, batchTransaction);
+ Signature signature = this.derivedKeySignatureService.multiSignInner(
+ privateKeyReference, batchTransaction, batchSignerAddress
+ );
assertThat(signature).isNotNull();
assertThat(signature.base16Value()).isNotEmpty();
// Verify signature is deterministic for SECP256K1
- Signature signature2 = this.derivedKeySignatureService.multiSignInner(privateKeyReference, batchTransaction);
+ Signature signature2 = this.derivedKeySignatureService.multiSignInner(
+ privateKeyReference, batchTransaction, batchSignerAddress
+ );
assertThat(signature.base16Value()).isEqualTo(signature2.base16Value());
return true;
};
diff --git a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/BatchTest.java b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/BatchTest.java
index d94eed4d1..158654fb3 100644
--- a/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/BatchTest.java
+++ b/xrpl4j-core/src/test/java/org/xrpl/xrpl4j/model/transactions/BatchTest.java
@@ -26,13 +26,16 @@
import com.google.common.collect.Lists;
import com.google.common.primitives.UnsignedInteger;
import org.junit.jupiter.api.Test;
+import org.xrpl.xrpl4j.codec.addresses.AddressCodec;
import org.xrpl.xrpl4j.crypto.keys.PublicKey;
import org.xrpl.xrpl4j.crypto.keys.Seed;
import org.xrpl.xrpl4j.crypto.signing.Signature;
import org.xrpl.xrpl4j.model.flags.BatchFlags;
+import org.xrpl.xrpl4j.model.flags.LoanSetFlags;
import org.xrpl.xrpl4j.model.flags.PaymentFlags;
import org.xrpl.xrpl4j.model.flags.TransactionFlags;
+import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -433,6 +436,128 @@ void testBatchWithMultipleAccountsAndAllBatchSigners() {
assertThat(batch.batchSigners()).hasSize(2);
}
+ @Test
+ void testBatchSignerRequiredIsDelegateNotAccount() {
+ // When an inner transaction has a Delegate, the delegate signs on the account holder's behalf, so the
+ // delegate (not the account) is the required BatchSigner.
+ Address innerAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ Address delegateAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ PublicKey delegateKey = Seed.ed25519Seed().deriveKeyPair().publicKey();
+
+ List transactions = Lists.newArrayList(
+ RawTransactionWrapper.of(createInnerPayment(innerAccount, delegateAccount, UnsignedInteger.ONE)),
+ RawTransactionWrapper.of(createInnerPayment(ACCOUNT, UnsignedInteger.valueOf(2)))
+ );
+
+ // Should succeed because BatchSigners contains a signature from the delegate.
+ Batch batch = Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(delegateAccount)
+ .signingPublicKey(delegateKey)
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()
+ )))
+ .build();
+
+ assertThat(batch.batchSigners()).hasSize(1);
+ }
+
+ @Test
+ void testBatchSignerFromAccountIsNotSufficientWhenDelegateIsSet() {
+ // A signature from the account holder itself does not satisfy the requirement when a Delegate is set;
+ // the delegate is the only account authorized to sign in that case.
+ Address innerAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ Address delegateAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ PublicKey innerKey = Seed.ed25519Seed().deriveKeyPair().publicKey();
+
+ List transactions = Lists.newArrayList(
+ RawTransactionWrapper.of(createInnerPayment(innerAccount, delegateAccount, UnsignedInteger.ONE)),
+ RawTransactionWrapper.of(createInnerPayment(ACCOUNT, UnsignedInteger.valueOf(2)))
+ );
+
+ // Should fail because BatchSigners contains a signature from the account holder, not the delegate.
+ assertThatThrownBy(() -> Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(innerAccount)
+ .signingPublicKey(innerKey)
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()
+ )))
+ .build()
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("BatchSigners must contain signatures from all accounts with inner transactions")
+ .hasMessageContaining(delegateAccount.value());
+ }
+
+ @Test
+ void testBatchSignerRequiredForLoanSetCounterparty() {
+ // A LoanSet inner transaction's Counterparty must also sign the Batch (in addition to the LoanSet Account).
+ Address innerAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ Address counterpartyAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ PublicKey innerKey = Seed.ed25519Seed().deriveKeyPair().publicKey();
+ PublicKey counterpartyKey = Seed.ed25519Seed().deriveKeyPair().publicKey();
+
+ List transactions = Lists.newArrayList(
+ RawTransactionWrapper.of(createInnerLoanSet(innerAccount, counterpartyAccount, UnsignedInteger.ONE)),
+ RawTransactionWrapper.of(createInnerPayment(ACCOUNT, UnsignedInteger.valueOf(2)))
+ );
+
+ // Should fail because BatchSigners is missing a signature from the Counterparty.
+ assertThatThrownBy(() -> Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(innerAccount)
+ .signingPublicKey(innerKey)
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()
+ )))
+ .build()
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("BatchSigners must contain signatures from all accounts with inner transactions")
+ .hasMessageContaining(counterpartyAccount.value());
+
+ // Should succeed once BatchSigners includes both the Account and the Counterparty.
+ Batch batch = Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(innerAccount)
+ .signingPublicKey(innerKey)
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()
+ ),
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(counterpartyAccount)
+ .signingPublicKey(counterpartyKey)
+ .transactionSignature(Signature.fromBase16("44556677"))
+ .build()
+ )))
+ .build();
+
+ assertThat(batch.batchSigners()).hasSize(2);
+ }
+
@Test
void testBatchWithOuterSignerAsOnlyInnerAccount() {
// Create inner transactions all from the outer signer account
@@ -652,23 +777,55 @@ void testBatchWithDuplicateTransactions() {
@Test
void testBatchWithTooManyBatchSigners() {
- // Create 2 inner transactions but 3 BatchSigners
- // Note: Having more BatchSigners than RawTransactions means at least one BatchSigner
- // has no inner transaction, so validateBatchSignersHaveInnerTransactions catches this
- // before validateBatchSignersSize. Both validations are logically equivalent in practice.
+ // V1_1: hard cap of 24 BatchSigners regardless of inner transaction count. Build 25 co-signer entries
+ // (all inner txs are from ACCOUNT, so no BatchSigners are strictly required) and verify the cap fires.
+ List tooManySigners = new ArrayList<>();
+ for (int i = 0; i < 25; i++) {
+ Address addr = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ PublicKey key = Seed.ed25519Seed().deriveKeyPair().publicKey();
+ tooManySigners.add(BatchSignerWrapper.of(BatchSigner.builder()
+ .account(addr)
+ .signingPublicKey(key)
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()));
+ }
+
+ assertThatThrownBy(() -> Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(createInnerTransactionsFromOuterSigner(2))
+ .batchSigners(tooManySigners)
+ .build()
+ ).isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("BatchSigners must not exceed 24 entries");
+ }
+
+ @Test
+ void testBatchSignersAreAutoSorted() {
+ // V1_1: BatchSigners must be in strictly ascending order by Account. xrpl4j auto-sorts them
+ // on construction so callers don't need to pre-sort.
Address innerAccount1 = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
Address innerAccount2 = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
- Address innerAccount3 = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
PublicKey pubKey1 = Seed.ed25519Seed().deriveKeyPair().publicKey();
PublicKey pubKey2 = Seed.ed25519Seed().deriveKeyPair().publicKey();
- PublicKey pubKey3 = Seed.ed25519Seed().deriveKeyPair().publicKey();
+
+ // Determine ascending order by AccountID bytes (same BigInteger comparison used in Batch.checkBatchSigners).
+ BigInteger id1 = new BigInteger(AddressCodec.getInstance().decodeAccountId(innerAccount1).hexValue(), 16);
+ BigInteger id2 = new BigInteger(AddressCodec.getInstance().decodeAccountId(innerAccount2).hexValue(), 16);
+ Address first = id1.compareTo(id2) < 0 ? innerAccount1 : innerAccount2;
+ Address second = first == innerAccount1 ? innerAccount2 : innerAccount1;
+ PublicKey firstKey = first == innerAccount1 ? pubKey1 : pubKey2;
+ PublicKey secondKey = first == innerAccount1 ? pubKey2 : pubKey1;
List transactions = Lists.newArrayList(
RawTransactionWrapper.of(createInnerPayment(innerAccount1, UnsignedInteger.ONE)),
RawTransactionWrapper.of(createInnerPayment(innerAccount2, UnsignedInteger.valueOf(2)))
);
- assertThatThrownBy(() -> Batch.builder()
+ // Intentionally provide signers in reverse (descending) order.
+ Batch batch = Batch.builder()
.account(ACCOUNT)
.fee(XrpCurrencyAmount.ofDrops(100))
.sequence(UnsignedInteger.ONE)
@@ -676,26 +833,163 @@ void testBatchWithTooManyBatchSigners() {
.rawTransactions(transactions)
.batchSigners(Lists.newArrayList(
BatchSignerWrapper.of(BatchSigner.builder()
- .account(innerAccount1)
- .signingPublicKey(pubKey1)
+ .account(second)
+ .signingPublicKey(secondKey)
+ .transactionSignature(Signature.fromBase16("44556677"))
+ .build()),
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(first)
+ .signingPublicKey(firstKey)
.transactionSignature(Signature.fromBase16("00112233"))
- .build()
- ),
+ .build())
+ ))
+ .build();
+
+ assertThat(batch.batchSigners()).hasSize(2);
+ assertThat(batch.batchSigners().get(0).batchSigner().account()).isEqualTo(first);
+ assertThat(batch.batchSigners().get(1).batchSigner().account()).isEqualTo(second);
+ }
+
+ @Test
+ void testBatchSignersSortOrderMatchesIndependentlyComputedOrder() {
+ // Regression guard against a subtly-wrong comparator (e.g. a reversed or non-transitive sort) in
+ // Batch#checkBatchSigners. The expected order below reuses the same AccountID decoding as production but
+ // compares the resulting hex strings directly (String#compareTo) rather than parsing them into a BigInteger,
+ // so this test does not just re-assert whatever the production comparator happens to compute. Note this does
+ // not independently verify that xrpl4j's canonical AccountID matches what rippled expects to sort by -- that
+ // is a concern for a batch-acceptance integration test, not this unit test.
+ List accounts = Lists.newArrayList(
+ Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress(),
+ Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress(),
+ Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress()
+ );
+
+ List expectedOrder = accounts.stream()
+ .sorted((addressA, addressB) -> {
+ String hexA = AddressCodec.getInstance().decodeAccountId(addressA).hexValue();
+ String hexB = AddressCodec.getInstance().decodeAccountId(addressB).hexValue();
+ // AccountID hex strings are a fixed 40 uppercase hex characters, so plain lexicographic String comparison
+ // is equivalent to a numeric comparison, without going through BigInteger parsing at all.
+ return hexA.compareTo(hexB);
+ })
+ .collect(Collectors.toList());
+
+ List transactions = accounts.stream()
+ .map(account -> RawTransactionWrapper.of(createInnerPayment(account, UnsignedInteger.ONE)))
+ .collect(Collectors.toList());
+
+ // Intentionally provide signers in an order unrelated to the expected sort order.
+ List unsortedSigners = accounts.stream()
+ .map(account -> BatchSignerWrapper.of(BatchSigner.builder()
+ .account(account)
+ .signingPublicKey(Seed.ed25519Seed().deriveKeyPair().publicKey())
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()))
+ .collect(Collectors.toList());
+
+ Batch batch = Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(unsortedSigners)
+ .build();
+
+ List actualOrder = batch.batchSigners().stream()
+ .map(wrapper -> wrapper.batchSigner().account())
+ .collect(Collectors.toList());
+
+ assertThat(actualOrder).isEqualTo(expectedOrder);
+ assertThat(batch.sortedBatchSigners()).isTrue();
+ }
+
+ @Test
+ void testSortedBatchSignersFlagIsTrustedNotVerified() {
+ // sortedBatchSigners is an internal, @JsonIgnore-marked bookkeeping flag used solely to prevent infinite
+ // recursion in checkBatchSigners's normalizing @Value.Check. Because the check only sorts when the flag is
+ // false, a caller that sets the flag to true directly via the builder can bypass sorting entirely. This
+ // documents that "sortedBatchSigners() == true" is only guaranteed to mean "actually sorted" when Batch is
+ // constructed the normal way (without ever calling builder().sortedBatchSigners(true) directly); it is not an
+ // invariant enforced by validation.
+ Address innerAccount1 = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ Address innerAccount2 = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+
+ BigInteger id1 = new BigInteger(AddressCodec.getInstance().decodeAccountId(innerAccount1).hexValue(), 16);
+ BigInteger id2 = new BigInteger(AddressCodec.getInstance().decodeAccountId(innerAccount2).hexValue(), 16);
+ Address first = id1.compareTo(id2) < 0 ? innerAccount1 : innerAccount2;
+ Address second = first == innerAccount1 ? innerAccount2 : innerAccount1;
+
+ List transactions = Lists.newArrayList(
+ RawTransactionWrapper.of(createInnerPayment(innerAccount1, UnsignedInteger.ONE)),
+ RawTransactionWrapper.of(createInnerPayment(innerAccount2, UnsignedInteger.valueOf(2)))
+ );
+
+ // Provide signers in reverse (descending, i.e. deliberately unsorted) order, but claim they are already sorted.
+ List unsortedSigners = Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(second)
+ .signingPublicKey(Seed.ed25519Seed().deriveKeyPair().publicKey())
+ .transactionSignature(Signature.fromBase16("44556677"))
+ .build()),
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(first)
+ .signingPublicKey(Seed.ed25519Seed().deriveKeyPair().publicKey())
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build())
+ );
+
+ Batch batch = Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(unsortedSigners)
+ .sortedBatchSigners(true)
+ .build();
+
+ // The flag is honored as-is and no sorting is performed, even though the data is not actually sorted.
+ assertThat(batch.sortedBatchSigners()).isTrue();
+ assertThat(batch.batchSigners().get(0).batchSigner().account()).isEqualTo(second);
+ assertThat(batch.batchSigners().get(1).batchSigner().account()).isEqualTo(first);
+ }
+
+ @Test
+ void testBatchSignersMayIncludeAccountWithoutInnerTransaction() {
+ // V1_1: a BatchSigner may be a co-signer or delegate for an inner transaction even if that
+ // account has no inner transaction of its own. Previously this was rejected; it is now allowed.
+ Address innerAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ Address coSignerAccount = Seed.ed25519Seed().deriveKeyPair().publicKey().deriveAddress();
+ PublicKey innerKey = Seed.ed25519Seed().deriveKeyPair().publicKey();
+ PublicKey coSignerKey = Seed.ed25519Seed().deriveKeyPair().publicKey();
+
+ List transactions = Lists.newArrayList(
+ RawTransactionWrapper.of(createInnerPayment(innerAccount, UnsignedInteger.ONE)),
+ RawTransactionWrapper.of(createInnerPayment(innerAccount, UnsignedInteger.valueOf(2)))
+ );
+
+ Batch batch = Batch.builder()
+ .account(ACCOUNT)
+ .fee(XrpCurrencyAmount.ofDrops(100))
+ .sequence(UnsignedInteger.ONE)
+ .flags(BatchFlags.ALL_OR_NOTHING)
+ .rawTransactions(transactions)
+ .batchSigners(Lists.newArrayList(
BatchSignerWrapper.of(BatchSigner.builder()
- .account(innerAccount2)
- .signingPublicKey(pubKey2)
- .transactionSignature(Signature.fromBase16("44556677"))
- .build()
- ),
+ .account(innerAccount)
+ .signingPublicKey(innerKey)
+ .transactionSignature(Signature.fromBase16("00112233"))
+ .build()),
BatchSignerWrapper.of(BatchSigner.builder()
- .account(innerAccount3)
- .signingPublicKey(pubKey3)
- .transactionSignature(Signature.fromBase16("8899AABB"))
- .build()
- )))
- .build()
- ).isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("BatchSigners must only contain signatures from accounts that have inner transactions");
+ .account(coSignerAccount)
+ .signingPublicKey(coSignerKey)
+ .transactionSignature(Signature.fromBase16("44556677"))
+ .build())
+ ))
+ .build();
+
+ assertThat(batch.batchSigners()).hasSize(2);
}
@Test
@@ -871,6 +1165,30 @@ private Payment createInnerPayment(Address account, UnsignedInteger sequence) {
.build();
}
+ private Payment createInnerPayment(Address account, Address delegate, UnsignedInteger sequence) {
+ return Payment.builder()
+ .account(account)
+ .delegate(delegate)
+ .destination(DESTINATION)
+ .fee(XrpCurrencyAmount.ofDrops(0))
+ .sequence(sequence)
+ .amount(XrpCurrencyAmount.ofDrops(1000))
+ .flags(PaymentFlags.INNER_BATCH_TXN)
+ .build();
+ }
+
+ private LoanSet createInnerLoanSet(Address account, Address counterparty, UnsignedInteger sequence) {
+ return LoanSet.builder()
+ .account(account)
+ .counterparty(counterparty)
+ .fee(XrpCurrencyAmount.ofDrops(0))
+ .sequence(sequence)
+ .flags(LoanSetFlags.of(TransactionFlags.INNER_BATCH_TXN.getValue()))
+ .loanBrokerId(Hash256.of("C031EFE677CDEF1C5F43475B374A16F990EE184F76015CB7548D34B500F72BFB"))
+ .principalRequested(Amount.of("1000000"))
+ .build();
+ }
+
private List createInnerTransactions(int count) {
return IntStream.range(0, count)
.mapToObj(i -> RawTransactionWrapper.of(
diff --git a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BatchTransactionIT.java b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BatchTransactionIT.java
index 2819270cc..2c7872c4b 100644
--- a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BatchTransactionIT.java
+++ b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BatchTransactionIT.java
@@ -27,7 +27,6 @@
import org.assertj.core.util.Lists;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledIf;
-import org.testcontainers.shaded.com.github.dockerjava.core.dockerfile.DockerfileStatement.Add;
import org.xrpl.xrpl4j.client.JsonRpcClientErrorException;
import org.xrpl.xrpl4j.crypto.keys.KeyPair;
import org.xrpl.xrpl4j.crypto.keys.PublicKey;
@@ -44,6 +43,7 @@
import org.xrpl.xrpl4j.model.flags.PaymentFlags;
import org.xrpl.xrpl4j.model.ledger.SignerEntry;
import org.xrpl.xrpl4j.model.ledger.SignerEntryWrapper;
+import org.xrpl.xrpl4j.model.ledger.TicketObject;
import org.xrpl.xrpl4j.model.transactions.Address;
import org.xrpl.xrpl4j.model.transactions.Batch;
import org.xrpl.xrpl4j.model.transactions.BatchSigner;
@@ -54,6 +54,7 @@
import org.xrpl.xrpl4j.model.transactions.Signer;
import org.xrpl.xrpl4j.model.transactions.SignerListSet;
import org.xrpl.xrpl4j.model.transactions.SignerWrapper;
+import org.xrpl.xrpl4j.model.transactions.TicketCreate;
import org.xrpl.xrpl4j.model.transactions.TransactionMetadata;
import org.xrpl.xrpl4j.model.transactions.TransactionResultCodes;
import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount;
@@ -255,7 +256,9 @@ private void batchWithInnerSingleSigOuterSingleSigDifferentHelper(
.account(account2KeyPair.publicKey().deriveAddress())
.signingPublicKey(account2KeyPair.publicKey())
.transactionSignature(
- signatureService.signInner(account2KeyPair.privateKey(), unsignedBatch) // <-- `signInner` is crucial here
+ signatureService.signInner(
+ account2KeyPair.privateKey(), unsignedBatch, account2KeyPair.publicKey().deriveAddress()
+ ) // <-- `signInner` is crucial here
)
.build()
)
@@ -528,13 +531,15 @@ private void batchWithInnerMultiSigOuterMultiSigDifferentHelper(
// Inner Multisign (account2Signer1 and account2Signer2 are the inner multi-signers)
// ///////////////
+ final Address account2BatchSignerAddress = account2KeyPair.publicKey().deriveAddress();
final List innerSignerWrappers = Lists.newArrayList(account2Signer1KeyPair, account2Signer2KeyPair)
.stream()
.map(keyPair -> {
final PublicKey signingPublicKey = signatureService.derivePublicKey(keyPair.privateKey());
final Signer signer = Signer.builder()
.signingPublicKey(signingPublicKey)
- .transactionSignature(signatureService.multiSignInner(keyPair.privateKey(), unsignedBatch))
+ .transactionSignature(
+ signatureService.multiSignInner(keyPair.privateKey(), unsignedBatch, account2BatchSignerAddress))
.build();
return SignerWrapper.of(signer);
})
@@ -715,7 +720,9 @@ private void batchWithInnerMultiSigOuterSingleSigHelper(
.account(account2KeyPair.publicKey().deriveAddress())
.signingPublicKey(account2KeyPair.publicKey())
.transactionSignature(
- signatureService.signInner(account2KeyPair.privateKey(), unsignedBatch) // <-- `signInner` is crucial here
+ signatureService.signInner(
+ account2KeyPair.privateKey(), unsignedBatch, account2KeyPair.publicKey().deriveAddress()
+ ) // <-- `signInner` is crucial here
)
.build()
)
@@ -895,7 +902,9 @@ private void batchWithInnerSingleSigOuterMultiSigHelper(
.account(account2KeyPair.publicKey().deriveAddress())
.signingPublicKey(account2KeyPair.publicKey())
.transactionSignature(
- signatureService.signInner(account2KeyPair.privateKey(), unsignedBatch) // <-- `signInner` is crucial here
+ signatureService.signInner(
+ account2KeyPair.privateKey(), unsignedBatch, account2KeyPair.publicKey().deriveAddress()
+ ) // <-- `signInner` is crucial here
)
.build()
)
@@ -1057,7 +1066,9 @@ private void batchWithTwoDifferentInnerSingleSigsPlusThirdOuterSingleSigHelper(
.account(innerSigner1KeyPair.publicKey().deriveAddress())
.signingPublicKey(innerSigner1KeyPair.publicKey())
.transactionSignature(
- signatureService.signInner(innerSigner1KeyPair.privateKey(), unsignedBatch) // <-- `signInner` is crucial here
+ signatureService.signInner(
+ innerSigner1KeyPair.privateKey(), unsignedBatch, innerSigner1KeyPair.publicKey().deriveAddress()
+ ) // <-- `signInner` is crucial here
)
.build()
),
@@ -1066,7 +1077,9 @@ private void batchWithTwoDifferentInnerSingleSigsPlusThirdOuterSingleSigHelper(
.account(innerSigner2KeyPair.publicKey().deriveAddress())
.signingPublicKey(innerSigner2KeyPair.publicKey())
.transactionSignature(
- signatureService.signInner(innerSigner2KeyPair.privateKey(), unsignedBatch) // <-- `signInner` is crucial here
+ signatureService.signInner(
+ innerSigner2KeyPair.privateKey(), unsignedBatch, innerSigner2KeyPair.publicKey().deriveAddress()
+ ) // <-- `signInner` is crucial here
)
.build()
)
@@ -1104,7 +1117,202 @@ private void batchWithTwoDifferentInnerSingleSigsPlusThirdOuterSingleSigHelper(
}
// //////////////////////
- // 8. This section tests a Batch transaction with an inner signer whose regular key has changed.
+ // 8. This section tests a Batch transaction with two separate multi-sig BatchSigner groups — one for each of two
+ // different inner transaction accounts. This specifically exercises encodeForBatchInnerMultiSigning with distinct
+ // batchSignerAddress + nestedSignerAddress pairs across two groups.
+ // //////////////////////
+
+ /**
+ * Test a batch transaction where two different multi-sig accounts each contribute inner transactions, requiring two
+ * separate multi-sig BatchSigner groups.
+ */
+ @Test
+ void batchWithTwoMultiSigBatchSignerGroups() throws JsonRpcClientErrorException, JsonProcessingException {
+ final KeyPair outerSignerKeyPair = createRandomAccountEd25519();
+ final KeyPair account1KeyPair = createRandomAccountEd25519();
+ final KeyPair account1Signer1KeyPair = createRandomAccountEd25519();
+ final KeyPair account1Signer2KeyPair = createRandomAccountEd25519();
+ final KeyPair account2KeyPair = createRandomAccountEd25519();
+ final KeyPair account2Signer1KeyPair = createRandomAccountEd25519();
+ final KeyPair account2Signer2KeyPair = createRandomAccountEd25519();
+ final KeyPair destinationKeyPair = createRandomAccountEd25519();
+
+ batchWithTwoMultiSigBatchSignerGroupsHelper(
+ outerSignerKeyPair,
+ account1KeyPair, account1Signer1KeyPair, account1Signer2KeyPair,
+ account2KeyPair, account2Signer1KeyPair, account2Signer2KeyPair,
+ destinationKeyPair, BatchFlags.ofAllOrNothing()
+ );
+ batchWithTwoMultiSigBatchSignerGroupsHelper(
+ outerSignerKeyPair,
+ account1KeyPair, account1Signer1KeyPair, account1Signer2KeyPair,
+ account2KeyPair, account2Signer1KeyPair, account2Signer2KeyPair,
+ destinationKeyPair, BatchFlags.ofOnlyOne()
+ );
+ batchWithTwoMultiSigBatchSignerGroupsHelper(
+ outerSignerKeyPair,
+ account1KeyPair, account1Signer1KeyPair, account1Signer2KeyPair,
+ account2KeyPair, account2Signer1KeyPair, account2Signer2KeyPair,
+ destinationKeyPair, BatchFlags.ofUntilFailure()
+ );
+ batchWithTwoMultiSigBatchSignerGroupsHelper(
+ outerSignerKeyPair,
+ account1KeyPair, account1Signer1KeyPair, account1Signer2KeyPair,
+ account2KeyPair, account2Signer1KeyPair, account2Signer2KeyPair,
+ destinationKeyPair, BatchFlags.ofIndependent()
+ );
+ }
+
+ /**
+ * Helper for a batch transaction where two different multi-sig accounts each contribute inner transactions with two
+ * separate multi-sig BatchSigner groups.
+ */
+ private void batchWithTwoMultiSigBatchSignerGroupsHelper(
+ final KeyPair outerSignerKeyPair,
+ final KeyPair account1KeyPair,
+ final KeyPair account1Signer1KeyPair,
+ final KeyPair account1Signer2KeyPair,
+ final KeyPair account2KeyPair,
+ final KeyPair account2Signer1KeyPair,
+ final KeyPair account2Signer2KeyPair,
+ final KeyPair destinationKeyPair,
+ final BatchFlags batchFlags
+ ) throws JsonRpcClientErrorException, JsonProcessingException {
+ Objects.requireNonNull(outerSignerKeyPair);
+ Objects.requireNonNull(account1KeyPair);
+ Objects.requireNonNull(account1Signer1KeyPair);
+ Objects.requireNonNull(account1Signer2KeyPair);
+ Objects.requireNonNull(account2KeyPair);
+ Objects.requireNonNull(account2Signer1KeyPair);
+ Objects.requireNonNull(account2Signer2KeyPair);
+ Objects.requireNonNull(destinationKeyPair);
+ Objects.requireNonNull(batchFlags);
+
+ final FeeResult feeResult = xrplClient.fee();
+
+ final AccountInfoResult account1Result = setupMultiSigAccount(
+ account1KeyPair, account1Signer1KeyPair, account1Signer2KeyPair
+ );
+ final AccountInfoResult account2Result = setupMultiSigAccount(
+ account2KeyPair, account2Signer1KeyPair, account2Signer2KeyPair
+ );
+ final AccountInfoResult outerSignerInfo = this.scanForResult(
+ () -> this.getValidatedAccountInfo(outerSignerKeyPair.publicKey().deriveAddress())
+ );
+
+ // Create inner payment from account1
+ final Payment innerPayment1 = createInnerPayment(
+ account1KeyPair.publicKey().deriveAddress(),
+ account1Result.accountData().sequence(),
+ destinationKeyPair.publicKey().deriveAddress(),
+ 10000
+ );
+ // Create inner payment from account2
+ final Payment innerPayment2 = createInnerPayment(
+ account2KeyPair.publicKey().deriveAddress(),
+ account2Result.accountData().sequence(),
+ destinationKeyPair.publicKey().deriveAddress(),
+ 20000
+ );
+
+ // Build the Batch transaction - outerSigner is the batch submitter (no inner transactions)
+ final Batch unsignedBatch = Batch.builder()
+ .account(outerSignerKeyPair.publicKey().deriveAddress())
+ .signingPublicKey(outerSignerKeyPair.publicKey())
+ .fee(FeeUtils.computeBatchFee(feeResult, UnsignedInteger.valueOf(2L)))
+ .sequence(outerSignerInfo.accountData().sequence())
+ .flags(batchFlags)
+ .addRawTransactions(
+ RawTransactionWrapper.of(innerPayment1),
+ RawTransactionWrapper.of(innerPayment2)
+ )
+ .build();
+
+ // ///////////////
+ // Inner Multisign - BatchSigner Group 1 (account1Signer1 and account1Signer2 sign for account1)
+ // ///////////////
+
+ final Address account1BatchSignerAddress = account1KeyPair.publicKey().deriveAddress();
+ final List account1InnerSignerWrappers = Lists.newArrayList(
+ account1Signer1KeyPair, account1Signer2KeyPair
+ ).stream()
+ .map(keyPair -> {
+ final PublicKey signingPublicKey = signatureService.derivePublicKey(keyPair.privateKey());
+ final Signer signer = Signer.builder()
+ .signingPublicKey(signingPublicKey)
+ .transactionSignature(
+ signatureService.multiSignInner(keyPair.privateKey(), unsignedBatch, account1BatchSignerAddress)
+ )
+ .build();
+ return SignerWrapper.of(signer);
+ })
+ .collect(Collectors.toList());
+
+ // ///////////////
+ // Inner Multisign - BatchSigner Group 2 (account2Signer1 and account2Signer2 sign for account2)
+ // ///////////////
+
+ final Address account2BatchSignerAddress = account2KeyPair.publicKey().deriveAddress();
+ final List account2InnerSignerWrappers = Lists.newArrayList(
+ account2Signer1KeyPair, account2Signer2KeyPair
+ ).stream()
+ .map(keyPair -> {
+ final PublicKey signingPublicKey = signatureService.derivePublicKey(keyPair.privateKey());
+ final Signer signer = Signer.builder()
+ .signingPublicKey(signingPublicKey)
+ .transactionSignature(
+ signatureService.multiSignInner(keyPair.privateKey(), unsignedBatch, account2BatchSignerAddress)
+ )
+ .build();
+ return SignerWrapper.of(signer);
+ })
+ .collect(Collectors.toList());
+
+ // Build the final batch with both BatchSigner groups
+ final Batch batchWithBatchSigners = Batch.builder()
+ .from(unsignedBatch)
+ .batchSigners(Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(account1BatchSignerAddress)
+ .signers(account1InnerSignerWrappers)
+ .build()
+ ),
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(account2BatchSignerAddress)
+ .signers(account2InnerSignerWrappers)
+ .build()
+ )
+ ))
+ .build();
+
+ // ///////////////
+ // Outer Sign (outerSigner single-signs the full batch with both BatchSigner groups)
+ // ///////////////
+
+ final SingleSignedTransaction signedBatch = signatureService.sign(
+ outerSignerKeyPair.privateKey(), batchWithBatchSigners
+ );
+
+ // Submit and wait for validation
+ final SubmitResult result = xrplClient.submit(signedBatch);
+ assertTesSuccess(result);
+ final TransactionResult validatedBatch = this.scanForResult(
+ () -> this.getValidatedTransaction(result.transactionResult().hash(), Batch.class)
+ );
+ assertTesSuccess(validatedBatch);
+
+ // Verify metadata
+ verifyBatchMetadata(validatedBatch);
+
+ // Verify the destination account received both payments
+ final AccountInfoResult destInfo = this.scanForResult(
+ () -> this.getValidatedAccountInfo(destinationKeyPair.publicKey().deriveAddress())
+ );
+ assertThat(destInfo.accountData().balance()).isNotNull();
+ }
+
+ // //////////////////////
+ // 9. This section tests a Batch transaction with an inner signer whose regular key has changed.
// //////////////////////
@Test
@@ -1172,13 +1380,16 @@ void batchWithRegularKeyChange() throws JsonRpcClientErrorException, JsonProcess
.addRawTransactions(RawTransactionWrapper.of(innerPayment1), RawTransactionWrapper.of(innerPayment2))
.build();
- // Sign inner with regular key - BatchSigner.account will be derived from regular key
+ // Sign inner with regular key - BatchSigner.account is account1's real (master-key) address, even though the
+ // signature itself is produced using the regular key's private key.
List signerWrappers = Lists.newArrayList(
BatchSignerWrapper.of(BatchSigner.builder()
.account(account1KeyPair.publicKey().deriveAddress()) // Must specify real XRP Address via account1KeyPair
.signingPublicKey(account1RegularKeyPair.publicKey()) // <-- Must specify regular public key.
.transactionSignature(
- signatureService.signInner(account1RegularKeyPair.privateKey(), unsignedBatch)
+ signatureService.signInner(
+ account1RegularKeyPair.privateKey(), unsignedBatch, account1KeyPair.publicKey().deriveAddress()
+ )
)
.build()
)
@@ -1218,6 +1429,250 @@ void batchWithRegularKeyChange() throws JsonRpcClientErrorException, JsonProcess
verifyBatchMetadata(validatedBatch);
}
+ // //////////////////////
+ // 10. This section tests the interplay between Batch transactions and Tickets (XLS-0056 V1_1, see
+ // https://github.com/XRPLF/XRPL-Standards/pull/563/changes). Sequence and TicketSequence are mutually exclusive
+ // on both the outer Batch transaction and each inner transaction (temSEQ_AND_TICKET). Also, when the outer
+ // transaction's Sequence is 0 (i.e. it was submitted via a Ticket), the BatchSigner signing payload must bind to
+ // the outer transaction's TicketSequence instead (see XrplBinaryCodec#buildBatchSigningPayload).
+ // //////////////////////
+
+ /**
+ * Test a single-account batch transaction where the outer Batch transaction itself is submitted using a Ticket
+ * (TicketSequence) instead of a Sequence number.
+ */
+ @Test
+ void batchWithOuterTicketSequence() throws JsonRpcClientErrorException, JsonProcessingException {
+ final KeyPair sourceKeyPair = createRandomAccountEd25519();
+ final KeyPair destination1KeyPair = createRandomAccountEd25519();
+ final KeyPair destination2KeyPair = createRandomAccountEd25519();
+
+ final FeeResult feeResult = xrplClient.fee();
+ final UnsignedInteger ticketSequence = createTicket(sourceKeyPair, feeResult);
+
+ final AccountInfoResult sourceAccountInfo = this.scanForResult(
+ () -> this.getValidatedAccountInfo(sourceKeyPair.publicKey().deriveAddress())
+ );
+
+ // The outer Batch tx doesn't consume a Sequence (it uses a Ticket instead), so the first inner payment can use
+ // the account's current (next-available) Sequence.
+ final Payment innerPayment1 = createInnerPayment(
+ sourceKeyPair.publicKey().deriveAddress(),
+ sourceAccountInfo.accountData().sequence(),
+ destination1KeyPair.publicKey().deriveAddress(),
+ 10000
+ );
+ final Payment innerPayment2 = createInnerPayment(
+ sourceKeyPair.publicKey().deriveAddress(),
+ sourceAccountInfo.accountData().sequence().plus(UnsignedInteger.ONE),
+ destination2KeyPair.publicKey().deriveAddress(),
+ 20000
+ );
+
+ // Build the Batch transaction, submitted via a Ticket instead of a Sequence (Sequence defaults to 0).
+ final Batch unsignedBatch = Batch.builder()
+ .account(sourceKeyPair.publicKey().deriveAddress())
+ .signingPublicKey(sourceKeyPair.publicKey())
+ .fee(FeeUtils.computeBatchFee(feeResult, UnsignedInteger.valueOf(2L)))
+ .ticketSequence(ticketSequence) // <-- outer Batch tx uses a Ticket, not a Sequence
+ .flags(BatchFlags.ofAllOrNothing())
+ .addRawTransactions(
+ RawTransactionWrapper.of(innerPayment1),
+ RawTransactionWrapper.of(innerPayment2)
+ )
+ .build();
+
+ assertThat(unsignedBatch.sequence()).isEqualTo(UnsignedInteger.ZERO);
+
+ // Only outer needs to be signed because the same account authorizes both inner transactions.
+ final SingleSignedTransaction signedBatch = signatureService.sign(
+ sourceKeyPair.privateKey(), unsignedBatch
+ );
+ final SubmitResult submitResult = xrplClient.submit(signedBatch);
+ assertTesSuccess(submitResult);
+
+ final TransactionResult validatedBatch = this.scanForResult(
+ () -> this.getValidatedTransaction(submitResult.transactionResult().hash(), Batch.class)
+ );
+ assertTesSuccess(validatedBatch);
+ assertThat(validatedBatch.transaction().ticketSequence()).hasValue(ticketSequence);
+ assertThat(validatedBatch.transaction().sequence()).isEqualTo(UnsignedInteger.ZERO);
+
+ verifyBatchMetadata(validatedBatch);
+
+ final AccountInfoResult dest1Info = this.scanForResult(
+ () -> this.getValidatedAccountInfo(destination1KeyPair.publicKey().deriveAddress())
+ );
+ final AccountInfoResult dest2Info = this.scanForResult(
+ () -> this.getValidatedAccountInfo(destination2KeyPair.publicKey().deriveAddress())
+ );
+ assertThat(dest1Info.accountData().balance()).isNotNull();
+ assertThat(dest2Info.accountData().balance()).isNotNull();
+ }
+
+ /**
+ * Test a multi-account batch transaction where the outer Batch transaction is submitted via a Ticket (so its Sequence
+ * is 0), and a second account contributes an inner transaction authorized via a {@link BatchSignerWrapper}. This
+ * specifically exercises the requirement that the BatchSigner's signing payload binds to the outer transaction's
+ * TicketSequence (instead of its Sequence, which is 0) — see {@code XrplBinaryCodec#buildBatchSigningPayload}.
+ */
+ @Test
+ void batchWithOuterTicketSequenceAndBatchSigner() throws JsonRpcClientErrorException, JsonProcessingException {
+ final KeyPair account1KeyPair = createRandomAccountEd25519();
+ final KeyPair account2KeyPair = createRandomAccountEd25519();
+ final KeyPair destinationKeyPair = createRandomAccountEd25519();
+
+ final FeeResult feeResult = xrplClient.fee();
+ final UnsignedInteger account1TicketSequence = createTicket(account1KeyPair, feeResult);
+
+ final AccountInfoResult account1Info = this.scanForResult(
+ () -> this.getValidatedAccountInfo(account1KeyPair.publicKey().deriveAddress())
+ );
+ final AccountInfoResult account2Info = this.scanForResult(
+ () -> this.getValidatedAccountInfo(account2KeyPair.publicKey().deriveAddress())
+ );
+
+ // Inner payment from account1 (the batch submitter), which still uses a normal Sequence.
+ final Payment innerPayment1 = createInnerPayment(
+ account1KeyPair.publicKey().deriveAddress(),
+ account1Info.accountData().sequence(),
+ destinationKeyPair.publicKey().deriveAddress(),
+ 10000
+ );
+ // Inner payment from account2, authorized via a BatchSigner.
+ final Payment innerPayment2 = createInnerPayment(
+ account2KeyPair.publicKey().deriveAddress(),
+ account2Info.accountData().sequence(),
+ destinationKeyPair.publicKey().deriveAddress(),
+ 20000
+ );
+
+ // Build the Batch transaction - account1 is the batch submitter, using a Ticket instead of a Sequence.
+ final Batch unsignedBatch = Batch.builder()
+ .account(account1KeyPair.publicKey().deriveAddress())
+ .signingPublicKey(account1KeyPair.publicKey())
+ .fee(FeeUtils.computeBatchFee(feeResult, UnsignedInteger.valueOf(2L)))
+ .ticketSequence(account1TicketSequence) // <-- outer Batch tx uses a Ticket, not a Sequence
+ .flags(BatchFlags.ofAllOrNothing())
+ .addRawTransactions(
+ RawTransactionWrapper.of(innerPayment1),
+ RawTransactionWrapper.of(innerPayment2)
+ )
+ .build();
+
+ // ///////////////
+ // Inner Sign (account2 is the inner signer). Because account1's Sequence is 0 on this outer transaction, the
+ // signing payload must bind to account1's TicketSequence instead.
+ // ///////////////
+ final List signerWrappers = Lists.newArrayList(
+ BatchSignerWrapper.of(BatchSigner.builder()
+ .account(account2KeyPair.publicKey().deriveAddress())
+ .signingPublicKey(account2KeyPair.publicKey())
+ .transactionSignature(
+ signatureService.signInner(
+ account2KeyPair.privateKey(), unsignedBatch, account2KeyPair.publicKey().deriveAddress()
+ )
+ )
+ .build()
+ )
+ );
+
+ // ///////////////
+ // Outer Sign (account1 is the batch submitter)
+ // ///////////////
+ final SingleSignedTransaction signedBatch = signatureService.sign(
+ account1KeyPair.privateKey(),
+ Batch.builder().from(unsignedBatch).batchSigners(signerWrappers).build()
+ );
+
+ final SubmitResult result = xrplClient.submit(signedBatch);
+ assertTesSuccess(result);
+ final TransactionResult validatedBatch = this.scanForResult(
+ () -> this.getValidatedTransaction(result.transactionResult().hash(), Batch.class)
+ );
+ assertTesSuccess(validatedBatch);
+ assertThat(validatedBatch.transaction().ticketSequence()).hasValue(account1TicketSequence);
+ assertThat(validatedBatch.transaction().sequence()).isEqualTo(UnsignedInteger.ZERO);
+
+ verifyBatchMetadata(validatedBatch);
+
+ final AccountInfoResult destInfo = this.scanForResult(
+ () -> this.getValidatedAccountInfo(destinationKeyPair.publicKey().deriveAddress())
+ );
+ assertThat(destInfo.accountData().balance()).isNotNull();
+ }
+
+ /**
+ * Test a single-account batch transaction where one inner {@link Payment} is authorized via a Ticket (TicketSequence)
+ * instead of a Sequence number, alongside a second inner {@link Payment} that uses a normal Sequence.
+ */
+ @Test
+ void batchWithInnerTicketSequence() throws JsonRpcClientErrorException, JsonProcessingException {
+ final KeyPair sourceKeyPair = createRandomAccountEd25519();
+ final KeyPair destination1KeyPair = createRandomAccountEd25519();
+ final KeyPair destination2KeyPair = createRandomAccountEd25519();
+
+ final FeeResult feeResult = xrplClient.fee();
+ final UnsignedInteger ticketSequence = createTicket(sourceKeyPair, feeResult);
+
+ final AccountInfoResult sourceAccountInfo = this.scanForResult(
+ () -> this.getValidatedAccountInfo(sourceKeyPair.publicKey().deriveAddress())
+ );
+
+ // Inner payment1 is authorized via a Ticket instead of a Sequence.
+ final Payment innerPayment1 = Payment.builder()
+ .account(sourceKeyPair.publicKey().deriveAddress())
+ .fee(XrpCurrencyAmount.ofDrops(0))
+ .sequence(UnsignedInteger.ZERO)
+ .ticketSequence(ticketSequence) // <-- inner Payment tx uses a Ticket, not a Sequence
+ .destination(destination1KeyPair.publicKey().deriveAddress())
+ .amount(XrpCurrencyAmount.ofDrops(10000))
+ .flags(PaymentFlags.INNER_BATCH_TXN)
+ .build();
+
+ // Inner payment2 uses a normal Sequence. The outer Batch tx below consumes the account's current Sequence, so
+ // this inner payment must use the next one (the Ticket used by innerPayment1 doesn't consume a Sequence).
+ final Payment innerPayment2 = createInnerPayment(
+ sourceKeyPair.publicKey().deriveAddress(),
+ sourceAccountInfo.accountData().sequence().plus(UnsignedInteger.ONE),
+ destination2KeyPair.publicKey().deriveAddress(),
+ 20000
+ );
+
+ // Build the Batch transaction (outer tx uses a normal Sequence).
+ final Batch batch = Batch.builder()
+ .account(sourceKeyPair.publicKey().deriveAddress())
+ .signingPublicKey(sourceKeyPair.publicKey())
+ .fee(FeeUtils.computeBatchFee(feeResult, UnsignedInteger.valueOf(2L)))
+ .sequence(sourceAccountInfo.accountData().sequence())
+ .flags(BatchFlags.ofAllOrNothing())
+ .addRawTransactions(
+ RawTransactionWrapper.of(innerPayment1),
+ RawTransactionWrapper.of(innerPayment2)
+ )
+ .build();
+
+ // Only outer needs to be signed because the same account authorizes both inner transactions.
+ final SingleSignedTransaction signedBatch = signatureService.sign(sourceKeyPair.privateKey(), batch);
+ final SubmitResult submitResult = xrplClient.submit(signedBatch);
+ assertTesSuccess(submitResult);
+
+ final TransactionResult validatedBatch = this.scanForResult(
+ () -> this.getValidatedTransaction(submitResult.transactionResult().hash(), Batch.class)
+ );
+ assertTesSuccess(validatedBatch);
+ verifyBatchMetadata(validatedBatch);
+
+ final AccountInfoResult dest1Info = this.scanForResult(
+ () -> this.getValidatedAccountInfo(destination1KeyPair.publicKey().deriveAddress())
+ );
+ final AccountInfoResult dest2Info = this.scanForResult(
+ () -> this.getValidatedAccountInfo(destination2KeyPair.publicKey().deriveAddress())
+ );
+ assertThat(dest1Info.accountData().balance()).isNotNull();
+ assertThat(dest2Info.accountData().balance()).isNotNull();
+ }
+
// //////////////////////
// Helper methods to reduce code duplication
// //////////////////////
@@ -1276,6 +1731,48 @@ private AccountInfoResult setupMultiSigAccount(
);
}
+ /**
+ * Helper method to create a single Ticket for the given account and return the resulting Ticket's sequence number.
+ *
+ * @param keyPair The {@link KeyPair} of the account that will own the Ticket.
+ * @param feeResult The {@link FeeResult} used to determine the appropriate transaction fee for the TicketCreate.
+ *
+ * @return The {@link UnsignedInteger} ticket sequence number of the newly-created Ticket.
+ */
+ private UnsignedInteger createTicket(
+ final KeyPair keyPair,
+ final FeeResult feeResult
+ ) throws JsonRpcClientErrorException, JsonProcessingException {
+ final AccountInfoResult accountInfo = this.scanForResult(
+ () -> this.getValidatedAccountInfo(keyPair.publicKey().deriveAddress())
+ );
+
+ final TicketCreate ticketCreate = TicketCreate.builder()
+ .account(keyPair.publicKey().deriveAddress())
+ .sequence(accountInfo.accountData().sequence())
+ .fee(FeeUtils.computeNetworkFees(feeResult).recommendedFee())
+ .ticketCount(UnsignedInteger.ONE)
+ .signingPublicKey(keyPair.publicKey())
+ .build();
+
+ final SingleSignedTransaction signedTicketCreate = signatureService.sign(
+ keyPair.privateKey(), ticketCreate
+ );
+ final SubmitResult ticketCreateResult = xrplClient.submit(signedTicketCreate);
+ assertThat(ticketCreateResult.engineResult()).isEqualTo(TransactionResultCodes.TES_SUCCESS);
+
+ this.scanForResult(
+ () -> this.getValidatedTransaction(ticketCreateResult.transactionResult().hash(), TicketCreate.class)
+ );
+
+ final List tickets = this.scanForResult(
+ () -> this.getValidatedAccountObjects(keyPair.publicKey().deriveAddress(), TicketObject.class),
+ result -> !result.isEmpty()
+ );
+
+ return tickets.get(0).ticketSequence();
+ }
+
/**
* Helper method to create an inner payment for a batch transaction.
*
diff --git a/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg b/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg
index 5e07a723c..7d09d4dee 100644
--- a/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg
+++ b/xrpl4j-integration-tests/src/test/resources/xrpld/xrpld.cfg
@@ -228,7 +228,6 @@ PermissionedDomains
fixInvalidTxFlags
fixFrozenLPTokenTransfer
# 2.5.0 Amendments
-Batch
PermissionedDEX
PermissionDelegationV1_1
TokenEscrow
@@ -248,5 +247,7 @@ LendingProtocol
fixCleanup3_1_3
# 3.2.0 Amendments
fixCleanup3_2_0
+# 3.3.0 Amendments
+BatchV1_1
# TBD Version
DynamicMPT