31: Add a minimal Solana wallet API and external-signer implementation

This commit is contained in:
2026-08-05 10:12:38 +02:00
parent 2e61011a68
commit 6a352e90e9
2 changed files with 186 additions and 0 deletions
@@ -0,0 +1,54 @@
package com.r35157.cryptowallet.solana;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
/**
* A Solana wallet capable of identifying itself and signing transactions.
*
* <p>Signing a transaction does not submit it to the Solana blockchain.
* Submission must be performed separately through {@code SolanaBlockChain}.</p>
*/
public interface SolanaWallet {
/**
* Returns the public Solana address controlled by this wallet.
*
* @return the wallet's public address
*/
@NotNull
ΩSolanaWalletIdΩ getAddress();
/**
* Returns the native SOL balance of this wallet.
*
* @return the wallet's native SOL balance
* @throws IOException if the balance cannot be fetched or decoded
* @throws InterruptedException if the calling thread is interrupted
*/
@NotNull
ΩSolanaAmountΩ getSolanaBalance()
throws IOException, InterruptedException;
/**
* Signs an unsigned Solana transaction.
*
* <p>The configured wallet must be a required signer for the supplied
* transaction. This method does not submit the resulting transaction
* to the Solana blockchain.</p>
*
* @param transaction the unsigned transaction to sign
* @return the transaction containing the wallet's required signature
* @throws IllegalArgumentException if the transaction is missing or blank
* @throws IOException if the transaction cannot be signed or the signer
* returns an invalid response
* @throws InterruptedException if the signing process is interrupted
*/
@NotNull
SolanaSignedTransaction signTransaction(
@NotNull SolanaUnsignedTransaction transaction
) throws IOException, InterruptedException;
}
@@ -0,0 +1,132 @@
package com.r35157.cryptowallet.solana.impl.ref;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.libs.solana.SolanaBlockChain;
import com.r35157.libs.solana.SolanaSignedTransaction;
import com.r35157.libs.solana.SolanaUnsignedTransaction;
import com.r35157.libs.valuetypes.basic.MoneyAmount;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.jetbrains.annotations.NotNull;
public class SolanaWalletImpl implements SolanaWallet {
public SolanaWalletImpl(
ΩSolanaWalletIdΩ address,
String signerKeyName,
SolanaBlockChain solanaBlockChain
) {
this.address = address;
this.signerKeyName = signerKeyName;
this.solanaBlockChain = solanaBlockChain;
}
@Override
public @NotNull ΩSolanaWalletIdΩ getAddress() {
return address;
}
@Override
public @NotNull ΩSolanaAmountΩ getSolanaBalance()
throws IOException, InterruptedException {
return solanaBlockChain.getBalanceInSolana(address);
}
@Override
public @NotNull SolanaSignedTransaction signTransaction(
@NotNull SolanaUnsignedTransaction transaction
) throws IOException, InterruptedException {
if (transaction.serializedTransaction() == null
|| transaction.serializedTransaction().isBlank()) {
throw new IllegalArgumentException(
"Unsigned serialized transaction must not be blank"
);
}
Process process = new ProcessBuilder(
"jup",
"sign",
"-f",
"json",
"--key",
signerKeyName,
"--tx",
transaction.serializedTransaction()
).start();
String standardOutput = new String(
process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8
);
String standardError = new String(
process.getErrorStream().readAllBytes(),
StandardCharsets.UTF_8
);
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IOException(
"Solana transaction signing failed with exit code "
+ exitCode
+ ": "
+ standardError
);
}
SignerResponse response;
try {
response = OBJECT_MAPPER.readValue(
standardOutput,
SignerResponse.class
);
} catch (JsonProcessingException e) {
throw new IOException(
"Unable to decode signer response: " + standardOutput,
e
);
}
if (response.signer() == null || response.signer().isBlank()) {
throw new IOException(
"Signer response did not contain a signer address"
);
}
if (!address.equals(response.signer())) {
throw new IOException(
"Unexpected signer address. Expected "
+ address
+ " but received "
+ response.signer()
);
}
if (response.signedTransaction() == null
|| response.signedTransaction().isBlank()) {
throw new IOException(
"Signer response did not contain a signed transaction"
);
}
ΩBase64StringΩ signedTransaction = response.signedTransaction();
SolanaSignedTransaction sst = new SolanaSignedTransaction(signedTransaction);
return sst;
}
private record SignerResponse(
String signer,
ΩBase64StringΩ signedTransaction
) {
}
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ΩSolanaWalletIdΩ address;
private final String signerKeyName;
private final SolanaBlockChain solanaBlockChain;
}