Skip to content

Scurbber crate - #2

Open
arminsabouri wants to merge 2 commits into
mainfrom
scurbber-crate
Open

Scurbber crate#2
arminsabouri wants to merge 2 commits into
mainfrom
scurbber-crate

Conversation

@arminsabouri

@arminsabouri arminsabouri commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Migrated from payjoin/concurrent-psbt#32
Implementation of payjoin/multiparty-protocol-docs#9.

Key type bytes overlap across PSBT map types. For example, 0x06 is TX_MODIFIABLE in
global but BIP32_DERIVATION in inputs. So filtering requires map context.

Buffers the global map to detect version and counts (INPUT_COUNT/
OUTPUT_COUNT for v2, UNSIGNED_TX parsed via Transaction::consensus_decode
for v0), then streams remaining maps with per-map allowlists for global,
input, and output.

Pair::decode is pub(crate) in psbt-v2 0.3.0; worked around with a
PairDecode extension trait replicating the upstream logic.

===

Follow up todo that I totally forgot about is to upstream visibility changes to decode upsteam to rust-psbt

@arminsabouri
arminsabouri changed the base branch from develop to main July 9, 2026 15:25
@nothingmuch

Copy link
Copy Markdown
Contributor

i edited the PR text to link to the correct original PR

@nothingmuch nothingmuch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just nits really, the code makes sense, the scrubbing logic is clear and easy to follow

Comment thread crates/scrubber/src/scrub.rs Outdated
Comment thread crates/scrubber/src/scrub.rs Outdated
}
}

fn encode_pair(out: &mut Vec<u8>, pair: &Pair) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decode takes a io::Read but this takes a &mut vec, which seems inconsistent...

since these are private you could make the argument that the abstract trait is superfluous, but i think it actually helps clarity, but if you'd rather keep this infallible then please make decode consistent with it (i.e. only using buffers)

IMO either is justified, since (valid) PSBTs are size constrained, and the high level API assumes it is already buffered then all of the streaming logic is not really required, but otoh it's not like it adds much complexity and decode cannot be infallible as the buffer may be contain truncated data

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather just keep these consistent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah consistency is my only request here, i'd be happy with both consistently &[u8] / &mut [u8] or &mut Vec<u8> (i prefer the former FWIW) or consistently io::{Read,Write}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up going with io::{Read,Write} and kept the infallible expects()

@nothingmuch nothingmuch Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that feels weird, io::{Read,Write} will in general not be infalliable, so if someone would use the API to write to a socket directly, which the type would allow, that may result in the process crashing.

I don't feel strongly either way, seems simpler to avoid the Result type since PSBT data will always be bounded (and scrubbed data will always fit in a buffer smaller than the input buffer), so i think it's perfectly fine to go with an infallible API, but in that case it should probably use slice refs, not the trait.

But if we want this to be more tolerant of broader range of readers/writers, unfortunately io::Read,Write hard code std::io::Error so that can't be statically asserted to be uninhabitable (like std::convert::Infallible), which would make this API inherently panicky unless it returns a Result instead of expecting.

@arminsabouri arminsabouri Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that feels weird, io::{Read,Write} will in general not be infalliable,

True, IIRC thats why I orignally had &mut vec.

so if someone would use the API to write to a socket directly,

i am not sure this is possible. decode/encode are both private methods. Perhaps if we allowed the callers to pass in readers and writers to scrub

but in that case it should probably use slice refs, not the trait.

Sure, makes sense. I think we can get away with this for now. I will update both encode and decode

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&mut [u8] for decode needs a write cursor (boilerplate that feels superfluous) or it can return the bytes for scurb() to extend (impl asymetry between encode and decode).

Eitherway a &mut Vec still seems like a natural fit to me for decode. But it does introduce the asymetry back. We could make encode take &mut encode, but thats unnececarry allocs.

Pushed the version where caller keeps track of position in the buffer

@nothingmuch nothingmuch Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since scrubbing by design guarantees that the output buffer will be shorter or equal to the input buffer, it's possible to preallocate a Vec and give a slice to it as a buffer. PSBTs are self terminating so the trailing 0 bytes are immaterial even if the total number of bytes written is not given to the caller, so a Vec provides an advantage there.

&mut [u8] for decode needs a write cursor (boilerplate that feels superfluous)

it is superfluous:

use std::io::Write;

