Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

204 changes: 177 additions & 27 deletions crates/rpc/src/handlers/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1516,40 +1516,65 @@ fn parse_scan_scripts(
"At most 1024 scan descriptors are supported".into(),
));
}
let mut scripts = std::collections::BTreeMap::new();
let mut descriptors = Vec::with_capacity(scanobjects.len());
let mut input_bytes = 0usize;
let mut script_bytes = 0usize;
let mut script_count = 0usize;
for scanobject in scanobjects {
if cancelled()? {
break;
return Ok(std::collections::BTreeMap::new());
}
let descriptor = scanobject_descriptor(scanobject)?;
let (descriptor, range) = scanobject_descriptor(scanobject)?;
let range = range.map(parse_derivation_range).transpose()?;
input_bytes = input_bytes.saturating_add(descriptor.len());
if descriptor.len() > MAX_SCAN_DESCRIPTOR_BYTES || input_bytes > MAX_SCAN_INPUT_BYTES {
return Err(RpcError::InvalidParameter(
"Scan descriptor byte limit exceeded".into(),
));
}
let ScanScript {
script_pubkey,
desc,
} = scan_descriptor(descriptor, network)?;
if let std::collections::btree_map::Entry::Vacant(entry) = scripts.entry(script_pubkey) {
script_bytes += entry.key().len();
if script_bytes > bitcoin_rs_utxo::scan::MAX_SCAN_SCRIPT_BYTES {
return Err(RpcError::InvalidParameter(
"Scan script byte limit exceeded".into(),
));
}
entry.insert(desc);
let descriptor = scan_descriptor(descriptor, range, network)?;
script_count = script_count.saturating_add(descriptor.script_count());
if script_count > bitcoin_rs_utxo::scan::MAX_SCAN_SCRIPTS {
return Err(RpcError::InvalidParameter(
"At most 1024 scan script expansions are supported".into(),
));
}
descriptors.push(descriptor);
}
// Admit the whole request's work before deriving any public keys. Count
// duplicate expansions too: deduplication bounds retained scripts, not CPU.
let mut scripts = std::collections::BTreeMap::new();
let mut script_bytes = 0usize;
for descriptor in descriptors {
if cancelled()? {
break;
}
descriptor.expand(
&cancelled,
|ScanScript {
script_pubkey,
desc,
}| {
if let std::collections::btree_map::Entry::Vacant(entry) =
scripts.entry(script_pubkey)
{
script_bytes += entry.key().len();
if script_bytes > bitcoin_rs_utxo::scan::MAX_SCAN_SCRIPT_BYTES {
return Err(RpcError::InvalidParameter(
"Scan script byte limit exceeded".into(),
));
}
entry.insert(desc);
}
Ok(())
},
)?;
}
Ok(scripts)
}

