fix regresssion of json wallet serialization with jdk25
What changed, and why it matters
This commit fixes a regression in how Sparrow Wallet saves and loads wallet data as JSON when running on newer Java versions (JDK 25). Previously, the app relied on a deep Java reflection trick to serialize certain objects, which no longer works in JDK 25. The fix removes that fragile dependency and explicitly tells the JSON library how to construct and serialize each wallet-related class. It is a compatibility/maintenance fix, not a patch for an active security vulnerability, but it touches code that handles encrypted wallet data and private-key material, so correctness matters for user safety.
Treat as a routine compatibility fix with security-adjacent risk. Review that each new InstanceCreator produces a valid default object that Gson can safely populate via reflection, and verify round-trip serialization tests pass for all wallet types. No urgent security patch is indicated, but ensure the change is included in the next release to avoid data-loss or startup failures on JDK 25.
Security signals we found
Touches serialization/deserialization of encrypted wallet structures (EncryptedData, DeterministicSeed, MasterPrivateExtendedKey)
Removes JVM reflective openness for java.base/java.io to Gson
Adds explicit instance creators for classes that previously relied on sun.misc.Unsafe fallback
Disables Gson JdkUnsafe to make tests fail consistently with production behavior
Fixes a regression that could prevent wallet JSON persistence from functioning on JDK 25
Evidence from the diff
The change removes –add-opens=java.base/java.io=com.google.gson JVM arguments and adds explicit Gson InstanceCreators and a custom File serializer/deserializer in JsonPersistence.java. The reason is that Gson’s fallback to sun.misc.Unsafe for classes lacking no-arg constructors is unavailable on the module path, and JDK 25 has tightened reflective access. The patch disables Unsafe (disableJdkUnsafe) and registers instance creators for Policy, Miniscript, KeyDerivation, WalletNode, BlockTransaction, BlockTransactionHashIndex, DeterministicSeed, MasterPrivateExtendedKey, EncryptedData, EncryptionType, PaymentCode, UtxoMixData, and WalletTable. A custom FileSerializer/FileDeserializer preserves the previous JSON shape for java.io.File without opening java.base/java.io to Gson.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.javabuild.gradle JVM arguments for test, application, and jlink tasksInspect captured patch +50 / −3
diff --git a/build.gradle b/build.gradle
index b6e172a..9e53dcd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -126,7 +126,7 @@ compileJava {
test {
useJUnitPlatform()
- jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--enable-native-access=ALL-UNNAMED"]
+ jvmArgs = ["--enable-native-access=ALL-UNNAMED"]
}
application {
@@ -153,7 +153,6 @@ application {
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
- "--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
"--add-reads=org.flywaydb.core=java.desktop"]
@@ -231,7 +230,6 @@ jlink {
"--add-opens=javafx.graphics/javafx.scene.input=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
- "--add-opens=java.base/java.io=com.google.gson",
"--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop",
"--add-reads=com.sparrowwallet.merged.module=java.sql",
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java
index 72b4005..46f1248 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/JsonPersistence.java
@@ -4,18 +4,30 @@ import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.FileType;
import com.sparrowwallet.drongo.IOUtils;
+import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
+import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.crypto.EncryptedData;
+import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
+import com.sparrowwallet.drongo.policy.Miniscript;
+import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
+import com.sparrowwallet.drongo.wallet.BlockTransaction;
+import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
+import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
+import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
+import com.sparrowwallet.drongo.wallet.UtxoMixData;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
+import com.sparrowwallet.drongo.wallet.WalletTable;
import java.io.*;
import java.lang.reflect.Type;
@@ -340,6 +352,26 @@ public class JsonPersistence implements Persistence {
gsonBuilder.registerTypeAdapter(Address.class, new AddressDeserializer());
gsonBuilder.registerTypeAdapter(PolicyType.class, new PolicyTypeSerializer());
gsonBuilder.registerTypeAdapter(PolicyType.class, new PolicyTypeDeserializer());
+ gsonBuilder.registerTypeAdapter(File.class, new FileSerializer());
+ gsonBuilder.registerTypeAdapter(File.class, new FileDeserializer());
+
+ //Instance creators are required for classes without no-args constructors, since Gson's fallback of sun.misc.Unsafe is unavailable on the module path (jdk.unsupported is not resolved)
+ //Unsafe is also explicitly disabled below so that classpath-based tests fail in the same way production does if a class is missed here
+ gsonBuilder.disableJdkUnsafe();
+ gsonBuilder.registerTypeAdapter(Policy.class, (InstanceCreator<Policy>) type -> new Policy(null));
+ gsonBuilder.registerTypeAdapter(Miniscript.class, (InstanceCreator<Miniscript>) type -> new Miniscript(null));
+ gsonBuilder.registerTypeAdapter(KeyDerivation.class, (InstanceCreator<KeyDerivation>) type -> new KeyDerivation(null, (String)null));
+ gsonBuilder.registerTypeAdapter(WalletNode.class, (InstanceCreator<WalletNode>) type -> new WalletNode("m/0"));
+ gsonBuilder.registerTypeAdapter(BlockTransaction.class, (InstanceCreator<BlockTransaction>) type -> new BlockTransaction(null, 0, null, null, null));
+ gsonBuilder.registerTypeAdapter(BlockTransactionHashIndex.class, (InstanceCreator<BlockTransactionHashIndex>) type -> new BlockTransactionHashIndex(null, 0, null, null, 0, 0));
+ gsonBuilder.registerTypeAdapter(DeterministicSeed.class, (InstanceCreator<DeterministicSeed>) type -> new DeterministicSeed((EncryptedData)null, false, 0, null));
+ gsonBuilder.registerTypeAdapter(MasterPrivateExtendedKey.class, (InstanceCreator<MasterPrivateExtendedKey>) type -> new MasterPrivateExtendedKey((EncryptedData)null));
+ gsonBuilder.registerTypeAdapter(EncryptedData.class, (InstanceCreator<EncryptedData>) type -> new EncryptedData(new byte[0], new byte[0], null, (EncryptionType)null));
+ gsonBuilder.registerTypeAdapter(EncryptionType.class, (InstanceCreator<EncryptionType>) type -> new EncryptionType(null, null));
+ gsonBuilder.registerTypeAdapter(PaymentCode.class, (InstanceCreator<PaymentCode>) type -> new PaymentCode(new byte[33], new byte[32]));
+ gsonBuilder.registerTypeAdapter(UtxoMixData.class, (InstanceCreator<UtxoMixData>) type -> new UtxoMixData(0, null));
+ gsonBuilder.registerTypeAdapter(WalletTable.class, (InstanceCreator<WalletTable>) type -> new WalletTable(null, null, 0, null));
+
if(includeWalletSerializers) {
gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeSerializer());
@@ -403,6 +435,23 @@ public class JsonPersistence implements Persistence {
}
}
+ //Maintains the same format as the previous reflective serialization of java.io.File, which requires java.base/java.io to be opened to Gson
+ private static class FileSerializer implements JsonSerializer<File> {
+ @Override
+ public JsonElement serialize(File src, Type typeOfSrc, JsonSerializationContext context) {
+ JsonObject jsonObject = new JsonObject();
+ jsonObject.addProperty("path", src.getPath());
+ return jsonObject;
+ }
+ }
+
+ private static class FileDeserializer implements JsonDeserializer<File> {
+ @Override
+ public File deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+ return new File(json.isJsonObject() ? json.getAsJsonObject().get("path").getAsString() : json.getAsJsonPrimitive().getAsString());
+ }
+ }
+
private static class DateSerializer implements JsonSerializer<Date> {
@Override
public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
Why this scored 31/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.