84: Validate 'jup' and the configured signer when constructing SolanaWalletImpl

This commit is contained in:
2026-09-04 11:39:29 +02:00
parent b6a6d544b5
commit f1dd5ed9b1
7 changed files with 449 additions and 1 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-04
@@ -0,0 +1,53 @@
## Context
See `proposal.md` for motivation. `SolanaWalletImpl` invokes the external `jup` CLI only when signing, while the Hub constructs and registers several wallet instances during passive component initialization. The constructor currently has no checked-exception contract, and the existing signing flow already validates the signer address returned after an actual transaction is signed.
## Goals / Non-Goals
**Goals:**
- Establish all locally knowable CLI and signer invariants when a wallet is constructed.
- Preserve clear startup propagation without expanding the public wallet API.
- Keep diagnostics useful while exposing no private key data.
**Non-Goals:**
- Supporting multiple `jup` versions or a compatibility range.
- Installing, updating, importing, selecting, or repairing `jup` keys.
- Replacing the existing per-transaction signer-address check.
- Adding automated tests or changing Detag configuration.
## Decisions
### Require an exact CLI version and fail construction on mismatch
The constructor runs `jup --version` and accepts only `0.10.1`. Exact failure is chosen over a warning because the CLI arguments and JSON representation form an external integration contract that has only been verified for that version. Allowing construction after a mismatch would weaken the component-readiness guarantee that motivates the change.
A version constant remains next to the wallet implementation so a future CLI upgrade requires an intentional source change and review. A compatibility range was rejected because no evidence currently establishes compatibility with other versions.
### Validate the key catalogue instead of signing a probe transaction
After the version check, construction runs `jup -f json keys list`. The JSON catalogue provides the signer name and public address needed to prove configuration without creating, signing, or submitting a transaction. A signing probe was rejected because it would require manufacturing transaction data and would couple startup to additional Solana semantics.
The catalogue is read as a JSON tree and only the `name` and `address` fields are inspected. This tolerates unrelated metadata added by `jup` while still rejecting a missing array, missing signer, or invalid address. Raw catalogue output is not copied into diagnostics so future CLI additions cannot accidentally disclose credential material.
### Preserve constructor signatures with unchecked configuration failures
External process, decoding, version, key, and address failures are wrapped in descriptive `IllegalStateException`s. This preserves existing constructor and Hub method signatures while allowing the failure to propagate out of passive graph construction and prevent the Hub from announcing itself online.
Invalid direct constructor arguments retain argument/null exception categories. If process waiting is interrupted, the child process is terminated, the interrupted status is restored, and construction fails with the interruption retained as the cause.
### Retain post-signing address verification
Construction-time validation proves the configured key catalogue at one point in time. The existing check of the signer address returned by every later signing response remains necessary defence in depth against configuration changes or unexpected CLI behavior after construction.
## Risks / Trade-offs
- [Each wallet construction starts two subprocesses] → Accept the small startup cost so every instance validates current external state independently.
- [An otherwise compatible future `jup` release stops startup] → Make upgrades explicit by reviewing the CLI contract and updating the expected version constant.
- [The key catalogue changes after construction] → Retain runtime signer-address validation for every signed transaction.
- [A process produces more output than expected] → Keep preflight commands narrowly scoped to version and key metadata and fail on command or read errors.
## Migration Plan
Before deployment, install `jup` version `0.10.1` and import every configured signer under its expected name and wallet address. Start Nenjim and allow constructor validation to gate the remaining startup sequence. If deployment must be rolled back, restore the previous application artifact; no persisted application data or public API migration is involved.
@@ -0,0 +1,25 @@
## Why
Nenjim currently constructs Solana wallet components without proving that their external `jup` signer configuration is usable. A missing production signer was therefore discovered only during transaction signing, after the operation could already have an uncertain outcome.
## What Changes
- Make `SolanaWalletImpl` validate its required constructor inputs before use.
- Run `jup --version` during construction and require the explicitly supported version `0.10.1`.
- Load the `jup` key catalogue during construction, require the configured signer name to exist, and require its public address to match the wallet address.
- Fail construction with actionable diagnostics when the executable, version, key catalogue, signer, or address is invalid, without exposing private key material.
- Retain signer-address verification after each actual signing operation as defence in depth.
## Capabilities
### New Capabilities
- `solana-wallet-signing`: Defines fail-fast construction and external signer validation for the reference Solana wallet implementation.
### Modified Capabilities
None.
## Impact
The reference implementation at `com.r35157.cryptowallet.solana.impl.ref.SolanaWalletImpl` gains a startup dependency on executable `jup` version `0.10.1` and a matching configured signer. Nenjim Hub initialization will now fail before registering the complete passive graph when a configured wallet cannot later sign transactions. No public API signatures, Detag configuration, generated sources, tests, or unrelated services change.
@@ -0,0 +1,78 @@
## Purpose
Define how a reference Solana wallet proves during construction that its external signer is compatible, available, and bound to the configured wallet address.
## ADDED Requirements
### Requirement: Wallet signing configuration is complete
The reference Solana wallet SHALL require a non-blank wallet address, a non-blank signer key name, and a non-null Solana blockchain dependency before validating any external signer state. Invalid direct inputs SHALL fail construction with an argument or null exception that identifies the invalid field.
#### Scenario: Complete direct configuration
- **WHEN** construction receives a non-blank wallet address, a non-blank signer key name, and a Solana blockchain dependency
- **THEN** external signer validation begins
#### Scenario: Invalid direct configuration
- **WHEN** the wallet address or signer key name is null or blank, or the Solana blockchain dependency is null
- **THEN** construction fails before executing an external signer command and identifies the invalid field
### Requirement: Construction requires the supported jup version
The reference Solana wallet SHALL execute `jup --version` during every construction, SHALL require that command to start and exit successfully, and SHALL accept exactly version `0.10.1`. A missing executable, process I/O failure, non-zero exit, blank or malformed version, unsupported version, or interruption SHALL prevent construction with an actionable `IllegalStateException`; interruption SHALL also restore the constructing thread's interrupted status.
#### Scenario: Supported jup version
- **WHEN** `jup --version` starts successfully, exits with zero, and reports `0.10.1`
- **THEN** construction proceeds to signer-key validation
#### Scenario: jup cannot execute successfully
- **WHEN** `jup --version` cannot be started, its output cannot be read, or it exits with a non-zero status
- **THEN** construction fails with a diagnostic identifying the command, signer key name, expected version, and available exit or error details
#### Scenario: jup version output is invalid
- **WHEN** `jup --version` exits successfully but reports a blank or malformed semantic version
- **THEN** construction fails with a diagnostic containing the expected version and reported output
#### Scenario: jup version is unsupported
- **WHEN** `jup --version` reports a valid semantic version other than `0.10.1`
- **THEN** construction fails with a diagnostic containing both expected and actual versions
#### Scenario: Version check is interrupted
- **WHEN** the constructing thread is interrupted while awaiting `jup --version`
- **THEN** construction fails, the child process is terminated, and the thread's interrupted status is restored
### Requirement: Construction validates signer identity
After validating the supported CLI version, the reference Solana wallet SHALL obtain the `jup` key catalogue as JSON, require a key whose name exactly matches the configured signer key name, require that key to contain a non-blank public address, and require that address to equal the configured wallet address. Command, I/O, JSON, missing-key, missing-address, and address-mismatch failures SHALL prevent construction with an actionable `IllegalStateException`.
#### Scenario: Configured signer matches the wallet
- **WHEN** the key catalogue contains the configured signer key name with a public address equal to the configured wallet address
- **THEN** wallet construction succeeds
#### Scenario: Key catalogue cannot be obtained
- **WHEN** the key-list command cannot be started, read, or completed successfully
- **THEN** construction fails with a diagnostic identifying the command, signer key name, and available exit or error details
#### Scenario: Key catalogue is malformed
- **WHEN** the key-list command succeeds but does not return a JSON array that can be decoded
- **THEN** construction fails with a diagnostic identifying the configured signer key name
#### Scenario: Configured signer is unavailable
- **WHEN** the key catalogue does not contain a key whose name exactly matches the configured signer key name
- **THEN** construction fails with a diagnostic identifying that signer key name
#### Scenario: Signer address is missing
- **WHEN** the matching key has no non-blank public address
- **THEN** construction fails with a diagnostic containing the signer key name and expected wallet address
#### Scenario: Signer address differs from the wallet
- **WHEN** the matching key's public address differs from the configured wallet address
- **THEN** construction fails with a diagnostic containing the signer key name, expected wallet address, and actual public address
#### Scenario: Validation diagnostics protect key material
- **WHEN** any signer validation fails
- **THEN** its diagnostic contains only the relevant command, name, public address, version, exit status, and stderr context and does not disclose private key material
### Requirement: Signing retains runtime identity validation
The reference Solana wallet SHALL continue to require every successful signing response to identify the configured wallet address before accepting its signed transaction, even after construction-time signer validation has succeeded.
#### Scenario: Signing response identifies another wallet
- **WHEN** a later signing response contains a signer address different from the configured wallet address
- **THEN** signing fails without accepting the returned signed transaction
@@ -0,0 +1,13 @@
## 1. Wallet Construction Validation
- [x] 1.1 Validate the wallet address, signer key name, and blockchain dependency before invoking `jup`.
- [x] 1.2 Execute `jup --version` during construction and fail unless it reports exactly `0.10.1`.
- [x] 1.3 Decode `jup -f json keys list`, require the configured signer, and require its public address to match the wallet address.
- [x] 1.4 Report process, version, key, and address failures without exposing private key data, and preserve interruption status.
- [x] 1.5 Retain signer-address validation in the transaction-signing response flow.
## 2. Verification
- [x] 2.1 Compile main and test source sets and run the existing test suite without adding or modifying automated tests.
- [x] 2.2 Run strict OpenSpec validation and `git diff --check`.
- [x] 2.3 Review the implementation and artifacts for exact-version failure, signer identity validation, safe diagnostics, and no generated-source edits.
@@ -0,0 +1,79 @@
# solana-wallet-signing Specification
## Purpose
Define how a reference Solana wallet proves during construction that its external signer is compatible, available, and bound to the configured wallet address.
## Requirements
### Requirement: Wallet signing configuration is complete
The reference Solana wallet SHALL require a non-blank wallet address, a non-blank signer key name, and a non-null Solana blockchain dependency before validating any external signer state. Invalid direct inputs SHALL fail construction with an argument or null exception that identifies the invalid field.
#### Scenario: Complete direct configuration
- **WHEN** construction receives a non-blank wallet address, a non-blank signer key name, and a Solana blockchain dependency
- **THEN** external signer validation begins
#### Scenario: Invalid direct configuration
- **WHEN** the wallet address or signer key name is null or blank, or the Solana blockchain dependency is null
- **THEN** construction fails before executing an external signer command and identifies the invalid field
### Requirement: Construction requires the supported jup version
The reference Solana wallet SHALL execute `jup --version` during every construction, SHALL require that command to start and exit successfully, and SHALL accept exactly version `0.10.1`. A missing executable, process I/O failure, non-zero exit, blank or malformed version, unsupported version, or interruption SHALL prevent construction with an actionable `IllegalStateException`; interruption SHALL also restore the constructing thread's interrupted status.
#### Scenario: Supported jup version
- **WHEN** `jup --version` starts successfully, exits with zero, and reports `0.10.1`
- **THEN** construction proceeds to signer-key validation
#### Scenario: jup cannot execute successfully
- **WHEN** `jup --version` cannot be started, its output cannot be read, or it exits with a non-zero status
- **THEN** construction fails with a diagnostic identifying the command, signer key name, expected version, and available exit or error details
#### Scenario: jup version output is invalid
- **WHEN** `jup --version` exits successfully but reports a blank or malformed semantic version
- **THEN** construction fails with a diagnostic containing the expected version and reported output
#### Scenario: jup version is unsupported
- **WHEN** `jup --version` reports a valid semantic version other than `0.10.1`
- **THEN** construction fails with a diagnostic containing both expected and actual versions
#### Scenario: Version check is interrupted
- **WHEN** the constructing thread is interrupted while awaiting `jup --version`
- **THEN** construction fails, the child process is terminated, and the thread's interrupted status is restored
### Requirement: Construction validates signer identity
After validating the supported CLI version, the reference Solana wallet SHALL obtain the `jup` key catalogue as JSON, require a key whose name exactly matches the configured signer key name, require that key to contain a non-blank public address, and require that address to equal the configured wallet address. Command, I/O, JSON, missing-key, missing-address, and address-mismatch failures SHALL prevent construction with an actionable `IllegalStateException`.
#### Scenario: Configured signer matches the wallet
- **WHEN** the key catalogue contains the configured signer key name with a public address equal to the configured wallet address
- **THEN** wallet construction succeeds
#### Scenario: Key catalogue cannot be obtained
- **WHEN** the key-list command cannot be started, read, or completed successfully
- **THEN** construction fails with a diagnostic identifying the command, signer key name, and available exit or error details
#### Scenario: Key catalogue is malformed
- **WHEN** the key-list command succeeds but does not return a JSON array that can be decoded
- **THEN** construction fails with a diagnostic identifying the configured signer key name
#### Scenario: Configured signer is unavailable
- **WHEN** the key catalogue does not contain a key whose name exactly matches the configured signer key name
- **THEN** construction fails with a diagnostic identifying that signer key name
#### Scenario: Signer address is missing
- **WHEN** the matching key has no non-blank public address
- **THEN** construction fails with a diagnostic containing the signer key name and expected wallet address
#### Scenario: Signer address differs from the wallet
- **WHEN** the matching key's public address differs from the configured wallet address
- **THEN** construction fails with a diagnostic containing the signer key name, expected wallet address, and actual public address
#### Scenario: Validation diagnostics protect key material
- **WHEN** any signer validation fails
- **THEN** its diagnostic contains only the relevant command, name, public address, version, exit status, and stderr context and does not disclose private key material
### Requirement: Signing retains runtime identity validation
The reference Solana wallet SHALL continue to require every successful signing response to identify the configured wallet address before accepting its signed transaction, even after construction-time signer validation has succeeded.
#### Scenario: Signing response identifies another wallet
- **WHEN** a later signing response contains a signer address different from the configured wallet address
- **THEN** signing fails without accepting the returned signed transaction
@@ -1,6 +1,7 @@
package com.r35157.cryptowallet.solana.impl.ref;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.r35157.cryptowallet.solana.SolanaWallet;
import com.r35157.cryptowallet.solana.MissingRecipientTokenAccountPolicy;
@@ -28,9 +29,202 @@ public class SolanaWalletImpl implements SolanaWallet {
String signerKeyName,
SolanaBlockChain solanaBlockChain
) {
if (address == null || address.isBlank()) {
throw new IllegalArgumentException(
"Solana wallet address must not be blank"
);
}
if (signerKeyName == null || signerKeyName.isBlank()) {
throw new IllegalArgumentException(
"Solana wallet signer key name must not be blank"
);
}
this.address = address;
this.signerKeyName = signerKeyName;
this.solanaBlockChain = solanaBlockChain;
this.solanaBlockChain = Objects.requireNonNull(
solanaBlockChain,
"Solana blockchain must not be null"
);
validateJupConfiguration();
}
private void validateJupConfiguration() {
JupCommandResult versionResult = runJupCommand(
"jup --version",
"--version"
);
String actualVersion = versionResult.standardOutput().trim();
if (!actualVersion.matches("\\d+\\.\\d+\\.\\d+")) {
throw new IllegalStateException(
"Invalid jup version while validating Solana wallet "
+ "signer '"
+ signerKeyName
+ "': expected version "
+ EXPECTED_JUP_VERSION
+ " but jup --version returned '"
+ actualVersion
+ "'"
);
}
// An exact match is intentional: the CLI arguments and JSON response
// structure used by this class are verified only for this version.
if (!EXPECTED_JUP_VERSION.equals(actualVersion)) {
throw new IllegalStateException(
"Unsupported jup version while validating Solana wallet "
+ "signer '"
+ signerKeyName
+ "': expected "
+ EXPECTED_JUP_VERSION
+ " but found "
+ actualVersion
);
}
JupCommandResult keysResult = runJupCommand(
"jup -f json keys list",
"-f",
"json",
"keys",
"list"
);
JsonNode keys;
try {
keys = OBJECT_MAPPER.readTree(keysResult.standardOutput());
} catch (JsonProcessingException e) {
throw new IllegalStateException(
"Unable to decode the jup key list while validating "
+ "Solana wallet signer '"
+ signerKeyName
+ "'",
e
);
}
if (keys == null || !keys.isArray()) {
throw new IllegalStateException(
"jup returned an invalid key list while validating "
+ "Solana wallet signer '"
+ signerKeyName
+ "': expected a JSON array"
);
}
JsonNode signerKey = null;
for (JsonNode key : keys) {
if (signerKeyName.equals(key.path("name").textValue())) {
signerKey = key;
break;
}
}
if (signerKey == null) {
throw new IllegalStateException(
"Solana wallet signer key '"
+ signerKeyName
+ "' is not available to jup"
);
}
ΩSolanaWalletIdΩ signerAddress = signerKey
.path("address")
.textValue();
if (signerAddress == null || signerAddress.isBlank()) {
throw new IllegalStateException(
"jup signer key '"
+ signerKeyName
+ "' does not contain a valid address; expected "
+ address
);
}
if (!address.equals(signerAddress)) {
throw new IllegalStateException(
"jup signer key '"
+ signerKeyName
+ "' has an unexpected address: expected "
+ address
+ " but found "
+ signerAddress
);
}
}
private JupCommandResult runJupCommand(
String commandDescription,
String... arguments
) {
String[] command = new String[arguments.length + 1];
command[0] = "jup";
System.arraycopy(arguments, 0, command, 1, arguments.length);
Process process;
try {
process = new ProcessBuilder(command).start();
} catch (IOException e) {
throw new IllegalStateException(
"Unable to start required jup CLI command '"
+ commandDescription
+ "' while validating Solana wallet signer '"
+ signerKeyName
+ "'. Ensure jup version "
+ EXPECTED_JUP_VERSION
+ " is installed and available on PATH",
e
);
}
try {
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 IllegalStateException(
"Required jup CLI command '"
+ commandDescription
+ "' failed while validating Solana wallet "
+ "signer '"
+ signerKeyName
+ "' with exit code "
+ exitCode
+ ": "
+ standardError.trim()
);
}
return new JupCommandResult(standardOutput);
} catch (IOException e) {
process.destroyForcibly();
throw new IllegalStateException(
"Unable to read the result of required jup CLI command '"
+ commandDescription
+ "' while validating Solana wallet signer '"
+ signerKeyName
+ "'",
e
);
} catch (InterruptedException e) {
process.destroyForcibly();
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Interrupted while running required jup CLI command '"
+ commandDescription
+ "' for Solana wallet signer '"
+ signerKeyName
+ "'",
e
);
}
}
@Override
@@ -475,6 +669,10 @@ public class SolanaWalletImpl implements SolanaWallet {
) {
}
private record JupCommandResult(String standardOutput) {
}
private static final String EXPECTED_JUP_VERSION = "0.10.1";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final ΩSolanaWalletIdΩ address;