Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
345716e95d | ||
|
|
642df57454 | ||
|
|
44b86e8148 | ||
|
|
708b523569 | ||
|
|
33873a4bc1 |
@ -13,12 +13,17 @@ import com.sparrowwallet.frigate.index.Index;
|
||||
import com.sparrowwallet.frigate.io.Config;
|
||||
import com.sparrowwallet.frigate.io.CoreAuthType;
|
||||
import com.sparrowwallet.frigate.io.RecentBlocksMap;
|
||||
import com.sparrowwallet.frigate.bitcoind.reader.FlatFileBlockDataSource;
|
||||
import com.sparrowwallet.frigate.io.Server;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
@ -28,6 +33,8 @@ public class BitcoindClient {
|
||||
|
||||
public static final int DEFAULT_SCRIPT_PUB_KEY_CACHE_SIZE = 10000000;
|
||||
private static final int MAX_REORG_DEPTH = 10;
|
||||
private static final int CONCURRENT_THRESHOLD = 100;
|
||||
private static final int CACHE_POPULATE_WINDOW = 2000;
|
||||
public static final int MIN_SUBMIT_PACKAGE_VERSION = 280000;
|
||||
|
||||
private final JsonRpcClient jsonRpcClient;
|
||||
@ -47,6 +54,7 @@ public class BitcoindClient {
|
||||
|
||||
private volatile boolean stopped;
|
||||
|
||||
private final BlockDataSource blockDataSource;
|
||||
private final Map<HashIndex, byte[]> scriptPubKeyCache;
|
||||
private final Set<Sha256Hash> mempoolTxIds = new HashSet<>();
|
||||
private final RecentBlocksMap recentBlocksMap = new RecentBlocksMap(MAX_REORG_DEPTH);
|
||||
@ -95,6 +103,23 @@ public class BitcoindClient {
|
||||
Config.get().setScriptPubKeyCacheSize(cacheSize);
|
||||
}
|
||||
this.scriptPubKeyCache = lruCache(cacheSize);
|
||||
|
||||
BlockDataSource dataSource = null;
|
||||
Path coreDataDirPath = coreDataDir.toPath();
|
||||
if(FlatFileBlockDataSource.isAvailable(coreDataDirPath)) {
|
||||
try {
|
||||
Path blocksDir = FlatFileBlockDataSource.resolveBlocksDir(coreDataDirPath);
|
||||
Path indexDir = blocksDir.resolve("index");
|
||||
dataSource = new FlatFileBlockDataSource(blocksDir, indexDir, scriptPubKeyCache);
|
||||
log.info("Using flat file block data source");
|
||||
} catch(IOException e) {
|
||||
log.warn("Flat file block index available but failed to load, falling back to RPC", e);
|
||||
}
|
||||
}
|
||||
if(dataSource == null) {
|
||||
dataSource = new RpcBlockDataSource(getBitcoindService(), scriptPubKeyCache);
|
||||
}
|
||||
this.blockDataSource = dataSource;
|
||||
}
|
||||
|
||||
public void initialize() {
|
||||
@ -130,37 +155,52 @@ public class BitcoindClient {
|
||||
}
|
||||
|
||||
lastBlock = blockchainInfo.bestblockhash();
|
||||
log.info("Initializing indexes...");
|
||||
int startHeight = blocksIndex.getLastBlockIndexed() + 1;
|
||||
int endHeight = Math.min(tip.height(), blockDataSource.getAvailableHeight());
|
||||
int blocksToIndex = endHeight - startHeight + 1;
|
||||
if(blocksToIndex > 0) {
|
||||
log.info("Indexing {} blocks ({} to {})...", blocksToIndex, startHeight, endHeight);
|
||||
} else {
|
||||
log.info("Block index is up to date");
|
||||
}
|
||||
long startTime = System.currentTimeMillis();
|
||||
updateBlocksIndex();
|
||||
if(blocksToIndex > 0) {
|
||||
long elapsedMs = System.currentTimeMillis() - startTime;
|
||||
double blocksPerSec = blocksToIndex / (elapsedMs / 1000.0);
|
||||
log.info("Indexed {} blocks in {}.{}s ({} blocks/sec)", blocksToIndex, elapsedMs / 1000, String.format("%03d", elapsedMs % 1000), String.format("%.1f", blocksPerSec));
|
||||
}
|
||||
updateMempoolIndex();
|
||||
}
|
||||
|
||||
private synchronized void updateBlocksIndex() {
|
||||
BitcoindClientService bitcoindService = getBitcoindService();
|
||||
HexFormat hexFormat = HexFormat.of();
|
||||
int startHeight = blocksIndex.getLastBlockIndexed() + 1;
|
||||
int maxHeight = Math.min(tip.height(), blockDataSource.getAvailableHeight());
|
||||
|
||||
for(int i = blocksIndex.getLastBlockIndexed() + 1; i <= tip.height(); i++) {
|
||||
String blockHash = getBitcoindService().getBlockHash(i);
|
||||
if(i > tip.height() - MAX_REORG_DEPTH) {
|
||||
recentBlocksMap.put(i, blockHash);
|
||||
if(maxHeight - startHeight + 1 >= CONCURRENT_THRESHOLD && blockDataSource instanceof FlatFileBlockDataSource) {
|
||||
updateBlocksIndexConcurrent(startHeight, maxHeight);
|
||||
} else {
|
||||
updateBlocksIndexSequential(startHeight, maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBlocksIndexSequential(int startHeight, int maxHeight) {
|
||||
int totalBlocks = maxHeight - startHeight + 1;
|
||||
long lastLogTime = System.currentTimeMillis();
|
||||
|
||||
for(int i = startHeight; i <= maxHeight; i++) {
|
||||
BlockWithSpentOutputs blockData = blockDataSource.getBlockForIndexing(i);
|
||||
blockDataSource.populateCache(blockData);
|
||||
Block block = blockData.block();
|
||||
Map<HashIndex, Script> spentScriptPubKeys = blockData.spentScriptPubKeys();
|
||||
|
||||
if(i > maxHeight - MAX_REORG_DEPTH) {
|
||||
recentBlocksMap.put(i, blockData.blockHash());
|
||||
}
|
||||
String blockHex = (String)bitcoindService.getBlock(blockHash, 0);
|
||||
Block block = new Block(hexFormat.parseHex(blockHex));
|
||||
|
||||
Map<BlockTransaction, byte[]> eligibleTransactions = new LinkedHashMap<>();
|
||||
Map<HashIndex, Script> spentScriptPubKeys = new HashMap<>();
|
||||
for(Transaction tx : block.getTransactions()) {
|
||||
for(int outputIndex = 0; outputIndex < tx.getOutputs().size(); outputIndex++) {
|
||||
byte[] scriptPubKeyBytes = tx.getOutputs().get(outputIndex).getScriptBytes();
|
||||
addtoScriptPubKeyCache(tx.getTxId(), outputIndex, scriptPubKeyBytes);
|
||||
}
|
||||
|
||||
if(!tx.isCoinBase() && containsTaprootOutput(tx)) {
|
||||
for(TransactionInput txInput : tx.getInputs()) {
|
||||
HashIndex hashIndex = new HashIndex(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex());
|
||||
spentScriptPubKeys.put(hashIndex, getScriptPubKey(bitcoindService, hexFormat, hashIndex));
|
||||
}
|
||||
|
||||
if(!tx.isCoinBase() && ScriptUtils.containsTaprootOutput(tx)) {
|
||||
byte[] tweak = SilentPaymentUtils.getTweak(tx, spentScriptPubKeys, false);
|
||||
if(tweak != null) {
|
||||
BlockTransaction blkTx = new BlockTransaction(tx.getTxId(), i, block.getBlockHeader().getTimeAsDate(), 0L, tx, block.getHash());
|
||||
@ -172,7 +212,100 @@ public class BitcoindClient {
|
||||
if(!eligibleTransactions.isEmpty()) {
|
||||
blocksIndex.addToIndex(eligibleTransactions);
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if(now - lastLogTime >= 30_000) {
|
||||
int blocksProcessed = i - startHeight + 1;
|
||||
log.info("Indexing progress: {} / {} blocks (height {})", blocksProcessed, totalBlocks, i);
|
||||
lastLogTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
if(maxHeight >= startHeight) {
|
||||
blocksIndex.setLastBlockIndexed(maxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBlocksIndexConcurrent(int startHeight, int maxHeight) {
|
||||
int threads = Runtime.getRuntime().availableProcessors();
|
||||
int batchSize = threads * 2;
|
||||
AtomicInteger counter = new AtomicInteger(0);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threads, r -> {
|
||||
Thread t = new Thread(r, "block-indexer-" + counter.incrementAndGet());
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
try {
|
||||
int totalBlocks = maxHeight - startHeight + 1;
|
||||
int blocksProcessed = 0;
|
||||
long lastLogTime = System.currentTimeMillis();
|
||||
|
||||
for(int batchStart = startHeight; batchStart <= maxHeight; batchStart += batchSize) {
|
||||
int batchEnd = Math.min(batchStart + batchSize - 1, maxHeight);
|
||||
|
||||
List<Future<BlockIndexResult>> futures = new ArrayList<>();
|
||||
for(int h = batchStart; h <= batchEnd; h++) {
|
||||
final int height = h;
|
||||
futures.add(executor.submit(() -> processBlock(height)));
|
||||
}
|
||||
|
||||
Map<BlockTransaction, byte[]> batchEligible = new LinkedHashMap<>();
|
||||
for(int idx = 0; idx < futures.size(); idx++) {
|
||||
int height = batchStart + idx;
|
||||
try {
|
||||
BlockIndexResult result = futures.get(idx).get();
|
||||
|
||||
if(height > maxHeight - CACHE_POPULATE_WINDOW) {
|
||||
blockDataSource.populateCache(result.blockData());
|
||||
}
|
||||
if(height > maxHeight - MAX_REORG_DEPTH) {
|
||||
recentBlocksMap.put(height, result.blockHash());
|
||||
}
|
||||
batchEligible.putAll(result.eligibleTransactions());
|
||||
} catch(ExecutionException e) {
|
||||
throw new RuntimeException("Failed to index block " + height, e.getCause());
|
||||
} catch(InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Interrupted during block indexing", e);
|
||||
}
|
||||
}
|
||||
|
||||
if(!batchEligible.isEmpty()) {
|
||||
blocksIndex.addToIndex(batchEligible);
|
||||
}
|
||||
|
||||
blocksProcessed += (batchEnd - batchStart + 1);
|
||||
long now = System.currentTimeMillis();
|
||||
if(now - lastLogTime >= 30_000) {
|
||||
log.info("Indexing progress: {} / {} blocks (height {})", blocksProcessed, totalBlocks, batchEnd);
|
||||
lastLogTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
blocksIndex.setLastBlockIndexed(maxHeight);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private BlockIndexResult processBlock(int height) {
|
||||
BlockWithSpentOutputs blockData = blockDataSource.getBlockForIndexing(height);
|
||||
Block block = blockData.block();
|
||||
Map<HashIndex, Script> spentScriptPubKeys = blockData.spentScriptPubKeys();
|
||||
|
||||
Map<BlockTransaction, byte[]> eligibleTransactions = new LinkedHashMap<>();
|
||||
for(Transaction tx : block.getTransactions()) {
|
||||
if(!tx.isCoinBase() && ScriptUtils.containsTaprootOutput(tx)) {
|
||||
byte[] tweak = SilentPaymentUtils.getTweak(tx, spentScriptPubKeys, false);
|
||||
if(tweak != null) {
|
||||
BlockTransaction blkTx = new BlockTransaction(tx.getTxId(), height, block.getBlockHeader().getTimeAsDate(), 0L, tx, block.getHash());
|
||||
eligibleTransactions.put(blkTx, SilentPaymentUtils.getSecp256k1PubKey(tweak));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BlockIndexResult(height, blockData.blockHash(), blockData, eligibleTransactions);
|
||||
}
|
||||
|
||||
private synchronized void updateMempoolIndex() {
|
||||
@ -194,10 +327,10 @@ public class BitcoindClient {
|
||||
Transaction tx = new Transaction(hexFormat.parseHex(txHex));
|
||||
for(int outputIndex = 0; outputIndex < tx.getOutputs().size(); outputIndex++) {
|
||||
byte[] scriptPubKeyBytes = tx.getOutputs().get(outputIndex).getScriptBytes();
|
||||
addtoScriptPubKeyCache(tx.getTxId(), outputIndex, scriptPubKeyBytes);
|
||||
addToScriptPubKeyCache(tx.getTxId(), outputIndex, scriptPubKeyBytes);
|
||||
}
|
||||
|
||||
if(!tx.isCoinBase() && containsTaprootOutput(tx)) {
|
||||
if(!tx.isCoinBase() && ScriptUtils.containsTaprootOutput(tx)) {
|
||||
for(TransactionInput txInput : tx.getInputs()) {
|
||||
HashIndex hashIndex = new HashIndex(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex());
|
||||
spentScriptPubKeys.put(hashIndex, getScriptPubKey(bitcoindService, hexFormat, hashIndex));
|
||||
@ -228,6 +361,11 @@ public class BitcoindClient {
|
||||
public void stop() {
|
||||
timer.cancel();
|
||||
stopped = true;
|
||||
try {
|
||||
blockDataSource.close();
|
||||
} catch(IOException e) {
|
||||
log.warn("Error closing block data source", e);
|
||||
}
|
||||
}
|
||||
|
||||
public BitcoindClientService getBitcoindService() {
|
||||
@ -249,7 +387,7 @@ public class BitcoindClient {
|
||||
String txHex = (String)bitcoindClientService.getRawTransaction(hashIndex.getHash().toString(), false);
|
||||
Transaction tx = new Transaction(hexFormat.parseHex(txHex));
|
||||
TransactionOutput txOutput = tx.getOutputs().get((int)hashIndex.getIndex());
|
||||
addtoScriptPubKeyCache(hashIndex.getHash(), (int)hashIndex.getIndex(), txOutput.getScriptBytes());
|
||||
addToScriptPubKeyCache(hashIndex.getHash(), (int)hashIndex.getIndex(), txOutput.getScriptBytes());
|
||||
scriptPubKey = getFromScriptPubKeyCache(hashIndex);
|
||||
} catch(Exception e) {
|
||||
log.error("Error retrieving scriptPubKey for txid " + hashIndex.getHash() + " output index " + hashIndex.getIndex(), e);
|
||||
@ -384,69 +522,16 @@ public class BitcoindClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void addtoScriptPubKeyCache(Sha256Hash txid, int outputIndex, byte[] scriptPubKeyBytes) {
|
||||
private void addToScriptPubKeyCache(Sha256Hash txid, int outputIndex, byte[] scriptPubKeyBytes) {
|
||||
HashIndex hashIndex = new HashIndex(txid, outputIndex);
|
||||
//Only cache if the length of the field matches one of the valid
|
||||
if(getValidScriptType(scriptPubKeyBytes) != null) {
|
||||
if(ScriptUtils.getValidScriptType(scriptPubKeyBytes) != null) {
|
||||
scriptPubKeyCache.put(hashIndex, scriptPubKeyBytes);
|
||||
} else {
|
||||
scriptPubKeyCache.put(hashIndex, new byte[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsTaprootOutput(Transaction tx) {
|
||||
for(TransactionOutput txOutput : tx.getOutputs()) {
|
||||
ScriptType scriptType = getValidScriptType(txOutput.getScriptBytes());
|
||||
if(scriptType == ScriptType.P2TR) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ScriptType getValidScriptType(byte[] scriptPubKey) {
|
||||
if(scriptPubKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int length = scriptPubKey.length;
|
||||
|
||||
// P2PKH: 25 bytes - OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG
|
||||
if(length == 25 &&
|
||||
scriptPubKey[0] == (byte) 0x76 && // OP_DUP
|
||||
scriptPubKey[1] == (byte) 0xa9 && // OP_HASH160
|
||||
scriptPubKey[2] == (byte) 0x14 && // Push 20 bytes
|
||||
scriptPubKey[23] == (byte) 0x88 && // OP_EQUALVERIFY
|
||||
scriptPubKey[24] == (byte) 0xac) { // OP_CHECKSIG
|
||||
return ScriptType.P2PKH;
|
||||
}
|
||||
|
||||
// P2SH-P2WPKH: 23 bytes - OP_HASH160 <20-byte hash> OP_EQUAL
|
||||
if(length == 23 &&
|
||||
scriptPubKey[0] == (byte) 0xa9 && // OP_HASH160
|
||||
scriptPubKey[1] == (byte) 0x14 && // Push 20 bytes
|
||||
scriptPubKey[22] == (byte) 0x87) { // OP_EQUAL
|
||||
return ScriptType.P2SH_P2WPKH;
|
||||
}
|
||||
|
||||
// P2WPKH: 22 bytes - OP_0 <20-byte hash>
|
||||
if(length == 22 &&
|
||||
scriptPubKey[0] == (byte) 0x00 && // OP_0
|
||||
scriptPubKey[1] == (byte) 0x14) { // Push 20 bytes
|
||||
return ScriptType.P2WPKH;
|
||||
}
|
||||
|
||||
// P2TR: 34 bytes - OP_1 <32-byte taproot output>
|
||||
if(length == 34 &&
|
||||
scriptPubKey[0] == (byte) 0x51 && // OP_1
|
||||
scriptPubKey[1] == (byte) 0x20) { // Push 32 bytes
|
||||
return ScriptType.P2TR;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static File getDefaultCoreDataDir() {
|
||||
OsType osType = OsType.getCurrent();
|
||||
if(osType == OsType.MACOS) {
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package com.sparrowwallet.frigate.bitcoind;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Provides block data and spent output information for Silent Payments indexing.
|
||||
* Implementations may use RPC, flat files, or other data sources.
|
||||
*/
|
||||
public interface BlockDataSource extends Closeable {
|
||||
/**
|
||||
* Get block data, block hash, and spent output scriptPubKeys needed for indexing
|
||||
* at the given height. The returned spentScriptPubKeys map covers all inputs of
|
||||
* eligible transactions (non-coinbase with taproot outputs) in the block.
|
||||
*/
|
||||
BlockWithSpentOutputs getBlockForIndexing(int height);
|
||||
|
||||
/**
|
||||
* Returns the maximum block height available from this data source.
|
||||
* For RPC, this is effectively unlimited (returns Integer.MAX_VALUE).
|
||||
* For flat files, this is the max height in the on-disk index.
|
||||
*/
|
||||
default int getAvailableHeight() {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the scriptPubKey cache for outputs in this block.
|
||||
* Called sequentially from the main thread after getBlockForIndexing().
|
||||
* Default no-op — RpcBlockDataSource populates its cache during getBlockForIndexing().
|
||||
*/
|
||||
default void populateCache(BlockWithSpentOutputs blockData) {}
|
||||
|
||||
@Override
|
||||
default void close() throws IOException {}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.sparrowwallet.frigate.bitcoind;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransaction;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
record BlockIndexResult(int height, String blockHash, BlockWithSpentOutputs blockData, Map<BlockTransaction, byte[]> eligibleTransactions) {}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.frigate.bitcoind;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Block;
|
||||
import com.sparrowwallet.drongo.protocol.HashIndex;
|
||||
import com.sparrowwallet.drongo.protocol.Script;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A block together with the block hash and the spent scriptPubKeys accumulated
|
||||
* across all eligible transactions (non-coinbase with taproot outputs).
|
||||
* The spentScriptPubKeys map contains entries for every input of every eligible
|
||||
* transaction in the block, keyed by outpoint.
|
||||
*/
|
||||
public record BlockWithSpentOutputs(Block block, String blockHash, Map<HashIndex, Script> spentScriptPubKeys) {}
|
||||
@ -0,0 +1,91 @@
|
||||
package com.sparrowwallet.frigate.bitcoind;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* BlockDataSource implementation that fetches block data and spent output scriptPubKeys
|
||||
* via Bitcoin Core JSON-RPC. Uses an LRU cache to minimize getrawtransaction calls.
|
||||
*/
|
||||
public class RpcBlockDataSource implements BlockDataSource {
|
||||
private static final Logger log = LoggerFactory.getLogger(RpcBlockDataSource.class);
|
||||
|
||||
private final BitcoindClientService rpcService;
|
||||
private final Map<HashIndex, byte[]> scriptPubKeyCache;
|
||||
|
||||
public RpcBlockDataSource(BitcoindClientService rpcService, Map<HashIndex, byte[]> scriptPubKeyCache) {
|
||||
this.rpcService = rpcService;
|
||||
this.scriptPubKeyCache = scriptPubKeyCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockWithSpentOutputs getBlockForIndexing(int height) {
|
||||
HexFormat hexFormat = HexFormat.of();
|
||||
|
||||
// Fetch the raw block via RPC (single getBlockHash call, reused for the result)
|
||||
String blockHash = rpcService.getBlockHash(height);
|
||||
String blockHex = (String) rpcService.getBlock(blockHash, 0);
|
||||
Block block = new Block(hexFormat.parseHex(blockHex));
|
||||
|
||||
// Single pass: cache outputs and resolve spent scriptPubKeys only for eligible txs.
|
||||
// Uses a single shared map across all transactions in the block, matching the
|
||||
// original BitcoindClient.updateBlocksIndex() behavior.
|
||||
Map<HashIndex, Script> spentScriptPubKeys = new HashMap<>();
|
||||
for(Transaction tx : block.getTransactions()) {
|
||||
for(int outputIndex = 0; outputIndex < tx.getOutputs().size(); outputIndex++) {
|
||||
byte[] scriptPubKeyBytes = tx.getOutputs().get(outputIndex).getScriptBytes();
|
||||
addToScriptPubKeyCache(tx.getTxId(), outputIndex, scriptPubKeyBytes);
|
||||
}
|
||||
|
||||
if(!tx.isCoinBase() && ScriptUtils.containsTaprootOutput(tx)) {
|
||||
for(TransactionInput txInput : tx.getInputs()) {
|
||||
HashIndex hashIndex = new HashIndex(txInput.getOutpoint().getHash(), txInput.getOutpoint().getIndex());
|
||||
spentScriptPubKeys.put(hashIndex, resolveScriptPubKey(hexFormat, hashIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BlockWithSpentOutputs(block, blockHash, spentScriptPubKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a scriptPubKey for a given outpoint, using the cache first and
|
||||
* falling back to getrawtransaction RPC on cache miss.
|
||||
*/
|
||||
private Script resolveScriptPubKey(HexFormat hexFormat, HashIndex hashIndex) {
|
||||
Script scriptPubKey = getFromScriptPubKeyCache(hashIndex);
|
||||
if(scriptPubKey == null) {
|
||||
try {
|
||||
String txHex = (String) rpcService.getRawTransaction(hashIndex.getHash().toString(), false);
|
||||
Transaction tx = new Transaction(hexFormat.parseHex(txHex));
|
||||
TransactionOutput txOutput = tx.getOutputs().get((int) hashIndex.getIndex());
|
||||
addToScriptPubKeyCache(hashIndex.getHash(), (int) hashIndex.getIndex(), txOutput.getScriptBytes());
|
||||
scriptPubKey = getFromScriptPubKeyCache(hashIndex);
|
||||
} catch(Exception e) {
|
||||
log.error("Error retrieving scriptPubKey for txid " + hashIndex.getHash() + " output index " + hashIndex.getIndex(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return scriptPubKey;
|
||||
}
|
||||
|
||||
private Script getFromScriptPubKeyCache(HashIndex hashIndex) {
|
||||
byte[] scriptPubKeyBytes = scriptPubKeyCache.get(hashIndex);
|
||||
if(scriptPubKeyBytes != null) {
|
||||
return new Script(scriptPubKeyBytes);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void addToScriptPubKeyCache(Sha256Hash txid, int outputIndex, byte[] scriptPubKeyBytes) {
|
||||
HashIndex hashIndex = new HashIndex(txid, outputIndex);
|
||||
if(ScriptUtils.getValidScriptType(scriptPubKeyBytes) != null) {
|
||||
scriptPubKeyCache.put(hashIndex, scriptPubKeyBytes);
|
||||
} else {
|
||||
scriptPubKeyCache.put(hashIndex, new byte[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package com.sparrowwallet.frigate.bitcoind;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.TransactionOutput;
|
||||
|
||||
public final class ScriptUtils {
|
||||
private ScriptUtils() {}
|
||||
|
||||
public static ScriptType getValidScriptType(byte[] scriptPubKey) {
|
||||
if(scriptPubKey == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int length = scriptPubKey.length;
|
||||
|
||||
// P2PKH: 25 bytes - OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG
|
||||
if(length == 25 &&
|
||||
scriptPubKey[0] == (byte) 0x76 &&
|
||||
scriptPubKey[1] == (byte) 0xa9 &&
|
||||
scriptPubKey[2] == (byte) 0x14 &&
|
||||
scriptPubKey[23] == (byte) 0x88 &&
|
||||
scriptPubKey[24] == (byte) 0xac) {
|
||||
return ScriptType.P2PKH;
|
||||
}
|
||||
|
||||
// P2SH-P2WPKH: 23 bytes - OP_HASH160 <20-byte hash> OP_EQUAL
|
||||
if(length == 23 &&
|
||||
scriptPubKey[0] == (byte) 0xa9 &&
|
||||
scriptPubKey[1] == (byte) 0x14 &&
|
||||
scriptPubKey[22] == (byte) 0x87) {
|
||||
return ScriptType.P2SH_P2WPKH;
|
||||
}
|
||||
|
||||
// P2WPKH: 22 bytes - OP_0 <20-byte hash>
|
||||
if(length == 22 &&
|
||||
scriptPubKey[0] == (byte) 0x00 &&
|
||||
scriptPubKey[1] == (byte) 0x14) {
|
||||
return ScriptType.P2WPKH;
|
||||
}
|
||||
|
||||
// P2TR: 34 bytes - OP_1 <32-byte taproot output>
|
||||
if(length == 34 &&
|
||||
scriptPubKey[0] == (byte) 0x51 &&
|
||||
scriptPubKey[1] == (byte) 0x20) {
|
||||
return ScriptType.P2TR;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean containsTaprootOutput(Transaction tx) {
|
||||
for(TransactionOutput txOutput : tx.getOutputs()) {
|
||||
ScriptType scriptType = getValidScriptType(txOutput.getScriptBytes());
|
||||
if(scriptType == ScriptType.P2TR) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package com.sparrowwallet.frigate.bitcoind.reader;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Block;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class BlockFileReader {
|
||||
// Bitcoin consensus: max block weight is 4M weight units = max ~4MB serialized
|
||||
private static final int MAX_BLOCK_SIZE = 4_000_000;
|
||||
|
||||
private final Path blocksDir;
|
||||
private final XorObfuscation xor;
|
||||
private final MappedBlockFiles mappedFiles;
|
||||
|
||||
public BlockFileReader(Path blocksDir, XorObfuscation xor) {
|
||||
this(blocksDir, xor, null);
|
||||
}
|
||||
|
||||
public BlockFileReader(Path blocksDir, XorObfuscation xor, MappedBlockFiles mappedFiles) {
|
||||
this.blocksDir = blocksDir;
|
||||
this.xor = xor;
|
||||
this.mappedFiles = mappedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw block bytes from the given file number and data position.
|
||||
*/
|
||||
public byte[] readBlock(int fileNumber, int dataPos) throws IOException {
|
||||
String fileName = String.format("blk%05d.dat", fileNumber);
|
||||
|
||||
if(mappedFiles != null) {
|
||||
return readBlockMapped(fileName, dataPos);
|
||||
}
|
||||
|
||||
return readBlockRaf(fileName, dataPos);
|
||||
}
|
||||
|
||||
private byte[] readBlockMapped(String fileName, int dataPos) throws IOException {
|
||||
byte[] sizeBytes = mappedFiles.read(fileName, dataPos - 4, 4);
|
||||
if(sizeBytes == null) {
|
||||
return readBlockRaf(fileName, dataPos);
|
||||
}
|
||||
xor.deobfuscate(sizeBytes, dataPos - 4);
|
||||
int blockSize = ByteBuffer.wrap(sizeBytes).order(ByteOrder.LITTLE_ENDIAN).getInt();
|
||||
|
||||
if(blockSize < 0 || blockSize > MAX_BLOCK_SIZE) {
|
||||
throw new IOException("Invalid block size " + blockSize + " in " + fileName + " pos " + dataPos);
|
||||
}
|
||||
|
||||
byte[] blockData = mappedFiles.read(fileName, dataPos, blockSize);
|
||||
xor.deobfuscate(blockData, dataPos);
|
||||
return blockData;
|
||||
}
|
||||
|
||||
private byte[] readBlockRaf(String fileName, int dataPos) throws IOException {
|
||||
Path blockFile = blocksDir.resolve(fileName);
|
||||
|
||||
try(RandomAccessFile raf = new RandomAccessFile(blockFile.toFile(), "r")) {
|
||||
raf.seek(dataPos - 4);
|
||||
byte[] sizeBytes = new byte[4];
|
||||
raf.readFully(sizeBytes);
|
||||
xor.deobfuscate(sizeBytes, dataPos - 4);
|
||||
int blockSize = ByteBuffer.wrap(sizeBytes).order(ByteOrder.LITTLE_ENDIAN).getInt();
|
||||
|
||||
if(blockSize < 0 || blockSize > MAX_BLOCK_SIZE) {
|
||||
throw new IOException("Invalid block size " + blockSize + " in " + fileName + " pos " + dataPos);
|
||||
}
|
||||
|
||||
byte[] blockData = new byte[blockSize];
|
||||
raf.readFully(blockData);
|
||||
xor.deobfuscate(blockData, dataPos);
|
||||
|
||||
return blockData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a block into a Drongo Block object.
|
||||
*/
|
||||
public Block readAndParseBlock(int fileNumber, int dataPos) throws IOException {
|
||||
return new Block(readBlock(fileNumber, dataPos));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,268 @@
|
||||
package com.sparrowwallet.frigate.bitcoind.reader;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.CRC32C;
|
||||
|
||||
public class BlockIndex {
|
||||
private static final Logger log = LoggerFactory.getLogger(BlockIndex.class);
|
||||
|
||||
private static final int MAGIC = 0x1d5e2eb2;
|
||||
private static final int VERSION = 1;
|
||||
private static final int HEADER_SIZE = 8;
|
||||
private static final int ENTRY_DATA_SIZE = 112;
|
||||
private static final int CHECKSUM_SIZE = 4;
|
||||
private static final int ENTRY_TOTAL_SIZE = ENTRY_DATA_SIZE + CHECKSUM_SIZE;
|
||||
|
||||
// nStatus bits (from chain.h BlockStatus enum)
|
||||
private static final int BLOCK_HAVE_DATA = 8;
|
||||
private static final int BLOCK_HAVE_UNDO = 16;
|
||||
private static final int BLOCK_FAILED_VALID = 32;
|
||||
private static final int BLOCK_FAILED_CHILD = 64;
|
||||
private static final int BLOCK_FAILED_MASK = BLOCK_FAILED_VALID | BLOCK_FAILED_CHILD;
|
||||
|
||||
// Entry layout: 80-byte block header starts at byte offset 32 within the 112-byte entry data.
|
||||
// prevHash is at bytes 4-35 of the block header (offset 36 in the entry).
|
||||
private static final int BLOCK_HEADER_OFFSET = 32;
|
||||
private static final int BLOCK_HEADER_SIZE = 80;
|
||||
private static final int PREV_HASH_OFFSET = 36;
|
||||
private static final int HASH_SIZE = 32;
|
||||
|
||||
// Sanity cap: no Bitcoin network will reach this height in our lifetimes.
|
||||
// Protects against corrupt entries causing OOM via new int[Integer.MAX_VALUE].
|
||||
private static final int MAX_SANE_HEIGHT = 10_000_000;
|
||||
|
||||
// Parallel arrays indexed by height. Unoccupied slots have fileNumber == -1.
|
||||
private final int[] status;
|
||||
private final int[] fileNumber;
|
||||
private final int[] dataPos;
|
||||
private final int[] undoPos;
|
||||
private int maxHeight;
|
||||
private int entryCount;
|
||||
|
||||
private record ParsedEntry(int height, int status, int fileNumber, int dataPos, int undoPos, Sha256Hash prevHash) {}
|
||||
|
||||
private BlockIndex(int arraySize) {
|
||||
int size = arraySize + 1;
|
||||
this.status = new int[size];
|
||||
this.fileNumber = new int[size];
|
||||
this.dataPos = new int[size];
|
||||
this.undoPos = new int[size];
|
||||
this.maxHeight = arraySize;
|
||||
Arrays.fill(fileNumber, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all entries from headers.dat and build a height-indexed structure.
|
||||
* Only includes blocks on the best chain (determined by following prevHash links
|
||||
* backwards from the tip). This correctly excludes stale/orphan blocks that share
|
||||
* the same height as best-chain blocks.
|
||||
*/
|
||||
public static BlockIndex load(Path headersFile) throws IOException {
|
||||
// First pass: find max height to size the arrays
|
||||
int rawMaxHeight = findMaxHeight(headersFile);
|
||||
if(rawMaxHeight < 0) {
|
||||
throw new IOException("Empty headers.dat");
|
||||
}
|
||||
|
||||
BlockIndex index = new BlockIndex(rawMaxHeight);
|
||||
|
||||
// Second pass: parse all entries, compute block hashes, and build a chain map
|
||||
Map<Sha256Hash, ParsedEntry> entryMap = new HashMap<>();
|
||||
Sha256Hash bestTipHash = null;
|
||||
int bestTipHeight = -1;
|
||||
|
||||
try(FileChannel channel = FileChannel.open(headersFile)) {
|
||||
long fileSize = channel.size();
|
||||
readAndVerifyFileHeader(channel);
|
||||
|
||||
ByteBuffer buf = ByteBuffer.allocate(ENTRY_TOTAL_SIZE).order(ByteOrder.LITTLE_ENDIAN);
|
||||
ByteBuffer posBuf = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
|
||||
CRC32C crc = new CRC32C();
|
||||
|
||||
while(channel.position() + ENTRY_TOTAL_SIZE <= fileSize) {
|
||||
long entryFilePos = channel.position();
|
||||
buf.clear();
|
||||
int bytesRead = channel.read(buf);
|
||||
if(bytesRead < ENTRY_TOTAL_SIZE) {
|
||||
break;
|
||||
}
|
||||
buf.flip();
|
||||
|
||||
byte[] dataBytes = new byte[ENTRY_DATA_SIZE];
|
||||
buf.get(dataBytes);
|
||||
int storedChecksum = buf.getInt();
|
||||
|
||||
crc.reset();
|
||||
crc.update(dataBytes);
|
||||
posBuf.clear();
|
||||
posBuf.putLong(entryFilePos);
|
||||
crc.update(posBuf.array());
|
||||
|
||||
if((int) crc.getValue() != storedChecksum) {
|
||||
throw new IOException("CRC32c mismatch at offset " + entryFilePos);
|
||||
}
|
||||
|
||||
ByteBuffer entry = ByteBuffer.wrap(dataBytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
int height = entry.getInt();
|
||||
int entryStatus = entry.getInt();
|
||||
|
||||
if(height < 0 || height > rawMaxHeight) {
|
||||
continue;
|
||||
}
|
||||
if((entryStatus & BLOCK_FAILED_MASK) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute block hash from the 80-byte block header embedded in the entry
|
||||
Sha256Hash blockHash = Sha256Hash.wrapReversed(Sha256Hash.hashTwice(dataBytes, BLOCK_HEADER_OFFSET, BLOCK_HEADER_SIZE));
|
||||
|
||||
// Extract prevHash (32 bytes in LE wire order)
|
||||
byte[] prevHashBytes = new byte[HASH_SIZE];
|
||||
System.arraycopy(dataBytes, PREV_HASH_OFFSET, prevHashBytes, 0, HASH_SIZE);
|
||||
Sha256Hash prevHash = Sha256Hash.wrapReversed(prevHashBytes);
|
||||
|
||||
entry.getInt(); // nTx
|
||||
int entryFileNumber = entry.getInt();
|
||||
int entryDataPos = entry.getInt();
|
||||
int entryUndoPos = entry.getInt();
|
||||
|
||||
entryMap.put(blockHash, new ParsedEntry(height, entryStatus, entryFileNumber, entryDataPos, entryUndoPos, prevHash));
|
||||
|
||||
// Track best tip candidate: highest height with block data on disk
|
||||
if((entryStatus & BLOCK_HAVE_DATA) != 0 && height > bestTipHeight) {
|
||||
bestTipHeight = height;
|
||||
bestTipHash = blockHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(bestTipHash == null) {
|
||||
throw new IOException("No valid entries found in headers.dat");
|
||||
}
|
||||
|
||||
// Walk backwards from the tip following prevHash links to identify the best chain.
|
||||
// Only populate the parallel arrays for blocks on this chain.
|
||||
int bestChainMaxHeight = -1;
|
||||
Sha256Hash current = bestTipHash;
|
||||
while(current != null) {
|
||||
ParsedEntry pe = entryMap.get(current);
|
||||
if(pe == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
int h = pe.height;
|
||||
boolean hasData = (pe.status & BLOCK_HAVE_DATA) != 0;
|
||||
boolean hasUndo = (pe.status & BLOCK_HAVE_UNDO) != 0;
|
||||
|
||||
if(hasData && (h == 0 || hasUndo) && h >= 0 && h <= rawMaxHeight) {
|
||||
if(index.fileNumber[h] == -1) {
|
||||
index.entryCount++;
|
||||
}
|
||||
index.status[h] = pe.status;
|
||||
index.fileNumber[h] = pe.fileNumber;
|
||||
index.dataPos[h] = pe.dataPos;
|
||||
index.undoPos[h] = pe.undoPos;
|
||||
bestChainMaxHeight = Math.max(bestChainMaxHeight, h);
|
||||
}
|
||||
|
||||
current = pe.prevHash;
|
||||
}
|
||||
|
||||
if(bestChainMaxHeight < 0) {
|
||||
throw new IOException("Chain walk from tip found no indexable blocks in headers.dat");
|
||||
}
|
||||
|
||||
index.maxHeight = bestChainMaxHeight;
|
||||
|
||||
int staleEntries = entryMap.size() - index.entryCount;
|
||||
if(staleEntries > 0) {
|
||||
log.debug("Excluded {} stale/orphan block entries from index", staleEntries);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick scan to find the maximum height in the file.
|
||||
* Skips entries with heights outside [0, MAX_SANE_HEIGHT) to guard against corruption.
|
||||
*/
|
||||
private static int findMaxHeight(Path headersFile) throws IOException {
|
||||
int maxHeight = -1;
|
||||
|
||||
try(FileChannel channel = FileChannel.open(headersFile)) {
|
||||
long fileSize = channel.size();
|
||||
readAndVerifyFileHeader(channel);
|
||||
|
||||
ByteBuffer buf = ByteBuffer.allocate(ENTRY_TOTAL_SIZE).order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
while(channel.position() + ENTRY_TOTAL_SIZE <= fileSize) {
|
||||
buf.clear();
|
||||
int bytesRead = channel.read(buf);
|
||||
if(bytesRead < ENTRY_TOTAL_SIZE) {
|
||||
break;
|
||||
}
|
||||
buf.flip();
|
||||
int height = buf.getInt();
|
||||
if(height >= 0 && height < MAX_SANE_HEIGHT && height > maxHeight) {
|
||||
maxHeight = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxHeight;
|
||||
}
|
||||
|
||||
private static void readAndVerifyFileHeader(FileChannel channel) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.allocate(HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN);
|
||||
channel.read(header);
|
||||
header.flip();
|
||||
if(header.getInt() != MAGIC) {
|
||||
throw new IOException("Invalid headers.dat magic");
|
||||
}
|
||||
if(header.getInt() != VERSION) {
|
||||
throw new IOException("Unsupported headers.dat version");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entry exists at the given height.
|
||||
*/
|
||||
public boolean has(int height) {
|
||||
return height >= 0 && height <= maxHeight && fileNumber[height] != -1;
|
||||
}
|
||||
|
||||
public int getFileNumber(int height) {
|
||||
return fileNumber[height];
|
||||
}
|
||||
|
||||
public int getDataPos(int height) {
|
||||
return dataPos[height];
|
||||
}
|
||||
|
||||
public int getUndoPos(int height) {
|
||||
return undoPos[height];
|
||||
}
|
||||
|
||||
public boolean hasUndo(int height) {
|
||||
return (status[height] & BLOCK_HAVE_UNDO) != 0;
|
||||
}
|
||||
|
||||
public int getMaxHeight() {
|
||||
return maxHeight;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return entryCount;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
package com.sparrowwallet.frigate.bitcoind.reader;
|
||||
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.frigate.bitcoind.BlockDataSource;
|
||||
import com.sparrowwallet.frigate.bitcoind.BlockWithSpentOutputs;
|
||||
import com.sparrowwallet.frigate.bitcoind.ScriptUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class FlatFileBlockDataSource implements BlockDataSource {
|
||||
private static final Logger log = LoggerFactory.getLogger(FlatFileBlockDataSource.class);
|
||||
|
||||
private final Path indexDir;
|
||||
private final MappedBlockFiles mappedFiles;
|
||||
private final BlockFileReader blockReader;
|
||||
private final UndoReader undoReader;
|
||||
private final Map<HashIndex, byte[]> scriptPubKeyCache;
|
||||
private volatile BlockIndex blockIndex;
|
||||
|
||||
/**
|
||||
* @param blocksDir path to the blocks/ directory
|
||||
* @param indexDir path to the blocks/index/ directory (containing headers.dat)
|
||||
* @param scriptPubKeyCache shared cache with BitcoindClient for mempool indexing benefit
|
||||
*/
|
||||
public FlatFileBlockDataSource(Path blocksDir, Path indexDir, Map<HashIndex, byte[]> scriptPubKeyCache) throws IOException {
|
||||
this.indexDir = indexDir;
|
||||
XorObfuscation xor = new XorObfuscation(blocksDir);
|
||||
this.mappedFiles = new MappedBlockFiles(blocksDir);
|
||||
this.blockReader = new BlockFileReader(blocksDir, xor, mappedFiles);
|
||||
this.undoReader = new UndoReader(blocksDir, xor, mappedFiles);
|
||||
this.scriptPubKeyCache = scriptPubKeyCache;
|
||||
this.blockIndex = BlockIndex.load(indexDir.resolve("headers.dat"));
|
||||
log.info("Loaded flat file block index with {} entries (max height {})", blockIndex.size(), blockIndex.getMaxHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockWithSpentOutputs getBlockForIndexing(int height) {
|
||||
BlockIndex idx = this.blockIndex; // snapshot for thread safety
|
||||
|
||||
if(!idx.has(height)) {
|
||||
if(height > idx.getMaxHeight()) {
|
||||
reloadIndex();
|
||||
idx = this.blockIndex;
|
||||
}
|
||||
if(!idx.has(height)) {
|
||||
throw new IllegalArgumentException("No block index entry for height " + height);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Block block = blockReader.readAndParseBlock(idx.getFileNumber(height), idx.getDataPos(height));
|
||||
String blockHash = block.getHash().toString();
|
||||
|
||||
// Genesis block (height 0) has no undo data and no non-coinbase transactions
|
||||
if(height == 0) {
|
||||
return new BlockWithSpentOutputs(block, blockHash, Map.of());
|
||||
}
|
||||
|
||||
if(!idx.hasUndo(height)) {
|
||||
throw new IOException("Block at height " + height + " has no undo data on disk");
|
||||
}
|
||||
|
||||
// Pass the previous block hash in LE (wire/internal) byte order for checksum verification.
|
||||
// Drongo stores hashes in BE (display) order internally (readHash() calls wrapReversed()),
|
||||
// so getReversedBytes() gives us the LE bytes that match Bitcoin Core's uint256 representation.
|
||||
byte[] prevBlockHash = block.getBlockHeader().getPrevBlockHash().getReversedBytes();
|
||||
UndoReader.BlockUndo undo = undoReader.readBlockUndo(idx.getFileNumber(height), idx.getUndoPos(height), prevBlockHash);
|
||||
|
||||
// Single pass: for each eligible tx, get spent scriptPubKeys from undo data.
|
||||
// undo.txUndos() is parallel to block.getTransactions() minus the coinbase:
|
||||
// undo index 0 = tx index 1, undo index 1 = tx index 2, etc.
|
||||
Map<HashIndex, Script> spentScriptPubKeys = new HashMap<>();
|
||||
for(int txIdx = 1; txIdx < block.getTransactions().size(); txIdx++) {
|
||||
Transaction tx = block.getTransactions().get(txIdx);
|
||||
if(!ScriptUtils.containsTaprootOutput(tx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
UndoReader.TxUndo txUndo = undo.txUndos().get(txIdx - 1);
|
||||
for(int inputIdx = 0; inputIdx < tx.getInputs().size(); inputIdx++) {
|
||||
TransactionInput input = tx.getInputs().get(inputIdx);
|
||||
HashIndex hashIndex = new HashIndex(input.getOutpoint().getHash(), input.getOutpoint().getIndex());
|
||||
spentScriptPubKeys.put(hashIndex, new Script(txUndo.prevouts().get(inputIdx).scriptPubKey()));
|
||||
}
|
||||
}
|
||||
|
||||
return new BlockWithSpentOutputs(block, blockHash, spentScriptPubKeys);
|
||||
} catch(IOException e) {
|
||||
throw new RuntimeException("Failed to read block at height " + height, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAvailableHeight() {
|
||||
return blockIndex.getMaxHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void populateCache(BlockWithSpentOutputs blockData) {
|
||||
for(Transaction tx : blockData.block().getTransactions()) {
|
||||
for(int outputIndex = 0; outputIndex < tx.getOutputs().size(); outputIndex++) {
|
||||
byte[] scriptPubKeyBytes = tx.getOutputs().get(outputIndex).getScriptBytes();
|
||||
addToScriptPubKeyCache(tx.getTxId(), outputIndex, scriptPubKeyBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void reloadIndex() {
|
||||
try {
|
||||
this.blockIndex = BlockIndex.load(indexDir.resolve("headers.dat"));
|
||||
log.debug("Reloaded flat file block index, max height {}", blockIndex.getMaxHeight());
|
||||
} catch(IOException e) {
|
||||
log.warn("Failed to reload flat file block index", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void addToScriptPubKeyCache(Sha256Hash txid, int outputIndex, byte[] scriptPubKeyBytes) {
|
||||
HashIndex hashIndex = new HashIndex(txid, outputIndex);
|
||||
if(ScriptUtils.getValidScriptType(scriptPubKeyBytes) != null) {
|
||||
scriptPubKeyCache.put(hashIndex, scriptPubKeyBytes);
|
||||
} else {
|
||||
scriptPubKeyCache.put(hashIndex, new byte[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
mappedFiles.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the blocks directory for the current network.
|
||||
* Bitcoin Core has never used a mainnet/ subdirectory — blocks are always at
|
||||
* {datadir}/blocks/ on mainnet. Network.getHome() returns "mainnet" (a Drongo
|
||||
* convention), so the resolve("mainnet") path will never exist and the fallback
|
||||
* to {datadir}/blocks/ always triggers.
|
||||
*/
|
||||
public static Path resolveBlocksDir(Path dataDir) {
|
||||
String home = Network.get().getHome();
|
||||
Path blocksDir = dataDir.resolve(home).resolve("blocks");
|
||||
if(!Files.isDirectory(blocksDir) && Network.get() == Network.MAINNET) {
|
||||
blocksDir = dataDir.resolve("blocks");
|
||||
}
|
||||
return blocksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the flat file block index is available (PR #32427 format).
|
||||
*/
|
||||
public static boolean isAvailable(Path dataDir) {
|
||||
Path blocksDir = resolveBlocksDir(dataDir);
|
||||
return Files.exists(blocksDir.resolve("index").resolve("headers.dat"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
package com.sparrowwallet.frigate.bitcoind.reader;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.lang.foreign.Arena;
|
||||
import java.lang.foreign.MemorySegment;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Memory-maps blk?????.dat and rev?????.dat files on demand, caching the mappings
|
||||
* for reuse across concurrent readers. Thread-safe.
|
||||
*
|
||||
* <p>Uses an LRU eviction strategy to bound the number of mapped files. Each mapping
|
||||
* has its own {@link Arena} so evicted mappings release their OS-level mapping immediately.
|
||||
* The most recent block/rev file (highest file number for each type) is excluded from
|
||||
* caching because Bitcoin Core may still be appending to it — reads to those files
|
||||
* return null, signaling callers to fall back to RandomAccessFile.
|
||||
*/
|
||||
public class MappedBlockFiles implements Closeable {
|
||||
// Each blk/rev file is ~134 MB. 16 files ≈ ~2 GB virtual address space, covering
|
||||
// ~8 block files + 8 rev files (consecutive blocks are in the same or adjacent files).
|
||||
private static final int MAX_CACHED_FILES = 16;
|
||||
|
||||
private final Path blocksDir;
|
||||
|
||||
private record MappedFile(Arena arena, MemorySegment segment) implements Closeable {
|
||||
@Override
|
||||
public void close() {
|
||||
arena.close();
|
||||
}
|
||||
}
|
||||
|
||||
// LRU cache: eldest entries are evicted and their Arena closed when size exceeds MAX_CACHED_FILES.
|
||||
// All access synchronized on 'this'.
|
||||
private final LinkedHashMap<String, MappedFile> mappedFiles = new LinkedHashMap<>(MAX_CACHED_FILES, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, MappedFile> eldest) {
|
||||
if(size() > MAX_CACHED_FILES) {
|
||||
eldest.getValue().close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Highest file number seen for blk and rev files. Reads to these files are
|
||||
// excluded from caching because Bitcoin Core may still be appending to them.
|
||||
private volatile int lastBlkFileNumber = -1;
|
||||
private volatile int lastRevFileNumber = -1;
|
||||
|
||||
public MappedBlockFiles(Path blocksDir) {
|
||||
this.blocksDir = blocksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read bytes from a block or undo file at the given offset.
|
||||
* Returns null if the file should not be memory-mapped (active file that may
|
||||
* still be growing), signaling the caller to fall back to RandomAccessFile.
|
||||
*/
|
||||
public byte[] read(String fileName, long offset, int length) throws IOException {
|
||||
if(isActiveFile(fileName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MemorySegment segment;
|
||||
synchronized(this) {
|
||||
MappedFile mapped = mappedFiles.get(fileName);
|
||||
if(mapped == null) {
|
||||
mapped = mapFile(fileName);
|
||||
mappedFiles.put(fileName, mapped);
|
||||
}
|
||||
segment = mapped.segment();
|
||||
}
|
||||
|
||||
if(offset + length > segment.byteSize()) {
|
||||
throw new IOException("Read beyond mapped file bounds: " + fileName + " offset=" + offset + " length=" + length + " fileSize=" + segment.byteSize());
|
||||
}
|
||||
|
||||
byte[] data = new byte[length];
|
||||
MemorySegment.copy(segment, offset, MemorySegment.ofArray(data), 0, length);
|
||||
return data;
|
||||
}
|
||||
|
||||
private MappedFile mapFile(String fileName) throws IOException {
|
||||
Path file = blocksDir.resolve(fileName);
|
||||
Arena arena = Arena.ofShared();
|
||||
try(FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
|
||||
MemorySegment segment = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size(), arena);
|
||||
return new MappedFile(arena, segment);
|
||||
} catch(IOException e) {
|
||||
arena.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the most recent (possibly still growing) file for its type.
|
||||
* Tracks the highest file number seen and excludes it from caching.
|
||||
*/
|
||||
private boolean isActiveFile(String fileName) {
|
||||
int fileNumber = Integer.parseInt(fileName.substring(3, 8));
|
||||
boolean isBlk = fileName.startsWith("blk");
|
||||
|
||||
if(isBlk) {
|
||||
if(fileNumber > lastBlkFileNumber) {
|
||||
lastBlkFileNumber = fileNumber;
|
||||
}
|
||||
return fileNumber == lastBlkFileNumber;
|
||||
} else {
|
||||
if(fileNumber > lastRevFileNumber) {
|
||||
lastRevFileNumber = fileNumber;
|
||||
}
|
||||
return fileNumber == lastRevFileNumber;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized(this) {
|
||||
for(MappedFile mapped : mappedFiles.values()) {
|
||||
mapped.close();
|
||||
}
|
||||
mappedFiles.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,286 @@
|
||||
package com.sparrowwallet.frigate.bitcoind.reader;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class UndoReader {
|
||||
// Generous upper bound — largest observed undo data is ~4MB for the largest blocks
|
||||
private static final int MAX_UNDO_SIZE = 8_000_000;
|
||||
|
||||
private final Path blocksDir;
|
||||
private final XorObfuscation xor;
|
||||
private final MappedBlockFiles mappedFiles;
|
||||
|
||||
public UndoReader(Path blocksDir, XorObfuscation xor) {
|
||||
this(blocksDir, xor, null);
|
||||
}
|
||||
|
||||
public UndoReader(Path blocksDir, XorObfuscation xor, MappedBlockFiles mappedFiles) {
|
||||
this.blocksDir = blocksDir;
|
||||
this.xor = xor;
|
||||
this.mappedFiles = mappedFiles;
|
||||
}
|
||||
|
||||
/** A single spent output from the undo data. */
|
||||
public record SpentOutput(long amount, byte[] scriptPubKey, int height, boolean coinbase) {}
|
||||
|
||||
/** All spent outputs for one transaction. */
|
||||
public record TxUndo(List<SpentOutput> prevouts) {}
|
||||
|
||||
/** All undo data for one block. */
|
||||
public record BlockUndo(List<TxUndo> txUndos) {}
|
||||
|
||||
/**
|
||||
* Read undo data for a block.
|
||||
*
|
||||
* @param fileNumber the rev file number (same as the blk file number)
|
||||
* @param undoPos byte offset in rev?????.dat (past the 8-byte storage header)
|
||||
* @param prevBlockHash 32-byte hash of the previous block (LE, wire byte order),
|
||||
* used to verify the undo data checksum. Pass null to skip verification.
|
||||
*/
|
||||
public BlockUndo readBlockUndo(int fileNumber, int undoPos, byte[] prevBlockHash) throws IOException {
|
||||
String fileName = String.format("rev%05d.dat", fileNumber);
|
||||
|
||||
if(mappedFiles != null) {
|
||||
return readBlockUndoMapped(fileName, undoPos, prevBlockHash);
|
||||
}
|
||||
|
||||
return readBlockUndoRaf(fileName, undoPos, prevBlockHash);
|
||||
}
|
||||
|
||||
private BlockUndo readBlockUndoMapped(String fileName, int undoPos, byte[] prevBlockHash) throws IOException {
|
||||
byte[] sizeBytes = mappedFiles.read(fileName, undoPos - 4, 4);
|
||||
if(sizeBytes == null) {
|
||||
return readBlockUndoRaf(fileName, undoPos, prevBlockHash);
|
||||
}
|
||||
xor.deobfuscate(sizeBytes, undoPos - 4);
|
||||
int undoSize = ByteBuffer.wrap(sizeBytes).order(ByteOrder.LITTLE_ENDIAN).getInt();
|
||||
|
||||
if(undoSize < 0 || undoSize > MAX_UNDO_SIZE) {
|
||||
throw new IOException("Invalid undo size " + undoSize + " in " + fileName + " pos " + undoPos);
|
||||
}
|
||||
|
||||
byte[] undoData = mappedFiles.read(fileName, undoPos, undoSize);
|
||||
xor.deobfuscate(undoData, undoPos);
|
||||
|
||||
if(prevBlockHash != null) {
|
||||
byte[] storedChecksum = mappedFiles.read(fileName, undoPos + undoSize, 32);
|
||||
xor.deobfuscate(storedChecksum, undoPos + undoSize);
|
||||
|
||||
byte[] computed = Sha256Hash.hashTwice(prevBlockHash, undoData);
|
||||
if(!Arrays.equals(storedChecksum, computed)) {
|
||||
throw new IOException("Undo data checksum mismatch in " + fileName + " pos " + undoPos);
|
||||
}
|
||||
}
|
||||
|
||||
return parseBlockUndo(new ByteArrayInputStream(undoData));
|
||||
}
|
||||
|
||||
private BlockUndo readBlockUndoRaf(String fileName, int undoPos, byte[] prevBlockHash) throws IOException {
|
||||
Path undoFile = blocksDir.resolve(fileName);
|
||||
|
||||
try(RandomAccessFile raf = new RandomAccessFile(undoFile.toFile(), "r")) {
|
||||
raf.seek(undoPos - 4);
|
||||
byte[] sizeBytes = new byte[4];
|
||||
raf.readFully(sizeBytes);
|
||||
xor.deobfuscate(sizeBytes, undoPos - 4);
|
||||
int undoSize = ByteBuffer.wrap(sizeBytes).order(ByteOrder.LITTLE_ENDIAN).getInt();
|
||||
|
||||
if(undoSize < 0 || undoSize > MAX_UNDO_SIZE) {
|
||||
throw new IOException("Invalid undo size " + undoSize + " in " + fileName + " pos " + undoPos);
|
||||
}
|
||||
|
||||
byte[] undoData = new byte[undoSize];
|
||||
raf.readFully(undoData);
|
||||
xor.deobfuscate(undoData, undoPos);
|
||||
|
||||
// Verify checksum: SHA256d(prevBlockHash + undoData)
|
||||
if(prevBlockHash != null) {
|
||||
byte[] storedChecksum = new byte[32];
|
||||
raf.readFully(storedChecksum);
|
||||
xor.deobfuscate(storedChecksum, undoPos + undoSize);
|
||||
|
||||
byte[] computed = Sha256Hash.hashTwice(prevBlockHash, undoData);
|
||||
if(!Arrays.equals(storedChecksum, computed)) {
|
||||
throw new IOException("Undo data checksum mismatch in " + fileName + " pos " + undoPos);
|
||||
}
|
||||
}
|
||||
|
||||
return parseBlockUndo(new ByteArrayInputStream(undoData));
|
||||
}
|
||||
}
|
||||
|
||||
private BlockUndo parseBlockUndo(InputStream in) throws IOException {
|
||||
long numTxUndo = readCompactSize(in);
|
||||
List<TxUndo> txUndos = new ArrayList<>((int) numTxUndo);
|
||||
|
||||
for(int i = 0; i < numTxUndo; i++) {
|
||||
long numPrevouts = readCompactSize(in);
|
||||
List<SpentOutput> prevouts = new ArrayList<>((int) numPrevouts);
|
||||
|
||||
for(int j = 0; j < numPrevouts; j++) {
|
||||
long nCode = readCoreVarInt(in);
|
||||
int height = (int) (nCode >> 1);
|
||||
boolean coinbase = (nCode & 1) != 0;
|
||||
|
||||
if(height > 0) {
|
||||
readCoreVarInt(in); // legacy nVersion, discard
|
||||
}
|
||||
|
||||
long compressedAmount = readCoreVarInt(in);
|
||||
long amount = decompressAmount(compressedAmount);
|
||||
|
||||
int scriptType = (int) readCoreVarInt(in);
|
||||
byte[] scriptPubKey;
|
||||
if(scriptType < 6) {
|
||||
int specialSize = getSpecialScriptSize(scriptType);
|
||||
byte[] compressed = in.readNBytes(specialSize);
|
||||
if(compressed.length < specialSize) {
|
||||
throw new EOFException("Truncated compressed script data");
|
||||
}
|
||||
scriptPubKey = decompressScript(scriptType, compressed);
|
||||
} else {
|
||||
int rawLen = scriptType - 6;
|
||||
byte[] raw = in.readNBytes(rawLen);
|
||||
if(raw.length < rawLen) {
|
||||
throw new EOFException("Truncated raw script data");
|
||||
}
|
||||
scriptPubKey = raw;
|
||||
}
|
||||
|
||||
prevouts.add(new SpentOutput(amount, scriptPubKey, height, coinbase));
|
||||
}
|
||||
|
||||
txUndos.add(new TxUndo(prevouts));
|
||||
}
|
||||
|
||||
return new BlockUndo(txUndos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitcoin Core's VARINT (base-128 with continuation bits).
|
||||
* NOT the same as CompactSize/VarInt used in transactions.
|
||||
*/
|
||||
static long readCoreVarInt(InputStream in) throws IOException {
|
||||
long n = 0;
|
||||
while(true) {
|
||||
int b = in.read();
|
||||
if(b < 0) {
|
||||
throw new EOFException();
|
||||
}
|
||||
n = (n << 7) | (b & 0x7F);
|
||||
if((b & 0x80) == 0) {
|
||||
return n;
|
||||
}
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CompactSize (same as Drongo's VarInt) — used for vector lengths in undo data.
|
||||
*/
|
||||
static long readCompactSize(InputStream in) throws IOException {
|
||||
int first = in.read();
|
||||
if(first < 0) {
|
||||
throw new EOFException();
|
||||
}
|
||||
first &= 0xFF;
|
||||
if(first < 253) {
|
||||
return first;
|
||||
}
|
||||
if(first == 253) {
|
||||
return readLE(in, 2);
|
||||
}
|
||||
if(first == 254) {
|
||||
return readLE(in, 4);
|
||||
}
|
||||
return readLE(in, 8);
|
||||
}
|
||||
|
||||
private static long readLE(InputStream in, int bytes) throws IOException {
|
||||
long value = 0;
|
||||
for(int i = 0; i < bytes; i++) {
|
||||
int b = in.read();
|
||||
if(b < 0) {
|
||||
throw new EOFException();
|
||||
}
|
||||
value |= ((long) b) << (i * 8);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static long decompressAmount(long x) {
|
||||
if(x == 0) {
|
||||
return 0;
|
||||
}
|
||||
x--;
|
||||
int e = (int) (x % 10);
|
||||
x /= 10;
|
||||
long n;
|
||||
if(e < 9) {
|
||||
int d = (int) (x % 9) + 1;
|
||||
x /= 9;
|
||||
n = x * 10 + d;
|
||||
} else {
|
||||
n = x + 1;
|
||||
}
|
||||
for(int i = 0; i < e; i++) {
|
||||
n *= 10;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
static int getSpecialScriptSize(int type) {
|
||||
if(type == 0 || type == 1) {
|
||||
return 20;
|
||||
}
|
||||
if(type >= 2 && type <= 5) {
|
||||
return 32;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static byte[] decompressScript(int type, byte[] compressed) {
|
||||
switch(type) {
|
||||
case 0: { // P2PKH
|
||||
byte[] script = new byte[25];
|
||||
script[0] = 0x76; // OP_DUP
|
||||
script[1] = (byte) 0xa9; // OP_HASH160
|
||||
script[2] = 0x14; // push 20 bytes
|
||||
System.arraycopy(compressed, 0, script, 3, 20);
|
||||
script[23] = (byte) 0x88; // OP_EQUALVERIFY
|
||||
script[24] = (byte) 0xac; // OP_CHECKSIG
|
||||
return script;
|
||||
}
|
||||
case 1: { // P2SH
|
||||
byte[] script = new byte[23];
|
||||
script[0] = (byte) 0xa9; // OP_HASH160
|
||||
script[1] = 0x14; // push 20 bytes
|
||||
System.arraycopy(compressed, 0, script, 2, 20);
|
||||
script[22] = (byte) 0x87; // OP_EQUAL
|
||||
return script;
|
||||
}
|
||||
case 2: case 3: { // P2PK compressed
|
||||
byte[] script = new byte[35];
|
||||
script[0] = 0x21; // push 33 bytes
|
||||
script[1] = (byte) type; // 0x02 or 0x03
|
||||
System.arraycopy(compressed, 0, script, 2, 32);
|
||||
script[34] = (byte) 0xac; // OP_CHECKSIG
|
||||
return script;
|
||||
}
|
||||
case 4: case 5: { // P2PK uncompressed — not an SP-eligible input type
|
||||
return new byte[]{(byte) 0xac};
|
||||
}
|
||||
default: { // Raw script (type >= 6, length = type - 6)
|
||||
return compressed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.sparrowwallet.frigate.bitcoind.reader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class XorObfuscation {
|
||||
private final byte[] key;
|
||||
|
||||
public XorObfuscation(Path blocksDir) throws IOException {
|
||||
Path xorFile = blocksDir.resolve("xor.dat");
|
||||
if(Files.exists(xorFile)) {
|
||||
this.key = Files.readAllBytes(xorFile);
|
||||
} else {
|
||||
this.key = new byte[8];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XOR-deobfuscate data in place. The XOR key repeats every 8 bytes
|
||||
* relative to the file position.
|
||||
*/
|
||||
public void deobfuscate(byte[] data, long fileOffset) {
|
||||
if(isNull()) {
|
||||
return;
|
||||
}
|
||||
for(int i = 0; i < data.length; i++) {
|
||||
data[i] ^= key[(int) ((fileOffset + i) % key.length)];
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isNull() {
|
||||
for(byte b : key) {
|
||||
if(b != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -138,6 +138,12 @@ public class Index {
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastBlockIndexed(int height) {
|
||||
if(height > lastBlockIndexed) {
|
||||
lastBlockIndexed = height;
|
||||
}
|
||||
}
|
||||
|
||||
public void addToIndex(Map<BlockTransaction, byte[]> transactions) {
|
||||
if(dbManager.isShutdown()) {
|
||||
return;
|
||||
@ -173,7 +179,7 @@ public class Index {
|
||||
if(blockHeight <= 0 && lastBlockIndexed < 0) {
|
||||
log.info("Indexed " + transactions.size() + " mempool transactions");
|
||||
} else if(blockHeight > 0) {
|
||||
log.info("Indexed " + transactions.size() + " transactions to block height " + blockHeight);
|
||||
log.debug("Indexed " + transactions.size() + " transactions to block height " + blockHeight);
|
||||
}
|
||||
|
||||
return blockHeight;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user