Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions bin/floresta-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ fn do_request(cmd: &Cli, client: Client) -> anyhow::Result<String> {
Methods::Uptime => serde_json::to_string_pretty(&client.uptime()?)?,
Methods::ListDescriptors => serde_json::to_string_pretty(&client.list_descriptors()?)?,
Methods::Ping => serde_json::to_string_pretty(&client.ping()?)?,
Methods::InvalidateBlock { blockhash } => {
serde_json::to_string_pretty(&client.invalidate_block(blockhash)?)?
}
Methods::SubmitHeader { hexdata } => {
serde_json::to_string_pretty(&client.submit_header(hexdata)?)?
}
})
}

Expand Down Expand Up @@ -373,4 +379,22 @@ pub enum Methods {
/// Result: json null
#[command(name = "ping")]
Ping,

#[doc = include_str!("../../../doc/rpc/invalidateblock.md")]
#[command(
name = "invalidateblock",
about = "Permanently marks a block as invalid, as if it violated a consensus rule.",
long_about = Some(include_str!("../../../doc/rpc/invalidateblock.md")),
disable_help_subcommand = true
)]
InvalidateBlock { blockhash: BlockHash },

#[doc = include_str!("../../../doc/rpc/submitheader.md")]
#[command(
name = "submitheader",
about = "Decodes the given hex data as a block header and submits it as a candidate chain tip if valid.",
long_about = Some(include_str!("../../../doc/rpc/submitheader.md")),
disable_help_subcommand = true
)]
SubmitHeader { hexdata: String },
}
124 changes: 112 additions & 12 deletions crates/floresta-chain/src/pruned_utreexo/chain_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,10 +848,20 @@ impl<PersistedState: ChainStore> ChainState<PersistedState> {
Ok(())
}

