Compare commits

...

5 Commits

13 changed files with 1353 additions and 80 deletions

View File

@ -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) {

View File

@ -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 {}
}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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]);
}
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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"));
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;