diff --git a/src/main/java/org/stellar/sdk/SorobanDataBuilder.java b/src/main/java/org/stellar/sdk/SorobanDataBuilder.java new file mode 100644 index 000000000..23480f4ca --- /dev/null +++ b/src/main/java/org/stellar/sdk/SorobanDataBuilder.java @@ -0,0 +1,166 @@ +package org.stellar.sdk; + +import java.io.IOException; +import java.util.Collection; +import javax.annotation.Nullable; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import org.stellar.sdk.xdr.ExtensionPoint; +import org.stellar.sdk.xdr.Int64; +import org.stellar.sdk.xdr.LedgerFootprint; +import org.stellar.sdk.xdr.LedgerKey; +import org.stellar.sdk.xdr.SorobanResources; +import org.stellar.sdk.xdr.SorobanTransactionData; +import org.stellar.sdk.xdr.Uint32; +import org.stellar.sdk.xdr.XdrUnsignedInteger; + +/** + * Supports building {@link SorobanTransactionData} structures with various items set to specific + * values. + * + *

This is recommended for when you are building {@link BumpFootprintExpirationOperation} and + * {@link RestoreFootprintOperation} operations to avoid (re)building the entire data structure from + * scratch. + */ +public class SorobanDataBuilder { + private final SorobanTransactionData data; + + /** Creates a new builder with an empty {@link SorobanTransactionData}. */ + public SorobanDataBuilder() { + data = + new SorobanTransactionData.Builder() + .resources( + new SorobanResources.Builder() + .footprint( + new LedgerFootprint.Builder() + .readOnly(new LedgerKey[] {}) + .readWrite(new LedgerKey[] {}) + .build()) + .instructions(new Uint32(new XdrUnsignedInteger(0))) + .readBytes(new Uint32(new XdrUnsignedInteger(0))) + .writeBytes(new Uint32(new XdrUnsignedInteger(0))) + .extendedMetaDataSizeBytes(new Uint32(new XdrUnsignedInteger(0))) + .build()) + .refundableFee(new Int64(0L)) + .ext(new ExtensionPoint.Builder().discriminant(0).build()) + .build(); + } + + /** + * Creates a new builder from a base64 representation of {@link SorobanTransactionData}. + * + * @param sorobanData base64 representation of {@link SorobanTransactionData} + */ + public SorobanDataBuilder(String sorobanData) { + try { + data = SorobanTransactionData.fromXdrBase64(sorobanData); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid SorobanData: " + sorobanData, e); + } + } + + /** + * Creates a new builder from a {@link SorobanTransactionData}. + * + * @param sorobanData {@link SorobanTransactionData}. + */ + public SorobanDataBuilder(SorobanTransactionData sorobanData) { + try { + data = SorobanTransactionData.fromXdrByteArray(sorobanData.toXdrByteArray()); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid SorobanData: " + sorobanData, e); + } + } + + /** + * Sets the "refundable" fee portion of the Soroban data. + * + * @param fee the refundable fee to set (int64) + * @return this builder instance + */ + public SorobanDataBuilder setRefundableFee(long fee) { + data.setRefundableFee(new Int64(fee)); + return this; + } + + /** + * Sets up the resource metrics. + * + *

You should almost NEVER need this, as its often generated/provided to you by transaction + * simulation/preflight from a Soroban RPC server. + * + * @param resources the resource metrics to set + * @return this builder instance + */ + public SorobanDataBuilder setResources(Resources resources) { + data.getResources() + .setInstructions(new Uint32(new XdrUnsignedInteger(resources.getCpuInstructions()))); + data.getResources().setReadBytes(new Uint32(new XdrUnsignedInteger(resources.getReadBytes()))); + data.getResources() + .setWriteBytes(new Uint32(new XdrUnsignedInteger(resources.getWriteBytes()))); + data.getResources() + .setExtendedMetaDataSizeBytes( + new Uint32(new XdrUnsignedInteger(resources.getMetadataBytes()))); + return this; + } + + /** + * Sets the read-only portion of the storage access footprint to be a certain set of ledger keys. + * + *

Passing {@code null} will leave that portion of the footprint untouched. If you want to + * clear a portion of the footprint, pass an empty collection. + * + * @param readOnly the set of ledger keys to set in the read-only portion of the transaction's + * sorobanData + * @return this builder instance + */ + public SorobanDataBuilder setReadOnly(@Nullable Collection readOnly) { + if (readOnly != null) { + data.getResources().getFootprint().setReadOnly(readOnly.toArray(new LedgerKey[0])); + } + return this; + } + + /** + * Sets the read-write portion of the storage access footprint to be a certain set of ledger keys. + * + *

Passing {@code null} will leave that portion of the footprint untouched. If you want to + * clear a portion of the footprint, pass an empty collection. + * + * @param readWrite the set of ledger keys to set in the read-write portion of the transaction's + * sorobanData + * @return this builder instance + */ + public SorobanDataBuilder setReadWrite(@Nullable Collection readWrite) { + if (readWrite != null) { + data.getResources().getFootprint().setReadWrite(readWrite.toArray(new LedgerKey[0])); + } + return this; + } + + /** + * @return the copy of the final {@link SorobanTransactionData}. + */ + public SorobanTransactionData build() { + try { + return SorobanTransactionData.fromXdrByteArray(data.toXdrByteArray()); + } catch (IOException e) { + throw new IllegalArgumentException("Copy SorobanData failed, please report this bug.", e); + } + } + + /** Represents the resource metrics of the Soroban data. */ + @Builder(toBuilder = true) + @Value + public static class Resources { + // number of CPU instructions (uint32) + @NonNull Long cpuInstructions; + // number of bytes being read (uint32) + @NonNull Long readBytes; + // number of bytes being written (uint32) + @NonNull Long writeBytes; + // number of extended metadata bytes (uint32) + @NonNull Long metadataBytes; + } +} diff --git a/src/main/java/org/stellar/sdk/Transaction.java b/src/main/java/org/stellar/sdk/Transaction.java index 2bc6df2ad..316c88c93 100644 --- a/src/main/java/org/stellar/sdk/Transaction.java +++ b/src/main/java/org/stellar/sdk/Transaction.java @@ -58,7 +58,7 @@ public class Transaction extends AbstractTransaction { this.mPreconditions = preconditions; this.mFee = fee; this.mMemo = memo != null ? memo : Memo.none(); - this.mSorobanData = sorobanData; + this.mSorobanData = sorobanData != null ? new SorobanDataBuilder(sorobanData).build() : null; } // setEnvelopeType is only used in tests which is why this method is package protected diff --git a/src/main/java/org/stellar/sdk/TransactionBuilder.java b/src/main/java/org/stellar/sdk/TransactionBuilder.java index da85d14f5..c1b900697 100644 --- a/src/main/java/org/stellar/sdk/TransactionBuilder.java +++ b/src/main/java/org/stellar/sdk/TransactionBuilder.java @@ -5,7 +5,6 @@ import static org.stellar.sdk.TransactionPreconditions.TIMEOUT_INFINITE; import com.google.common.base.Function; -import java.io.IOException; import java.math.BigInteger; import java.util.Collection; import java.util.List; @@ -264,29 +263,36 @@ public static org.stellar.sdk.xdr.TimeBounds buildTimeBounds(long minTime, long } /** - * Sets Soroban data to the transaction. TODO: After adding SorobanServer, add more descriptions. + * Sets the transaction's internal Soroban transaction data (resources, footprint, etc.). + * + *

For non-contract(non-Soroban) transactions, this setting has no effect. In the case of + * Soroban transactions, this is either an instance of {@link SorobanTransactionData} or a + * base64-encoded string of said structure. This is usually obtained from the simulation response + * based on a transaction with a Soroban operation (e.g. {@link InvokeHostFunctionOperation}, + * providing necessary resource and storage footprint estimations for contract invocation. * * @param sorobanData Soroban data to set * @return Builder object so you can chain methods. */ public TransactionBuilder setSorobanData(SorobanTransactionData sorobanData) { - this.mSorobanData = sorobanData; + this.mSorobanData = new SorobanDataBuilder(sorobanData).build(); return this; } /** - * Sets Soroban data to the transaction. TODO: After adding SorobanServer, add more descriptions. + * Sets the transaction's internal Soroban transaction data (resources, footprint, etc.). + * + *

For non-contract(non-Soroban) transactions, this setting has no effect. In the case of + * Soroban transactions, this is either an instance of {@link SorobanTransactionData} or a + * base64-encoded string of said structure. This is usually obtained from the simulation response + * based on a transaction with a Soroban operation (e.g. {@link InvokeHostFunctionOperation}, + * providing necessary resource and storage footprint estimations for contract invocation. * * @param sorobanData Soroban data to set * @return Builder object so you can chain methods. */ public TransactionBuilder setSorobanData(String sorobanData) { - SorobanTransactionData data; - try { - data = SorobanTransactionData.fromXdrBase64(sorobanData); - } catch (IOException e) { - throw new IllegalArgumentException("Invalid Soroban data: " + sorobanData, e); - } - return setSorobanData(data); + this.mSorobanData = new SorobanDataBuilder(sorobanData).build(); + return this; } } diff --git a/src/test/java/org/stellar/sdk/SorobanDataBuilderTest.java b/src/test/java/org/stellar/sdk/SorobanDataBuilderTest.java new file mode 100644 index 000000000..f55a4ae74 --- /dev/null +++ b/src/test/java/org/stellar/sdk/SorobanDataBuilderTest.java @@ -0,0 +1,145 @@ +package org.stellar.sdk; + +import static com.google.common.collect.ImmutableList.of; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; + +import java.io.IOException; +import java.util.ArrayList; +import org.junit.Test; +import org.stellar.sdk.xdr.ExtensionPoint; +import org.stellar.sdk.xdr.Int64; +import org.stellar.sdk.xdr.LedgerEntryType; +import org.stellar.sdk.xdr.LedgerFootprint; +import org.stellar.sdk.xdr.LedgerKey; +import org.stellar.sdk.xdr.SorobanResources; +import org.stellar.sdk.xdr.SorobanTransactionData; +import org.stellar.sdk.xdr.Uint32; +import org.stellar.sdk.xdr.XdrUnsignedInteger; + +public class SorobanDataBuilderTest { + LedgerKey readOnly = + new LedgerKey.Builder() + .discriminant(LedgerEntryType.ACCOUNT) + .account( + new LedgerKey.LedgerKeyAccount.Builder() + .accountID( + KeyPair.fromAccountId( + "GB7TAYRUZGE6TVT7NHP5SMIZRNQA6PLM423EYISAOAP3MKYIQMVYP2JO") + .getXdrAccountId()) + .build()) + .build(); + LedgerKey readWrite = + new LedgerKey.Builder() + .discriminant(LedgerEntryType.ACCOUNT) + .account( + new LedgerKey.LedgerKeyAccount.Builder() + .accountID( + KeyPair.fromAccountId( + "GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX") + .getXdrAccountId()) + .build()) + .build(); + + SorobanTransactionData emptySorobanData = + new SorobanTransactionData.Builder() + .resources( + new SorobanResources.Builder() + .footprint( + new LedgerFootprint.Builder() + .readOnly(new LedgerKey[] {}) + .readWrite(new LedgerKey[] {}) + .build()) + .instructions(new Uint32(new XdrUnsignedInteger(0))) + .readBytes(new Uint32(new XdrUnsignedInteger(0))) + .writeBytes(new Uint32(new XdrUnsignedInteger(0))) + .extendedMetaDataSizeBytes(new Uint32(new XdrUnsignedInteger(0))) + .build()) + .refundableFee(new Int64(0L)) + .ext(new ExtensionPoint.Builder().discriminant(0).build()) + .build(); + + SorobanTransactionData presetSorobanData = + new SorobanTransactionData.Builder() + .resources( + new SorobanResources.Builder() + .footprint( + new LedgerFootprint.Builder() + .readOnly(new LedgerKey[] {readOnly}) + .readWrite(new LedgerKey[] {readWrite}) + .build()) + .instructions(new Uint32(new XdrUnsignedInteger(1))) + .readBytes(new Uint32(new XdrUnsignedInteger(2))) + .writeBytes(new Uint32(new XdrUnsignedInteger(3))) + .extendedMetaDataSizeBytes(new Uint32(new XdrUnsignedInteger(4))) + .build()) + .refundableFee(new Int64(5L)) + .ext(new ExtensionPoint.Builder().discriminant(0).build()) + .build(); + + @Test + public void testConstructorFromEmpty() { + SorobanTransactionData actualData = new SorobanDataBuilder().build(); + assertEquals(emptySorobanData, actualData); + } + + @Test + public void testConstructorFromBase64() throws IOException { + String base64 = presetSorobanData.toXdrBase64(); + SorobanTransactionData actualData = new SorobanDataBuilder(base64).build(); + assertEquals(presetSorobanData, actualData); + } + + @Test + public void testConstructorFromSorobanTransactionData() { + SorobanTransactionData actualData = new SorobanDataBuilder(presetSorobanData).build(); + assertEquals(presetSorobanData, actualData); + } + + @Test + public void testSetProperties() { + SorobanTransactionData actualData = + new SorobanDataBuilder() + .setReadOnly(of(readOnly)) + .setReadWrite(of(readWrite)) + .setRefundableFee(5) + .setResources( + new SorobanDataBuilder.Resources.ResourcesBuilder() + .cpuInstructions(1L) + .readBytes(2L) + .writeBytes(3L) + .metadataBytes(4L) + .build()) + .build(); + assertEquals(presetSorobanData, actualData); + } + + @Test + public void testLeavesUntouchedFootprintsUntouched() { + SorobanTransactionData data0 = + new SorobanDataBuilder(presetSorobanData).setReadOnly(null).build(); + assertArrayEquals( + new LedgerKey[] {readOnly}, data0.getResources().getFootprint().getReadOnly()); + + SorobanTransactionData data1 = + new SorobanDataBuilder(presetSorobanData).setReadOnly(new ArrayList<>()).build(); + assertArrayEquals(new LedgerKey[] {}, data1.getResources().getFootprint().getReadOnly()); + + SorobanTransactionData data3 = + new SorobanDataBuilder(presetSorobanData).setReadWrite(null).build(); + assertArrayEquals( + new LedgerKey[] {readWrite}, data3.getResources().getFootprint().getReadWrite()); + + SorobanTransactionData data4 = + new SorobanDataBuilder(presetSorobanData).setReadWrite(new ArrayList<>()).build(); + assertArrayEquals(new LedgerKey[] {}, data4.getResources().getFootprint().getReadWrite()); + } + + @Test + public void testBuildCopy() { + SorobanTransactionData actualData = new SorobanDataBuilder(presetSorobanData).build(); + assertEquals(presetSorobanData, actualData); + assertNotSame(presetSorobanData, actualData); + } +} diff --git a/src/test/java/org/stellar/sdk/SorobanServerTest.java b/src/test/java/org/stellar/sdk/SorobanServerTest.java index 57bba78ee..58cf86bfd 100644 --- a/src/test/java/org/stellar/sdk/SorobanServerTest.java +++ b/src/test/java/org/stellar/sdk/SorobanServerTest.java @@ -1362,35 +1362,40 @@ private Transaction buildSorobanTransaction( auth = new ArrayList<>(); } - return new TransactionBuilder(AccountConverter.enableMuxed(), source, Network.STANDALONE) - .setBaseFee(50000) - .addPreconditions( - TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build()) - .addOperation( - InvokeHostFunctionOperation.builder() - .sourceAccount(opInvokerKp.getAccountId()) - .hostFunction( - new HostFunction.Builder() - .discriminant(HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT) - .invokeContract( - new SCVec( - new SCVal[] { - new Address(contractId).toSCVal(), - new SCVal.Builder() - .discriminant(SCValType.SCV_SYMBOL) - .sym(new SCSymbol(new XdrString("increment"))) - .build(), - new Address(opInvokerKp.getAccountId()).toSCVal(), - new SCVal.Builder() - .discriminant(SCValType.SCV_U32) - .u32(new Uint32(new XdrUnsignedInteger(10))) - .build() - })) - .build()) - .auth(auth) - .build()) - .setSorobanData(sorobanData) - .build(); + TransactionBuilder transactionBuilder = + new TransactionBuilder(AccountConverter.enableMuxed(), source, Network.STANDALONE) + .setBaseFee(50000) + .addPreconditions( + TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build()) + .addOperation( + InvokeHostFunctionOperation.builder() + .sourceAccount(opInvokerKp.getAccountId()) + .hostFunction( + new HostFunction.Builder() + .discriminant(HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT) + .invokeContract( + new SCVec( + new SCVal[] { + new Address(contractId).toSCVal(), + new SCVal.Builder() + .discriminant(SCValType.SCV_SYMBOL) + .sym(new SCSymbol(new XdrString("increment"))) + .build(), + new Address(opInvokerKp.getAccountId()).toSCVal(), + new SCVal.Builder() + .discriminant(SCValType.SCV_U32) + .u32(new Uint32(new XdrUnsignedInteger(10))) + .build() + })) + .build()) + .auth(auth) + .build()); + + if (sorobanData != null) { + transactionBuilder.setSorobanData(sorobanData); + } + + return transactionBuilder.build(); } private static SorobanAuthorizationEntry sorobanAuthorizationEntryFromXdrBase64(