fn main() {
    let mut buffer = [0u8; 12];
    let mut cursor = &mut buffer[..];
    
    println!("remaining: {}", cursor.len());
    write!(cursor, "hello ").unwrap();
    println!("remaining: {}", cursor.len());
    write!(cursor, "world").unwrap();
    println!("remaining: {}", cursor.len());
    
    println!("{:?}", &buffer[..])
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=3cc9c1ab2f257d94ab2a074bdca0ffab

Eitherway a &mut Vec still seems like a natural fit to me for decode. But it does introduce the asymetry back. We could make encode take &mut encode, but thats unnececarry allocs.

i don't see that as an asymmetry. &[u8] is a region of memory to read from, &mut [u8] is a region of memory and a position to write to the way io::Write is implemented on it, and &mut Vec<u8> is a (growable, relocatable, preallocatable, not that we really care) region of memory, a position to write to and an implied length that is stored in the data structure itself.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

derp, an explicit length. &mut [u8] is the one with an implied length (but per the example that is readily calculated)

also note that what i said about trailing 0 bytes is not correct, if the last output map's ends in 0x00 bytes (e.g. the value of the last key value pair does), stripping trailing ones may truncate the PSBT, and finding the actual terminating byte would require parsing

Comment thread crates/scrubber/src/scrub.rs Outdated
}

