67: Add generic awaitTransaction() support to SolanaBlockChain

This commit is contained in:
2026-08-11 11:42:41 +02:00
parent e7f551b8dd
commit cb2d3e2a16
11 changed files with 838 additions and 14 deletions
@@ -5,6 +5,7 @@ import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -277,4 +278,38 @@ public interface SolanaBlockChain {
ΩSolanaTransactionSignatureΩ sendTransaction(
SolanaSignedTransaction transaction
) throws IOException, InterruptedException;
/**
* Waits for an already submitted transaction to reach a commitment level.
*
* <p>This operation does not submit or resubmit the transaction. Both
* successful and failed transactions must reach or exceed the requested
* commitment before a definitive result is returned.</p>
*
* <p>{@link SolanaTransactionOutcome.Status#TIMED_OUT TIMED_OUT} means the
* outcome remains unknown: the transaction may still be confirmed,
* finalized, dropped, or otherwise unresolved. Callers must not
* automatically resubmit an equivalent transaction after a timeout.</p>
*
* @param signature Base58 transaction signature that decodes to exactly
* 64 bytes
* @param commitment minimum commitment required for a definitive outcome
* @param timeout strictly positive overall timeout, including RPC-gate,
* throttling, and HTTP time
* @return definitive success or on-chain failure at the requested
* commitment, or an unknown timed-out outcome
* @throws IllegalArgumentException if the signature is {@code null},
* blank, invalid Base58, not 64 bytes, or
* the timeout is zero or negative
* @throws NullPointerException if the commitment or timeout is null
* @throws IOException if communication fails or Solana returns an invalid
* HTTP, JSON-RPC, or protocol response
* @throws InterruptedException if the calling thread is interrupted while
* waiting for the RPC gate, throttling, or HTTP
*/
SolanaTransactionOutcome awaitTransaction(
ΩSolanaTransactionSignatureΩ signature,
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException;
}
@@ -0,0 +1,25 @@
package com.r35157.libs.solana;
/**
* Commitment level required when observing a Solana transaction.
*
* <p>The constants are ordered from the weakest to the strongest commitment:
* {@link #PROCESSED}, {@link #CONFIRMED}, then {@link #FINALIZED}. An observed
* level satisfies the same requested level and every weaker level.</p>
*/
public enum SolanaCommitment {
/**
* The transaction has been processed by the connected node.
*/
PROCESSED,
/**
* The transaction has been voted on by a supermajority of the cluster.
*/
CONFIRMED,
/**
* The transaction has been finalized by the cluster.
*/
FINALIZED
}
@@ -0,0 +1,92 @@
package com.r35157.libs.solana;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
/**
* Outcome observed for a Solana transaction at a requested commitment level.
*
* <p>A timed-out outcome remains unknown. It does not prove that the
* transaction failed, was rejected, expired, or will never land, and callers
* must not automatically resubmit an equivalent transaction.</p>
*
* @param status definitive success, definitive on-chain failure, or timeout
* @param slot transaction slot for a definitive outcome, otherwise
* {@code null}
* @param failureDetails compact JSON containing Solana's complete on-chain
* {@code err} value for a failed outcome, otherwise
* {@code null}
*/
public record SolanaTransactionOutcome(
@NotNull Status status,
@Nullable ΩSolanaSlotΩ slot,
@Nullable String failureDetails
) {
/**
* Validates the field invariants associated with the outcome status.
*/
public SolanaTransactionOutcome {
Objects.requireNonNull(status, "status");
switch (status) {
case SUCCEEDED -> {
if (slot == null) {
throw new IllegalArgumentException(
"A successful transaction outcome requires a slot"
);
}
if (failureDetails != null) {
throw new IllegalArgumentException(
"A successful transaction outcome must not contain "
+ "failure details"
);
}
}
case FAILED -> {
if (slot == null) {
throw new IllegalArgumentException(
"A failed transaction outcome requires a slot"
);
}
if (failureDetails == null || failureDetails.isBlank()) {
throw new IllegalArgumentException(
"A failed transaction outcome requires failure "
+ "details"
);
}
}
case TIMED_OUT -> {
if (slot != null || failureDetails != null) {
throw new IllegalArgumentException(
"A timed-out transaction outcome must not contain "
+ "a slot or failure details"
);
}
}
}
}
/**
* Classification of a transaction observation.
*/
public enum Status {
/**
* The transaction reached the requested commitment without an
* on-chain execution error.
*/
SUCCEEDED,
/**
* The transaction reached the requested commitment with a non-null
* Solana on-chain {@code err} value.
*/
FAILED,
/**
* The requested definitive outcome remained unknown at the deadline.
*/
TIMED_OUT
}
}
@@ -6,10 +6,12 @@ import com.r35157.libs.solana.SPLTokenHolding;
import com.r35157.libs.solana.SPLTokenSupply;
import com.r35157.libs.solana.SolanaAccountInfo;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaCommitment;
import com.r35157.libs.solana.SolanaLatestBlockhash;
import com.r35157.libs.solana.SolanaProgramAccountMemcmpFilter;
import com.r35157.libs.solana.SolanaProgramAddressSeed;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaTransactionOutcome;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.solana.valuetypes.SolanaProgramDerivedAddress;
import com.r35157.libs.solana.valuetypes.economic.SolanaSPLTokenProgram;
@@ -17,6 +19,7 @@ import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -210,6 +213,15 @@ public final class CachedSolanaBlockChain implements SolanaBlockChain {
return delegate.sendTransaction(transaction);
}
@Override
public SolanaTransactionOutcome awaitTransaction(
ΩSolanaTransactionSignatureΩ signature,
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException {
return delegate.awaitTransaction(signature, commitment, timeout);
}
@Override
public SolanaUnsignedTransaction buildSPLTokenTransferTransaction(
ΩSolanaAddressΩ sender,
@@ -24,10 +24,13 @@ import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.SOLANA_ID;
import static com.r35157.libs.solana.SolanaConstants.RPC_URL;
@@ -803,6 +806,256 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
return signature;
}
@Override
public SolanaTransactionOutcome awaitTransaction(
ΩSolanaTransactionSignatureΩ signature,
SolanaCommitment commitment,
Duration timeout
) throws IOException, InterruptedException {
long startedNanos = System.nanoTime();
validateAwaitTransactionArguments(signature, commitment, timeout);
Deadline deadline = Deadline.startingAt(startedNanos, timeout);
String requestBody = createGetSignatureStatusesBody(signature);
while (!deadline.hasExpired()) {
HttpResponse<String> response;
try {
response = sendThrottled(requestBody, deadline);
} catch (IOException e) {
if (deadline.hasExpired()) {
return timedOutTransactionOutcome();
}
throw new IOException(
"Solana getSignatureStatuses HTTP communication "
+ "failed for "
+ RPC_URL
+ ": "
+ e.getMessage(),
e
);
}
if (response == null || deadline.hasExpired()) {
return timedOutTransactionOutcome();
}
SolanaTransactionOutcome outcome = parseSignatureStatus(
response,
commitment
);
if (outcome != null) {
return deadline.hasExpired()
? timedOutTransactionOutcome()
: outcome;
}
}
return timedOutTransactionOutcome();
}
private void validateAwaitTransactionArguments(
ΩSolanaTransactionSignatureΩ signature,
SolanaCommitment commitment,
Duration timeout
) {
if (signature == null || signature.isBlank()) {
throw new IllegalArgumentException(
"Solana transaction signature must not be blank"
);
}
byte[] decodedSignature;
try {
decodedSignature = base58Decode(signature);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Solana transaction signature must be valid Base58",
e
);
}
if (decodedSignature.length != SOLANA_SIGNATURE_LENGTH) {
throw new IllegalArgumentException(
"Solana transaction signature must decode to exactly "
+ SOLANA_SIGNATURE_LENGTH
+ " bytes, but decoded to "
+ decodedSignature.length
);
}
Objects.requireNonNull(commitment, "commitment");
Objects.requireNonNull(timeout, "timeout");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException(
"Solana transaction await timeout must be greater than "
+ "zero"
);
}
}
private String createGetSignatureStatusesBody(
ΩSolanaTransactionSignatureΩ signature
) throws IOException {
ObjectNode request = objectMapper.createObjectNode();
request.put("jsonrpc", "2.0");
request.put("id", 1);
request.put("method", "getSignatureStatuses");
ArrayNode params = request.putArray("params");
params.addArray().add(signature);
params.addObject().put("searchTransactionHistory", true);
return objectMapper.writeValueAsString(request);
}
private SolanaTransactionOutcome parseSignatureStatus(
HttpResponse<String> response,
SolanaCommitment requestedCommitment
) throws IOException {
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException(
"Solana getSignatureStatuses RPC call failed: HTTP "
+ response.statusCode()
+ ": "
+ response.body()
);
}
JsonNode root;
try {
root = objectMapper.readTree(response.body());
} catch (IOException e) {
throw new IOException(
"Solana getSignatureStatuses response contained invalid "
+ "JSON: "
+ response.body(),
e
);
}
if (root == null || !root.isObject()) {
throw new IOException(
"Solana getSignatureStatuses response was not a JSON "
+ "object: "
+ response.body()
);
}
JsonNode errorNode = root.get("error");
if (errorNode != null && !errorNode.isNull()) {
throw new IOException(
"Solana getSignatureStatuses RPC error: "
+ errorNode.toString()
);
}
JsonNode resultNode = root.get("result");
JsonNode valueNode = resultNode == null
? null
: resultNode.get("value");
if (valueNode == null || !valueNode.isArray()) {
throw new IOException(
"Solana getSignatureStatuses response did not contain a "
+ "result.value array: "
+ response.body()
);
}
if (valueNode.size() != 1) {
throw new IOException(
"Solana getSignatureStatuses result.value must contain "
+ "exactly one element, but contained "
+ valueNode.size()
+ ": "
+ response.body()
);
}
JsonNode statusNode = valueNode.get(0);
if (statusNode == null || statusNode.isNull()) {
return null;
}
if (!statusNode.isObject()) {
throw new IOException(
"Solana getSignatureStatuses result contained a non-object "
+ "status: "
+ statusNode
);
}
JsonNode slotNode = statusNode.get("slot");
if (slotNode == null
|| !slotNode.isIntegralNumber()
|| !slotNode.canConvertToLong()
|| slotNode.longValue() < 0) {
throw new IOException(
"Solana getSignatureStatuses status contained an invalid "
+ "slot: "
+ slotNode
);
}
JsonNode confirmationStatusNode = statusNode.get(
"confirmationStatus"
);
if (confirmationStatusNode == null
|| !confirmationStatusNode.isTextual()) {
throw new IOException(
"Solana getSignatureStatuses status did not contain a "
+ "textual confirmationStatus: "
+ statusNode
);
}
SolanaCommitment observedCommitment = parseCommitment(
confirmationStatusNode.asText()
);
if (!statusNode.has("err")) {
throw new IOException(
"Solana getSignatureStatuses status did not contain err: "
+ statusNode
);
}
if (observedCommitment.ordinal() < requestedCommitment.ordinal()) {
return null;
}
ΩSolanaSlotΩ slot = slotNode.longValue();
JsonNode transactionError = statusNode.get("err");
if (transactionError == null || transactionError.isNull()) {
return new SolanaTransactionOutcome(
SolanaTransactionOutcome.Status.SUCCEEDED,
slot,
null
);
}
return new SolanaTransactionOutcome(
SolanaTransactionOutcome.Status.FAILED,
slot,
transactionError.toString()
);
}
private SolanaCommitment parseCommitment(String confirmationStatus)
throws IOException {
return switch (confirmationStatus) {
case "processed" -> SolanaCommitment.PROCESSED;
case "confirmed" -> SolanaCommitment.CONFIRMED;
case "finalized" -> SolanaCommitment.FINALIZED;
default -> throw new IOException(
"Solana getSignatureStatuses status contained unknown "
+ "confirmationStatus: "
+ confirmationStatus
);
};
}
private static SolanaTransactionOutcome timedOutTransactionOutcome() {
return new SolanaTransactionOutcome(
SolanaTransactionOutcome.Status.TIMED_OUT,
null,
null
);
}
private String createSendTransactionBody(
SolanaSignedTransaction transaction
) throws IOException {
@@ -1091,29 +1344,98 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
""".formatted(programId, filtersJson);
}
private synchronized HttpResponse<String> sendThrottled(HttpRequest request) throws IOException, InterruptedException {
waitBeforeRemoteCall();
private HttpResponse<String> sendThrottled(HttpRequest request)
throws IOException, InterruptedException {
rpcGate.lockInterruptibly();
try {
waitBeforeRemoteCall();
try {
return httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
);
} finally {
previousRemoteCallTimeNanos = System.nanoTime();
hasCompletedRemoteCall = true;
}
} finally {
rpcGate.unlock();
}
}
private void waitBeforeRemoteCall() throws InterruptedException {
while (hasCompletedRemoteCall) {
long elapsed = System.nanoTime() - previousRemoteCallTimeNanos;
long remaining = MINIMUM_REMOTE_CALL_INTERVAL_NANOS - elapsed;
if (remaining <= 0) {
return;
}
TimeUnit.NANOSECONDS.sleep(remaining);
}
}
private HttpResponse<String> sendThrottled(
String requestBody,
Deadline deadline
) throws IOException, InterruptedException {
long remaining = deadline.remainingNanos();
if (remaining <= 0
|| !rpcGate.tryLock(remaining, TimeUnit.NANOSECONDS)) {
return null;
}
boolean requestStarted = false;
try {
if (!waitBeforeRemoteCall(deadline)) {
return null;
}
remaining = deadline.remainingNanos();
if (remaining <= 0) {
return null;
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(RPC_URL))
.timeout(Duration.ofNanos(remaining))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
requestStarted = true;
return httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
);
} finally {
previousRemoteCallTime = System.currentTimeMillis();
if (requestStarted) {
previousRemoteCallTimeNanos = System.nanoTime();
hasCompletedRemoteCall = true;
}
rpcGate.unlock();
}
}
private void waitBeforeRemoteCall() throws InterruptedException {
long now = System.currentTimeMillis();
long elapsed = now - previousRemoteCallTime;
private boolean waitBeforeRemoteCall(Deadline deadline)
throws InterruptedException {
while (hasCompletedRemoteCall) {
long elapsed = System.nanoTime() - previousRemoteCallTimeNanos;
long throttleRemaining = MINIMUM_REMOTE_CALL_INTERVAL_NANOS
- elapsed;
if (throttleRemaining <= 0) {
return true;
}
if (elapsed < MINIMUM_REMOTE_CALL_INTERVAL) {
ΩmilliSecondsΩ sleepTime = MINIMUM_REMOTE_CALL_INTERVAL - elapsed;
//System.out.println("Throttling Solana request for " + sleepTime + "ms...");
Thread.sleep(sleepTime);
//System.out.println("Ready");
long deadlineRemaining = deadline.remainingNanos();
if (deadlineRemaining <= 0) {
return false;
}
TimeUnit.NANOSECONDS.sleep(
Math.min(throttleRemaining, deadlineRemaining)
);
}
return !deadline.hasExpired();
}
private boolean isSolanaNFTCandidate(SPLTokenHolding tokenHolding) {
@@ -1389,6 +1711,33 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
) {
}
private record Deadline(long startedNanos, long timeoutNanos) {
private static Deadline startingAt(
long startedNanos,
Duration timeout
) {
long timeoutNanos;
try {
timeoutNanos = timeout.toNanos();
} catch (ArithmeticException e) {
timeoutNanos = Long.MAX_VALUE;
}
return new Deadline(startedNanos, timeoutNanos);
}
private long remainingNanos() {
long elapsed = System.nanoTime() - startedNanos;
if (elapsed < 0 || elapsed >= timeoutNanos) {
return 0;
}
return timeoutNanos - elapsed;
}
private boolean hasExpired() {
return remainingNanos() <= 0;
}
}
private static final String SYSTEM_PROGRAM_ADDRESS =
"11111111111111111111111111111111";
private static final String ASSOCIATED_TOKEN_PROGRAM_ADDRESS =
@@ -1398,7 +1747,8 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
private static final int SOLANA_ADDRESS_LENGTH = 32;
private static final int SOLANA_SIGNATURE_LENGTH = 64;
private static final ΩAmountΩ LAMPORTS_PER_SOL = new BigDecimal("1000000000");
private static final ΩmilliSecondsΩ MINIMUM_REMOTE_CALL_INTERVAL = 5000L;
private static final long MINIMUM_REMOTE_CALL_INTERVAL_NANOS =
TimeUnit.SECONDS.toNanos(5);
private static final byte[] PROGRAM_DERIVED_ADDRESS_MARKER = "ProgramDerivedAddress".getBytes(StandardCharsets.UTF_8);
private static final String BASE58_ALPHABET_STRING = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
private static final char[] BASE58_ALPHABET = BASE58_ALPHABET_STRING.toCharArray();
@@ -1411,5 +1761,7 @@ public class SolanaBlockChainImpl implements SolanaBlockChain {
private final CurrencyIdentityService currencyIdentityService;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private ΩmilliSecondsΩ previousRemoteCallTime = 0L;
private final ReentrantLock rpcGate = new ReentrantLock();
private long previousRemoteCallTimeNanos;
private boolean hasCompletedRemoteCall;
}