@@ -6,7 +6,10 @@ mod spend_tests {
66 use crate :: utils:: tests_util;
77 use bdk_wallet:: rusqlite:: Connection ;
88 use ngwallet:: account:: NgAccount ;
9- use ngwallet:: send:: { DraftTransaction , FeeRateSatPerKvb , TransactionParams } ;
9+ use ngwallet:: rbf:: BumpFeeError ;
10+ use ngwallet:: send:: {
11+ DraftTransaction , FeeRateSatPerKvb , TransactionComposeError , TransactionParams ,
12+ } ;
1013
1114 use crate :: utils:: tests_util:: get_ng_hot_wallet;
1215
@@ -25,7 +28,7 @@ mod spend_tests {
2528 } ;
2629 let draft = account. get_max_fee ( params. clone ( ) ) . unwrap ( ) ;
2730 assert_eq ! ( draft. max_fee_rate, FeeRateSatPerKvb ( 553_828 ) ) ; // 138_457 sat/kwu * 4 = sat/kvB
28- assert_eq ! ( draft. min_fee_rate, FeeRateSatPerKvb ( 1000 ) ) ; // 1 sat/vB in sat/kvB
31+ assert_eq ! ( draft. min_fee_rate, FeeRateSatPerKvb ( 1000 ) ) ; // 1 sat/vB in sat/kvB
2932 check_draft_tx_match_params ( draft. draft_transaction . clone ( ) , params. clone ( ) ) ;
3033 }
3134
@@ -147,6 +150,157 @@ mod spend_tests {
147150 assert_eq ! ( transaction. note, params. note) ;
148151 assert_eq ! ( transaction. get_change_tag( ) , params. tag) ;
149152 }
153+
154+ // Audit P2-01: a UTXO marked do_not_spend must never be spent, even when
155+ // the caller passes it explicitly in `selected_outputs`. Both the send
156+ // and RBF paths share this policy and must surface a dedicated error.
157+
158+ #[ test]
159+ fn test_compose_psbt_rejects_locked_selected_utxo ( ) {
160+ let mut account = get_ng_hot_wallet ( ) ;
161+ tests_util:: add_funds_to_wallet ( & mut account) ;
162+
163+ let locked = account. utxos ( ) . unwrap ( ) [ 0 ] . clone ( ) ;
164+ account
165+ . set_do_not_spend ( locked. get_id ( ) . as_str ( ) , true )
166+ . unwrap ( ) ;
167+ let locked_live = account
168+ . utxos ( )
169+ . unwrap ( )
170+ . into_iter ( )
171+ . find ( |o| o. get_id ( ) == locked. get_id ( ) )
172+ . expect ( "locked utxo should still exist" ) ;
173+ assert ! ( locked_live. do_not_spend) ;
174+
175+ let params = TransactionParams {
176+ address : "tb1pspfcrvz538vvj9f9gfkd85nu5ty98zw9y5e302kha6zurv6vg07s8z7a8w" . to_string ( ) ,
177+ amount : 4000 ,
178+ fee_rate : FeeRateSatPerKvb ( 2000 ) ,
179+ selected_outputs : vec ! [ locked_live. clone( ) ] ,
180+ note : None ,
181+ tag : None ,
182+ do_not_spend_change : false ,
183+ } ;
184+ match account. compose_psbt ( params) {
185+ Err ( TransactionComposeError :: LockedUtxoSelected ( ids) ) => {
186+ assert_eq ! ( ids, vec![ locked_live. get_id( ) ] ) ;
187+ }
188+ other => panic ! ( "expected LockedUtxoSelected, got {other:?}" ) ,
189+ }
190+ }
191+
192+ // A caller cannot bypass the lock by zeroing `do_not_spend` on the
193+ // Output values it passes in `selected_outputs`. The wallet's live UTXO
194+ // set is the source of truth.
195+ #[ test]
196+ fn test_compose_psbt_rejects_stale_unlocked_selected_utxo ( ) {
197+ let mut account = get_ng_hot_wallet ( ) ;
198+ tests_util:: add_funds_to_wallet ( & mut account) ;
199+
200+ let locked_id = {
201+ let utxo = account. utxos ( ) . unwrap ( ) [ 0 ] . clone ( ) ;
202+ account
203+ . set_do_not_spend ( utxo. get_id ( ) . as_str ( ) , true )
204+ . unwrap ( ) ;
205+ utxo. get_id ( )
206+ } ;
207+
208+ let mut stale = account
209+ . utxos ( )
210+ . unwrap ( )
211+ . into_iter ( )
212+ . find ( |o| o. get_id ( ) == locked_id)
213+ . unwrap ( ) ;
214+ // Simulate a compromised caller forging do_not_spend=false.
215+ stale. do_not_spend = false ;
216+
217+ let params = TransactionParams {
218+ address : "tb1pspfcrvz538vvj9f9gfkd85nu5ty98zw9y5e302kha6zurv6vg07s8z7a8w" . to_string ( ) ,
219+ amount : 4000 ,
220+ fee_rate : FeeRateSatPerKvb ( 2000 ) ,
221+ selected_outputs : vec ! [ stale] ,
222+ note : None ,
223+ tag : None ,
224+ do_not_spend_change : false ,
225+ } ;
226+ match account. compose_psbt ( params) {
227+ Err ( TransactionComposeError :: LockedUtxoSelected ( ids) ) => {
228+ assert_eq ! ( ids, vec![ locked_id] ) ;
229+ }
230+ other => panic ! ( "expected LockedUtxoSelected, got {other:?}" ) ,
231+ }
232+ }
233+
234+ #[ test]
235+ fn test_get_max_fee_rejects_locked_selected_utxo ( ) {
236+ let mut account = get_ng_hot_wallet ( ) ;
237+ tests_util:: add_funds_to_wallet ( & mut account) ;
238+
239+ let locked = account. utxos ( ) . unwrap ( ) [ 0 ] . clone ( ) ;
240+ account
241+ . set_do_not_spend ( locked. get_id ( ) . as_str ( ) , true )
242+ . unwrap ( ) ;
243+ let locked_live = account
244+ . utxos ( )
245+ . unwrap ( )
246+ . into_iter ( )
247+ . find ( |o| o. get_id ( ) == locked. get_id ( ) )
248+ . unwrap ( ) ;
249+
250+ let params = TransactionParams {
251+ address : "tb1pspfcrvz538vvj9f9gfkd85nu5ty98zw9y5e302kha6zurv6vg07s8z7a8w" . to_string ( ) ,
252+ amount : 2003 ,
253+ fee_rate : FeeRateSatPerKvb ( 1000 ) ,
254+ selected_outputs : vec ! [ locked_live. clone( ) ] ,
255+ note : None ,
256+ tag : None ,
257+ do_not_spend_change : false ,
258+ } ;
259+ match account. get_max_fee ( params) {
260+ Err ( TransactionComposeError :: LockedUtxoSelected ( ids) ) => {
261+ assert_eq ! ( ids, vec![ locked_live. get_id( ) ] ) ;
262+ }
263+ other => panic ! ( "expected LockedUtxoSelected, got {other:?}" ) ,
264+ }
265+ }
266+
267+ #[ test]
268+ fn test_rbf_rejects_locked_selected_utxo ( ) {
269+ let mut account = get_ng_hot_wallet ( ) ;
270+ tests_util:: add_funds_wallet_with_unconfirmed ( & mut account) ;
271+
272+ // Lock a confirmed mature UTXO (one of the funding outputs) so the
273+ // RBF builder will see it as a candidate input.
274+ let mature = account
275+ . utxos ( )
276+ . unwrap ( )
277+ . into_iter ( )
278+ . find ( |o| o. is_confirmed )
279+ . expect ( "at least one confirmed utxo" ) ;
280+ account
281+ . set_do_not_spend ( mature. get_id ( ) . as_str ( ) , true )
282+ . unwrap ( ) ;
283+ let locked_live = account
284+ . utxos ( )
285+ . unwrap ( )
286+ . into_iter ( )
287+ . find ( |o| o. get_id ( ) == mature. get_id ( ) )
288+ . unwrap ( ) ;
289+
290+ let unconfirmed_tx = account
291+ . transactions ( )
292+ . unwrap ( )
293+ . into_iter ( )
294+ . find ( |tx| tx. confirmations == 0 )
295+ . expect ( "expected an unconfirmed tx for RBF" ) ;
296+
297+ match account. get_max_bump_fee ( vec ! [ locked_live. clone( ) ] , unconfirmed_tx) {
298+ Err ( BumpFeeError :: LockedUtxoSelected ( ids) ) => {
299+ assert_eq ! ( ids, vec![ locked_live. get_id( ) ] ) ;
300+ }
301+ other => panic ! ( "expected LockedUtxoSelected, got {other:?}" ) ,
302+ }
303+ }
150304}
151305
152306// fn pretty_print<T: serde::Serialize>(value: &T) -> String {
0 commit comments