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.
This commit is contained in:
jwinterm
2026-07-11 12:21:03 -04:00
parent 533f945290
commit c5c8dd5bf1
4 changed files with 49 additions and 17 deletions
+5 -5
View File
@@ -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() {
+14 -7
View File
@@ -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, \
+22 -1
View File
@@ -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(())
}
+8 -4
View File
@@ -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());