diff --git a/xrpl4j-binary-codec/src/main/java/org/xrpl/xrpl4j/codec/binary/types/STObjectType.java b/xrpl4j-binary-codec/src/main/java/org/xrpl/xrpl4j/codec/binary/types/STObjectType.java index e53b6f67d..e1f3f4927 100644 --- a/xrpl4j-binary-codec/src/main/java/org/xrpl/xrpl4j/codec/binary/types/STObjectType.java +++ b/xrpl4j-binary-codec/src/main/java/org/xrpl/xrpl4j/codec/binary/types/STObjectType.java @@ -9,9 +9,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,20 +22,28 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.collect.Lists; +import org.xrpl.xrpl4j.codec.addresses.AddressCodec; import org.xrpl.xrpl4j.codec.addresses.UnsignedByteArray; import org.xrpl.xrpl4j.codec.binary.BinaryCodecObjectMapperFactory; import org.xrpl.xrpl4j.codec.binary.definitions.DefinitionsService; import org.xrpl.xrpl4j.codec.binary.definitions.FieldInstance; import org.xrpl.xrpl4j.codec.binary.serdes.BinaryParser; import org.xrpl.xrpl4j.codec.binary.serdes.BinarySerializer; +import org.xrpl.xrpl4j.model.jackson.ObjectMapperFactory; +import org.xrpl.xrpl4j.model.transactions.Address; +import java.math.BigInteger; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * Codec for XRPL STObject type. @@ -87,7 +95,6 @@ public STObjectType fromJson(JsonNode node) { isUNLModify = false; } - List> fields = new ArrayList<>(); for (String fieldName : Lists.newArrayList(node.fieldNames())) { @@ -98,7 +105,29 @@ public STObjectType fromJson(JsonNode node) { continue; } - JsonNode fieldNode = node.get(fieldName); + JsonNode fieldNode; + // rippled expects signers canonically based on address + if (fieldName.equals("Signers")) { + final AddressCodec addressCodec = AddressCodec.getInstance(); + ArrayNode arrayNode = (ArrayNode) node.get(fieldName); + List jsonNodeList = new ArrayList<>(); + for (JsonNode x : arrayNode) { + jsonNodeList.add(x); + } + List jsonNodesSorted = jsonNodeList.stream().sorted( + Comparator.comparing( + signature -> new BigInteger(addressCodec.decodeAccountId( + Address.of(signature.get("Signer").get("Account").asText()) + ).hexValue(), 16) + ) + ).collect(Collectors.toList()); + + final ObjectMapper objectMapper = ObjectMapperFactory.create(); + fieldNode = objectMapper.createObjectNode().arrayNode().addAll(jsonNodesSorted); + } else { + fieldNode = node.get(fieldName); + } + definitionsService.getFieldInstance(fieldName) .filter(FieldInstance::isSerialized) .ifPresent(fieldInstance -> fields.add(FieldWithValue.builder() diff --git a/xrpl4j-binary-codec/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java b/xrpl4j-binary-codec/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java index 1fabae681..70cdefdeb 100644 --- a/xrpl4j-binary-codec/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java +++ b/xrpl4j-binary-codec/src/test/java/org/xrpl/xrpl4j/codec/binary/XrplBinaryCodecTest.java @@ -9,9 +9,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -23,15 +23,11 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.Strings; -import com.google.common.collect.Lists; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.xrpl.xrpl4j.codec.binary.serdes.BinaryParser; -import org.xrpl.xrpl4j.codec.binary.types.STObjectType; import org.xrpl.xrpl4j.codec.fixtures.FixtureUtils; import org.xrpl.xrpl4j.codec.fixtures.data.WholeObject; @@ -269,6 +265,33 @@ void encodeForMultiSigning() throws JsonProcessingException { assertThat(encoder.encodeForMultiSigning(json, signerAccountId)).isEqualTo(expected); } + @Test + public void encodePaymentWithSigners() throws JsonProcessingException { + String json = "{\"Account\":\"rGs8cFHMfJanAXVtn6e8Lz2iH8FtnGdexw\",\"Fee\":\"30\"," + + "\"Sequence\":6," + + "\"Signers\":" + + "[{\"Signer\":{\"Account\":\"rGDG5dYzvaNMaNGHAYGJKGH1vPBTHeD4fy\"," + + "\"TxnSignature\":\"F5354C2AEAE320FCE49CE18733EA6C27103989878E2C5561028292A09A0AE920792847982D26" + + "8392B8134EF4CA35159C170C40E51F5AFB4F1400DCC9287A3709\"," + + "\"SigningPubKey\":\"ED62267B5A9A0917D5F0D52531428294A80EEFEEB1DB595AED1C94964B35F79F2C\"}}," + + "{\"Signer\":{\"Account\":\"rwm8zSsHG5oTrHMTkKQFKCV3QDQEG1zHvB\"," + + "\"TxnSignature\":\"26A90616049EA684FDD4685726DF674815B48CBC1827D2F0D1DBC8537AC8508F715480AF70C426" + + "B35C193DE49C851831E767BF4AA51880CD1F90618E74B93D0C\"," + + "\"SigningPubKey\":\"ED9018780E2D6D454ED59E40DBEFA4681B7307940B45B67E4C8DE80DBA79626BB8\"}}]," + + "\"SigningPubKey\":\"\",\"Flags\":2147483648,\"Amount\":\"12345\"," + + "\"Destination\":\"rfA6dKpRbJfZo9HgAVGLJP3T2qPgQMA9QB\",\"TransactionType\":\"Payment\"}"; + + String expected = "1200002280000000240000000661400000000000303968400000000000001E73008114A510CC5A6" + + "84976D8986ADA04D2AC4A9C5B77ADD983144C29212A966F2C128FCEB5DB86F5C0A0635275B6F3E0107321ED9018780E" + + "2D6D454ED59E40DBEFA4681B7307940B45B67E4C8DE80DBA79626BB8744026A90616049EA684FDD4685726DF674815B" + + "48CBC1827D2F0D1DBC8537AC8508F715480AF70C426B35C193DE49C851831E767BF4AA51880CD1F90618E74B93D0C81" + + "146B31D3372135AEEB0AA33540B2FBAD8CA4C2EBE2E1E0107321ED62267B5A9A0917D5F0D52531428294A80EEFEEB1D" + + "B595AED1C94964B35F79F2C7440F5354C2AEAE320FCE49CE18733EA6C27103989878E2C5561028292A09A0AE9207928" + + "47982D268392B8134EF4CA35159C170C40E51F5AFB4F1400DCC9287A37098114A6DBFFB301F614A2F7B5E6B94392E17" + + "AAF898E9DE1F1"; + assertThat(encoder.encode(json)).isEqualTo(expected); + } + @ParameterizedTest @MethodSource("dataDrivenFixtures") void dataDriven(WholeObject wholeObject) throws IOException { diff --git a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BinarySerializationTests.java b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BinarySerializationTests.java index 1f16817bd..1a6e02d67 100644 --- a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BinarySerializationTests.java +++ b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/BinarySerializationTests.java @@ -31,6 +31,7 @@ import com.ripple.cryptoconditions.der.DerEncodingException; import org.assertj.core.util.Lists; import org.junit.jupiter.api.Test; +import org.xrpl.xrpl4j.codec.addresses.AddressCodec; import org.xrpl.xrpl4j.codec.binary.XrplBinaryCodec; import org.xrpl.xrpl4j.model.flags.Flags; import org.xrpl.xrpl4j.model.flags.Flags.PaymentFlags; @@ -58,12 +59,17 @@ import org.xrpl.xrpl4j.model.transactions.PaymentChannelCreate; import org.xrpl.xrpl4j.model.transactions.PaymentChannelFund; import org.xrpl.xrpl4j.model.transactions.SetRegularKey; +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.Transaction; import org.xrpl.xrpl4j.model.transactions.TrustSet; import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount; +import java.math.BigInteger; +import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; public class BinarySerializationTests { @@ -516,6 +522,110 @@ public void serializePaymentWithPaths() throws JsonProcessingException { assertSerializesAndDeserializes(payment, expected); } + @Test + public void submitMultisignedWithSignersOutOfOrder() throws JsonProcessingException { + + Payment unsignedPayment = Payment.builder() + .account(Address.of("rBcTTDopDWefBJoRHkLQ4SXcJ7jmERsB54")) + .fee(XrpCurrencyAmount.ofDrops(20)) + .sequence(UnsignedInteger.ONE) + .amount(XrpCurrencyAmount.ofDrops(12345)) + .destination(Address.of("rB7KywQ7ewYrCTZimJBH7VSegnt8KYyVze")) + .build(); + + List signers = Lists.newArrayList( + SignerWrapper.of(Signer.builder() + .account(Address.of("rPbYo1myPZq4JHbB7D587iR5rqBZ2L47J1")) + .signingPublicKey("ED5B9B29CC4C59DB744B59814AF3D891AF7217E403ED734BA598DBE16994DED7EC") + .transactionSignature("1DDF2D82C8C010F3ED4C4687AECD5977E60410EA89614E316CF26195A8336CD57B3026" + + "4471C54D5BA089A91EDC8321B16C2444CFE0F9385E6B2A37B7C758000E") + .build() + ), + SignerWrapper.of(Signer.builder() + .account(Address.of("rEzLrF8UtmrvgS8JjXvmUr1dEZnxVCicnJ")) + .signingPublicKey("EDB53FEFAB30FA2AD6EDB6EA3F5F9E4976CF765B94472933E1DAE3088F9707DDD7") + .transactionSignature("FAB3FA0C72F1FDAC99EA31701983F8F4F89803D6F7DF5465A22378EFE4ADA1C993B86B3" + + "226AAE3A239553F6DCF95CDED8E8FEBBC1C5343D3089A04C758353F03") + .build() + ) + ); + + Payment multiSigPayment = Payment.builder() + .from(unsignedPayment) + .signers(signers) + .build(); + + String expected = "1200002280000000240000000161400000000000303968400000000000001481147465E5C80EA" + + "A9829630FB52A2DBE5F47FF7B95E9831472DC52A028EF1F8317D87794DEC6036A7292D502F3E0107321EDB53FEFAB30" + + "FA2AD6EDB6EA3F5F9E4976CF765B94472933E1DAE3088F9707DDD77440FAB3FA0C72F1FDAC99EA31701983F8F4F89803" + + "D6F7DF5465A22378EFE4ADA1C993B86B3226AAE3A239553F6DCF95CDED8E8FEBBC1C5343D3089A04C758353F038114" + + "A46957D247443D07D04ADF0DA0D9412111134744E1E0107321ED5B9B29CC4C59DB744B59814AF3D891AF7217E403ED7" + + "34BA598DBE16994DED7EC74401DDF2D82C8C010F3ED4C4687AECD5977E60410EA89614E316CF26195A8336CD57B3026" + + "4471C54D5BA089A91EDC8321B16C2444CFE0F9385E6B2A37B7C758000E8114F7DB764B848A8250D9967E19EEBB8F909C62B2E2E1F1"; + + + String decodedBinary = binaryCodec.decode(expected); + Payment deserialized = objectMapper.readValue( + decodedBinary, + objectMapper.getTypeFactory().constructType(Payment.class) + ); + + assertThat(multiSigPayment).isNotEqualTo(deserialized); + + AddressCodec addressCodec = AddressCodec.getInstance(); + assertThat(multiSigPayment.signers().stream().sorted(Comparator.comparing( + sign -> new BigInteger(addressCodec.decodeAccountId( + Address.of(sign.signer().account().value()) + ).hexValue(), 16) + )).collect(Collectors.toList())) + .isEqualTo(deserialized.signers()); + + } + + @Test + public void submitMultisignedWithSignersInOrder() throws JsonProcessingException { + + Payment unsignedPayment = Payment.builder() + .account(Address.of("rBcTTDopDWefBJoRHkLQ4SXcJ7jmERsB54")) + .fee(XrpCurrencyAmount.ofDrops(20)) + .sequence(UnsignedInteger.ONE) + .amount(XrpCurrencyAmount.ofDrops(12345)) + .destination(Address.of("rB7KywQ7ewYrCTZimJBH7VSegnt8KYyVze")) + .build(); + + List signers = Lists.newArrayList( + SignerWrapper.of(Signer.builder() + .account(Address.of("rEzLrF8UtmrvgS8JjXvmUr1dEZnxVCicnJ")) + .signingPublicKey("EDB53FEFAB30FA2AD6EDB6EA3F5F9E4976CF765B94472933E1DAE3088F9707DDD7") + .transactionSignature("FAB3FA0C72F1FDAC99EA31701983F8F4F89803D6F7DF5465A22378EFE4ADA1C993B86B3" + + "226AAE3A239553F6DCF95CDED8E8FEBBC1C5343D3089A04C758353F03") + .build() + ), + SignerWrapper.of(Signer.builder() + .account(Address.of("rPbYo1myPZq4JHbB7D587iR5rqBZ2L47J1")) + .signingPublicKey("ED5B9B29CC4C59DB744B59814AF3D891AF7217E403ED734BA598DBE16994DED7EC") + .transactionSignature("1DDF2D82C8C010F3ED4C4687AECD5977E60410EA89614E316CF26195A8336CD57B3026" + + "4471C54D5BA089A91EDC8321B16C2444CFE0F9385E6B2A37B7C758000E") + .build() + ) + ); + + Payment multiSigPayment = Payment.builder() + .from(unsignedPayment) + .signers(signers) + .build(); + + String expected = "1200002280000000240000000161400000000000303968400000000000001481147465E5C80EA" + + "A9829630FB52A2DBE5F47FF7B95E9831472DC52A028EF1F8317D87794DEC6036A7292D502F3E0107321EDB53FEFAB30" + + "FA2AD6EDB6EA3F5F9E4976CF765B94472933E1DAE3088F9707DDD77440FAB3FA0C72F1FDAC99EA31701983F8F4F89803" + + "D6F7DF5465A22378EFE4ADA1C993B86B3226AAE3A239553F6DCF95CDED8E8FEBBC1C5343D3089A04C758353F038114" + + "A46957D247443D07D04ADF0DA0D9412111134744E1E0107321ED5B9B29CC4C59DB744B59814AF3D891AF7217E403ED7" + + "34BA598DBE16994DED7EC74401DDF2D82C8C010F3ED4C4687AECD5977E60410EA89614E316CF26195A8336CD57B3026" + + "4471C54D5BA089A91EDC8321B16C2444CFE0F9385E6B2A37B7C758000E8114F7DB764B848A8250D9967E19EEBB8F909C62B2E2E1F1"; + + assertSerializesAndDeserializes(multiSigPayment, expected); + } + private void assertSerializesAndDeserializes( T transaction, String expectedBinary diff --git a/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SubmitMultisignedIT.java b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SubmitMultisignedIT.java new file mode 100644 index 000000000..e3a05f496 --- /dev/null +++ b/xrpl4j-integration-tests/src/test/java/org/xrpl/xrpl4j/tests/SubmitMultisignedIT.java @@ -0,0 +1,273 @@ +package org.xrpl.xrpl4j.tests; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.primitives.UnsignedInteger; +import org.assertj.core.util.Lists; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xrpl.xrpl4j.client.JsonRpcClientErrorException; +import org.xrpl.xrpl4j.codec.addresses.AddressCodec; +import org.xrpl.xrpl4j.codec.binary.XrplBinaryCodec; +import org.xrpl.xrpl4j.keypairs.DefaultKeyPairService; +import org.xrpl.xrpl4j.keypairs.KeyPairService; +import org.xrpl.xrpl4j.model.client.accounts.AccountInfoResult; +import org.xrpl.xrpl4j.model.client.fees.FeeResult; +import org.xrpl.xrpl4j.model.client.transactions.SignedTransaction; +import org.xrpl.xrpl4j.model.client.transactions.SubmitMultiSignedResult; +import org.xrpl.xrpl4j.model.client.transactions.SubmitResult; +import org.xrpl.xrpl4j.model.jackson.ObjectMapperFactory; +import org.xrpl.xrpl4j.model.ledger.SignerEntry; +import org.xrpl.xrpl4j.model.ledger.SignerEntryWrapper; +import org.xrpl.xrpl4j.model.transactions.Address; +import org.xrpl.xrpl4j.model.transactions.Payment; +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.Transaction; +import org.xrpl.xrpl4j.model.transactions.TransactionResultCodes; +import org.xrpl.xrpl4j.model.transactions.XrpCurrencyAmount; +import org.xrpl.xrpl4j.wallet.Wallet; + +import java.math.BigInteger; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +public class SubmitMultisignedIT extends AbstractIT { + + protected final ObjectMapper objectMapper = ObjectMapperFactory.create(); + protected final XrplBinaryCodec binaryCodec = new XrplBinaryCodec(); + protected final KeyPairService keyPairService = DefaultKeyPairService.getInstance(); + + ///////////////////////////// + // Create four accounts, one for the multisign account owner, one for their two friends, + // and one to send a Payment to. + Wallet sourceWallet = createRandomAccount(); + Wallet aliceWallet = createRandomAccount(); + Wallet bobWallet = createRandomAccount(); + Wallet destinationWallet = createRandomAccount(); + + FeeResult feeResult; + AccountInfoResult sourceAccountInfoAfterSignerListSet; + SubmitResult signerListSetResult; + + @BeforeEach + public void setUp() throws JsonRpcClientErrorException { + + ///////////////////////////// + // Wait for all of the accounts to show up in a validated ledger + final AccountInfoResult sourceAccountInfo = scanForResult( + () -> this.getValidatedAccountInfo(sourceWallet.classicAddress()) + ); + scanForResult(() -> this.getValidatedAccountInfo(aliceWallet.classicAddress())); + scanForResult(() -> this.getValidatedAccountInfo(bobWallet.classicAddress())); + scanForResult(() -> this.getValidatedAccountInfo(destinationWallet.classicAddress())); + + ///////////////////////////// + // And validate that the source account has not set up any signer lists + assertThat(sourceAccountInfo.accountData().signerLists()).isEmpty(); + + ///////////////////////////// + // Then submit a SignerListSet transaction to add alice and bob as signers on the account + feeResult = xrplClient.fee(); + SignerListSet signerListSet = SignerListSet.builder() + .account(sourceWallet.classicAddress()) + .fee(feeResult.drops().openLedgerFee()) + .sequence(sourceAccountInfo.accountData().sequence()) + .signerQuorum(UnsignedInteger.valueOf(2)) + .addSignerEntries( + SignerEntryWrapper.of( + SignerEntry.builder() + .account(aliceWallet.classicAddress()) + .signerWeight(UnsignedInteger.ONE) + .build() + ), + SignerEntryWrapper.of( + SignerEntry.builder() + .account(bobWallet.classicAddress()) + .signerWeight(UnsignedInteger.ONE) + .build() + ) + ) + .signingPublicKey(sourceWallet.publicKey()) + .build(); + + ///////////////////////////// + // Validate that the transaction was submitted successfully + signerListSetResult = xrplClient.submit(sourceWallet, signerListSet); + assertThat(signerListSetResult.result()).isEqualTo(TransactionResultCodes.TES_SUCCESS); + assertThat(signerListSetResult.transactionResult().transaction().hash()).isNotEmpty().get() + .isEqualTo(signerListSetResult.transactionResult().hash()); + logger.info( + "SignerListSet transaction successful: https://testnet.xrpl.org/transactions/" + + signerListSetResult.transactionResult().hash() + ); + + ///////////////////////////// + // Then wait until the transaction enters a validated ledger and the source account's signer list + // exists + sourceAccountInfoAfterSignerListSet = scanForResult( + () -> this.getValidatedAccountInfo(sourceWallet.classicAddress()), + infoResult -> infoResult.accountData().signerLists().size() == 1 + ); + + assertThat( + sourceAccountInfoAfterSignerListSet.accountData().signerLists().get(0) + .signerEntries().stream() + .sorted(Comparator.comparing(entry -> entry.signerEntry().account())) + .collect(Collectors.toList()) + ).isEqualTo(signerListSet.signerEntries().stream() + .sorted(Comparator.comparing(entry -> entry.signerEntry().account())) + .collect(Collectors.toList())); + } + + @Test + public void submitMultisignedAndVerifyHash() throws JsonRpcClientErrorException, JsonProcessingException { + + ///////////////////////////// + // Construct an unsigned Payment transaction to be multisigned + Payment unsignedPayment = Payment.builder() + .account(sourceWallet.classicAddress()) + .fee( + Transaction.computeMultiSigFee( + feeResult.drops().openLedgerFee(), + sourceAccountInfoAfterSignerListSet.accountData().signerLists().get(0) + ) + ) + .sequence(sourceAccountInfoAfterSignerListSet.accountData().sequence()) + .amount(XrpCurrencyAmount.ofDrops(12345)) + .destination(destinationWallet.classicAddress()) + .signingPublicKey("") + .build(); + + ///////////////////////////// + // Alice and Bob sign the transaction with their private keys + List signers = Lists.newArrayList(aliceWallet, bobWallet).stream() + .map(wallet -> { + try { + String unsignedJson = objectMapper.writeValueAsString(unsignedPayment); + + String unsignedBinaryHex = binaryCodec.encodeForMultiSigning(unsignedJson, wallet.classicAddress().value()); + String signature = keyPairService.sign(unsignedBinaryHex, wallet.privateKey() + .orElseThrow(() -> new RuntimeException("Wallet must provide a private key to sign the transaction."))); + return SignerWrapper.of(Signer.builder() + .account(wallet.classicAddress()) + .signingPublicKey(wallet.publicKey()) + .transactionSignature(signature) + .build() + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + ) + .collect(Collectors.toList()); + + ///////////////////////////// + // Then we add the signatures to the Payment object and submit it + Payment multiSigPayment = Payment.builder() + .from(unsignedPayment) + .signers(signers) + .build(); + + SignedTransaction signedTransaction = SignedTransaction.builder() + .signedTransaction(multiSigPayment) + .signedTransactionBlob(binaryCodec.encode(objectMapper.writeValueAsString(multiSigPayment))) + .build(); + String libraryCalculatedHash = signedTransaction.hash().value(); + + SubmitMultiSignedResult paymentResult = xrplClient.submitMultisigned(multiSigPayment); + + assertThat(paymentResult.transaction().hash().value()).isEqualTo(libraryCalculatedHash); + + assertThat(paymentResult.result()).isEqualTo(TransactionResultCodes.TES_SUCCESS); + assertThat(signerListSetResult.transactionResult().transaction().hash()).isNotEmpty().get() + .isEqualTo(signerListSetResult.transactionResult().hash()); + logger.info( + "Payment transaction successful: https://testnet.xrpl.org/transactions/" + + paymentResult.transaction().hash() + ); + } + + @Test + public void submitMultisignedWithSignersInDescOrderAndVerifyHash() throws + JsonRpcClientErrorException, JsonProcessingException { + + ///////////////////////////// + // Construct an unsigned Payment transaction to be multisigned + Payment unsignedPayment = Payment.builder() + .account(sourceWallet.classicAddress()) + .fee( + Transaction.computeMultiSigFee( + feeResult.drops().openLedgerFee(), + sourceAccountInfoAfterSignerListSet.accountData().signerLists().get(0) + ) + ) + .sequence(sourceAccountInfoAfterSignerListSet.accountData().sequence()) + .amount(XrpCurrencyAmount.ofDrops(12345)) + .destination(destinationWallet.classicAddress()) + .signingPublicKey("") + .build(); + + ///////////////////////////// + // Alice and Bob sign the transaction with their private keys + List signers = Lists.newArrayList(aliceWallet, bobWallet).stream() + .map(wallet -> { + try { + String unsignedJson = objectMapper.writeValueAsString(unsignedPayment); + + String unsignedBinaryHex = binaryCodec.encodeForMultiSigning(unsignedJson, wallet.classicAddress().value()); + String signature = keyPairService.sign(unsignedBinaryHex, wallet.privateKey() + .orElseThrow(() -> new RuntimeException("Wallet must provide a private key to sign the transaction."))); + return SignerWrapper.of(Signer.builder() + .account(wallet.classicAddress()) + .signingPublicKey(wallet.publicKey()) + .transactionSignature(signature) + .build() + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + ) + .collect(Collectors.toList()); + + final AddressCodec addressCodec = AddressCodec.getInstance(); + signers = signers.stream().sorted( + Comparator.comparing( + signature -> new BigInteger(addressCodec.decodeAccountId( + Address.of(signature.signer().account().value()) + ).hexValue(), 16), + Comparator.reverseOrder() + ) + ).collect(Collectors.toList()); + + ///////////////////////////// + // Then we add the signatures to the Payment object and submit it + Payment multiSigPayment = Payment.builder() + .from(unsignedPayment) + .signers(signers) + .build(); + + SignedTransaction signedTransaction = SignedTransaction.builder() + .signedTransaction(multiSigPayment) + .signedTransactionBlob(binaryCodec.encode(objectMapper.writeValueAsString(multiSigPayment))) + .build(); + String libraryCalculatedHash = signedTransaction.hash().value(); + + SubmitMultiSignedResult paymentResult = xrplClient.submitMultisigned(multiSigPayment); + + assertThat(paymentResult.transaction().hash().value()).isEqualTo(libraryCalculatedHash); + + assertThat(paymentResult.result()).isEqualTo(TransactionResultCodes.TES_SUCCESS); + assertThat(signerListSetResult.transactionResult().transaction().hash()).isNotEmpty().get() + .isEqualTo(signerListSetResult.transactionResult().hash()); + logger.info( + "Payment transaction successful: https://testnet.xrpl.org/transactions/" + + paymentResult.transaction().hash() + ); + } +}