3434import org .xrpl .xrpl4j .codec .binary .types .AccountIdType ;
3535import org .xrpl .xrpl4j .codec .binary .types .STObjectType ;
3636import org .xrpl .xrpl4j .codec .binary .types .UInt64Type ;
37+ import org .xrpl .xrpl4j .crypto .HashingUtils ;
38+ import org .xrpl .xrpl4j .crypto .signing .SignedTransaction ;
39+ import org .xrpl .xrpl4j .model .transactions .Address ;
40+ import org .xrpl .xrpl4j .model .transactions .Batch ;
41+ import org .xrpl .xrpl4j .model .transactions .Hash256 ;
42+ import org .xrpl .xrpl4j .model .transactions .RawTransactionWrapper ;
3743
3844import java .util .Map ;
3945import java .util .Objects ;
@@ -47,11 +53,13 @@ public class XrplBinaryCodec {
4753 public static final String TRX_MULTI_SIGNATURE_PREFIX = "534D5400" ;
4854
4955 public static final String PAYMENT_CHANNEL_CLAIM_SIGNATURE_PREFIX = "434C4D00" ;
56+ public static final String BATCH_SIGNATURE_PREFIX = "42434800" ; // "BCH\0" per XLS-0056
57+
5058 public static final String CHANNEL_FIELD_NAME = "Channel" ;
5159 public static final String AMOUNT_FIELD_NAME = "Amount" ;
5260
53- private static final DefinitionsService definitionsService = DefinitionsService .getInstance ();
54- private static final ObjectMapper objectMapper = BinaryCodecObjectMapperFactory .getObjectMapper ();
61+ private static final DefinitionsService DEFINITIONS_SERVICE = DefinitionsService .getInstance ();
62+ private static final ObjectMapper BINARY_CODEC_OBJECT_MAPPER = BinaryCodecObjectMapperFactory .getObjectMapper ();
5563
5664 private static final XrplBinaryCodec INSTANCE = new XrplBinaryCodec ();
5765
@@ -70,11 +78,12 @@ public static XrplBinaryCodec getInstance() {
7078 * @param json A {@link String} containing JSON to be encoded.
7179 *
7280 * @return A {@link String} containing the hex-encoded representation of {@code json}.
81+ *
7382 * @throws JsonProcessingException if {@code json} is not valid JSON.
7483 */
7584 public String encode (String json ) throws JsonProcessingException {
7685 Objects .requireNonNull (json );
77- JsonNode node = BinaryCodecObjectMapperFactory . getObjectMapper () .readTree (json );
86+ JsonNode node = BINARY_CODEC_OBJECT_MAPPER .readTree (json );
7887 return encode (node );
7988 }
8089
@@ -84,7 +93,6 @@ public String encode(String json) throws JsonProcessingException {
8493 * @param jsonNode A {@link JsonNode} containing JSON to be encoded.
8594 *
8695 * @return A {@link String} containing the hex-encoded representation of {@code jsonNode}.
87- * @throws JsonProcessingException if {@code jsonNode} is not valid JSON.
8896 */
8997 private String encode (final JsonNode jsonNode ) {
9098 Objects .requireNonNull (jsonNode );
@@ -99,10 +107,11 @@ private String encode(final JsonNode jsonNode) {
99107 * @param json String containing JSON to be encoded.
100108 *
101109 * @return hex encoded representations
110+ *
102111 * @throws JsonProcessingException if JSON is not valid.
103112 */
104113 public String encodeForSigning (String json ) throws JsonProcessingException {
105- JsonNode node = BinaryCodecObjectMapperFactory . getObjectMapper () .readTree (json );
114+ JsonNode node = BINARY_CODEC_OBJECT_MAPPER .readTree (json );
106115 return TRX_SIGNATURE_PREFIX + encode (removeNonSigningFields (node ));
107116 }
108117
@@ -113,10 +122,11 @@ public String encodeForSigning(String json) throws JsonProcessingException {
113122 * @param xrpAccountId A {@link String} containing the XRPL AccountId.
114123 *
115124 * @return hex encoded representations
125+ *
116126 * @throws JsonProcessingException if JSON is not valid.
117127 */
118128 public String encodeForMultiSigning (String json , String xrpAccountId ) throws JsonProcessingException {
119- JsonNode node = BinaryCodecObjectMapperFactory . getObjectMapper () .readTree (json );
129+ JsonNode node = BINARY_CODEC_OBJECT_MAPPER .readTree (json );
120130 if (!node .isObject ()) {
121131 throw new IllegalArgumentException ("JSON object required for signing" );
122132 }
@@ -126,17 +136,90 @@ public String encodeForMultiSigning(String json, String xrpAccountId) throws Jso
126136 return TRX_MULTI_SIGNATURE_PREFIX + encode (removeNonSigningFields (node )) + suffix ;
127137 }
128138
139+ /**
140+ * Encodes a {@link Batch} transaction to canonical XRPL binary as a hex string for signing purposes. Note that this
141+ * function slightly diverges from the pattern of the other encodeForSigning functions because the bytes to be signed
142+ * for a Batch transaction are not simply the canonical binary representation of the JSON. Instead, we have distinct
143+ * portions of the Batch transaction that are signed. Also, unlike {@link #encodeForSigning(String)}, which accepts
144+ * JSON and then checks for JsonNode values, this implementation instead accepts a well-typed Java object and operates
145+ * on that, for safety and correctness.
146+ *
147+ * @param batch A {@link Batch} containing JSON to be encoded.
148+ *
149+ * @return hex encoded representations
150+ *
151+ * @throws JsonProcessingException if JSON is not valid.
152+ */
153+ public UnsignedByteArray encodeForBatchInnerSigning (Batch batch ) throws JsonProcessingException {
154+ Objects .requireNonNull (batch );
155+ try {
156+ // Start with batch prefix (0x42434800 = "BCH\0")
157+ UnsignedByteArray signableBytes = UnsignedByteArray .fromHex (XrplBinaryCodec .BATCH_SIGNATURE_PREFIX );
158+
159+ // Add flags (4 bytes, big-endian)
160+ HashingUtils .addUInt32 (signableBytes , (int ) batch .flags ().getValue ());
161+
162+ // Add count of inner transactions (4 bytes, big-endian)
163+ HashingUtils .addUInt32 (signableBytes , batch .rawTransactions ().size ());
164+
165+ // Add each inner transaction ID (32 bytes each)
166+ for (RawTransactionWrapper wrapper : batch .rawTransactions ()) {
167+ final UnsignedByteArray transactionId = computeInnerBatchTransactionId (wrapper );
168+ signableBytes .append (transactionId );
169+ }
170+
171+ return signableBytes ;
172+ } catch (JsonProcessingException e ) {
173+ // Test Coverage Note: this catch block is for defensive error handling and is otherwise challenging to test
174+ // in a unit test without mocking static fields or using reflection to create malformed objects, which would
175+ // not be representative of real usage scenarios. In practice, JsonProcessingException should never be thrown
176+ // during normal operation with valid objects, which immutables typically will enforce.
177+ throw new RuntimeException (e .getMessage (), e );
178+ }
179+ }
180+
181+ /**
182+ * Encode a {@link Batch} for multi-signing by a specific signer. This is used when a multi-sig account acts as a
183+ * BatchSigner with nested Signers. Per rippled's checkBatchMultiSign, this uses batch serialization (serializeBatch)
184+ * followed by appending the signer's account ID (finishMultiSigningData).
185+ *
186+ * @param batch The {@link Batch} to encode.
187+ * @param signerAddress The address of the signer (will be appended as account ID suffix).
188+ *
189+ * @return An {@link UnsignedByteArray} containing the batch serialization with account ID suffix.
190+ *
191+ * @throws JsonProcessingException if there is an error processing the JSON.
192+ */
193+ public UnsignedByteArray encodeForBatchInnerMultiSigning (Batch batch , Address signerAddress )
194+ throws JsonProcessingException {
195+ Objects .requireNonNull (batch );
196+ Objects .requireNonNull (signerAddress );
197+
198+ // Start with batch serialization (HashPrefix::batch + flags + count + tx IDs)
199+ UnsignedByteArray batchBytes = encodeForBatchInnerSigning (batch );
200+
201+ // Create a copy to avoid mutating the original (since UnsignedByteArray.append() mutates)
202+ UnsignedByteArray result = UnsignedByteArray .of (batchBytes .toByteArray ());
203+
204+ // Append the signer's account ID (like finishMultiSigningData does in rippled)
205+ String accountIdHex = new AccountIdType ().fromJson (new TextNode (signerAddress .value ())).toHex ();
206+ result .append (UnsignedByteArray .fromHex (accountIdHex ));
207+
208+ return result ;
209+ }
210+
129211 /**
130212 * Encodes JSON to canonical XRPL binary as a hex string for signing payment channel claims. The only JSON fields
131213 * which will be encoded are "Channel" and "Amount".
132214 *
133215 * @param json String containing JSON to be encoded.
134216 *
135217 * @return The binary encoded JSON in hexadecimal form.
218+ *
136219 * @throws JsonProcessingException If the JSON is not valid.
137220 */
138221 public String encodeForSigningClaim (String json ) throws JsonProcessingException {
139- JsonNode node = BinaryCodecObjectMapperFactory . getObjectMapper () .readTree (json );
222+ JsonNode node = BINARY_CODEC_OBJECT_MAPPER .readTree (json );
140223 if (!node .isObject ()) {
141224 throw new IllegalArgumentException ("JSON object required for signing" );
142225 }
@@ -201,12 +284,40 @@ private JsonNode removeNonSigningFields(JsonNode node) {
201284 .filter (this ::isSigningField )
202285 .collect (Collectors .toMap (Function .identity (), node ::get ));
203286
204- return new ObjectNode (objectMapper .getNodeFactory (), signingFields );
287+ return new ObjectNode (BINARY_CODEC_OBJECT_MAPPER .getNodeFactory (), signingFields );
205288 }
206289
207290 private Boolean isSigningField (String fieldName ) {
208- return definitionsService .getFieldInstance (fieldName ).map (FieldInstance ::isSigningField ).orElse (false );
291+ return DEFINITIONS_SERVICE .getFieldInstance (fieldName ).map (FieldInstance ::isSigningField ).orElse (false );
209292 }
210293
294+ /**
295+ * Computes the transaction ID for an unsigned inner transaction.
296+ *
297+ * @param rawTransactionWrapper A {@link RawTransactionWrapper} with an unsigned inner transaction, used for Batch.
298+ *
299+ * @return A {@link Hash256} containing the transaction's transaction ID.
300+ */
301+ private UnsignedByteArray computeInnerBatchTransactionId (final RawTransactionWrapper rawTransactionWrapper )
302+ throws JsonProcessingException {
303+ Objects .requireNonNull (rawTransactionWrapper );
211304
305+ String txJson = BINARY_CODEC_OBJECT_MAPPER .writeValueAsString (rawTransactionWrapper .rawTransaction ());
306+ // Parse the JSON and ensure SigningPubKey is set to empty string for inner transactions
307+ JsonNode txNode = removeNonSigningFields (BINARY_CODEC_OBJECT_MAPPER .readTree (txJson ));
308+ if (txNode .isObject ()) {
309+ final ObjectNode objNode = (ObjectNode ) txNode ;
310+ // Fix Flags field if it's serialized as an object instead of a number
311+ // NOTE: Once https://github.com/XRPLF/xrpl4j/issues/649 is implemented, this line can be removed.
312+ final JsonNode flagsNode = objNode .get ("Flags" );
313+ if (flagsNode != null && flagsNode .isObject () && flagsNode .has ("value" )) {
314+ objNode .put ("Flags" , flagsNode .get ("value" ).asLong ());
315+ }
316+ }
317+ final String txHex = this .encode (txNode );
318+ final UnsignedByteArray txBytes = UnsignedByteArray .fromHex (
319+ SignedTransaction .SIGNED_TRANSACTION_HASH_PREFIX + txHex
320+ );
321+ return HashingUtils .sha512Half (txBytes );
322+ }
212323}
0 commit comments