impl InputInsensitive {
const ALL: &[Self] = &[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has the same syntactic overhead as a match statement but doesn't let the compiler exploit the fact that it's just a hard coded list

i don't think this will ever make a difference for performance, linear scan of such a small array will be very fast but it feels a bit odd to have an enum but then treat the enum as just a namespace for constants (basically equivalent to a mod { pub const FOO = ... })

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this hybrid const + enum is wierd. I think we can get away with just using matches! . and keep the contains() internal fn call

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't a match block make contains and by extension the vec itself redundant? the enum provides the same values statically at compile time

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Vec removed

Comment thread crates/scrubber/src/scrub.rs Outdated
Comment thread crates/scrubber/src/scrub.rs Outdated
}

#[test]
fn scrub_input_with_multiple_maps() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth asserting the drop direction too?

(e.g. an input 0x06 (BIP32_DERIVATION) is scrubbed even though global 0x06 (TX_MODIFIABLE) is kept)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scurbber is completly blind to modifiable flags but I think still worth adding. Especially if we end up using the upstream methods in the future (e.g decode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrubbing those fields is correct in that setting, in fact that is the whole purpose of this spec existing - the narrow goal to be able to share essential information about the transaction structure without leaking the fields that should stay local/private (but of course scrubbing like this has more uses in other settings)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(to be clear, 'that setting' = during the modifiable phase of a psbt's lifetime)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test added. Thanks

@arminsabouri
arminsabouri force-pushed the scurbber-crate branch 2 times, most recently from e1caf90 to 3707b59 Compare July 13, 2026 20:30

@bc1cindy bc1cindy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK

@yuval-block
yuval-block deleted the scurbber-crate branch July 18, 2026 19:48
@nothingmuch
nothingmuch restored the scurbber-crate branch July 18, 2026 22:23
@nothingmuch

Copy link
Copy Markdown
Contributor

urgh damnit, i again thought i had a typoed branch name and pushed it accidentally, but it turns out when i went to "fix" it i deleted your branch, sorry @arminsabouri

@nothingmuch nothingmuch reopened this Jul 18, 2026
@yuval-block
yuval-block deleted the scurbber-crate branch July 18, 2026 23:31
@nothingmuch
nothingmuch restored the scurbber-crate branch July 19, 2026 18:05
@nothingmuch

Copy link
Copy Markdown
Contributor

what in the fuck, the previous one i deliberately pushed, but this last push was again the result of jj git push --all or something, sigh.

if it happens again you can revoke my privileges without explanation1 ;-)

Footnotes

  1. because i will have already given one myself

@nothingmuch nothingmuch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only nits.

Please consider splitting for clarity, i suggest:

  • scrub.rs - pub fn scrub at the top, supporting code below
  • fields.rs - the various enums
  • decode.rs - keep the borrowed code separate

Comment thread crates/scrubber/src/scrub.rs Outdated
Comment on lines +23 to +36
impl GlobalInsensitive {
fn contains(v: u8) -> bool {
matches!(
v,
x if x == Self::UnsignedTx as u8
|| x == Self::TxVersion as u8
|| x == Self::FallbackLocktime as u8
|| x == Self::InputCount as u8
|| x == Self::OutputCount as u8
|| x == Self::TxModifiable as u8
|| x == Self::Version as u8
)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just observing that in principle this could be impl TryFrom<u8> for GlobalInsensitive and then try_from(v).is_ok()

right now this type is private and there isn't much use for it, but i think for the gui stuff it would be useful to be able to statically query information about which fields are considered insensitive, so it may be helpful to make this pub. the main advantage of a try_from over a contains is that this gives the values static names that can be Displayed, but it's not clear to me that that is really necessary as compared to just contains(u8) -> bool.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm this is a really good point. Mind as well bite the bullet and just do it from the get go

Comment thread crates/scrubber/src/scrub.rs Outdated
fn get_number_of_inputs_and_outputs(global: &[Pair]) -> Result<(u64, u64), Error> {
let is_v2 = global
.iter()
.find(|p| p.key.type_value == 0xFB && p.key.key.is_empty())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would help readability if these constants were named

Comment thread crates/scrubber/src/scrub.rs Outdated
///
/// Buffers the global map to detect version and input/output counts, then streams
/// the remaining maps applying per-map-type filters. Both PSBT v0 and v2 are supported.
pub fn scrub(psbt: &[u8]) -> Result<Vec<u8>, Error> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bikeshedding: move to the top. this function is the main event as far as implementation and the pub entry point to the api but it's relegated below a bunch of private stuff used in its implementation

Comment thread crates/scrubber/src/scrub.rs Outdated
encode_pair(&mut w, pair);
}
}
write_byte(&mut w, 0x00);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't

Suggested change
write_byte(&mut w, 0x00);
write!(w, 0x00);

dtrt?

Comment thread crates/scrubber/src/scrub.rs Outdated
return Err(Error::InvalidMagic);
}
let mut r = &psbt[5..];
let mut out = vec![0u8; psbt.len()];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the backing store is a vec already, not an arbitrary slice, i think using that internally as the io.Write impl is still cleaner?

Suggested change
let mut out = vec![0u8; psbt.len()];
let mut out = Vec<u8>::with_capacity(psbt.len());

then just use out instead of w throughout, and no need to calculate the length or truncate at the end. still just one alloc.

&mut [u8] is useful when a buffer is already given by the caller, i.e. pub fn scrub(psbt: &[u8], out: &mut u8) but i think your approach of returning Result<Vec<u8>, Error> is cleaner than my previous suggestion

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that if we ever support a no_alloc version this may become useful again

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack agreed. Then we can also use out.push(0x00); instead of write_byte(&mut w, 0x00);.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but i think your approach of returning Result<Vec, Error> is cleaner than my previous suggestion

specifically in encode_pair() ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no only talking about scrub, whether out: &mut [u8] arg vs. -> Result<Vec<u8>> (internal doesn't really matter except for readability)

@arminsabouri

Copy link
Copy Markdown
Collaborator Author

The job was not started because recent account payments have failed or your spending limit needs to be increased. Please check the 'Billing & plans' section in your settings

:(

@nothingmuch

Copy link
Copy Markdown
Contributor

The job was not started because recent account payments have failed or your spending limit needs to be increased. Please check the 'Billing & plans' section in your settings

:(

yet another reason to make the repos public, i guess it's time to tear the bandaid off

@arminsabouri
arminsabouri force-pushed the scurbber-crate branch 3 times, most recently from bdd23bb to e85512d Compare July 29, 2026 20:11
Comment thread crates/scrubber/src/scrub.rs Outdated
@arminsabouri
arminsabouri force-pushed the scurbber-crate branch 4 times, most recently from 12e268e to 5c6e95a Compare July 30, 2026 01:06
Comment thread crates/scrubber/src/scrub.rs Outdated
Comment thread crates/scrubber/src/scrub.rs
@arminsabouri
arminsabouri force-pushed the scurbber-crate branch 3 times, most recently from 5c476e1 to 0810344 Compare August 4, 2026 14:26
@arminsabouri
arminsabouri requested a review from bc1cindy August 4, 2026 16:48

@bc1cindy bc1cindy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK 0810344

Comment thread crates/scrubber/src/scrub.rs Outdated
}

if !r.is_empty() {
return Err(Error::UnexpectedEof);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnexpectedEof typically implies truncation, but this is the opposite, I would add another variant UnexpectedTrailingBytes as that feels clearer but if there is a technical argument for just a single variant for both conditions i don't feel too strongly about this

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thats fair. Fixed

Key type bytes overlap across PSBT map types. For example, 0x06 is TX_MODIFIABLE in
global but BIP32_DERIVATION in inputs. So filtering requires map context.

Buffers the global map to detect version and counts (INPUT_COUNT/
OUTPUT_COUNT for v2, UNSIGNED_TX parsed via Transaction::consensus_decode
for v0), then streams remaining maps with per-map allowlists for global,
input, and output.

Pair::decode is pub(crate) in psbt-v2 0.3.0; worked around with a
PairDecode extension trait replicating the upstream logic.
let mut out = Vec::with_capacity(psbt.len());
out.extend_from_slice(&MAGIC);

// Buffer the global map to detect version and input/output counts before streaming the rest.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ordering is not currently covered by the BIP but i think it ought to be, see fungi-protocol/docs#12 (review)

it could be implemented with relatively minimal changes by just buffering every map into a btreemap<(type, keydata),value>

this would also allow erroring on buggy serializers that may emit duplicate key value pairs, which a streaming approach does not detect

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrub should preserve the original ordering. Pairs are decoded and encoded in the order they are currently in.
is the main issue duplicate key value pairs?

@nothingmuch nothingmuch Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i posted a rationale here: fungi-protocol/docs#12 (comment)

tl;dr if library a serializes in one order, and library b does in another, for example psbt_v2 will do that predictably because of static field ordering in its structs, then the resulting PSBT will reveal the wallet software being used

if the goal of scrubbing is to make the information revealed in multiparty setting canonical and therefore unidentifying, ordering factors into that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants