@@ -65,12 +65,60 @@ fn init_tls_acceptor(cert_key: (Vec<u8>, Vec<u8>)) -> Result<tokio_rustls::TlsAc
6565 Ok ( TlsAcceptor :: from ( std:: sync:: Arc :: new ( server_config) ) )
6666}
6767
68+ /// Opaque blocklist of Bitcoin addresses stored as script pubkeys.
69+ ///
70+ /// Addresses are converted to `ScriptBuf` at parse time so that
71+ /// screening only requires a `HashSet::contains` on raw scripts,
72+ /// avoiding address-encoding round-trips and bech32 case issues.
73+ #[ derive( Clone ) ]
74+ pub struct BlockedAddresses (
75+ Arc < tokio:: sync:: RwLock < std:: collections:: HashSet < bitcoin:: ScriptBuf > > > ,
76+ ) ;
77+
78+ impl BlockedAddresses {
79+ pub fn empty ( ) -> Self {
80+ Self ( Arc :: new ( tokio:: sync:: RwLock :: new ( std:: collections:: HashSet :: new ( ) ) ) )
81+ }
82+
83+ pub fn from_address_lines ( text : & str ) -> Self {
84+ Self ( Arc :: new ( tokio:: sync:: RwLock :: new ( parse_address_lines ( text) ) ) )
85+ }
86+
87+ /// Replace the contents with scripts parsed from newline-delimited
88+ /// address text. Returns the number of entries after update.
89+ pub async fn update_from_lines ( & self , text : & str ) -> usize {
90+ let scripts = parse_address_lines ( text) ;
91+ let count = scripts. len ( ) ;
92+ * self . 0 . write ( ) . await = scripts;
93+ count
94+ }
95+ }
96+
97+ fn parse_address_lines ( text : & str ) -> std:: collections:: HashSet < bitcoin:: ScriptBuf > {
98+ text. lines ( )
99+ . filter_map ( |l| {
100+ let trimmed = l. trim ( ) ;
101+ if trimmed. is_empty ( ) {
102+ return None ;
103+ }
104+ match trimmed. parse :: < bitcoin:: Address < bitcoin:: address:: NetworkUnchecked > > ( ) {
105+ Ok ( addr) => Some ( addr. assume_checked ( ) . script_pubkey ( ) ) ,
106+ Err ( e) => {
107+ tracing:: warn!( "Skipping unparsable blocked address {trimmed:?}: {e}" ) ;
108+ None
109+ }
110+ }
111+ } )
112+ . collect ( )
113+ }
114+
68115#[ derive( Clone ) ]
69116pub struct Service < D : Db > {
70117 db : D ,
71118 ohttp : ohttp:: Server ,
72119 sentinel_tag : SentinelTag ,
73120 enable_v1 : bool ,
121+ blocked_addresses : Option < BlockedAddresses > ,
74122}
75123
76124impl < D : Db , B > tower:: Service < Request < B > > for Service < D >
@@ -95,7 +143,12 @@ where
95143
96144impl < D : Db > Service < D > {
97145 pub fn new ( db : D , ohttp : ohttp:: Server , sentinel_tag : SentinelTag , enable_v1 : bool ) -> Self {
98- Self { db, ohttp, sentinel_tag, enable_v1 }
146+ Self { db, ohttp, sentinel_tag, enable_v1, blocked_addresses : None }
147+ }
148+
149+ pub fn with_blocked_addresses ( mut self , addrs : BlockedAddresses ) -> Self {
150+ self . blocked_addresses = Some ( addrs) ;
151+ self
99152 }
100153
101154 #[ cfg( feature = "_manual-tls" ) ]
@@ -419,6 +472,24 @@ impl<D: Db> Service<D> {
419472 Err ( _) => return Ok ( bad_request_body_res) ,
420473 } ;
421474
475+ if let Some ( blocked) = & self . blocked_addresses {
476+ let scripts = blocked. 0 . read ( ) . await ;
477+ if !scripts. is_empty ( ) {
478+ match screen_v1_addresses ( & body_str, & scripts) {
479+ ScreenResult :: Blocked => {
480+ return Ok ( Response :: builder ( )
481+ . status ( StatusCode :: FORBIDDEN )
482+ . body ( empty ( ) ) ?) ;
483+ }
484+ ScreenResult :: Clean => { }
485+ ScreenResult :: ParseError ( e) => {
486+ warn ! ( "Could not screen V1 payload: {e}" ) ;
487+ // fail-open: unparsable PSBTs can't complete transactions
488+ }
489+ }
490+ }
491+ }
492+
422493 let v2_compat_body = format ! ( "{body_str}\n {query}" ) ;
423494 let id = ShortId :: from_str ( id) ?;
424495 handle_peek (
@@ -629,6 +700,57 @@ fn full<T: Into<Bytes>>(chunk: T) -> BoxBody<Bytes, hyper::Error> {
629700 Full :: new ( chunk. into ( ) ) . map_err ( |never| match never { } ) . boxed ( )
630701}
631702
703+ enum ScreenResult {
704+ Blocked ,
705+ Clean ,
706+ ParseError ( String ) ,
707+ }
708+
709+ fn screen_v1_addresses (
710+ body : & str ,
711+ blocked : & std:: collections:: HashSet < bitcoin:: ScriptBuf > ,
712+ ) -> ScreenResult {
713+ use bitcoin:: base64:: prelude:: { Engine , BASE64_STANDARD } ;
714+ use bitcoin:: psbt:: Psbt ;
715+
716+ let psbt_bytes = match BASE64_STANDARD . decode ( body) {
717+ Ok ( b) => b,
718+ Err ( e) => return ScreenResult :: ParseError ( format ! ( "base64 decode: {e}" ) ) ,
719+ } ;
720+
721+ let psbt = match Psbt :: deserialize ( & psbt_bytes) {
722+ Ok ( p) => p,
723+ Err ( e) => return ScreenResult :: ParseError ( format ! ( "PSBT deserialize: {e}" ) ) ,
724+ } ;
725+
726+ // Check output scripts
727+ for txout in & psbt. unsigned_tx . output {
728+ if blocked. contains ( & txout. script_pubkey ) {
729+ return ScreenResult :: Blocked ;
730+ }
731+ }
732+
733+ // Check input scripts from witness_utxo and non_witness_utxo
734+ for ( i, input) in psbt. inputs . iter ( ) . enumerate ( ) {
735+ if let Some ( ref utxo) = input. witness_utxo {
736+ if blocked. contains ( & utxo. script_pubkey ) {
737+ return ScreenResult :: Blocked ;
738+ }
739+ }
740+ if let Some ( ref tx) = input. non_witness_utxo {
741+ if let Some ( prev_out) = psbt. unsigned_tx . input . get ( i) {
742+ if let Some ( txout) = tx. output . get ( prev_out. previous_output . vout as usize ) {
743+ if blocked. contains ( & txout. script_pubkey ) {
744+ return ScreenResult :: Blocked ;
745+ }
746+ }
747+ }
748+ }
749+ }
750+
751+ ScreenResult :: Clean
752+ }
753+
632754#[ cfg( test) ]
633755mod tests {
634756 use std:: time:: Duration ;
@@ -712,3 +834,88 @@ mod tests {
712834 assert_eq ! ( body, V1_UNAVAILABLE_RES_JSON ) ;
713835 }
714836}
837+
838+ #[ cfg( test) ]
839+ mod screen_tests {
840+ use super :: * ;
841+
842+ fn addr_to_script ( address : & str ) -> bitcoin:: ScriptBuf {
843+ let addr: bitcoin:: Address < bitcoin:: address:: NetworkUnchecked > =
844+ address. parse ( ) . expect ( "valid address" ) ;
845+ addr. assume_checked ( ) . script_pubkey ( )
846+ }
847+
848+ fn make_test_psbt_base64 ( output_address : & str ) -> String {
849+ use bitcoin:: base64:: prelude:: { Engine , BASE64_STANDARD } ;
850+ use bitcoin:: psbt:: Psbt ;
851+ use bitcoin:: { Amount , Transaction , TxIn , TxOut } ;
852+
853+ let script_pubkey = addr_to_script ( output_address) ;
854+
855+ let tx = Transaction {
856+ version : bitcoin:: transaction:: Version :: TWO ,
857+ lock_time : bitcoin:: blockdata:: locktime:: absolute:: LockTime :: ZERO ,
858+ input : vec ! [ TxIn :: default ( ) ] ,
859+ output : vec ! [ TxOut { value: Amount :: from_sat( 50_000 ) , script_pubkey } ] ,
860+ } ;
861+
862+ let psbt = Psbt :: from_unsigned_tx ( tx) . expect ( "valid psbt" ) ;
863+ let serialized = psbt. serialize ( ) ;
864+ BASE64_STANDARD . encode ( & serialized)
865+ }
866+
867+ #[ test]
868+ fn screen_blocks_blocked_output_address ( ) {
869+ let blocked_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" ;
870+ let blocked = std:: collections:: HashSet :: from ( [ addr_to_script ( blocked_addr) ] ) ;
871+
872+ let psbt_b64 = make_test_psbt_base64 ( blocked_addr) ;
873+ assert ! ( matches!( screen_v1_addresses( & psbt_b64, & blocked) , ScreenResult :: Blocked ) ) ;
874+ }
875+
876+ #[ test]
877+ fn screen_allows_clean_psbt ( ) {
878+ let clean_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" ;
879+ let blocked = std:: collections:: HashSet :: new ( ) ; // empty
880+ let psbt_b64 = make_test_psbt_base64 ( clean_addr) ;
881+ assert ! ( matches!( screen_v1_addresses( & psbt_b64, & blocked) , ScreenResult :: Clean ) ) ;
882+ }
883+
884+ #[ test]
885+ fn screen_allows_non_blocked_address ( ) {
886+ let addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" ;
887+ let blocked =
888+ std:: collections:: HashSet :: from ( [ addr_to_script ( "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" ) ] ) ;
889+
890+ let psbt_b64 = make_test_psbt_base64 ( addr) ;
891+ assert ! ( matches!( screen_v1_addresses( & psbt_b64, & blocked) , ScreenResult :: Clean ) ) ;
892+ }
893+
894+ #[ test]
895+ fn screen_parse_error_on_invalid_base64 ( ) {
896+ let blocked =
897+ std:: collections:: HashSet :: from ( [ addr_to_script ( "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" ) ] ) ;
898+ assert ! ( matches!(
899+ screen_v1_addresses( "not-valid-base64!!!" , & blocked) ,
900+ ScreenResult :: ParseError ( _)
901+ ) ) ;
902+ }
903+
904+ #[ test]
905+ fn screen_parse_error_on_invalid_psbt ( ) {
906+ use bitcoin:: base64:: prelude:: { Engine , BASE64_STANDARD } ;
907+ let blocked =
908+ std:: collections:: HashSet :: from ( [ addr_to_script ( "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" ) ] ) ;
909+ let bad_psbt = BASE64_STANDARD . encode ( b"not a psbt" ) ;
910+ assert ! ( matches!( screen_v1_addresses( & bad_psbt, & blocked) , ScreenResult :: ParseError ( _) ) ) ;
911+ }
912+
913+ #[ test]
914+ fn screen_blocks_bech32_address ( ) {
915+ let addr = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh" ;
916+ let blocked = std:: collections:: HashSet :: from ( [ addr_to_script ( addr) ] ) ;
917+
918+ let psbt_b64 = make_test_psbt_base64 ( addr) ;
919+ assert ! ( matches!( screen_v1_addresses( & psbt_b64, & blocked) , ScreenResult :: Blocked ) ) ;
920+ }
921+ }
0 commit comments