fn scanobject_descriptor(scanobject: &Value) -> Result<&str, RpcError> {
fn scanobject_descriptor(scanobject: &Value) -> Result<(&str, Option<&Value>), RpcError> {
if let Some(descriptor) = scanobject.as_str() {
return Ok(descriptor);
return Ok((descriptor, None));
}
let object = scanobject.as_object().ok_or_else(|| {
RpcError::InvalidParameter("Scan object needs to be either a string or an object".into())
Expand All @@ -1563,10 +1588,8 @@ fn scanobject_descriptor(scanobject: &Value) -> Result<&str, RpcError> {
let descriptor = descriptor
.as_str()
.ok_or_else(|| super::wrong_type_plain(descriptor, "string"))?;
if let Some(range) = object.get(&"range").filter(|value| !value.is_null()) {
parse_derivation_range(range)?;
}
Ok(descriptor)
let range = object.get(&"range").filter(|value| !value.is_null());
Ok((descriptor, range))
}

fn scan_unspents(
Expand Down Expand Up @@ -5742,11 +5765,6 @@ mod scantxoutset_tests {
json!(["start", [{"range": 0}]]),
"Descriptor needs to be provided",
),
(
"ranged descriptor",
json!(["start", [{"desc": "addr(foo*)", "range": 1}]]),
"Ranged and multipath scantxoutset descriptors are not supported",
),
(
"inverted range",
json!(["start", [{"desc": "addr(1111111111111111111114oLvT2)", "range": [2, 1]}]]),
Expand Down Expand Up @@ -6165,6 +6183,138 @@ mod scantxoutset_tests {
);
}

const SCAN_XPUB: &str = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";

#[test]
fn scan_expansion_budget_counts_ranges_paths_and_duplicates() -> anyhow::Result<()> {
let ctx = Arc::new(Context::new());
let single = format!("wpkh({SCAN_XPUB}/0/*)");
let multi = format!("wpkh({SCAN_XPUB}/<0;1>/*)");
for objects in [
json!([{"desc":single,"range":1024}]),
json!([{"desc":multi,"range":512}]),
json!([multi]), // Default 0..=1000 applies to each path.
json!([{"desc":single,"range":512},{"desc":single,"range":512}]),
// The trailing object takes the total over budget before the
// earlier descriptor can attempt an impossible hardened derive.
json!([{"desc":format!("wpkh({SCAN_XPUB}/*h)"),"range":1023},"raw(51)"]),
] {
let error = scantxoutset(&ctx, &json!(["start", objects]))
.err()
.ok_or_else(|| anyhow::anyhow!("oversized scan succeeded"))?;
assert_eq!(error.code(), -8);
assert!(error.to_string().contains("1024 scan script expansions"));
assert_eq!(ctx.chain.utxo.scan_progress(), None);
}
for objects in [
json!([{"desc":single,"range":1023}]),
json!([{"desc":multi,"range":511}]),
json!([single]),
json!([{"desc":format!("wpkh({SCAN_XPUB}/<0;1>/7)"),"range":999_999}]),
] {
let result = scantxoutset(&ctx, &json!(["start", objects]))?;
assert_eq!(result["success"].as_bool(), Some(true));
assert_eq!(ctx.chain.utxo.scan_progress(), None);
}
Ok(())
}

#[test]
fn scan_ranged_keys_preserve_refusals_and_release_reservation() -> anyhow::Result<()> {
let ctx = Arc::new(Context::new());
let tpub = "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B";
let secret = "tprv8ZgxMBicQKsPd7Uf69XL1XwhmjHopUGep8GuEiJDZmbQz6o58LninorQAfcKZWARbtRtfnLcJ5MQ2AtHcQJCCRUcMRvmDUjyEmNUWwx8UbK";
for desc in [
format!("wpkh({SCAN_XPUB}/0h/*)"),
format!("wpkh({SCAN_XPUB}/<0;1h>/*)"),
format!("wpkh({SCAN_XPUB}/*h)"),
format!("wpkh({SCAN_XPUB}/<0;1>/*h)"),
format!("wpkh({SCAN_XPUB}/<0;0>/*)"),
format!("wpkh({tpub}/0/*)"),
format!("wpkh({secret}/<0;1>/*)"),
format!("wpkh({SCAN_XPUB}/0/*)#badbadba"),
"addr(foo*)".into(),
] {
let error = scantxoutset(&ctx, &json!(["start", [{"desc":desc,"range":[0,1]}]]))
.err()
.ok_or_else(|| anyhow::anyhow!("invalid descriptor succeeded: {desc}"))?;
assert_eq!(error.code(), -5, "{desc}: {error}");
assert_eq!(ctx.chain.utxo.scan_progress(), None);
assert_eq!(
scantxoutset(&ctx, &json!(["start", []]))?["success"].as_bool(),
Some(true)
);
}
Ok(())
}

#[test]
fn scan_expansion_observes_abort_shutdown_and_deadline() -> anyhow::Result<()> {
use std::cell::Cell;

let ctx = Arc::new(Context::new());
for cause in ["abort", "shutdown", "deadline"] {
let permit = ctx.chain.utxo.reserve_scan()?;
let cancellation = bitcoin_rs_chain::LatchReader::fixture_never();
let descriptor = scan_descriptor(
&format!("wpkh({SCAN_XPUB}/<0;1>/*)"),
Some((0, 9)),
ctx.chain.chain_network,
)?;
let emitted = Cell::new(0);
let result = descriptor.expand(
&|| {
if emitted.get() >= 2 {
if cause == "deadline" {
return Err(scan_error(&bitcoin_rs_utxo::scan::UtxoScanError::Limit(
"60 seconds",
)));
}
if cause == "shutdown" {
cancellation.store(true);
} else {
ctx.chain.utxo.abort_scan();
}
}
permit
.cancelled(|| cancellation.is_triggered())
.map_err(|error| scan_error(&error))
},
|_| {
emitted.set(emitted.get() + 1);
Ok(())
},
);
assert!(emitted.get() > 0 && emitted.get() < 20);
if cause == "deadline" {
assert!(
result
.err()
.ok_or_else(|| anyhow::anyhow!("deadline ignored"))?
.to_string()
.contains("60 seconds")
);
} else {
result?;
let response = scantxoutset_scan(
&ctx,
&permit,
&std::collections::BTreeMap::new(),
&cancellation,
)?;
assert_eq!(response["success"].as_bool(), Some(false));
assert_eq!(response["txouts"].as_u64(), Some(0));
}
drop(permit);
assert_eq!(ctx.chain.utxo.scan_progress(), None);
assert_eq!(
scantxoutset(&ctx, &json!(["start", []]))?["success"].as_bool(),
Some(true)
);
}
Ok(())
}

#[test]
fn scantxoutset_abort_returns_false() {
let ctx = Arc::new(Context::new());
Expand Down
Loading
Loading