diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava new file mode 100644 index 0000000..0eb8f20 --- /dev/null +++ b/src/main/tjava/com/r35157/cryptowallet/solana/SolanaWallet.tjava @@ -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. + * + *
Signing a transaction does not submit it to the Solana blockchain. + * Submission must be performed separately through {@code SolanaBlockChain}.
+ */ +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. + * + *The configured wallet must be a required signer for the supplied + * transaction. This method does not submit the resulting transaction + * to the Solana blockchain.
+ * + * @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; +} \ No newline at end of file diff --git a/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava new file mode 100644 index 0000000..9cbde97 --- /dev/null +++ b/src/main/tjava/com/r35157/cryptowallet/solana/impl/ref/SolanaWalletImpl.tjava @@ -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; +} \ No newline at end of file