diff --git a/README.md b/README.md index 5387565..745e540 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,34 @@ Tokencore is a Java multi-chain wallet core library for exchange backends, custo - Offline transaction signing for major chain families Supported chains include: -- **EVM**: Ethereum -- **Bitcoin family**: Bitcoin, Litecoin, Dogecoin, Dash, Bitcoin Cash, Bitcoin SV +- **EVM**: Ethereum (built-in), plus **any EVM chain** you register at runtime (`chainId`, default BIP44 path) +- **Bitcoin family**: Bitcoin, Litecoin, Dogecoin, Dash, Bitcoin Cash, Bitcoin SV, plus **bitcoin-style UTXO chains** registered with address/BIP32 headers - **Others**: TRON, Filecoin, EOS +### Global chain support (registry + catalog) + +Tokencore does not ship an authoritative list of every network in the world. Instead it provides: + +- **`ChainRegistry`**: register EVM chains (`EvmChainRegistration`) and bitcoin-style UTXO chains (`UtxoChainRegistration` + `CustomBitcoinStyleNetParams`). +- **`ChainCatalogLoader`**: bulk-register from a JSON array (e.g. app-shipped file or data filtered from [chainlist](https://chainlist.org)). +- **Wallet identity**: multiple EVM chains share the same address for the same key; wallets are keyed by **`(chainType, address)`** — use a **unique `chainType` string per chain** (e.g. `POLYGON`, `ARBITRUM_ONE`). + +```java +import org.consenlabs.tokencore.wallet.chain.*; +import org.consenlabs.tokencore.wallet.model.BIP44Util; + +// Single EVM L2 +ChainRegistry.getInstance().registerEvm( + new EvmChainRegistration("MYL2", 84532L, BIP44Util.defaultEvmAccountZeroPath(60))); + +// Bulk from JSON: [ { "chainType": "...", "family": "EVM", "chainId": 1, "slip44": 60 } ] +ChainCatalogLoader.registerAllFromJson("[...]"); +``` + +**EIP-1559 (type-2)**: use `org.consenlabs.tokencore.wallet.transaction.Eip1559Transaction` (raw tx = `0x02 || rlp(...)`). + +**EIP-712**: `Eip712Hasher.hashTypedDataV4(JsonNode)` and `TypedDataSigner.signTypedDataV4(JsonNode, privateKeyBytes)`. + --- ## Core Features (Recommended Minimum) diff --git a/src/main/java/org/consenlabs/tokencore/wallet/Identity.java b/src/main/java/org/consenlabs/tokencore/wallet/Identity.java index 62f5905..b7ce8a9 100755 --- a/src/main/java/org/consenlabs/tokencore/wallet/Identity.java +++ b/src/main/java/org/consenlabs/tokencore/wallet/Identity.java @@ -13,6 +13,10 @@ import org.consenlabs.tokencore.foundation.utils.ByteUtil; import org.consenlabs.tokencore.foundation.utils.MnemonicUtil; import org.consenlabs.tokencore.foundation.utils.NumericUtil; +import org.consenlabs.tokencore.wallet.chain.ChainFamily; +import org.consenlabs.tokencore.wallet.chain.ChainRegistry; +import org.consenlabs.tokencore.wallet.chain.EvmChainRegistration; +import org.consenlabs.tokencore.wallet.chain.UtxoChainRegistration; import org.consenlabs.tokencore.wallet.keystore.*; import org.consenlabs.tokencore.wallet.model.*; import org.consenlabs.tokencore.wallet.transaction.EthereumSign; @@ -165,35 +169,21 @@ public List deriveWalletsByMnemonics(List chainTypes, String pas List wallets = new ArrayList<>(); for (String chainType : chainTypes) { Wallet wallet; - switch (chainType) { - case ChainType.BITCOIN: - wallet = deriveBitcoinWallet(mnemonics, password, this.getMetadata().getSegWit()); + ChainFamily family = ChainRegistry.getInstance().resolveFamily(chainType); + switch (family) { + case BITCOIN_STYLE_UTXO: + wallet = deriveUtxoWalletByChainType(chainType, mnemonics, password, this.getMetadata().getSegWit()); break; - case ChainType.ETHEREUM: - wallet = deriveEthereumWallet(mnemonics, password); + case EVM: + wallet = deriveEvmWalletByChainType(chainType, mnemonics, password); break; - case ChainType.LITECOIN: - wallet = deriveLitecoinWallet(mnemonics, password, this.getMetadata().getSegWit()); - break; - case ChainType.DOGECOIN: - wallet = deriveDogecoinWallet(mnemonics, password, this.getMetadata().getSegWit()); - break; - case ChainType.DASH: - wallet = deriveDashWallet(mnemonics, password, this.getMetadata().getSegWit()); - break; - case ChainType.BITCOINSV: - wallet = deriveBitcoinSVWallet(mnemonics, password, this.getMetadata().getSegWit()); - break; - case ChainType.BITCOINCASH: - wallet = deriveBitcoinCASHWallet(mnemonics, password, this.getMetadata().getSegWit()); - break; - case ChainType.EOS: + case EOS: wallet = deriveEOSWallet(mnemonics, password); break; - case ChainType.TRON: + case TRON: wallet = deriveTronWallet(mnemonics, password); break; - case ChainType.FILECOIN: + case FILECOIN: wallet = deriveFilecoinWallet(mnemonics, password); break; default: @@ -206,6 +196,54 @@ public List deriveWalletsByMnemonics(List chainTypes, String pas return wallets; } + private Wallet deriveEvmWalletByChainType(String chainType, List mnemonics, String password) { + EvmChainRegistration reg = ChainRegistry.getInstance().getEvmRegistration(chainType); + if (reg == null) { + throw new TokenException(String.format("Doesn't support deriving %s wallet", chainType)); + } + Metadata walletMetadata = new Metadata(); + walletMetadata.setChainType(reg.getChainType()); + walletMetadata.setPasswordHint(this.getMetadata().getPasswordHint()); + walletMetadata.setSource(this.getMetadata().getSource()); + walletMetadata.setName(reg.getChainType()); + IMTKeystore keystore = V3MnemonicKeystore.create(walletMetadata, password, mnemonics, reg.getDefaultMnemonicPath()); + return WalletManager.createWallet(keystore); + } + + private Wallet deriveUtxoWalletByChainType(String chainType, List mnemonics, String password, String segWit) { + if (ChainType.BITCOIN.equalsIgnoreCase(chainType)) { + return deriveBitcoinWallet(mnemonics, password, segWit); + } + if (ChainType.LITECOIN.equalsIgnoreCase(chainType)) { + return deriveLitecoinWallet(mnemonics, password, segWit); + } + if (ChainType.DOGECOIN.equalsIgnoreCase(chainType)) { + return deriveDogecoinWallet(mnemonics, password, segWit); + } + if (ChainType.DASH.equalsIgnoreCase(chainType)) { + return deriveDashWallet(mnemonics, password, segWit); + } + if (ChainType.BITCOINSV.equalsIgnoreCase(chainType)) { + return deriveBitcoinSVWallet(mnemonics, password, segWit); + } + if (ChainType.BITCOINCASH.equalsIgnoreCase(chainType)) { + return deriveBitcoinCASHWallet(mnemonics, password, segWit); + } + UtxoChainRegistration reg = ChainRegistry.getInstance().getUtxoRegistration(chainType); + if (reg == null) { + throw new TokenException(String.format("Doesn't support deriving %s wallet", chainType)); + } + Metadata walletMetadata = new Metadata(); + walletMetadata.setChainType(reg.getChainType()); + walletMetadata.setPasswordHint(this.getMetadata().getPasswordHint()); + walletMetadata.setSource(this.getMetadata().getSource()); + walletMetadata.setNetwork(this.getMetadata().getNetwork()); + walletMetadata.setName(reg.getChainType()); + walletMetadata.setSegWit(segWit); + IMTKeystore keystore = HDMnemonicKeystore.create(walletMetadata, password, mnemonics, reg.getDefaultMnemonicPath()); + return WalletManager.createWallet(keystore); + } + private static Identity tryLoadFromFile() { try { diff --git a/src/main/java/org/consenlabs/tokencore/wallet/WalletManager.java b/src/main/java/org/consenlabs/tokencore/wallet/WalletManager.java index 7df8706..137bf4f 100755 --- a/src/main/java/org/consenlabs/tokencore/wallet/WalletManager.java +++ b/src/main/java/org/consenlabs/tokencore/wallet/WalletManager.java @@ -14,6 +14,8 @@ import org.consenlabs.tokencore.foundation.utils.NumericUtil; import org.consenlabs.tokencore.wallet.address.AddressCreatorManager; import org.consenlabs.tokencore.wallet.address.EthereumAddressCreator; +import org.consenlabs.tokencore.wallet.chain.ChainFamily; +import org.consenlabs.tokencore.wallet.chain.ChainRegistry; import org.consenlabs.tokencore.wallet.keystore.*; import org.consenlabs.tokencore.wallet.model.*; import org.consenlabs.tokencore.wallet.validators.PrivateKeyValidator; @@ -165,21 +167,17 @@ public static Wallet importWalletFromMnemonic(Metadata metadata, @Nullable Strin IMTKeystore keystore = null; List mnemonicCodes = MnemonicUtil.toMnemonicCodes(mnemonic); MnemonicUtil.validateMnemonics(mnemonicCodes); - switch (metadata.getChainType()) { - case ChainType.ETHEREUM: - case ChainType.TRON: - case ChainType.FILECOIN: + ChainFamily family = ChainRegistry.getInstance().resolveFamily(metadata.getChainType()); + switch (family) { + case EVM: + case TRON: + case FILECOIN: keystore = V3MnemonicKeystore.create(metadata, password, mnemonicCodes, path); break; - case ChainType.BITCOIN: - case ChainType.LITECOIN: - case ChainType.DASH: - case ChainType.DOGECOIN: - case ChainType.BITCOINCASH: - case ChainType.BITCOINSV: + case BITCOIN_STYLE_UTXO: keystore = HDMnemonicKeystore.create(metadata, password, mnemonicCodes, path); break; - case ChainType.EOS: + case EOS: keystore = EOSKeystore.create(metadata, password, accountName, mnemonicCodes, path, permissions); break; default: @@ -194,7 +192,7 @@ public static Wallet importWalletFromMnemonic(Metadata metadata, String mnemonic } public static Wallet findWalletByPrivateKey(String chainType, String network, String privateKey, String segWit) { - if (ChainType.ETHEREUM.equals(chainType)) { + if (ChainRegistry.getInstance().isRegisteredEvm(chainType)) { new PrivateKeyValidator(privateKey).validate(); } Network net = new Network(network); @@ -403,15 +401,10 @@ private static String deriveAddressByChain(String chainType, Metadata metadata, private static boolean isBitcoinFamily(String chainType) { - return ChainType.BITCOIN.equalsIgnoreCase(chainType) - || ChainType.LITECOIN.equalsIgnoreCase(chainType) - || ChainType.DOGECOIN.equalsIgnoreCase(chainType) - || ChainType.DASH.equalsIgnoreCase(chainType) - || ChainType.BITCOINCASH.equalsIgnoreCase(chainType) - || ChainType.BITCOINSV.equalsIgnoreCase(chainType); + return ChainRegistry.getInstance().isRegisteredUtxo(chainType); } private static boolean isEvmFamily(String chainType) { - return ChainType.ETHEREUM.equalsIgnoreCase(chainType) || chainType.toUpperCase().contains("EVM"); + return ChainRegistry.getInstance().isRegisteredEvm(chainType); } private static IMTKeystore mustFindKeystoreById(String id) { diff --git a/src/main/java/org/consenlabs/tokencore/wallet/address/AddressCreatorManager.java b/src/main/java/org/consenlabs/tokencore/wallet/address/AddressCreatorManager.java index a100bd2..b9b6105 100755 --- a/src/main/java/org/consenlabs/tokencore/wallet/address/AddressCreatorManager.java +++ b/src/main/java/org/consenlabs/tokencore/wallet/address/AddressCreatorManager.java @@ -3,6 +3,8 @@ import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.params.MainNetParams; import org.bitcoinj.params.TestNet3Params; +import org.consenlabs.tokencore.wallet.chain.ChainRegistry; +import org.consenlabs.tokencore.wallet.chain.UtxoChainRegistration; import org.consenlabs.tokencore.wallet.model.ChainType; import org.consenlabs.tokencore.wallet.model.Messages; import org.consenlabs.tokencore.wallet.model.Metadata; @@ -12,7 +14,7 @@ public class AddressCreatorManager { public static AddressCreator getInstance(String type, boolean isMainnet, String segWit) { - if (ChainType.ETHEREUM.equals(type)) { + if (ChainType.ETHEREUM.equals(type) || ChainRegistry.getInstance().isRegisteredEvm(type)) { return new EthereumAddressCreator(); }else if (ChainType.FILECOIN.equals(type)) { return new FilecoinAddressCreator(); @@ -40,6 +42,10 @@ public static AddressCreator getInstance(String type, boolean isMainnet, String } return new BitcoinAddressCreator(network); } else { + UtxoChainRegistration reg = ChainRegistry.getInstance().getUtxoRegistration(type); + if (reg != null) { + return new BitcoinAddressCreator(reg.getNetworkParameters()); + } throw new TokenException(Messages.WALLET_INVALID_TYPE); } } diff --git a/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainCatalogEntry.java b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainCatalogEntry.java new file mode 100644 index 0000000..9158670 --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainCatalogEntry.java @@ -0,0 +1,119 @@ +package org.consenlabs.tokencore.wallet.chain; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * JSON row for {@link ChainCatalogLoader}. Extra fields are ignored. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ChainCatalogEntry { + + private String chainType; + + /** + * EVM, UTXO (bitcoin-style), or built-in TRON, FILECOIN, EOS (no-op registration for catalog consistency). + */ + private String family; + + private Long chainId; + + private Integer slip44; + + private String defaultMnemonicPath; + + private Integer addressHeader; + private Integer p2shHeader; + private Integer dumpedPrivateKeyHeader; + private Integer bip32HeaderPub; + private Integer bip32HeaderPriv; + private Boolean useMainnetDifficultyGenesis; + + public String getChainType() { + return chainType; + } + + public void setChainType(String chainType) { + this.chainType = chainType; + } + + public String getFamily() { + return family; + } + + public void setFamily(String family) { + this.family = family; + } + + public Long getChainId() { + return chainId; + } + + public void setChainId(Long chainId) { + this.chainId = chainId; + } + + public Integer getSlip44() { + return slip44; + } + + public void setSlip44(Integer slip44) { + this.slip44 = slip44; + } + + public String getDefaultMnemonicPath() { + return defaultMnemonicPath; + } + + public void setDefaultMnemonicPath(String defaultMnemonicPath) { + this.defaultMnemonicPath = defaultMnemonicPath; + } + + public Integer getAddressHeader() { + return addressHeader; + } + + public void setAddressHeader(Integer addressHeader) { + this.addressHeader = addressHeader; + } + + public Integer getP2shHeader() { + return p2shHeader; + } + + public void setP2shHeader(Integer p2shHeader) { + this.p2shHeader = p2shHeader; + } + + public Integer getDumpedPrivateKeyHeader() { + return dumpedPrivateKeyHeader; + } + + public void setDumpedPrivateKeyHeader(Integer dumpedPrivateKeyHeader) { + this.dumpedPrivateKeyHeader = dumpedPrivateKeyHeader; + } + + public Integer getBip32HeaderPub() { + return bip32HeaderPub; + } + + public void setBip32HeaderPub(Integer bip32HeaderPub) { + this.bip32HeaderPub = bip32HeaderPub; + } + + public Integer getBip32HeaderPriv() { + return bip32HeaderPriv; + } + + public void setBip32HeaderPriv(Integer bip32HeaderPriv) { + this.bip32HeaderPriv = bip32HeaderPriv; + } + + public Boolean getUseMainnetDifficultyGenesis() { + return useMainnetDifficultyGenesis; + } + + public void setUseMainnetDifficultyGenesis(Boolean useMainnetDifficultyGenesis) { + this.useMainnetDifficultyGenesis = useMainnetDifficultyGenesis; + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainCatalogLoader.java b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainCatalogLoader.java new file mode 100644 index 0000000..0620e86 --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainCatalogLoader.java @@ -0,0 +1,106 @@ +package org.consenlabs.tokencore.wallet.chain; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.consenlabs.tokencore.wallet.model.BIP44Util; +import org.consenlabs.tokencore.wallet.model.TokenException; +import org.consenlabs.tokencore.wallet.network.CustomBitcoinStyleNetParams; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Locale; + +/** + * Bulk-register chains from JSON. Intended for app-shipped catalogs or synced chainlists + * (fetching remote JSON is left to the application). + */ +public final class ChainCatalogLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ChainCatalogLoader() { + } + + public static int registerAllFromJson(String json) { + try { + List entries = MAPPER.readValue(json, new TypeReference>() { }); + return registerAll(entries); + } catch (IOException e) { + throw new TokenException("Invalid chain catalog JSON", e); + } + } + + public static int registerAllFromStream(InputStream in) throws IOException { + List entries = MAPPER.readValue(in, new TypeReference>() { }); + return registerAll(entries); + } + + public static int registerAll(List entries) { + ChainRegistry reg = ChainRegistry.getInstance(); + int n = 0; + for (ChainCatalogEntry e : entries) { + if (e.getChainType() == null || e.getFamily() == null) { + throw new TokenException("chain catalog entry missing chainType or family"); + } + String fam = e.getFamily().trim().toUpperCase(Locale.ROOT); + switch (fam) { + case "EVM": + if (e.getChainId() == null) { + throw new TokenException("EVM entry requires chainId: " + e.getChainType()); + } + String evmPath = e.getDefaultMnemonicPath(); + if (evmPath == null || evmPath.trim().isEmpty()) { + if (e.getSlip44() == null) { + throw new TokenException("EVM entry requires defaultMnemonicPath or slip44: " + e.getChainType()); + } + evmPath = BIP44Util.defaultEvmAccountZeroPath(e.getSlip44()); + } + reg.registerEvm(new EvmChainRegistration(e.getChainType(), e.getChainId(), evmPath)); + n++; + break; + case "UTXO": + case "BITCOIN_STYLE": + case "BITCOIN_STYLE_UTXO": + requireUtxoFields(e); + boolean mainTpl = e.getUseMainnetDifficultyGenesis() == null || e.getUseMainnetDifficultyGenesis(); + CustomBitcoinStyleNetParams net = new CustomBitcoinStyleNetParams( + "custom." + e.getChainType().trim().toLowerCase(Locale.ROOT), + e.getAddressHeader(), + e.getP2shHeader(), + e.getDumpedPrivateKeyHeader(), + e.getBip32HeaderPub(), + e.getBip32HeaderPriv(), + mainTpl); + String utxoPath = e.getDefaultMnemonicPath(); + if (utxoPath == null || utxoPath.trim().isEmpty()) { + if (e.getSlip44() == null) { + throw new TokenException("UTXO entry requires defaultMnemonicPath or slip44: " + e.getChainType()); + } + utxoPath = BIP44Util.defaultAccountZeroPath(e.getSlip44()); + } + reg.registerUtxo(new UtxoChainRegistration(e.getChainType(), net, utxoPath)); + n++; + break; + case "TRON": + case "FILECOIN": + case "EOS": + // Built-ins: catalog row is informational only + break; + default: + throw new TokenException("Unknown family in catalog: " + fam); + } + } + return n; + } + + private static void requireUtxoFields(ChainCatalogEntry e) { + if (e.getAddressHeader() == null + || e.getP2shHeader() == null + || e.getDumpedPrivateKeyHeader() == null + || e.getBip32HeaderPub() == null + || e.getBip32HeaderPriv() == null) { + throw new TokenException("UTXO entry requires address headers: " + e.getChainType()); + } + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainFamily.java b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainFamily.java new file mode 100644 index 0000000..cc8b590 --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainFamily.java @@ -0,0 +1,12 @@ +package org.consenlabs.tokencore.wallet.chain; + +/** + * High-level chain family for registry routing. + */ +public enum ChainFamily { + EVM, + BITCOIN_STYLE_UTXO, + TRON, + FILECOIN, + EOS +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainRegistry.java b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainRegistry.java new file mode 100644 index 0000000..b6efbf3 --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/chain/ChainRegistry.java @@ -0,0 +1,142 @@ +package org.consenlabs.tokencore.wallet.chain; + +import com.google.common.collect.ImmutableSet; +import org.consenlabs.tokencore.wallet.model.BIP44Util; +import org.consenlabs.tokencore.wallet.model.ChainType; +import org.consenlabs.tokencore.wallet.model.TokenException; + +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe registry for dynamically added EVM and Bitcoin-style UTXO chains. + * Built-in {@link ChainType} constants remain valid without explicit registration. + */ +public final class ChainRegistry { + + private static final ChainRegistry INSTANCE = new ChainRegistry(); + + private static final Set BITCOIN_STYLE_BUILTIN = ImmutableSet.of( + ChainType.BITCOIN, + ChainType.LITECOIN, + ChainType.DASH, + ChainType.DOGECOIN, + ChainType.BITCOINCASH, + ChainType.BITCOINSV); + + private final ConcurrentHashMap evmChains = new ConcurrentHashMap<>(); + private final ConcurrentHashMap utxoChains = new ConcurrentHashMap<>(); + + private ChainRegistry() { + } + + public static ChainRegistry getInstance() { + return INSTANCE; + } + + private static String normalize(String chainType) { + if (chainType == null) { + throw new TokenException("chainType must not be null"); + } + String t = chainType.trim().toUpperCase(Locale.ROOT); + if (t.isEmpty()) { + throw new TokenException("chainType must not be empty"); + } + return t; + } + + public void registerEvm(EvmChainRegistration registration) { + String key = registration.getChainType(); + evmChains.put(key, registration); + } + + public void registerUtxo(UtxoChainRegistration registration) { + String key = registration.getChainType(); + utxoChains.put(key, registration); + } + + public EvmChainRegistration getEvmRegistration(String chainType) { + String key = normalize(chainType); + if (ChainType.ETHEREUM.equals(key)) { + return new EvmChainRegistration(ChainType.ETHEREUM, 1L, BIP44Util.ETHEREUM_PATH); + } + return evmChains.get(key); + } + + public UtxoChainRegistration getUtxoRegistration(String chainType) { + return utxoChains.get(normalize(chainType)); + } + + public boolean isRegisteredEvm(String chainType) { + String key = normalize(chainType); + return ChainType.ETHEREUM.equals(key) || evmChains.containsKey(key); + } + + public boolean isRegisteredUtxo(String chainType) { + String key = normalize(chainType); + return BITCOIN_STYLE_BUILTIN.contains(key) || utxoChains.containsKey(key); + } + + public ChainFamily resolveFamily(String chainType) { + String key = normalize(chainType); + if (ChainType.ETHEREUM.equals(key) || evmChains.containsKey(key)) { + return ChainFamily.EVM; + } + if (ChainType.TRON.equals(key)) { + return ChainFamily.TRON; + } + if (ChainType.FILECOIN.equals(key)) { + return ChainFamily.FILECOIN; + } + if (ChainType.EOS.equals(key)) { + return ChainFamily.EOS; + } + if (BITCOIN_STYLE_BUILTIN.contains(key) || utxoChains.containsKey(key)) { + return ChainFamily.BITCOIN_STYLE_UTXO; + } + throw new TokenException("Unsupported chain type: " + key); + } + + public boolean isSupportedChainType(String chainType) { + try { + resolveFamily(chainType); + return true; + } catch (TokenException e) { + return false; + } + } + + public long getEvmChainId(String chainType) { + EvmChainRegistration r = getEvmRegistration(chainType); + if (r == null) { + throw new TokenException("Not an EVM chain type: " + chainType); + } + return r.getChainId(); + } + + public String getDefaultMnemonicPath(String chainType) { + String key = normalize(chainType); + if (ChainType.ETHEREUM.equals(key)) { + return BIP44Util.ETHEREUM_PATH; + } + EvmChainRegistration ev = evmChains.get(key); + if (ev != null) { + return ev.getDefaultMnemonicPath(); + } + if (ChainType.TRON.equals(key)) { + return BIP44Util.TRON_PATH; + } + if (ChainType.FILECOIN.equals(key)) { + return BIP44Util.FILECOIN_PATH; + } + if (ChainType.EOS.equals(key)) { + return BIP44Util.EOS_LEDGER; + } + UtxoChainRegistration ut = utxoChains.get(key); + if (ut != null) { + return ut.getDefaultMnemonicPath(); + } + return null; + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/chain/EvmChainRegistration.java b/src/main/java/org/consenlabs/tokencore/wallet/chain/EvmChainRegistration.java new file mode 100644 index 0000000..fa6f9dd --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/chain/EvmChainRegistration.java @@ -0,0 +1,62 @@ +package org.consenlabs.tokencore.wallet.chain; + +import com.google.common.base.Preconditions; +import org.consenlabs.tokencore.wallet.model.TokenException; + +import java.util.Objects; + +/** + * Runtime registration for an EVM-compatible chain. Wallets are disambiguated by {@code chainType} + * string (unique per chain) even when the derived address matches another EVM chain. + */ +public final class EvmChainRegistration { + private final String chainType; + private final long chainId; + private final String defaultMnemonicPath; + + public EvmChainRegistration(String chainType, long chainId, String defaultMnemonicPath) { + Preconditions.checkNotNull(chainType, "chainType"); + Preconditions.checkNotNull(defaultMnemonicPath, "defaultMnemonicPath"); + String normalized = chainType.trim().toUpperCase(); + if (normalized.isEmpty()) { + throw new TokenException("chainType must not be empty"); + } + if (chainId <= 0) { + throw new TokenException("chainId must be positive"); + } + this.chainType = normalized; + this.chainId = chainId; + this.defaultMnemonicPath = defaultMnemonicPath.trim(); + } + + public String getChainType() { + return chainType; + } + + public long getChainId() { + return chainId; + } + + public String getDefaultMnemonicPath() { + return defaultMnemonicPath; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + EvmChainRegistration that = (EvmChainRegistration) o; + return chainId == that.chainId + && chainType.equals(that.chainType) + && defaultMnemonicPath.equals(that.defaultMnemonicPath); + } + + @Override + public int hashCode() { + return Objects.hash(chainType, chainId, defaultMnemonicPath); + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/chain/UtxoChainRegistration.java b/src/main/java/org/consenlabs/tokencore/wallet/chain/UtxoChainRegistration.java new file mode 100644 index 0000000..c98eaa1 --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/chain/UtxoChainRegistration.java @@ -0,0 +1,60 @@ +package org.consenlabs.tokencore.wallet.chain; + +import com.google.common.base.Preconditions; +import org.bitcoinj.core.NetworkParameters; +import org.consenlabs.tokencore.wallet.model.TokenException; + +import java.util.Objects; + +/** + * Runtime registration for a Bitcoin-style UTXO chain using a {@link NetworkParameters} instance. + */ +public final class UtxoChainRegistration { + private final String chainType; + private final NetworkParameters networkParameters; + private final String defaultMnemonicPath; + + public UtxoChainRegistration(String chainType, NetworkParameters networkParameters, String defaultMnemonicPath) { + Preconditions.checkNotNull(chainType, "chainType"); + Preconditions.checkNotNull(networkParameters, "networkParameters"); + Preconditions.checkNotNull(defaultMnemonicPath, "defaultMnemonicPath"); + String normalized = chainType.trim().toUpperCase(); + if (normalized.isEmpty()) { + throw new TokenException("chainType must not be empty"); + } + this.chainType = normalized; + this.networkParameters = networkParameters; + this.defaultMnemonicPath = defaultMnemonicPath.trim(); + } + + public String getChainType() { + return chainType; + } + + public NetworkParameters getNetworkParameters() { + return networkParameters; + } + + public String getDefaultMnemonicPath() { + return defaultMnemonicPath; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UtxoChainRegistration that = (UtxoChainRegistration) o; + return chainType.equals(that.chainType) + && defaultMnemonicPath.equals(that.defaultMnemonicPath) + && networkParameters.getId().equals(that.networkParameters.getId()); + } + + @Override + public int hashCode() { + return Objects.hash(chainType, networkParameters.getId(), defaultMnemonicPath); + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/model/BIP44Util.java b/src/main/java/org/consenlabs/tokencore/wallet/model/BIP44Util.java index 89a1a66..ba749d5 100755 --- a/src/main/java/org/consenlabs/tokencore/wallet/model/BIP44Util.java +++ b/src/main/java/org/consenlabs/tokencore/wallet/model/BIP44Util.java @@ -48,4 +48,18 @@ public static String getBTCMnemonicPath(String segWit, boolean isMainnet) { } } + /** + * BIP44 account 0 default path for UTXO-style chains: {@code m/44'/coin'/0'}. + */ + public static String defaultAccountZeroPath(int slip44CoinType) { + return "m/44'/" + slip44CoinType + "'/0'"; + } + + /** + * Default Ethereum-style path for registered EVM chains: {@code m/44'/coin'/0'/0/0}. + */ + public static String defaultEvmAccountZeroPath(int slip44CoinType) { + return "m/44'/" + slip44CoinType + "'/0'/0/0"; + } + } diff --git a/src/main/java/org/consenlabs/tokencore/wallet/model/ChainType.java b/src/main/java/org/consenlabs/tokencore/wallet/model/ChainType.java index 2adea71..b718055 100755 --- a/src/main/java/org/consenlabs/tokencore/wallet/model/ChainType.java +++ b/src/main/java/org/consenlabs/tokencore/wallet/model/ChainType.java @@ -1,5 +1,7 @@ package org.consenlabs.tokencore.wallet.model; +import org.consenlabs.tokencore.wallet.chain.ChainRegistry; + public class ChainType { public final static String ETHEREUM = "ETHEREUM"; public final static String BITCOIN = "BITCOIN"; @@ -14,16 +16,7 @@ public class ChainType { public static void validate(String type) { - if (!ETHEREUM.equals(type) && - !BITCOIN.equals(type) && - !EOS.equals(type) && - !LITECOIN.equals(type) && - !DASH.equals(type) && - !BITCOINSV.equals(type) && - !BITCOINCASH.equals(type) && - !DOGECOIN.equals(type) && - !TRON.equals(type)&& - !FILECOIN.equals(type)) { + if (!ChainRegistry.getInstance().isSupportedChainType(type)) { throw new TokenException(Messages.WALLET_INVALID_TYPE); } } diff --git a/src/main/java/org/consenlabs/tokencore/wallet/network/CustomBitcoinStyleNetParams.java b/src/main/java/org/consenlabs/tokencore/wallet/network/CustomBitcoinStyleNetParams.java new file mode 100644 index 0000000..88554c2 --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/network/CustomBitcoinStyleNetParams.java @@ -0,0 +1,60 @@ +package org.consenlabs.tokencore.wallet.network; + +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.params.AbstractBitcoinNetParams; +import org.bitcoinj.params.MainNetParams; +import org.bitcoinj.params.TestNet3Params; + +/** + * Bitcoin-style {@link NetworkParameters} for registered UTXO chains. Difficulty/genesis template + * is copied from mainnet or testnet3; address and BIP32 headers are supplied by the caller. + */ +public final class CustomBitcoinStyleNetParams extends AbstractBitcoinNetParams { + + private final String netId; + + /** + * @param id unique network id (e.g. "custom.myregtest") + * @param useMainnetDifficultyGenesis if true, clone difficulty/genesis scaffolding from mainnet; else from testnet3 + */ + public CustomBitcoinStyleNetParams( + String id, + int addressHeader, + int p2shHeader, + int dumpedPrivateKeyHeader, + int bip32HeaderPub, + int bip32HeaderPriv, + boolean useMainnetDifficultyGenesis) { + super(); + if (id == null || id.trim().isEmpty()) { + throw new IllegalArgumentException("id must not be empty"); + } + this.netId = id.trim(); + NetworkParameters template = useMainnetDifficultyGenesis ? MainNetParams.get() : TestNet3Params.get(); + this.genesisBlock = template.getGenesisBlock(); + this.maxTarget = template.getMaxTarget(); + this.port = template.getPort(); + this.packetMagic = template.getPacketMagic(); + this.interval = template.getInterval(); + this.targetTimespan = template.getTargetTimespan(); + this.majorityEnforceBlockUpgrade = template.getMajorityEnforceBlockUpgrade(); + this.majorityRejectBlockOutdated = template.getMajorityRejectBlockOutdated(); + this.majorityWindow = template.getMajorityWindow(); + this.subsidyDecreaseBlockCount = template.getSubsidyDecreaseBlockCount(); + this.spendableCoinbaseDepth = template.getSpendableCoinbaseDepth(); + this.checkpoints.clear(); + + this.addressHeader = addressHeader; + this.p2shHeader = p2shHeader; + this.dumpedPrivateKeyHeader = dumpedPrivateKeyHeader; + this.acceptableAddressCodes = new int[] {this.addressHeader, this.p2shHeader}; + this.bip32HeaderPub = bip32HeaderPub; + this.bip32HeaderPriv = bip32HeaderPriv; + this.id = this.netId; + } + + @Override + public String getPaymentProtocolId() { + return "main"; + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/transaction/Eip1559Transaction.java b/src/main/java/org/consenlabs/tokencore/wallet/transaction/Eip1559Transaction.java new file mode 100644 index 0000000..d2538dd --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/transaction/Eip1559Transaction.java @@ -0,0 +1,106 @@ +package org.consenlabs.tokencore.wallet.transaction; + +import org.bitcoinj.core.ECKey; +import org.consenlabs.tokencore.foundation.crypto.Hash; +import org.consenlabs.tokencore.foundation.rlp.RlpEncoder; +import org.consenlabs.tokencore.foundation.rlp.RlpList; +import org.consenlabs.tokencore.foundation.rlp.RlpString; +import org.consenlabs.tokencore.foundation.rlp.RlpType; +import org.consenlabs.tokencore.foundation.utils.ByteUtil; +import org.consenlabs.tokencore.foundation.utils.NumericUtil; +import org.consenlabs.tokencore.wallet.Wallet; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * EIP-1559 (type-2) transaction signing. Raw format: {@code 0x02 || rlp([...])}. + */ +public class Eip1559Transaction implements TransactionSigner { + + private static final int TYPE_2 = 0x02; + + private final BigInteger chainId; + private final BigInteger nonce; + private final BigInteger maxPriorityFeePerGas; + private final BigInteger maxFeePerGas; + private final BigInteger gasLimit; + private final String to; + private final BigInteger value; + private final String data; + + public Eip1559Transaction( + BigInteger chainId, + BigInteger nonce, + BigInteger maxPriorityFeePerGas, + BigInteger maxFeePerGas, + BigInteger gasLimit, + String to, + BigInteger value, + String data) { + this.chainId = chainId; + this.nonce = nonce; + this.maxPriorityFeePerGas = maxPriorityFeePerGas; + this.maxFeePerGas = maxFeePerGas; + this.gasLimit = gasLimit; + this.to = to; + this.value = value; + if (data != null) { + this.data = NumericUtil.cleanHexPrefix(data); + } else { + this.data = ""; + } + } + + @Override + public TxSignResult signTransaction(String chainID, String password, Wallet wallet) { + if (chainId.signum() <= 0 || chainId.bitLength() > 31) { + throw new IllegalArgumentException("chainId must be a positive 32-bit integer"); + } + int expected = chainId.intValue(); + int provided = Integer.parseInt(chainID); + if (expected != provided) { + throw new IllegalArgumentException("chainID argument must match transaction chainId"); + } + String signed = sign(expected, wallet.decryptMainKey(password)); + String txHash = NumericUtil.prependHexPrefix(Hash.keccak256(signed)); + return new TxSignResult(signed, txHash); + } + + public String sign(int chainIdInt, byte[] privateKey) { + byte[] unsigned = encodeUnsigned(); + byte[] hash = Hash.keccak256(ByteUtil.concat(new byte[] {(byte) TYPE_2}, unsigned)); + SignatureData base = EthereumSign.signAsRecoverable(hash, ECKey.fromPrivate(privateKey)); + int yParity = (base.getV() - 27) & 1; + List all = new ArrayList<>(unsignedList()); + all.add(RlpString.create(yParity)); + all.add(RlpString.create(ByteUtil.trimLeadingZeroes(base.getR()))); + all.add(RlpString.create(ByteUtil.trimLeadingZeroes(base.getS()))); + byte[] body = RlpEncoder.encode(new RlpList(all)); + return NumericUtil.bytesToHex(ByteUtil.concat(new byte[] {(byte) TYPE_2}, body)); + } + + private List unsignedList() { + List result = new ArrayList<>(); + result.add(RlpString.create(chainId)); + result.add(RlpString.create(nonce)); + result.add(RlpString.create(maxPriorityFeePerGas)); + result.add(RlpString.create(maxFeePerGas)); + result.add(RlpString.create(gasLimit)); + if (to != null && to.length() > 0) { + result.add(RlpString.create(NumericUtil.hexToBytes(to))); + } else { + result.add(RlpString.create("")); + } + result.add(RlpString.create(value)); + result.add(RlpString.create(NumericUtil.hexToBytes(data))); + result.add(new RlpList(Collections.emptyList())); + return result; + } + + private byte[] encodeUnsigned() { + return RlpEncoder.encode(new RlpList(unsignedList())); + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/transaction/Eip712Hasher.java b/src/main/java/org/consenlabs/tokencore/wallet/transaction/Eip712Hasher.java new file mode 100644 index 0000000..48fca5e --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/transaction/Eip712Hasher.java @@ -0,0 +1,253 @@ +package org.consenlabs.tokencore.wallet.transaction; + +import com.fasterxml.jackson.databind.JsonNode; +import org.consenlabs.tokencore.foundation.crypto.Hash; +import org.consenlabs.tokencore.foundation.utils.NumericUtil; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * EIP-712 typed structured data hashing (eth_signTypedData_v4). + */ +public final class Eip712Hasher { + + private Eip712Hasher() { + } + + public static byte[] hashTypedDataV4(JsonNode root) { + JsonNode types = root.get("types"); + String primaryType = root.get("primaryType").asText(); + JsonNode domain = root.get("domain"); + JsonNode message = root.get("message"); + Map> typeMap = parseTypes(types); + byte[] domainSeparator = hashStruct(typeMap, "EIP712Domain", domain); + byte[] messageHash = hashStruct(typeMap, primaryType, message); + byte[] prefix = new byte[] {0x19, 0x01}; + return Hash.keccak256(concat(prefix, domainSeparator, messageHash)); + } + + private static Map> parseTypes(JsonNode types) { + java.util.LinkedHashMap> map = new java.util.LinkedHashMap<>(); + Iterator> fields = types.fields(); + while (fields.hasNext()) { + Map.Entry e = fields.next(); + String typeName = e.getKey(); + List list = new ArrayList<>(); + for (JsonNode f : e.getValue()) { + list.add(new Field(f.get("name").asText(), f.get("type").asText())); + } + map.put(typeName, list); + } + return map; + } + + private static byte[] hashStruct(Map> types, String type, JsonNode data) { + return Hash.keccak256(concat(typeHash(types, type), encodeData(types, type, data))); + } + + private static byte[] typeHash(Map> types, String primaryType) { + List deps = new ArrayList<>(collectStructTypes(types, primaryType)); + Collections.sort(deps); + StringBuilder enc = new StringBuilder(); + for (String t : deps) { + enc.append(structDef(types, t)); + } + return Hash.keccak256(enc.toString().getBytes(StandardCharsets.UTF_8)); + } + + private static Set collectStructTypes(Map> types, String root) { + Set order = new LinkedHashSet<>(); + collectStructTypesDfs(types, root, order); + return order; + } + + private static void collectStructTypesDfs(Map> types, String typeName, Set order) { + if (!types.containsKey(typeName)) { + return; + } + for (Field f : types.get(typeName)) { + String struct = firstStructType(types, f.type); + if (struct != null) { + collectStructTypesDfs(types, struct, order); + } + } + order.add(typeName); + } + + /** First struct type name referenced by field type (e.g. Person from Person[]). */ + private static String firstStructType(Map> types, String fieldType) { + String t = fieldType.trim(); + int bracket = t.indexOf('['); + String base = bracket > 0 ? t.substring(0, bracket) : t; + return types.containsKey(base) ? base : null; + } + + private static String structDef(Map> types, String typeName) { + List fields = types.get(typeName); + if (fields == null) { + throw new IllegalArgumentException("Unknown type: " + typeName); + } + StringBuilder sb = new StringBuilder(); + sb.append(typeName).append('('); + for (int i = 0; i < fields.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(fields.get(i).type).append(' ').append(fields.get(i).name); + } + sb.append(')'); + return sb.toString(); + } + + private static byte[] encodeData(Map> types, String type, JsonNode data) { + List fields = types.get(type); + if (fields == null) { + throw new IllegalArgumentException("Unknown type for encodeData: " + type); + } + byte[][] parts = new byte[fields.size()][]; + int i = 0; + for (Field f : fields) { + parts[i++] = encodeField(types, f.type, data == null ? null : data.get(f.name)); + } + return concat(parts); + } + + private static byte[] encodeField(Map> types, String type, JsonNode value) { + String t = type.trim(); + if (t.endsWith("[]")) { + String inner = t.substring(0, t.length() - 2); + if (value == null || !value.isArray()) { + throw new IllegalArgumentException("Expected JSON array for type " + type); + } + List chunks = new ArrayList<>(); + for (JsonNode el : value) { + if (types.containsKey(inner)) { + chunks.add(encodeData(types, inner, el)); + } else { + chunks.add(encodeAtomic(inner, el)); + } + } + return Hash.keccak256(concat(chunks.toArray(new byte[0][]))); + } + if (types.containsKey(t)) { + return hashStruct(types, t, value); + } + return encodeAtomic(t, value); + } + + private static byte[] encodeAtomic(String type, JsonNode value) { + String t = type.trim(); + if ("string".equals(t)) { + String s = value == null || value.isNull() ? "" : value.asText(); + return Hash.keccak256(s.getBytes(StandardCharsets.UTF_8)); + } + if ("bool".equals(t)) { + BigInteger v = value != null && value.asBoolean() ? BigInteger.ONE : BigInteger.ZERO; + return uintToBytes32(v); + } + if ("address".equals(t)) { + String hex = value.asText(); + byte[] addr = NumericUtil.hexToBytes(NumericUtil.cleanHexPrefix(hex)); + byte[] word = new byte[32]; + System.arraycopy(addr, 0, word, 32 - addr.length, addr.length); + return word; + } + if ("bytes".equals(t)) { + byte[] raw; + if (value == null || value.isNull()) { + raw = new byte[0]; + } else if (value.isTextual()) { + String s = value.asText(); + if (NumericUtil.isValidHex(s)) { + raw = NumericUtil.hexToBytes(NumericUtil.cleanHexPrefix(s)); + } else { + raw = s.getBytes(StandardCharsets.UTF_8); + } + } else { + raw = new byte[0]; + } + return Hash.keccak256(raw); + } + if (t.startsWith("bytes") && t.length() > 5 && Character.isDigit(t.charAt(5))) { + int n = Integer.parseInt(t.substring(5)); + byte[] raw = NumericUtil.hexToBytes(NumericUtil.cleanHexPrefix(value.asText())); + byte[] word = new byte[32]; + System.arraycopy(raw, 0, word, 0, Math.min(n, raw.length)); + return word; + } + if (t.startsWith("uint") || t.startsWith("int")) { + BigInteger v; + if (value.isIntegralNumber()) { + v = BigInteger.valueOf(value.longValue()); + } else { + v = new BigInteger(value.asText(), 10); + } + if (t.startsWith("uint")) { + return uintToBytes32(v); + } + return intToBytes32(v); + } + throw new IllegalArgumentException("Unsupported atomic type: " + type); + } + + private static byte[] uintToBytes32(BigInteger v) { + if (v.signum() < 0) { + throw new IllegalArgumentException("uint must be non-negative"); + } + byte[] src = v.toByteArray(); + byte[] out = new byte[32]; + int len = Math.min(32, src.length); + int srcPos = src.length > 32 ? src.length - 32 : 0; + System.arraycopy(src, srcPos, out, 32 - len, len); + return out; + } + + private static byte[] intToBytes32(BigInteger v) { + byte[] src = v.toByteArray(); + byte[] out = new byte[32]; + if (src.length >= 32) { + System.arraycopy(src, src.length - 32, out, 0, 32); + } else { + int pad = 32 - src.length; + if (v.signum() < 0) { + for (int i = 0; i < pad; i++) { + out[i] = (byte) 0xff; + } + } + System.arraycopy(src, 0, out, pad, src.length); + } + return out; + } + + private static byte[] concat(byte[]... parts) { + int len = 0; + for (byte[] p : parts) { + len += p.length; + } + byte[] out = new byte[len]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } + return out; + } + + private static final class Field { + final String name; + final String type; + + Field(String name, String type) { + this.name = name; + this.type = type; + } + } +} diff --git a/src/main/java/org/consenlabs/tokencore/wallet/transaction/EthereumSign.java b/src/main/java/org/consenlabs/tokencore/wallet/transaction/EthereumSign.java index 7c7349c..dd02260 100755 --- a/src/main/java/org/consenlabs/tokencore/wallet/transaction/EthereumSign.java +++ b/src/main/java/org/consenlabs/tokencore/wallet/transaction/EthereumSign.java @@ -66,6 +66,14 @@ private static byte[] dataToBytes(String data) { return messageBytes; } + /** + * Sign a pre-hashed 32-byte digest (used by EIP-712 and other callers). + */ + public static SignatureData signDigest(byte[] digest32, byte[] prvKeyBytes) { + ECKey ecKey = ECKey.fromPrivate(prvKeyBytes); + return signAsRecoverable(digest32, ecKey); + } + static SignatureData signMessage(byte[] message, byte[] prvKeyBytes) { ECKey ecKey = ECKey.fromPrivate(prvKeyBytes); byte[] messageHash = Hash.keccak256(message); diff --git a/src/main/java/org/consenlabs/tokencore/wallet/transaction/TypedDataSigner.java b/src/main/java/org/consenlabs/tokencore/wallet/transaction/TypedDataSigner.java new file mode 100644 index 0000000..248451d --- /dev/null +++ b/src/main/java/org/consenlabs/tokencore/wallet/transaction/TypedDataSigner.java @@ -0,0 +1,22 @@ +package org.consenlabs.tokencore.wallet.transaction; + +import com.fasterxml.jackson.databind.JsonNode; +import org.bitcoinj.core.ECKey; + +/** + * Sign EIP-712 typed data (v4) using the same secp256k1 recovery format as {@link EthereumSign}. + */ +public final class TypedDataSigner { + + private TypedDataSigner() { + } + + public static SignatureData signTypedDataV4(JsonNode typedDataRoot, byte[] privateKeyBytes) { + byte[] digest = Eip712Hasher.hashTypedDataV4(typedDataRoot); + return EthereumSign.signAsRecoverable(digest, ECKey.fromPrivate(privateKeyBytes)); + } + + public static String signTypedDataV4Hex(JsonNode typedDataRoot, byte[] privateKeyBytes) { + return signTypedDataV4(typedDataRoot, privateKeyBytes).toString(); + } +} diff --git a/src/test/java/org/consenlabs/tokencore/wallet/chain/ChainRegistryTest.java b/src/test/java/org/consenlabs/tokencore/wallet/chain/ChainRegistryTest.java new file mode 100644 index 0000000..da5f115 --- /dev/null +++ b/src/test/java/org/consenlabs/tokencore/wallet/chain/ChainRegistryTest.java @@ -0,0 +1,77 @@ +package org.consenlabs.tokencore.wallet.chain; + +import org.consenlabs.tokencore.wallet.address.AddressCreatorManager; +import org.consenlabs.tokencore.wallet.model.BIP44Util; +import org.consenlabs.tokencore.wallet.model.ChainType; +import org.consenlabs.tokencore.wallet.model.Metadata; +import org.consenlabs.tokencore.wallet.model.TokenException; +import org.consenlabs.tokencore.wallet.network.CustomBitcoinStyleNetParams; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChainRegistryTest { + + @Test + void registerEvm_thenValidateAndAddressCreatorWork() { + String ct = "TESTEVM_" + System.nanoTime(); + ChainRegistry.getInstance() + .registerEvm(new EvmChainRegistration(ct, 999999L, "m/44'/1'/0'/0/0")); + + assertDoesNotThrow(() -> org.consenlabs.tokencore.wallet.model.ChainType.validate(ct)); + + String addr = + AddressCreatorManager.getInstance(ct, true, Metadata.NONE) + .fromPrivateKey( + "4c0883a69102937d6231471b5dbb6204fe512961708279f14a15c89a7e5a5c3c"); + assertEquals(40, addr.length()); + } + + @Test + void registerUtxo_thenAddressCreatorDerivesBase58() { + String ct = "TESTUTXO_" + System.nanoTime(); + CustomBitcoinStyleNetParams net = + new CustomBitcoinStyleNetParams( + "custom.testutxo." + ct, + 0x30, + 0x16, + 0x9e, + 0x0488b21e, + 0x0488ade4, + true); + ChainRegistry.getInstance() + .registerUtxo(new UtxoChainRegistration(ct, net, "m/44'/3'/0'")); + + String addr = + AddressCreatorManager.getInstance(ct, true, Metadata.NONE) + .fromPrivateKey( + "4c0883a69102937d6231471b5dbb6204fe512961708279f14a15c89a7e5a5c3c"); + assertTrue(addr.length() > 20); + } + + @Test + void chainCatalogLoader_registersEvmFromJson() { + String ct = "CAT_" + System.nanoTime(); + String json = + "[{\"chainType\":\"" + + ct + + "\",\"family\":\"EVM\",\"chainId\":424242,\"slip44\":1}]"; + int n = ChainCatalogLoader.registerAllFromJson(json); + assertEquals(1, n); + assertDoesNotThrow(() -> ChainType.validate(ct)); + assertEquals("m/44'/1'/0'/0/0", ChainRegistry.getInstance().getDefaultMnemonicPath(ct)); + } + + @Test + void resolveFamily_unknownThrows() { + assertThrows(TokenException.class, () -> ChainRegistry.getInstance().resolveFamily("NOT_A_CHAIN_" + System.nanoTime())); + } + + @Test + void ethereumBuiltin_chainId() { + assertEquals(1L, ChainRegistry.getInstance().getEvmChainId(ChainType.ETHEREUM)); + } +} diff --git a/src/test/java/org/consenlabs/tokencore/wallet/model/BIP44UtilTest.java b/src/test/java/org/consenlabs/tokencore/wallet/model/BIP44UtilTest.java index 8e7b8b3..2725307 100644 --- a/src/test/java/org/consenlabs/tokencore/wallet/model/BIP44UtilTest.java +++ b/src/test/java/org/consenlabs/tokencore/wallet/model/BIP44UtilTest.java @@ -68,4 +68,14 @@ void pathConstants_areCorrect() { assertTrue(BIP44Util.DOGECOIN_MAINNET_PATH.startsWith("m/44'/3'")); assertTrue(BIP44Util.DASH_MAINNET_PATH.startsWith("m/44'/5'")); } + + @Test + void defaultAccountZeroPath_matchesSlip44() { + assertEquals("m/44'/137'/0'", BIP44Util.defaultAccountZeroPath(137)); + } + + @Test + void defaultEvmAccountZeroPath_matchesSlip44() { + assertEquals("m/44'/60'/0'/0/0", BIP44Util.defaultEvmAccountZeroPath(60)); + } } diff --git a/src/test/java/org/consenlabs/tokencore/wallet/transaction/Eip1559TransactionTest.java b/src/test/java/org/consenlabs/tokencore/wallet/transaction/Eip1559TransactionTest.java new file mode 100644 index 0000000..6be0bf4 --- /dev/null +++ b/src/test/java/org/consenlabs/tokencore/wallet/transaction/Eip1559TransactionTest.java @@ -0,0 +1,37 @@ +package org.consenlabs.tokencore.wallet.transaction; + +import org.consenlabs.tokencore.foundation.utils.NumericUtil; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Eip1559TransactionTest { + + @Test + void sign_producesType2PayloadAndTxHash() { + Eip1559Transaction tx = + new Eip1559Transaction( + BigInteger.valueOf(1), + BigInteger.ZERO, + BigInteger.valueOf(2_000_000_000L), + BigInteger.valueOf(100_000_000_000L), + BigInteger.valueOf(21_000), + "0x3535353535353535353535353535353535353535", + BigInteger.valueOf(1_000_000_000_000_000_000L), + ""); + + byte[] pk = + NumericUtil.hexToBytes( + "0xc85ef7d79691fe79591b22373951e8f881976134b5f761f16494d6877a6dd51a"); + String signed = tx.sign(1, pk); + assertTrue(signed.startsWith("02"), "raw tx must be type-2 (0x02 prefix when hex)"); + assertTrue(signed.length() > 100); + String hash = + NumericUtil.prependHexPrefix( + org.consenlabs.tokencore.foundation.crypto.Hash.keccak256(signed)); + assertEquals(66, hash.length()); + } +} diff --git a/src/test/java/org/consenlabs/tokencore/wallet/transaction/Eip712HasherTest.java b/src/test/java/org/consenlabs/tokencore/wallet/transaction/Eip712HasherTest.java new file mode 100644 index 0000000..f1221ba --- /dev/null +++ b/src/test/java/org/consenlabs/tokencore/wallet/transaction/Eip712HasherTest.java @@ -0,0 +1,53 @@ +package org.consenlabs.tokencore.wallet.transaction; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class Eip712HasherTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void hashTypedDataV4_isDeterministic() throws Exception { + String json = + "{" + + "\"types\":{" + + "\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"}]," + + "\"Message\":[{\"name\":\"text\",\"type\":\"string\"}]" + + "}," + + "\"primaryType\":\"Message\"," + + "\"domain\":{\"name\":\"TestDomain\"}," + + "\"message\":{\"text\":\"hello\"}" + + "}"; + byte[] h1 = Eip712Hasher.hashTypedDataV4(MAPPER.readTree(json)); + byte[] h2 = Eip712Hasher.hashTypedDataV4(MAPPER.readTree(json)); + assertArrayEquals(h1, h2); + assertEquals(32, h1.length); + } + + @Test + void signTypedDataV4_producesRecoverableSignature() throws Exception { + String json = + "{" + + "\"types\":{" + + "\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"}]," + + "\"Message\":[{\"name\":\"text\",\"type\":\"string\"}]" + + "}," + + "\"primaryType\":\"Message\"," + + "\"domain\":{\"name\":\"TestDomain\"}," + + "\"message\":{\"text\":\"hello\"}" + + "}"; + byte[] pk = + org.consenlabs.tokencore.foundation.utils.NumericUtil.hexToBytes( + "4c0883a69102937d6231471b5dbb6204fe512961708279f14a15c89a7e5a5c3c"); + SignatureData sig = + TypedDataSigner.signTypedDataV4(MAPPER.readTree(json), pk); + assertEquals(32, sig.getR().length); + assertEquals(32, sig.getS().length); + int v = sig.getV() & 0xff; + assertEquals(true, v == 27 || v == 28); + } +}