fn update_tip(&self, best_block: BlockHash, height: u32) {
/// Updates the chain tip state to point at `best_block` at the given `height`.
///
/// This sets both `best_block` and `validation_index` to the same hash,
/// so it should only be used when the caller knows the target block is
/// fully validated. If `acc` is provided, the in-memory accumulator is
/// also replaced.
fn update_tip(&self, best_block: BlockHash, height: u32, acc: Option<Stump>) {
let mut inner = write_lock!(self);
inner.best_block.validation_index = best_block;
inner.best_block.best_block = best_block;
inner.best_block.depth = height;
if let Some(unwrapped_acc) = acc {
inner.acc = unwrapped_acc
}
}

fn verify_script(&self, height: u32) -> Result<bool, PersistedState::Error> {
Expand Down Expand Up @@ -1201,23 +1211,25 @@ impl<PersistedState: ChainStore> UpdatableChainstate for ChainState<PersistedSta
}

fn invalidate_block(&self, block: BlockHash) -> Result<(), BlockchainError> {
let height = self.get_disk_block_header(&block)?.try_height()?;
let height_to_invalidate = self.get_disk_block_header(&block)?.try_height()?;
let current_height = self.get_height()?;

// Mark all blocks after this one as invalid
for h in height..=current_height {
for h in height_to_invalidate..=current_height {
let hash = self.get_block_hash(h)?;
let header = self.get_block_header(&hash)?;
let new_header = DiskBlockHeader::InvalidChain(header);
self.update_header(&new_header)?;
}
// Row back to our previous state. Note that acc doesn't actually change in this case
// only the currently best known block.
self.update_tip(
self.get_ancestor(&self.get_block_header(&block)?)?
.block_hash(),
height - 1,
);

let new_tip = self
.get_ancestor(&self.get_block_header(&block)?)?
.block_hash();

// Get the acc for the new tip, if theres any.
let new_acc = self.get_roots_for_block(height_to_invalidate - 1)?;

self.update_tip(new_tip, height_to_invalidate - 1, new_acc);
Ok(())
}

Expand Down Expand Up @@ -1472,6 +1484,7 @@ mod test {
use crate::pruned_utreexo::utxo_data::UtxoData;
use crate::AssumeValidArg;
use crate::BlockchainError;
use crate::ChainStore;
use crate::FlatChainStore;

fn setup_test_chain(
Expand Down Expand Up @@ -1777,10 +1790,97 @@ mod test {
assert_eq!(chain.get_height().unwrap() as usize, random_height - 1);

// update_tip
chain.update_tip(headers[1].prev_blockhash, 1);
chain.update_tip(headers[1].prev_blockhash, 1, None);
assert_eq!(
read_lock!(chain).best_block.best_block,
headers[1].prev_blockhash
headers[1].prev_blockhash,
);
}

/// After connecting FullyValid blocks, invalidating one must roll back the
/// validation_index so that `get_validation_index()` still returns a valid
/// height. Before the fix, `invalidate_block` left `validation_index`
/// pointing at a now-InvalidChain block, causing `get_validation_index()`
/// to fail with `BadValidationIndex`.
#[test]
fn test_invalidateblock_updatesvalidationindex() {
let chain = setup_test_chain(Network::Regtest, AssumeValidArg::Hardcoded);
let json_blocks = include_str!("../../testdata/test_reorg.json");
let blocks: Vec<Vec<&str>> = serde_json::from_str(json_blocks).unwrap();

// Connect the first 10 blocks (they become FullyValid)
let short_chain: Vec<Block> = blocks[0]
.iter()
.map(|s| deserialize_hex(s).unwrap())
.collect();

for block in &short_chain {
chain.accept_header(block.header).unwrap();
chain
.connect_block(block, Proof::default(), HashMap::new(), Vec::new())
.unwrap();
}

let expected_height = 10;

assert_eq!(chain.get_height().unwrap(), expected_height);

// validation_index should be the same as height.
let val_before = chain.get_validation_index().unwrap();
assert!(val_before == expected_height);

// Invalidate block at height 5
let hash_at_5 = chain.get_block_hash(5).unwrap();

chain.invalidate_block(hash_at_5).unwrap();

assert_eq!(chain.get_height().unwrap(), 4);

// This is the critical check. Before the commit fixing
// the update of validation_index, this will break. Otherwise will pass fine.
let validation_index_after = chain.get_validation_index().unwrap();
assert!(validation_index_after == 4);

// The blocks before `hash_at_5`, 4..0 should be FullyValid.

// check the tip:
let val_hash = read_lock!(chain).best_block.validation_index;
let val_header = chain.get_disk_block_header(&val_hash).unwrap();
assert!(
matches!(val_header, DiskBlockHeader::FullyValid(..)),
"expected FullyValid, got: {val_header:?}"
);

// check the rest of the blocks.
for i in 0..=4 {
let val_hash = read_lock!(chain)
.chainstore
.get_block_hash(i)
.unwrap()
.unwrap();

let val_header = chain.get_disk_block_header(&val_hash).unwrap();

assert!(
matches!(val_header, DiskBlockHeader::FullyValid(..)),
"expected FullyValid, got: {val_header:?}"
);
}

// The blocks after `hash_at_5`, including `hash_at_5`, 5..10 should be InvalidChain.
for i in 5..10 {
let invalid_hash = read_lock!(chain)
.chainstore
.get_block_hash(i)
.unwrap()
.unwrap();

let invalid_header = chain.get_disk_block_header(&invalid_hash).unwrap();

assert!(
matches!(invalid_header, DiskBlockHeader::InvalidChain(..)),
"expected InvalidChain, got: {invalid_header:?}"
);
}
}
}
21 changes: 21 additions & 0 deletions crates/floresta-node/src/json_rpc/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use bitcoin::block::Header;
use bitcoin::consensus::encode::serialize_hex;
use bitcoin::consensus::Encodable;
use bitcoin::constants::genesis_block;
use bitcoin::hashes::hex::FromHex;
use bitcoin::hashes::Hash;
use bitcoin::Address;
use bitcoin::Block;
Expand Down Expand Up @@ -312,6 +313,26 @@ impl<Blockchain: RpcChain> RpcImpl<Blockchain> {
// getchainstates
// getchaintips
// getchaintxstats

// invalidateblock
pub(super) fn invalidate_block(&self, hash: BlockHash) -> Result<(), JsonRpcError> {
self.chain
.invalidate_block(hash)
.map_err(|_| JsonRpcError::BlockNotFound)
}

// submitheader
pub(super) fn submit_header(&self, hex: String) -> Result<(), JsonRpcError> {
let bytes = Vec::from_hex(&hex).map_err(|_| JsonRpcError::InvalidHex)?;

let header: Header = bitcoin::consensus::deserialize(&bytes)
.map_err(|e| JsonRpcError::Decode(e.to_string()))?;

self.chain
.accept_header(header)
.map_err(|_| JsonRpcError::Chain)
}

// getdeploymentinfo
// getdifficulty
// getmempoolancestors
Expand Down
12 changes: 12 additions & 0 deletions crates/floresta-node/src/json_rpc/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,18 @@ async fn handle_json_rpc_request(

"getroots" => state.get_roots().map(|v| serde_json::to_value(v).unwrap()),

"invalidateblock" => {
let hash = get_hash(&params, 0, "blockhash")?;
state.invalidate_block(hash)?;
Ok(Value::Null)
}

"submitheader" => {
let hex = get_string(&params, 0, "hexdata")?;
state.submit_header(hex)?;
Ok(Value::Null)
}

"findtxout" => {
let txid = get_hash(&params, 0, "txid")?;
let vout = get_numeric(&params, 1, "vout")?;
Expand Down
14 changes: 14 additions & 0 deletions crates/floresta-rpc/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ pub trait FlorestaRPC {
fn list_descriptors(&self) -> Result<Vec<String>>;
/// Sends a ping to all peers, checking if they are still alive
fn ping(&self) -> Result<()>;

#[doc = include_str!("../../../doc/rpc/invalidateblock.md")]
fn invalidate_block(&self, blockhash: BlockHash) -> Result<()>;

#[doc = include_str!("../../../doc/rpc/submitheader.md")]
fn submit_header(&self, hexdata: String) -> Result<()>;
}

/// Since the workflow for jsonrpc is the same for all methods, we can implement a trait
Expand Down Expand Up @@ -336,4 +342,12 @@ impl<T: JsonRPCClient> FlorestaRPC for T {
fn ping(&self) -> Result<()> {
self.call("ping", &[])
}

fn invalidate_block(&self, blockhash: BlockHash) -> Result<()> {
self.call("invalidateblock", &[Value::String(blockhash.to_string())])
}

fn submit_header(&self, hexdata: String) -> Result<()> {
self.call("submitheader", &[Value::String(hexdata)])
}
}
39 changes: 39 additions & 0 deletions doc/rpc/invalidateblock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `invalidateblock`

Permanently marks a block as invalid, as if it violated a consensus rule. All descendants of the block are also marked invalid and the tip rolls back to the parent of the invalidated block.

## Usage

### Synopsis

```bash
floresta-cli invalidateblock <blockhash>
```

### Examples

```bash
# Invalidate a specific block
floresta-cli invalidateblock "00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f9a68c5"
```

## Arguments

`blockhash` - (string, required) The hash of the block to mark as invalid, in hexadecimal format (64 characters).

## Returns

### Ok Response

Returns `null` on success.

### Error Enum `CommandError`

* `JsonRpcError::BlockNotFound` - If the specified block hash is not found in the blockchain or the invalidation fails.

## Notes

- This is a hidden RPC in Bitcoin Core, intended for testing and debugging.
- The accumulator state is not modified; only the best known block and validation index are rolled back.
- All blocks from the invalidated height up to the current tip are marked as `InvalidChain`.
- There is currently no `reconsiderblock` equivalent to undo this operation.
40 changes: 40 additions & 0 deletions doc/rpc/submitheader.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# `submitheader`

Decodes the given hex data as a block header and submits it as a candidate chain tip if valid. The header is accepted but the full block data is not required.

## Usage

### Synopsis

```bash
floresta-cli submitheader <hexdata>
```

### Examples

```bash
# Submit a raw 80-byte block header in hex (160 hex characters)
floresta-cli submitheader "0000002006226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f..."
```

## Arguments

`hexdata` - (string, required) The hex-encoded block header data (160 hex characters representing 80 bytes).

## Returns

### Ok Response

Returns `null` on success.

### Error Enum `CommandError`

- `JsonRpcError::InvalidHex` - If the provided hex string is not valid hexadecimal.
- `JsonRpcError::Decode` - If the hex data cannot be deserialized as a block header.
- `JsonRpcError::Chain` - If the header is invalid (e.g., unknown previous block, invalid proof of work).

## Notes

- The header's `prev_blockhash` must reference a block already known to the node.
- On success the node's tip advances but the block is stored as `HeadersOnly`, itll become `FullyValid` after validating its transactions.
- This is useful for testing header-first sync scenarios without providing full block data.
Loading
Loading