From c5c8dd5bf13ff1011243db04fa78646e0549ee37 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 11 Jul 2026 12:21:03 -0400 Subject: [PATCH] tips: grin config gate, reorg sweep_txid preservation, LWS import retry - config: grin is a supported tip chain (voucher model verified against the node), so FEATURE_TIPS with only grin enabled no longer fails closed at boot. - social_tips: revert_sweep_on_reorg now preserves sweep_txid instead of nulling it. Address-scan chains (btc/ltc/xmr/wow) overwrite it via confirm_sweep_onchain on the next cycle, so preserving the orphaned value is harmless; grin needs the kernel excess to re-date a re-mined sweep via get_kernel, so nulling it stranded reorged grin tips in 'claiming'. Test updated to assert preservation. - lws import_account: retry the birthday backfill rescan (3 attempts, backoff) so a transient failure no longer strands a new account at the chain tip. The already-registered Some(_) no-op is unchanged, so this cannot reintroduce the balance-reset bug. --- src/config.rs | 10 +++++----- src/infra/db/social_tips.rs | 21 ++++++++++++++------- src/infra/lws/client.rs | 23 ++++++++++++++++++++++- tests/tips.rs | 12 ++++++++---- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/config.rs b/src/config.rs index edc1ec5..44b71b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1105,14 +1105,14 @@ impl Config { // Public tips escrow on-chain and deliver by share URL, so an instance // with FEATURE_TIPS on but no chain to escrow on, or no TIP_SHARE_BASE_URL // to build share links from, would advertise a tips subsystem it cannot - // actually serve. Fail closed rather than boot broken. (Grin is out of the - // tips port, so it is NOT a supported tip chain here.) + // actually serve. Fail closed rather than boot broken. Grin tips (voucher + // model, verified against the grin node) are supported, so grin counts. if self.features.tips { let c = &self.features.chains; - if !(c.btc || c.ltc || c.xmr || c.wow) { + if !(c.btc || c.ltc || c.xmr || c.wow || c.grin) { return Err(cfg_err( - "FEATURE_TIPS is on but no supported tip chain is enabled — enable at least \ - one of FEATURE_BTC / FEATURE_LTC / FEATURE_XMR / FEATURE_WOW", + "FEATURE_TIPS is on but no supported tip chain is enabled: enable at least \ + one of FEATURE_BTC / FEATURE_LTC / FEATURE_XMR / FEATURE_WOW / FEATURE_GRIN", )); } if self.tip_share_base.is_none() { diff --git a/src/infra/db/social_tips.rs b/src/infra/db/social_tips.rs index f409195..8a265e1 100644 --- a/src/infra/db/social_tips.rs +++ b/src/infra/db/social_tips.rs @@ -694,12 +694,20 @@ impl Database { } /// Revert a settled (`claimed`) tip back to `claiming` after a reorg orphaned - /// its recorded sweep. Clearing `sweep_txid` is LOAD-BEARING: the next - /// confirm cycle re-scans the address and records the NEW on-chain winner, - /// not the orphaned one. Also clears `sweep_confirmed_dm_sent_at` so a fresh - /// "claimed" notify can fire once re-confirmation lands. The - /// `status = 'claimed' AND sweep_confirmed_at IS NOT NULL` guard is the only - /// path off `claimed`; `None` if the row isn't settled. + /// its recorded sweep. Clears the settlement markers (`sweep_confirmed_at`, + /// block height/hash) and `sweep_confirmed_dm_sent_at` so a fresh "claimed" + /// notify can fire once re-confirmation lands. + /// + /// `sweep_txid` is deliberately PRESERVED. For address-scan chains + /// (btc/ltc/xmr/wow) the next confirm cycle re-scans the address and + /// `confirm_sweep_onchain` overwrites `sweep_txid` with the new on-chain + /// winner, so preserving the orphaned value is harmless. For grin (voucher) + /// it is REQUIRED: `probe_grin_spends` dates the re-mined sweep via + /// `get_kernel(sweep_txid /* = kernel excess */)`, so nulling it would strand + /// the row in `claiming` forever after a reorg. + /// + /// The `status = 'claimed' AND sweep_confirmed_at IS NOT NULL` guard is the + /// only path off `claimed`; `None` if the row isn't settled. #[instrument(skip(self))] pub async fn revert_sweep_on_reorg( &self, @@ -709,7 +717,6 @@ impl Database { "UPDATE social_tips \ SET status = 'claiming', \ sweep_confirmed_at = NULL, \ - sweep_txid = NULL, \ sweep_block_height = NULL, \ sweep_block_hash = NULL, \ sweep_confirmed_dm_sent_at = NULL, \ diff --git a/src/infra/lws/client.rs b/src/infra/lws/client.rs index 3333465..48d437d 100644 --- a/src/infra/lws/client.rs +++ b/src/infra/lws/client.rs @@ -245,7 +245,28 @@ impl LwsClient { let current = self.account_scan_height(address).await?.unwrap_or(u64::MAX); if start_height < current { - self.rescan(vec![address.to_string()], start_height).await?; + // The account is now added at the chain tip. If this backfill + // rescan fails, the account is stranded at the tip: it reads a + // 0 balance and the `Some(_)` short-circuit above (which exists + // to prevent the reset loop) means a later re-registration will + // NOT retry it. So retry a transient failure here, where we + // still know this is a fresh account that owes a backwards scan. + let mut attempt = 0u32; + loop { + match self.rescan(vec![address.to_string()], start_height).await { + Ok(()) => break, + Err(e) => { + attempt += 1; + if attempt >= 3 { + return Err(e); + } + tokio::time::sleep(std::time::Duration::from_millis( + 300 * u64::from(attempt), + )) + .await; + } + } + } } Ok(()) } diff --git a/tests/tips.rs b/tests/tips.rs index cdcef6d..a0f8e75 100644 --- a/tests/tips.rs +++ b/tests/tips.rs @@ -601,7 +601,10 @@ async fn confirm_sweep_onchain_settles_claiming_only() { } /// (d) revert_sweep_on_reorg reverts 'claimed' -> 'claiming' ONLY, clearing the -/// sweep witness (incl. sweep_txid so the next cycle records the new winner). +/// settlement witness (sweep_confirmed_at, block height/hash) but PRESERVING +/// sweep_txid. Address-scan chains overwrite it via confirm_sweep_onchain on the +/// next cycle; grin (voucher) needs it to re-date the re-mined sweep via +/// get_kernel, so nulling it would strand the row in 'claiming'. #[tokio::test] async fn revert_sweep_on_reorg_only_from_claimed() { let Some(app) = tips_app().await else { return }; @@ -612,9 +615,10 @@ async fn revert_sweep_on_reorg_only_from_claimed() { let reverted = app.state.db.revert_sweep_on_reorg(id).await.expect("revert").expect("reverted row"); assert_eq!(reverted.status, "claiming"); assert!(reverted.sweep_confirmed_at.is_none()); - assert!( - reverted.sweep_txid.is_none(), - "reorg clears sweep_txid so the next confirm cycle records the new on-chain winner" + assert_eq!( + reverted.sweep_txid.as_deref(), + Some("onchain-swp"), + "reorg preserves sweep_txid: address-scan chains overwrite it next cycle, grin needs it to re-date via get_kernel" ); assert!(reverted.sweep_block_height.is_none());