@@ -3067,3 +3067,226 @@ func TestApplyTxBatchChildBeforeParent(t *testing.T) {
30673067 Hash : parentTx .TxHash (), Index : 0 ,
30683068 }))
30693069}
3070+
3071+ // TestApplyTxBatchStoresTxAndSyncTip verifies that a runtime batch can persist
3072+ // transaction history and advance the wallet sync tip atomically.
3073+ func TestApplyTxBatchStoresTxAndSyncTip (t * testing.T ) {
3074+ t .Parallel ()
3075+
3076+ store := NewTestStore (t )
3077+ walletName := "wallet-apply-tx-batch"
3078+ walletID := newWallet (t , store , walletName )
3079+ createDerivedAccount (t , store , walletID , db .KeyScopeBIP0084 , "default" )
3080+
3081+ addr := newDerivedAddress (
3082+ t , store , walletID , db .KeyScopeBIP0084 , "default" , false ,
3083+ )
3084+ tx := newRegularTx (
3085+ []wire.OutPoint {randomOutPoint ()},
3086+ []* wire.TxOut {{Value : 7000 , PkScript : addr .ScriptPubKey }},
3087+ )
3088+ syncedTo := NewBlockFixture (212 )
3089+
3090+ err := store .ApplyTxBatch (t .Context (), db.TxBatchParams {
3091+ WalletID : walletID ,
3092+ Transactions : []db.CreateTxParams {{
3093+ WalletID : walletID ,
3094+ Tx : tx ,
3095+ Received : time .Unix (1710000150 , 0 ),
3096+ Status : db .TxStatusPending ,
3097+ Credits : map [uint32 ]btcutil.Address {0 : nil },
3098+ }},
3099+ SyncedTo : & syncedTo ,
3100+ })
3101+ require .NoError (t , err )
3102+
3103+ txInfo , err := store .GetTx (t .Context (), db.GetTxQuery {
3104+ WalletID : walletID ,
3105+ Txid : tx .TxHash (),
3106+ })
3107+ require .NoError (t , err )
3108+ require .Equal (t , db .TxStatusPending , txInfo .Status )
3109+ require .Nil (t , txInfo .Block )
3110+ require .True (t , walletUtxoExists (t , store , walletID , wire.OutPoint {
3111+ Hash : tx .TxHash (), Index : 0 ,
3112+ }))
3113+
3114+ walletInfo , err := store .GetWallet (t .Context (), walletName )
3115+ require .NoError (t , err )
3116+ require .NotNil (t , walletInfo .SyncedTo )
3117+ require .Equal (t , syncedTo .Hash , walletInfo .SyncedTo .Hash )
3118+ require .Equal (t , syncedTo .Height , walletInfo .SyncedTo .Height )
3119+ require .Equal (t , syncedTo .Timestamp .Unix (),
3120+ walletInfo .SyncedTo .Timestamp .Unix ())
3121+ }
3122+
3123+ // TestApplyTxBatchConfirmsTxInSameBlock verifies that a batch can record a
3124+ // transaction confirmed in the very block the same batch introduces as the new
3125+ // sync tip. The confirming block row does not exist before the batch, so the
3126+ // batch must create the sync-tip block before recording the confirmed
3127+ // transaction; otherwise the confirmed insert fails with ErrBlockNotFound.
3128+ func TestApplyTxBatchConfirmsTxInSameBlock (t * testing.T ) {
3129+ t .Parallel ()
3130+
3131+ store := NewTestStore (t )
3132+ walletName := "wallet-apply-tx-batch-confirmed"
3133+ walletID := newWallet (t , store , walletName )
3134+ createDerivedAccount (t , store , walletID , db .KeyScopeBIP0084 , "default" )
3135+
3136+ addr := newDerivedAddress (
3137+ t , store , walletID , db .KeyScopeBIP0084 , "default" , false ,
3138+ )
3139+ tx := newRegularTx (
3140+ []wire.OutPoint {randomOutPoint ()},
3141+ []* wire.TxOut {{Value : 7000 , PkScript : addr .ScriptPubKey }},
3142+ )
3143+
3144+ // The confirming block is also the batch's new sync tip. It is not
3145+ // inserted ahead of time, so the batch itself must create it before the
3146+ // confirmed transaction is recorded.
3147+ block := NewBlockFixture (213 )
3148+
3149+ err := store .ApplyTxBatch (t .Context (), db.TxBatchParams {
3150+ WalletID : walletID ,
3151+ Transactions : []db.CreateTxParams {{
3152+ WalletID : walletID ,
3153+ Tx : tx ,
3154+ Received : time .Unix (1710000160 , 0 ),
3155+ Block : & block ,
3156+ Status : db .TxStatusPublished ,
3157+ Credits : map [uint32 ]btcutil.Address {0 : nil },
3158+ }},
3159+ SyncedTo : & block ,
3160+ })
3161+ require .NoError (t , err )
3162+
3163+ // The transaction is recorded as confirmed in the batch's block and its
3164+ // credited output is in the wallet UTXO set.
3165+ txInfo , err := store .GetTx (t .Context (), db.GetTxQuery {
3166+ WalletID : walletID ,
3167+ Txid : tx .TxHash (),
3168+ })
3169+ require .NoError (t , err )
3170+ require .Equal (t , db .TxStatusPublished , txInfo .Status )
3171+ require .NotNil (t , txInfo .Block )
3172+ require .Equal (t , block .Height , txInfo .Block .Height )
3173+ require .Equal (t , block .Hash , txInfo .Block .Hash )
3174+ require .True (t , walletUtxoExists (t , store , walletID , wire.OutPoint {
3175+ Hash : tx .TxHash (), Index : 0 ,
3176+ }))
3177+
3178+ // The sync tip advanced to the same block.
3179+ walletInfo , err := store .GetWallet (t .Context (), walletName )
3180+ require .NoError (t , err )
3181+ require .NotNil (t , walletInfo .SyncedTo )
3182+ require .Equal (t , block .Height , walletInfo .SyncedTo .Height )
3183+ require .Equal (t , block .Hash , walletInfo .SyncedTo .Hash )
3184+ }
3185+
3186+ // TestApplyTxBatchRejectsMismatchedWalletID verifies that a batch is rejected
3187+ // when any transaction is owned by a wallet other than the batch wallet, and
3188+ // that the rejection commits nothing: the sync tip is not advanced and no
3189+ // transaction row is written.
3190+ func TestApplyTxBatchRejectsMismatchedWalletID (t * testing.T ) {
3191+ t .Parallel ()
3192+
3193+ store := NewTestStore (t )
3194+ walletName := "wallet-apply-tx-batch-mismatch"
3195+ walletID := newWallet (t , store , walletName )
3196+ createDerivedAccount (t , store , walletID , db .KeyScopeBIP0084 , "default" )
3197+
3198+ addr := newDerivedAddress (
3199+ t , store , walletID , db .KeyScopeBIP0084 , "default" , false ,
3200+ )
3201+ tx := newRegularTx (
3202+ []wire.OutPoint {randomOutPoint ()},
3203+ []* wire.TxOut {{Value : 7000 , PkScript : addr .ScriptPubKey }},
3204+ )
3205+ syncedTo := NewBlockFixture (214 )
3206+
3207+ // The batch targets walletID, but the lone transaction claims a different
3208+ // wallet. The whole batch must be rejected before any write.
3209+ err := store .ApplyTxBatch (t .Context (), db.TxBatchParams {
3210+ WalletID : walletID ,
3211+ Transactions : []db.CreateTxParams {{
3212+ WalletID : walletID + 99 ,
3213+ Tx : tx ,
3214+ Received : time .Unix (1710000170 , 0 ),
3215+ Status : db .TxStatusPending ,
3216+ Credits : map [uint32 ]btcutil.Address {0 : nil },
3217+ }},
3218+ SyncedTo : & syncedTo ,
3219+ })
3220+ require .ErrorIs (t , err , db .ErrInvalidParam )
3221+
3222+ // The sync tip was not advanced: the wallet is still unsynced.
3223+ walletInfo , err := store .GetWallet (t .Context (), walletName )
3224+ require .NoError (t , err )
3225+ require .Nil (t , walletInfo .SyncedTo )
3226+
3227+ // No transaction row was written for either wallet.
3228+ _ , err = store .GetTx (t .Context (), db.GetTxQuery {
3229+ WalletID : walletID ,
3230+ Txid : tx .TxHash (),
3231+ })
3232+ require .ErrorIs (t , err , db .ErrTxNotFound )
3233+ }
3234+
3235+ // TestApplyTxBatchRejectsNilTx verifies that a multi-transaction batch with one
3236+ // nil Tx is rejected with ErrInvalidParam rather than panicking. ApplyTxBatch
3237+ // reorders the batch parents-first before applying it, and that sort
3238+ // dereferences each transaction's Tx, so a nil member must be caught up front;
3239+ // the rejection must also commit nothing.
3240+ func TestApplyTxBatchRejectsNilTx (t * testing.T ) {
3241+ t .Parallel ()
3242+
3243+ store := NewTestStore (t )
3244+ walletName := "wallet-apply-tx-batch-nil-tx"
3245+ walletID := newWallet (t , store , walletName )
3246+ createDerivedAccount (t , store , walletID , db .KeyScopeBIP0084 , "default" )
3247+
3248+ addr := newDerivedAddress (
3249+ t , store , walletID , db .KeyScopeBIP0084 , "default" , false ,
3250+ )
3251+ tx := newRegularTx (
3252+ []wire.OutPoint {randomOutPoint ()},
3253+ []* wire.TxOut {{Value : 7000 , PkScript : addr .ScriptPubKey }},
3254+ )
3255+ syncedTo := NewBlockFixture (215 )
3256+
3257+ // The batch carries a valid transaction and a second one with a nil Tx.
3258+ // The parents-first sort runs before per-tx request validation, so the nil
3259+ // member must be rejected by the up-front guard rather than panicking.
3260+ err := store .ApplyTxBatch (t .Context (), db.TxBatchParams {
3261+ WalletID : walletID ,
3262+ Transactions : []db.CreateTxParams {
3263+ {
3264+ WalletID : walletID ,
3265+ Tx : tx ,
3266+ Received : time .Unix (1710000210 , 0 ),
3267+ Status : db .TxStatusPending ,
3268+ Credits : map [uint32 ]btcutil.Address {0 : nil },
3269+ },
3270+ {
3271+ WalletID : walletID ,
3272+ Tx : nil ,
3273+ Received : time .Unix (1710000211 , 0 ),
3274+ Status : db .TxStatusPending ,
3275+ },
3276+ },
3277+ SyncedTo : & syncedTo ,
3278+ })
3279+ require .ErrorIs (t , err , db .ErrInvalidParam )
3280+
3281+ // The sync tip was not advanced and the valid transaction was not written:
3282+ // the whole batch is rejected before any write.
3283+ walletInfo , err := store .GetWallet (t .Context (), walletName )
3284+ require .NoError (t , err )
3285+ require .Nil (t , walletInfo .SyncedTo )
3286+
3287+ _ , err = store .GetTx (t .Context (), db.GetTxQuery {
3288+ WalletID : walletID ,
3289+ Txid : tx .TxHash (),
3290+ })
3291+ require .ErrorIs (t , err , db .ErrTxNotFound )
3292+ }
0 commit comments