diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 487e0a327..05cef0c90 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -560,7 +560,7 @@ public SendTransactionResponse sendTransaction(Transaction transaction) { "sendTransaction", params, new TypeToken>() {}); } - private Transaction assembleTransaction( + public static Transaction assembleTransaction( Transaction transaction, SimulateTransactionResponse simulateTransactionResponse) { if (!transaction.isSorobanTransaction()) { throw new IllegalArgumentException( diff --git a/src/main/java/org/stellar/sdk/TransactionBuilder.java b/src/main/java/org/stellar/sdk/TransactionBuilder.java index 0e1bb44e1..110eef5d2 100644 --- a/src/main/java/org/stellar/sdk/TransactionBuilder.java +++ b/src/main/java/org/stellar/sdk/TransactionBuilder.java @@ -6,17 +6,18 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import lombok.Getter; import lombok.NonNull; import org.stellar.sdk.operations.Operation; import org.stellar.sdk.xdr.SorobanTransactionData; /** Builds a new Transaction object. */ public class TransactionBuilder { - private final TransactionBuilderAccount sourceAccount; + @Getter private final TransactionBuilderAccount sourceAccount; private Memo memo; private final List operations; - private Long baseFee; - private final Network network; + @Getter private Long baseFee; + @Getter private final Network network; @NonNull private TransactionPreconditions preconditions; private SorobanTransactionData sorobanData; private BigInteger txTimeout; @@ -158,16 +159,20 @@ public Transaction build() { "Can not set both TransactionPreconditions.timeBounds and timeout."); } + TransactionPreconditions txPreconditions; + if (txTimeout != null) { BigInteger maxTime = !TIMEOUT_INFINITE.equals(txTimeout) ? txTimeout.add(BigInteger.valueOf(System.currentTimeMillis() / 1000L)) : TIMEOUT_INFINITE; - preconditions = + txPreconditions = preconditions.toBuilder().timeBounds(new TimeBounds(BigInteger.ZERO, maxTime)).build(); + } else { + txPreconditions = preconditions; } - preconditions.validate(); + txPreconditions.validate(); if (baseFee == null) { throw new IllegalStateException("baseFee has to be set. you must call setBaseFee()."); @@ -193,7 +198,7 @@ public Transaction build() { sequenceNumber, operations, memo, - preconditions, + txPreconditions, sorobanData, network); sourceAccount.setSequenceNumber(sequenceNumber); diff --git a/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java b/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java new file mode 100644 index 000000000..b2446c313 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java @@ -0,0 +1,490 @@ +package org.stellar.sdk.contract; + +import static org.stellar.sdk.Auth.authorizeEntry; +import static org.stellar.sdk.SorobanServer.assembleTransaction; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.Value; +import org.jetbrains.annotations.Nullable; +import org.stellar.sdk.*; +import org.stellar.sdk.TimeBounds; +import org.stellar.sdk.Transaction; +import org.stellar.sdk.contract.exception.*; +import org.stellar.sdk.exception.UnexpectedException; +import org.stellar.sdk.operations.InvokeHostFunctionOperation; +import org.stellar.sdk.operations.Operation; +import org.stellar.sdk.operations.RestoreFootprintOperation; +import org.stellar.sdk.responses.sorobanrpc.GetTransactionResponse; +import org.stellar.sdk.responses.sorobanrpc.SendTransactionResponse; +import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse; +import org.stellar.sdk.xdr.*; + +public class AssembledTransaction { + private final SorobanServer server; + private final int submitTimeout; + + private final KeyPair transactionSigner; + private final Function parseResultXdrFn; + + private final TransactionBuilder transactionBuilder; + @Getter private Transaction builtTransaction; + + @Getter private SimulateTransactionResponse simulation; + private SimulateTransactionResponse.SimulateHostFunctionResult simulationResult; + private SorobanTransactionData simulationTransactionData; + + @Getter private SendTransactionResponse sendTransactionResponse; + @Getter private GetTransactionResponse getTransactionResponse; + + /** + * Creates a new AssembledTransaction. + * + * @param transactionBuilder the transaction builder + * @param server the Soroban server + * @param transactionSigner the keypair to sign the transaction with + * @param parseResultXdrFn the function to parse the result XDR + * @param submitTimeout the timeout for submitting the transaction + */ + public AssembledTransaction( + TransactionBuilder transactionBuilder, + SorobanServer server, + @Nullable KeyPair transactionSigner, + @Nullable Function parseResultXdrFn, + int submitTimeout) { + this.server = server; + this.submitTimeout = submitTimeout; + this.transactionSigner = transactionSigner; + this.parseResultXdrFn = parseResultXdrFn; + this.transactionBuilder = transactionBuilder; + } + + /** + * Simulates the transaction on the network. Must be called before signing or submitting the + * transaction. Will automatically restore required contract state if restore to + * true and this is not a read call. + * + * @param restore whether to automatically restore contract state if needed + * @return this AssembledTransaction + * @throws SimulationFailedException if the simulation failed + * @throws RestorationFailureException if the contract state could not be restored + */ + public AssembledTransaction simulate(boolean restore) { + simulationResult = null; + simulationTransactionData = null; + + TransactionBuilderAccount source = + server.getAccount(transactionBuilder.getSourceAccount().getAccountId()); + transactionBuilder.getSourceAccount().setSequenceNumber(source.getSequenceNumber()); + + Transaction builtTx = transactionBuilder.build(); + simulation = server.simulateTransaction(builtTx); + + if (restore && simulation.getRestorePreamble() != null && !isReadCall()) { + try { + restoreFootprint(); + } catch (SimulationFailedException + | TransactionStillPendingException + | SendTransactionFailedException + | TransactionFailedException e) { + throw new RestorationFailureException("Failed to restore contract data.", this); + } + return simulate(false); + } + + if (simulation.getError() != null) { + throw new SimulationFailedException( + "Transaction simulation failed: " + simulation.getError(), this); + } + builtTransaction = assembleTransaction(builtTx, simulation); + return this; + } + + /** + * Signs and submits the transaction in one step. + * + *

A convenience method combining {@link #sign(KeyPair, boolean)} and {@link #submit()}. + * + * @param transactionSigner the keypair to sign the transaction with, or null to use + * the signer provided in the constructor + * @param force whether to sign and submit even if the transaction is a read call + * @return The value returned by the invoked function, parsed if parseResultXdrFn was + * set, otherwise raw {@link SCVal} + * @throws NotYetSimulatedException if the transaction has not yet been simulated + * @throws NoSignatureNeededException if the transaction is a read call, and force is + * not set to true + * @throws NeedsMoreSignaturesException if the transaction requires more signatures + * @throws SendTransactionFailedException if sending the transaction to the network failed + * @throws TransactionStillPendingException if the transaction is still pending after the timeout + * @throws ExpiredStateException if the transaction requires restoring contract state + * @throws TransactionFailedException if the transaction failed + */ + public T signAndSubmit(@Nullable KeyPair transactionSigner, boolean force) { + sign(transactionSigner, force); + return submit(); + } + + /** + * Signs the transaction. + * + * @param transactionSigner the keypair to sign the transaction with, or null to use + * the signer provided in the constructor + * @param force whether to sign and submit even if the transaction is a read call + * @return this AssembledTransaction + * @throws NotYetSimulatedException if the transaction has not yet been simulated + * @throws NoSignatureNeededException if the transaction is a read call, and force is + * not set to true + * @throws ExpiredStateException if the transaction requires restoring contract state + * @throws NeedsMoreSignaturesException if the transaction requires more signatures + */ + public AssembledTransaction sign(@Nullable KeyPair transactionSigner, boolean force) { + if (builtTransaction == null) { + throw new NotYetSimulatedException("Transaction has not yet been simulated.", this); + } + + if (!force && isReadCall()) { + throw new NoSignatureNeededException( + "This is a read call. It requires no signature or submitting. Set force=true to sign and submit anyway.", + this); + } + + if (simulation != null && simulation.getRestorePreamble() != null) { + throw new ExpiredStateException( + "You need to restore contract state before you can invoke this method. " + + "You can set `restore` to true in order to " + + "automatically restore the contract state when needed.", + this); + } + + KeyPair signer = transactionSigner != null ? transactionSigner : this.transactionSigner; + if (signer == null) { + throw new IllegalArgumentException( + "You must provide a signTransactionFunc to sign the transaction, either here or in the constructor."); + } + + Set sigsNeeded = needsNonInvokerSigningBy(false); + sigsNeeded.removeIf(s -> s.startsWith("C")); + if (!sigsNeeded.isEmpty()) { + throw new NeedsMoreSignaturesException( + "Transaction requires signatures from " + + sigsNeeded + + ". See `needsNonInvokerSigningBy` for details.", + this); + } + + builtTransaction.sign(signer); + return this; + } + + /** + * Signs the transaction's authorization entries. + * + *

An alias for {@link #signAuthEntries(KeyPair, Long)} with null as the second + * argument. + * + * @param authEntriesSigner the keypair to sign the authorization entries with + * @return this AssembledTransaction + * @throws NotYetSimulatedException if the transaction has not yet been simulated + */ + public AssembledTransaction signAuthEntries(KeyPair authEntriesSigner) { + return signAuthEntries(authEntriesSigner, null); + } + + /** + * Signs the transaction's authorization entries. + * + * @param authEntriesSigner the keypair to sign the authorization entries with + * @param validUntilLedgerSequence the ledger sequence number until which the authorization + * entries are valid, or null to set it to the current ledger sequence + 100 + * @return this AssembledTransaction + * @throws NotYetSimulatedException if the transaction has not yet been simulated + */ + public AssembledTransaction signAuthEntries( + KeyPair authEntriesSigner, @Nullable Long validUntilLedgerSequence) { + if (builtTransaction == null) { + throw new NotYetSimulatedException("Transaction has not yet been simulated.", this); + } + + if (validUntilLedgerSequence == null) { + validUntilLedgerSequence = server.getLatestLedger().getSequence() + 100L; + } + + Operation op = builtTransaction.getOperations()[0]; + if (!(op instanceof InvokeHostFunctionOperation)) { + throw new IllegalStateException("Expected InvokeHostFunction operation"); + } + InvokeHostFunctionOperation invokeHostFunctionOp = (InvokeHostFunctionOperation) op; + + for (int i = 0; i < invokeHostFunctionOp.getAuth().size(); i++) { + SorobanAuthorizationEntry e = invokeHostFunctionOp.getAuth().get(i); + if (SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT.equals( + e.getCredentials().getDiscriminant())) { + continue; + } + if (e.getCredentials().getAddress() == null) { + throw new IllegalStateException("Expected address in credentials"); + } + if (!Address.fromSCAddress(e.getCredentials().getAddress().getAddress()) + .toString() + .equals(authEntriesSigner.getAccountId())) { + continue; + } + invokeHostFunctionOp + .getAuth() + .set( + i, + authorizeEntry( + e, authEntriesSigner, validUntilLedgerSequence, builtTransaction.getNetwork())); + } + return this; + } + + /** + * Get the addresses that need to sign the authorization entries. + * + * @param includeAlreadySigned whether to include addresses that have already signed the + * authorization entries + * @return The addresses that need to sign the authorization entries. + * @throws NotYetSimulatedException if the transaction has not yet been simulated + */ + public Set needsNonInvokerSigningBy(boolean includeAlreadySigned) { + if (builtTransaction == null) { + throw new NotYetSimulatedException("Transaction has not yet been simulated.", this); + } + + Operation op = builtTransaction.getOperations()[0]; + if (!(op instanceof InvokeHostFunctionOperation)) { + return new HashSet<>(); + } + InvokeHostFunctionOperation invokeHostFunctionOp = (InvokeHostFunctionOperation) op; + + return invokeHostFunctionOp.getAuth().stream() + .filter( + entry -> + entry.getCredentials().getDiscriminant() + == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) + .filter( + entry -> + includeAlreadySigned + || entry.getCredentials().getAddress().getSignature().getDiscriminant() + == SCValType.SCV_VOID) + .map( + entry -> + Address.fromSCAddress(entry.getCredentials().getAddress().getAddress()).toString()) + .collect(Collectors.toSet()); + } + + /** + * Get the result of the function invocation from the simulation. + * + * @return The value returned by the invoked function, parsed if parseResultXdrFn was + * set, otherwise raw {@link SCVal} + * @throws NotYetSimulatedException if the transaction has not yet been simulated + */ + public T result() throws NotYetSimulatedException { + SimulationData simulationData = simulationData(); + SCVal rawResult; + try { + rawResult = SCVal.fromXdrBase64(simulationData.result.getXdr()); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to convert simulation result to SCVal", e); + } + if (parseResultXdrFn != null) { + return parseResultXdrFn.apply(rawResult); + } + @SuppressWarnings("unchecked") + T result = (T) rawResult; + return result; + } + + /** + * Check if the transaction is a read call. + * + * @return true if the transaction is a read call, false otherwise + * @throws NotYetSimulatedException if the transaction has not yet been simulated + */ + public boolean isReadCall() { + SimulationData simulationData = simulationData(); + List auths = simulationData.result.getAuth(); + LedgerKey[] writes = + simulationData.transactionData.getResources().getFootprint().getReadWrite(); + return auths.isEmpty() && writes.length == 0; + } + + /** + * Get the transaction envelope XDR. + * + * @return The transaction envelope XDR. + */ + public String toEnvelopeXdrBase64() { + return builtTransaction.toEnvelopeXdrBase64(); + } + + /** + * Restore the contract state. + * + * @throws TransactionFailedException if the transaction failed + * @throws TransactionStillPendingException if the transaction is still pending after the timeout + * @throws SendTransactionFailedException if sending the transaction to the network failed + */ + public void restoreFootprint() { + if (transactionSigner == null) { + throw new IllegalArgumentException( + "For automatic restore to work you must provide a transactionSigner when initializing AssembledTransaction."); + } + + TransactionBuilder restoreTx = + new TransactionBuilder( + transactionBuilder.getSourceAccount(), transactionBuilder.getNetwork()) + .setBaseFee(transactionBuilder.getBaseFee()) + .addOperation(RestoreFootprintOperation.builder().build()) + .setSorobanData( + new SorobanDataBuilder(simulation.getRestorePreamble().getTransactionData()) + .build()) + .addPreconditions( + TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build()); + AssembledTransaction restoreAssembled = + new AssembledTransaction<>(restoreTx, server, transactionSigner, null, submitTimeout); + restoreAssembled.simulate(false).sign(this.transactionSigner, true).submitInternal(); + } + + /** + * Submits the transaction to the network. + * + *

It will send the transaction to the network and wait for the result. + * + * @return The value returned by the invoked function, parsed if parseResultXdrFn was + * set, otherwise raw {@link SCVal} + * @throws NotYetSimulatedException if the transaction has not yet been simulated + * @throws SendTransactionFailedException if sending the transaction to the network failed + * @throws TransactionStillPendingException if the transaction is still pending after the timeout + * @throws TransactionFailedException if the transaction failed + */ + @SuppressWarnings("unchecked") + public T submit() { + GetTransactionResponse response = submitInternal(); + TransactionMeta transactionMeta; + try { + transactionMeta = TransactionMeta.fromXdrBase64(response.getResultMetaXdr()); + } catch (IOException e) { + throw new IllegalArgumentException( + "Unable to convert transaction meta to TransactionMeta", e); + } + SCVal resultVal = transactionMeta.getV3().getSorobanMeta().getReturnValue(); + return parseResultXdrFn != null ? parseResultXdrFn.apply(resultVal) : (T) resultVal; + } + + private SimulationData simulationData() { + if (simulationResult != null && simulationTransactionData != null) { + return new SimulationData(simulationResult, simulationTransactionData); + } + + if (simulation == null) { + throw new NotYetSimulatedException("Transaction has not yet been simulated.", this); + } + + simulationResult = simulation.getResults().get(0); + try { + simulationTransactionData = + SorobanTransactionData.fromXdrBase64(simulation.getTransactionData()); + } catch (IOException e) { + throw new IllegalArgumentException( + "Unable to convert transaction data to SorobanTransactionData", e); + } + return new SimulationData(simulationResult, simulationTransactionData); + } + + private GetTransactionResponse submitInternal() { + if (builtTransaction == null) { + throw new NotYetSimulatedException("Transaction has not yet been simulated.", this); + } + + if (sendTransactionResponse == null) { + sendTransactionResponse = server.sendTransaction(builtTransaction); + if (sendTransactionResponse.getStatus() + != SendTransactionResponse.SendTransactionStatus.PENDING) { + throw new SendTransactionFailedException( + "Sending the transaction to the network failed!", this); + } + } + + String txHash = sendTransactionResponse.getHash(); + List attempts = + withExponentialBackoff( + () -> server.getTransaction(txHash), + resp -> resp.getStatus() == GetTransactionResponse.GetTransactionStatus.NOT_FOUND, + submitTimeout); + getTransactionResponse = attempts.get(attempts.size() - 1); + + if (getTransactionResponse.getStatus() == GetTransactionResponse.GetTransactionStatus.SUCCESS) { + return getTransactionResponse; + } + if (getTransactionResponse.getStatus() + == GetTransactionResponse.GetTransactionStatus.NOT_FOUND) { + throw new TransactionStillPendingException( + "Waited " + + submitTimeout + + " seconds for transaction to complete, but it did not. " + + "Returning anyway. You can call result() to await the result later " + + "or check the status of the transaction manually.", + this); + } else if (getTransactionResponse.getStatus() + == GetTransactionResponse.GetTransactionStatus.FAILED) { + throw new TransactionFailedException("Transaction failed.", this); + } else { + throw new IllegalStateException("Unexpected transaction status."); + } + } + + private static List withExponentialBackoff( + Supplier fn, Predicate keepWaitingIf, long timeout) { + List attempts = new ArrayList<>(); + attempts.add(fn.get()); + if (!keepWaitingIf.test(attempts.get(0))) { + return attempts; + } + + long waitUntil = System.currentTimeMillis() + timeout * 1000; + long waitTime = 1000; + long maxWaitTime = 60000; + + while (System.currentTimeMillis() < waitUntil + && keepWaitingIf.test(attempts.get(attempts.size() - 1))) { + try { + CompletableFuture future = CompletableFuture.supplyAsync(fn); + future.get(waitTime, TimeUnit.MILLISECONDS); + attempts.add(future.getNow(null)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new UnexpectedException("Exponential backoff interrupted", e); + } catch (ExecutionException | TimeoutException e) { + // Ignore the exception and continue with the next iteration + } + + waitTime *= 2; + if (waitTime > maxWaitTime) { + waitTime = maxWaitTime; + } + + if (waitUntil - System.currentTimeMillis() < waitTime) { + waitTime = waitUntil - System.currentTimeMillis(); + } + } + return attempts; + } + + @Value + private static class SimulationData { + SimulateTransactionResponse.SimulateHostFunctionResult result; + SorobanTransactionData transactionData; + } +} diff --git a/src/main/java/org/stellar/sdk/contract/ContractClient.java b/src/main/java/org/stellar/sdk/contract/ContractClient.java new file mode 100644 index 000000000..8715912a7 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/ContractClient.java @@ -0,0 +1,108 @@ +package org.stellar.sdk.contract; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import org.jetbrains.annotations.Nullable; +import org.stellar.sdk.*; +import org.stellar.sdk.operations.InvokeHostFunctionOperation; +import org.stellar.sdk.xdr.SCVal; + +/** + * A client to interact with Soroban smart contracts. + * + *

This client is a wrapper for {@link SorobanServer} and {@link TransactionBuilder} to make it + * easier to interact with Soroban smart contracts. If you need more fine-grained control, please + * consider using them directly. + */ +public class ContractClient implements Closeable { + private final String contractId; + private final Network network; + private final SorobanServer server; + + /** + * Creates a new {@link ContractClient} with the given contract ID, RPC URL, and network. + * + * @param contractId The contract ID to interact with. + * @param rpcUrl The RPC URL of the Soroban server. + * @param network The network to interact with. + */ + public ContractClient(String contractId, String rpcUrl, Network network) { + this.contractId = contractId; + this.network = network; + this.server = new SorobanServer(rpcUrl); + } + + /** + * Build an {@link AssembledTransaction} to invoke a function on the contract. + * + *

An alias for {@link #invoke(String, Collection, String, KeyPair, Function, int, int, int, + * boolean, boolean)}. + * + * @param functionName The name of the function to invoke. + * @param parameters The parameters to pass to the function. + * @param source The source account to use for the transaction. + * @param signer The key pair to sign the transaction with. + * @param parseResultXdrFn A function to parse the result XDR of the transaction. + * @param baseFee The base fee for the transaction. + */ + public AssembledTransaction invoke( + String functionName, + List parameters, + String source, + KeyPair signer, + Function parseResultXdrFn, + int baseFee) { + return invoke( + functionName, parameters, source, signer, parseResultXdrFn, baseFee, 300, 30, true, true); + } + + /** + * Build an {@link AssembledTransaction} to invoke a function on the contract. + * + * @param functionName The name of the function to invoke. + * @param parameters The parameters to pass to the function. + * @param source The source account to use for the transaction. + * @param signer The key pair to sign the transaction with. + * @param parseResultXdrFn A function to parse the result XDR of the transaction. + * @param baseFee The base fee for the transaction. + * @param transactionTimeout The timeout for the transaction. + * @param submitTimeout The timeout for submitting the transaction. + * @param simulate Whether to simulate the transaction. + * @param restore Whether to restore the transaction, only valid when simulate is + * true, and the signer is provided. + */ + public AssembledTransaction invoke( + String functionName, + Collection parameters, + String source, + @Nullable KeyPair signer, + @Nullable Function parseResultXdrFn, + int baseFee, + int transactionTimeout, + int submitTimeout, + boolean simulate, + boolean restore) { + TransactionBuilder builder = + new TransactionBuilder(new Account(source, 0L), network) + .addOperation( + InvokeHostFunctionOperation.invokeContractFunctionOperationBuilder( + contractId, functionName, parameters) + .build()) + .setTimeout(transactionTimeout) + .setBaseFee(baseFee); + AssembledTransaction assembled = + new AssembledTransaction<>(builder, server, signer, parseResultXdrFn, submitTimeout); + if (simulate) { + assembled = assembled.simulate(restore); + } + return assembled; + } + + @Override + public void close() throws IOException { + server.close(); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/AssembledTransactionException.java b/src/main/java/org/stellar/sdk/contract/exception/AssembledTransactionException.java new file mode 100644 index 000000000..93b8da294 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/AssembledTransactionException.java @@ -0,0 +1,18 @@ +package org.stellar.sdk.contract.exception; + +import lombok.Getter; +import org.stellar.sdk.contract.AssembledTransaction; +import org.stellar.sdk.exception.SdkException; + +/** Raised when an assembled transaction fails. */ +@Getter +public class AssembledTransactionException extends SdkException { + /** The assembled transaction that failed. */ + private final AssembledTransaction assembledTransaction; + + public AssembledTransactionException( + String message, AssembledTransaction assembledTransaction) { + super(message); + this.assembledTransaction = assembledTransaction; + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/ExpiredStateException.java b/src/main/java/org/stellar/sdk/contract/exception/ExpiredStateException.java new file mode 100644 index 000000000..00f8fc7cf --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/ExpiredStateException.java @@ -0,0 +1,10 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when the state has expired. */ +public class ExpiredStateException extends AssembledTransactionException { + public ExpiredStateException(String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/NeedsMoreSignaturesException.java b/src/main/java/org/stellar/sdk/contract/exception/NeedsMoreSignaturesException.java new file mode 100644 index 000000000..9efd840e7 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/NeedsMoreSignaturesException.java @@ -0,0 +1,11 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when more signatures are needed. */ +public class NeedsMoreSignaturesException extends AssembledTransactionException { + public NeedsMoreSignaturesException( + String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/NoSignatureNeededException.java b/src/main/java/org/stellar/sdk/contract/exception/NoSignatureNeededException.java new file mode 100644 index 000000000..7d5f843ea --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/NoSignatureNeededException.java @@ -0,0 +1,10 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when no signature is needed. */ +public class NoSignatureNeededException extends AssembledTransactionException { + public NoSignatureNeededException(String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/NotYetSimulatedException.java b/src/main/java/org/stellar/sdk/contract/exception/NotYetSimulatedException.java new file mode 100644 index 000000000..368220629 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/NotYetSimulatedException.java @@ -0,0 +1,10 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when trying to get the result of a transaction that has not been simulated yet. */ +public class NotYetSimulatedException extends AssembledTransactionException { + public NotYetSimulatedException(String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/RestorationFailureException.java b/src/main/java/org/stellar/sdk/contract/exception/RestorationFailureException.java new file mode 100644 index 000000000..adafb1bc1 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/RestorationFailureException.java @@ -0,0 +1,10 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when a restoration fails. */ +public class RestorationFailureException extends AssembledTransactionException { + public RestorationFailureException(String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/SendTransactionFailedException.java b/src/main/java/org/stellar/sdk/contract/exception/SendTransactionFailedException.java new file mode 100644 index 000000000..41274b3a4 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/SendTransactionFailedException.java @@ -0,0 +1,11 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when invoking `sendTransaction` fails. */ +public class SendTransactionFailedException extends AssembledTransactionException { + public SendTransactionFailedException( + String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/SimulationFailedException.java b/src/main/java/org/stellar/sdk/contract/exception/SimulationFailedException.java new file mode 100644 index 000000000..68f3907ff --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/SimulationFailedException.java @@ -0,0 +1,10 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when a simulation fails. */ +public class SimulationFailedException extends AssembledTransactionException { + public SimulationFailedException(String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/TransactionFailedException.java b/src/main/java/org/stellar/sdk/contract/exception/TransactionFailedException.java new file mode 100644 index 000000000..3279e7c63 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/TransactionFailedException.java @@ -0,0 +1,10 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when invoking `getTransaction` fails. */ +public class TransactionFailedException extends AssembledTransactionException { + public TransactionFailedException(String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/TransactionStillPendingException.java b/src/main/java/org/stellar/sdk/contract/exception/TransactionStillPendingException.java new file mode 100644 index 000000000..c5f474578 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/TransactionStillPendingException.java @@ -0,0 +1,11 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.contract.AssembledTransaction; + +/** Raised when the transaction is still pending. */ +public class TransactionStillPendingException extends AssembledTransactionException { + public TransactionStillPendingException( + String message, AssembledTransaction assembledTransaction) { + super(message, assembledTransaction); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/package-info.java b/src/main/java/org/stellar/sdk/contract/package-info.java new file mode 100644 index 000000000..9ca5135ed --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/package-info.java @@ -0,0 +1,2 @@ +/** Provides classes for easy interaction with Stellar smart contracts. */ +package org.stellar.sdk.contract; diff --git a/src/test/java/org/stellar/sdk/contract/ContractClientTest.java b/src/test/java/org/stellar/sdk/contract/ContractClientTest.java new file mode 100644 index 000000000..4107d57c5 --- /dev/null +++ b/src/test/java/org/stellar/sdk/contract/ContractClientTest.java @@ -0,0 +1,175 @@ +package org.stellar.sdk.contract; + +import static org.junit.Assert.*; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Function; +import javax.swing.*; +import org.junit.Ignore; +import org.junit.Test; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Network; +import org.stellar.sdk.contract.exception.NoSignatureNeededException; +import org.stellar.sdk.scval.Scv; +import org.stellar.sdk.xdr.SCVal; + +@Ignore +public class ContractClientTest { + // private static final String ENABLE_E2E_TESTS_VARIABLE = "ENABLE_E2E_TESTS"; + + // @Before + // public void setUp() { + // boolean enabled = "true".equals(System.getenv(ENABLE_E2E_TESTS_VARIABLE)); + // if (!enabled) { + // throw new AssumptionViolatedException("Skipping e2e tests in non-CI environment"); + // } + // } + // + + @Test + public void testContractClientWithoutParseResultFnSet() throws IOException { + // stellar contract deploy --source-account dev --network testnet --wasm + // ./target/wasm32-unknown-unknown/release/soroban_hello_world_contract.wasm --salt + // 0000000000000000000000000000000000000000000000000000000000000000 + KeyPair submitter = + KeyPair.fromSecretSeed("SAAPYAPTTRZMCUZFPG3G66V4ZMHTK4TWA6NS7U4F7Z3IMUD52EK4DDEV"); + String contractId = "CD3YDDSN3G3UG3QKNUI3CLWJKIRWGXPRFJDBNMXGDBQDR7THE2OZRGR2"; + String rpcUrl = "https://soroban-testnet.stellar.org"; + ContractClient client = new ContractClient(contractId, rpcUrl, Network.TESTNET); + AssembledTransaction assembledTransaction = + client.invoke( + "hello", + Collections.singletonList(Scv.toString("Stellar")), + submitter.getAccountId(), + null, + null, + 100); + assertTrue(assembledTransaction.isReadCall()); + assertEquals( + assembledTransaction.result(), + Scv.toVec(Arrays.asList(Scv.toString("Hello"), Scv.toString("Stellar")))); + client.close(); + } + + @Test + public void testContractClientWithParseResultFnSet() throws IOException { + // stellar contract deploy --source-account dev --network testnet --wasm + // ./target/wasm32-unknown-unknown/release/soroban_hello_world_contract.wasm --salt + KeyPair submitter = + KeyPair.fromSecretSeed("SAAPYAPTTRZMCUZFPG3G66V4ZMHTK4TWA6NS7U4F7Z3IMUD52EK4DDEV"); + String contractId = "CD3YDDSN3G3UG3QKNUI3CLWJKIRWGXPRFJDBNMXGDBQDR7THE2OZRGR2"; + String rpcUrl = "https://soroban-testnet.stellar.org"; + ContractClient client = new ContractClient(contractId, rpcUrl, Network.TESTNET); + + Function> parseResultXdrFn = + scVal -> { + Collection vec = Scv.fromVec(scVal); + Iterator iterator = vec.iterator(); + String first = new String(Scv.fromString(iterator.next()), StandardCharsets.UTF_8); + String second = new String(Scv.fromString(iterator.next()), StandardCharsets.UTF_8); + return Arrays.asList(first, second); + }; + + AssembledTransaction> assembledTransaction = + client.invoke( + "hello", + Collections.singletonList(Scv.toString("Stellar")), + submitter.getAccountId(), + null, + parseResultXdrFn, + 100); + assertTrue(assembledTransaction.isReadCall()); + assertEquals(assembledTransaction.result(), Arrays.asList("Hello", "Stellar")); + client.close(); + } + + @Test + public void testContractClientForceSendReadCall() throws IOException { + // stellar contract deploy --source-account dev --network testnet --wasm + // ./target/wasm32-unknown-unknown/release/soroban_hello_world_contract.wasm --salt + KeyPair submitter = + KeyPair.fromSecretSeed("SAAPYAPTTRZMCUZFPG3G66V4ZMHTK4TWA6NS7U4F7Z3IMUD52EK4DDEV"); + String contractId = "CD3YDDSN3G3UG3QKNUI3CLWJKIRWGXPRFJDBNMXGDBQDR7THE2OZRGR2"; + String rpcUrl = "https://soroban-testnet.stellar.org"; + ContractClient client = new ContractClient(contractId, rpcUrl, Network.TESTNET); + + Function> parseResultXdrFn = + scVal -> { + Collection vec = Scv.fromVec(scVal); + Iterator iterator = vec.iterator(); + String first = new String(Scv.fromString(iterator.next()), StandardCharsets.UTF_8); + String second = new String(Scv.fromString(iterator.next()), StandardCharsets.UTF_8); + return Arrays.asList(first, second); + }; + + AssembledTransaction> assembledTransaction = + client.invoke( + "hello", + Collections.singletonList(Scv.toString("Stellar")), + submitter.getAccountId(), + submitter, + parseResultXdrFn, + 100); + assertTrue(assembledTransaction.isReadCall()); + assertEquals(assembledTransaction.result(), Arrays.asList("Hello", "Stellar")); + assertThrows( + NoSignatureNeededException.class, () -> assembledTransaction.signAndSubmit(null, false)); + assertEquals(assembledTransaction.signAndSubmit(null, true), Arrays.asList("Hello", "Stellar")); + client.close(); + } + + @Test + public void testContractClientSignAuth() throws IOException { + // stellar contract deploy --source-account dev --network testnet --wasm + // ./target/wasm32-unknown-unknown/release/soroban_atomic_swap_contract.wasm --salt + // 0000000000000000000000000000000000000000000000000000000000000000 + KeyPair submitter = + KeyPair.fromSecretSeed("SAAPYAPTTRZMCUZFPG3G66V4ZMHTK4TWA6NS7U4F7Z3IMUD52EK4DDEV"); + KeyPair alice = + KeyPair.fromSecretSeed("SBPTTA3D3QYQ6E2GSACAZDUFH2UILBNG3EBJCK3NNP7BE4O757KGZUGA"); + KeyPair bob = + KeyPair.fromSecretSeed("SBJQCT3YSSVRHVGNMGDHJ35SZ635KXPJGGDEBHWWKCPZ7ZY46H2LM7KM"); + + String swapContractId = "CDLMS36NCJ4S35RJVTJG32JB7XV2KEEAP5PRROF4H2UU657DDAIB5ZZI"; + String nativeAssetContractId = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + String customAssetContractId = "CADEDRPB3MIT2QWLK5DGAFR3JMCIZMTEFT6R4KUGW5ZZYCQKAMPR5WAJ"; + + String rpcUrl = "https://soroban-testnet.stellar.org"; + + ContractClient client = new ContractClient(swapContractId, rpcUrl, Network.TESTNET); + + AssembledTransaction assembledTransaction = + client.invoke( + "swap", + Arrays.asList( + Scv.toAddress(alice.getAccountId()), + Scv.toAddress(bob.getAccountId()), + Scv.toAddress(nativeAssetContractId), + Scv.toAddress(customAssetContractId), + Scv.toInt128(BigInteger.valueOf(1000)), + Scv.toInt128(BigInteger.valueOf(4500)), + Scv.toInt128(BigInteger.valueOf(5000)), + Scv.toInt128(BigInteger.valueOf(950))), + submitter.getAccountId(), + submitter, + null, + 100); + + assertFalse(assembledTransaction.isReadCall()); + assertEquals( + assembledTransaction.needsNonInvokerSigningBy(false), + new HashSet<>(Arrays.asList(alice.getAccountId(), bob.getAccountId()))); + assembledTransaction.signAuthEntries(alice); + assertEquals( + assembledTransaction.needsNonInvokerSigningBy(false), + new HashSet<>(Collections.singletonList(bob.getAccountId()))); + assembledTransaction.signAuthEntries(bob); + assertEquals(assembledTransaction.needsNonInvokerSigningBy(false), new HashSet<>()); + SCVal result = assembledTransaction.signAndSubmit(submitter, false); + assertEquals(result, Scv.toVoid()); + client.close(); + } +}