diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodec.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodec.java index aa7a5d1fc..adc3398a8 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodec.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodec.java @@ -144,29 +144,23 @@ public String encodeForMultiSigning(String json, String xrpAccountId) throws Jso * JSON and then checks for JsonNode values, this implementation instead accepts a well-typed Java object and operates * on that, for safety and correctness. * - * @param batch A {@link Batch} containing JSON to be encoded. + *

Per XLS-0056 V1_1, the payload is: {@code HashPrefix::Batch} + outer {@code Account} + sequence + {@code Flags} + * + count + inner tx IDs, followed by the {@code batchSignerAddress} as the per-signer suffix.

* - * @return hex encoded representations + * @param batch A {@link Batch} to be encoded. + * @param batchSignerAddress The {@link Address} of the BatchSigner entry (appended as a per-signer suffix). * - * @throws JsonProcessingException if JSON is not valid. + * @return An {@link UnsignedByteArray} with the signable bytes. */ - public UnsignedByteArray encodeForBatchInnerSigning(Batch batch) throws JsonProcessingException { + public UnsignedByteArray encodeForBatchInnerSigning(Batch batch, Address batchSignerAddress) { Objects.requireNonNull(batch); + Objects.requireNonNull(batchSignerAddress); try { - // Start with batch prefix (0x42434800 = "BCH\0") - UnsignedByteArray signableBytes = UnsignedByteArray.fromHex(XrplBinaryCodec.BATCH_SIGNATURE_PREFIX); - - // Add flags (4 bytes, big-endian) - HashingUtils.addUInt32(signableBytes, (int) batch.flags().getValue()); - - // Add count of inner transactions (4 bytes, big-endian) - HashingUtils.addUInt32(signableBytes, batch.rawTransactions().size()); + UnsignedByteArray signableBytes = buildBatchSigningPayload(batch); - // Add each inner transaction ID (32 bytes each) - for (RawTransactionWrapper wrapper : batch.rawTransactions()) { - final UnsignedByteArray transactionId = computeInnerBatchTransactionId(wrapper); - signableBytes.append(transactionId); - } + // Append BatchSigner's own account ID as per-signer suffix (V1_1 single-sign suffix) + String signerAccountIdHex = new AccountIdType().fromJson(new TextNode(batchSignerAddress.value())).toHex(); + signableBytes.append(UnsignedByteArray.fromHex(signerAccountIdHex)); return signableBytes; } catch (JsonProcessingException e) { @@ -180,32 +174,70 @@ public UnsignedByteArray encodeForBatchInnerSigning(Batch batch) throws JsonProc /** * Encode a {@link Batch} for multi-signing by 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 (serializeBatch) - * followed by appending the signer's account ID (finishMultiSigningData). - * - * @param batch The {@link Batch} to encode. - * @param signerAddress The address of the signer (will be appended as account ID suffix). + * 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} (i.e. + * {@code finishMultiSigningData(batchSignerAddress, nestedSignerAddress)}). * - * @return An {@link UnsignedByteArray} containing the batch serialization with account ID suffix. + * @param batch The {@link Batch} to encode. + * @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. * - * @throws JsonProcessingException if there is an error processing the JSON. + * @return An {@link UnsignedByteArray} containing the batch serialization with both account ID suffixes. */ - public UnsignedByteArray encodeForBatchInnerMultiSigning(Batch batch, Address signerAddress) - throws JsonProcessingException { + public UnsignedByteArray encodeForBatchInnerMultiSigning( + final Batch batch, final Address batchSignerAddress, final Address nestedSignerAddress + ) { Objects.requireNonNull(batch); - Objects.requireNonNull(signerAddress); + Objects.requireNonNull(batchSignerAddress); + Objects.requireNonNull(nestedSignerAddress); + try { + UnsignedByteArray result = buildBatchSigningPayload(batch); - // Start with batch serialization (HashPrefix::batch + flags + count + tx IDs) - UnsignedByteArray batchBytes = encodeForBatchInnerSigning(batch); + // Append batchSignerAddress + nestedSignerAddress (finishMultiSigningData per V1_1) + String batchSignerIdHex = new AccountIdType().fromJson(new TextNode(batchSignerAddress.value())).toHex(); + result.append(UnsignedByteArray.fromHex(batchSignerIdHex)); - // Create a copy to avoid mutating the original (since UnsignedByteArray.append() mutates) - UnsignedByteArray result = UnsignedByteArray.of(batchBytes.toByteArray()); + String nestedSignerIdHex = new AccountIdType().fromJson(new TextNode(nestedSignerAddress.value())).toHex(); + result.append(UnsignedByteArray.fromHex(nestedSignerIdHex)); - // Append the signer's account ID (like finishMultiSigningData does in rippled) - String accountIdHex = new AccountIdType().fromJson(new TextNode(signerAddress.value())).toHex(); - result.append(UnsignedByteArray.fromHex(accountIdHex)); + return result; + } catch (JsonProcessingException e) { + throw new RuntimeException(e.getMessage(), e); + } + } + + /** + * Builds the base batch signing payload (items 1–6 from XLS-0056 V1_1 §2.1.3.2), shared by both single-sign and + * multi-sign paths: {@code HashPrefix::Batch} + outer {@code Account} + sequence + {@code Flags} + count + inner tx + * IDs. + */ + private UnsignedByteArray buildBatchSigningPayload(Batch batch) throws JsonProcessingException { + // Start with batch prefix (0x42434800 = "BCH\0") + UnsignedByteArray signableBytes = UnsignedByteArray.fromHex(XrplBinaryCodec.BATCH_SIGNATURE_PREFIX); + + // Add outer account ID (20 bytes) + String accountIdHex = new AccountIdType().fromJson(new TextNode(batch.account().value())).toHex(); + signableBytes.append(UnsignedByteArray.fromHex(accountIdHex)); + + // Add sequence value (4 bytes): TicketSequence if Sequence==0, else Sequence + final int sequenceValue = batch.sequence().longValue() == 0L ? + batch.ticketSequence().map(ts -> ts.intValue()).orElse(0) : + batch.sequence().intValue(); + HashingUtils.addUInt32(signableBytes, sequenceValue); + + // Add flags (4 bytes, big-endian) + HashingUtils.addUInt32(signableBytes, (int) batch.flags().getValue()); + + // Add count of inner transactions (4 bytes, big-endian) + HashingUtils.addUInt32(signableBytes, batch.rawTransactions().size()); + + // Add each inner transaction ID (32 bytes each) + for (RawTransactionWrapper wrapper : batch.rawTransactions()) { + final UnsignedByteArray transactionId = computeInnerBatchTransactionId(wrapper); + signableBytes.append(transactionId); + } - return result; + return signableBytes; } /** diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureService.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureService.java index eae5df6d0..704939238 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureService.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractSignatureService.java @@ -27,6 +27,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.Signer; @@ -110,8 +111,10 @@ public Signature sign(final P privateKeyable, final UnsignedClaim unsignedClaim) } @Override - public Signature signInner(final P privateKeyable, final Batch batchTransaction) { - return this.abstractTransactionSigner.signInner(privateKeyable, batchTransaction); + public Signature signInner( + final P privateKeyable, final Batch batchTransaction, final Address batchSignerAddress + ) { + return this.abstractTransactionSigner.signInner(privateKeyable, batchTransaction, batchSignerAddress); } @Override @@ -120,8 +123,9 @@ public Signature multiSign(final P privateKeyable, final } @Override - public Signature multiSignInner(final P privateKeyable, final Batch batchTransaction) { - return abstractTransactionSigner.multiSignInner(privateKeyable, batchTransaction); + public Signature multiSignInner(final P privateKeyable, final Batch batchTransaction, + final Address batchSignerAddress) { + return abstractTransactionSigner.multiSignInner(privateKeyable, batchTransaction, batchSignerAddress); } @Override diff --git a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSigner.java b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSigner.java index 6477701a8..8da137963 100644 --- a/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSigner.java +++ b/xrpl4j-core/src/main/java/org/xrpl/xrpl4j/crypto/signing/AbstractTransactionSigner.java @@ -87,10 +87,14 @@ public Signature sign(P privateKeyable, Attestation attestation) { } @Override - public Signature signInner(final P privateKeyable, final Batch batchTransaction) { + public Signature signInner( + final P privateKeyable, final Batch batchTransaction, final Address batchSignerAddress + ) { Objects.requireNonNull(privateKeyable); Objects.requireNonNull(batchTransaction); - final UnsignedByteArray signableBytes = this.signatureUtils.toSignableInnerBytes(batchTransaction); + Objects.requireNonNull(batchSignerAddress); + final UnsignedByteArray signableBytes = this.signatureUtils.toSignableInnerBytes(batchTransaction, + batchSignerAddress); return this.signatureHelper(privateKeyable, signableBytes); } @@ -105,12 +109,16 @@ public 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