@@ -114,6 +114,13 @@ const REST_RETRY_BASE_DELAYS: [Duration; 3] = [
114114 Duration :: from_millis ( 2000 ) ,
115115] ;
116116
117+ fn unix_now_secs ( ) -> u64 {
118+ std:: time:: SystemTime :: now ( )
119+ . duration_since ( std:: time:: UNIX_EPOCH )
120+ . unwrap_or_default ( )
121+ . as_secs ( )
122+ }
123+
117124impl RestClient {
118125 // ── NIP-98 signing ────────────────────────────────────────────────────
119126
@@ -370,6 +377,7 @@ enum RelayCommand {
370377 Subscribe {
371378 channel_id : Uuid ,
372379 filter : ChannelFilter ,
380+ replay_since : Option < u64 > ,
373381 } ,
374382 /// Unsubscribe from a channel (sends a NIP-01 CLOSE).
375383 Unsubscribe { channel_id : Uuid } ,
@@ -634,9 +642,28 @@ impl HarnessRelay {
634642 & mut self ,
635643 channel_id : Uuid ,
636644 filter : ChannelFilter ,
645+ ) -> Result < ( ) , RelayError > {
646+ self . subscribe_channel_from ( channel_id, filter, None ) . await
647+ }
648+
649+ /// Subscribe to events in a channel, replaying from a known timestamp.
650+ ///
651+ /// Used for channels discovered from membership notifications: the mention
652+ /// that invited an agent can be published immediately after the membership
653+ /// event, before this subscription is active. Replaying from the membership
654+ /// event timestamp closes that race.
655+ pub async fn subscribe_channel_from (
656+ & mut self ,
657+ channel_id : Uuid ,
658+ filter : ChannelFilter ,
659+ replay_since : Option < u64 > ,
637660 ) -> Result < ( ) , RelayError > {
638661 self . cmd_tx
639- . send ( RelayCommand :: Subscribe { channel_id, filter } )
662+ . send ( RelayCommand :: Subscribe {
663+ channel_id,
664+ filter,
665+ replay_since,
666+ } )
640667 . await
641668 . map_err ( |_| RelayError :: ConnectionClosed ) ?;
642669 debug ! ( "queued subscribe for channel {channel_id}" ) ;
@@ -888,12 +915,12 @@ struct BgState {
888915 /// (Finding #22). Used as the floor `since` for membership notification
889916 /// replay so events predating this session are never re-delivered.
890917 startup_watermark : Option < u64 > ,
891- /// Wall-clock timestamp when each channel was first subscribed.
918+ /// Replay floor captured when each channel was first subscribed.
892919 /// Used as the `since` fallback on reconnect for channels that have no
893920 /// `last_seen` or `channel_dropped_since`. This prevents channels joined
894- /// after startup from replaying from `startup_watermark` (which could be
895- /// hours old), while still allowing startup-era channels to use the
896- /// startup watermark via their `subscribe_since ≈ startup_watermark` .
921+ /// after startup from replaying from an hours-old `startup_watermark`.
922+ /// Startup-era channels use the startup watermark; dynamic channels use
923+ /// the membership notification timestamp that caused the subscription .
897924 subscribe_since : HashMap < Uuid , u64 > ,
898925}
899926
@@ -977,20 +1004,22 @@ impl BgState {
9771004/// arm here is a logic error.
9781005fn apply_command_to_state ( state : & mut BgState , cmd : RelayCommand ) {
9791006 match cmd {
980- RelayCommand :: Subscribe { channel_id, filter } => {
1007+ RelayCommand :: Subscribe {
1008+ channel_id,
1009+ filter,
1010+ replay_since,
1011+ } => {
9811012 state
9821013 . active_subscriptions
9831014 . insert ( channel_id, channel_sub_id ( channel_id) ) ;
9841015 state. active_filters . insert ( channel_id, filter) ;
9851016 state. subscribe_since . entry ( channel_id) . or_insert_with ( || {
986- // Use startup_watermark as floor when available — closes the
1017+ // Use an explicit replay floor when available (dynamic
1018+ // membership), otherwise startup_watermark closes the startup
9871019 // blind spot between watermark capture and first REQ.
988- state. startup_watermark . unwrap_or_else ( || {
989- std:: time:: SystemTime :: now ( )
990- . duration_since ( std:: time:: UNIX_EPOCH )
991- . unwrap_or_default ( )
992- . as_secs ( )
993- } )
1020+ replay_since
1021+ . or ( state. startup_watermark )
1022+ . unwrap_or_else ( unix_now_secs)
9941023 } ) ;
9951024 }
9961025 RelayCommand :: Unsubscribe { channel_id } => {
@@ -1040,19 +1069,18 @@ async fn execute_connected_command(
10401069 cmd : RelayCommand ,
10411070) -> bool {
10421071 match cmd {
1043- RelayCommand :: Subscribe { channel_id, filter } => {
1072+ RelayCommand :: Subscribe {
1073+ channel_id,
1074+ filter,
1075+ replay_since,
1076+ } => {
10441077 // Seed subscribe_since BEFORE computing since — on first
10451078 // subscribe, this provides the fallback timestamp that
1046- // closes the startup blind spot. Use startup_watermark as
1047- // floor when available so events between watermark capture
1048- // and this REQ are not missed.
1079+ // closes the startup/dynamic-membership blind spot.
10491080 state. subscribe_since . entry ( channel_id) . or_insert_with ( || {
1050- state. startup_watermark . unwrap_or_else ( || {
1051- std:: time:: SystemTime :: now ( )
1052- . duration_since ( std:: time:: UNIX_EPOCH )
1053- . unwrap_or_default ( )
1054- . as_secs ( )
1055- } )
1081+ replay_since
1082+ . or ( state. startup_watermark )
1083+ . unwrap_or_else ( unix_now_secs)
10561084 } ) ;
10571085 let since = state
10581086 . last_seen
@@ -1070,7 +1098,14 @@ async fn execute_connected_command(
10701098 } else {
10711099 // Send failed — record intent so reconnect restores it.
10721100 warn ! ( "subscribe REQ failed for channel {channel_id} — recording intent for reconnect" ) ;
1073- apply_command_to_state ( state, RelayCommand :: Subscribe { channel_id, filter } ) ;
1101+ apply_command_to_state (
1102+ state,
1103+ RelayCommand :: Subscribe {
1104+ channel_id,
1105+ filter,
1106+ replay_since,
1107+ } ,
1108+ ) ;
10741109 false
10751110 }
10761111 }
@@ -3352,6 +3387,38 @@ mod tests {
33523387 ) ;
33533388 }
33543389
3390+ #[ test]
3391+ fn dynamic_subscribe_records_membership_replay_floor ( ) {
3392+ let mut state = BgState :: new ( ) ;
3393+ state. startup_watermark = Some ( 2_000 ) ;
3394+ let channel_id = Uuid :: new_v4 ( ) ;
3395+ let membership_ts = 10_000 ;
3396+ let filter = ChannelFilter {
3397+ kinds : Some ( vec ! [ 9 ] ) ,
3398+ require_mention : true ,
3399+ } ;
3400+
3401+ apply_command_to_state (
3402+ & mut state,
3403+ RelayCommand :: Subscribe {
3404+ channel_id,
3405+ filter,
3406+ replay_since : Some ( membership_ts) ,
3407+ } ,
3408+ ) ;
3409+
3410+ assert_eq ! (
3411+ state. subscribe_since. get( & channel_id) . copied( ) ,
3412+ Some ( membership_ts) ,
3413+ "dynamic channel subscriptions should replay from the membership notification, not startup"
3414+ ) ;
3415+ assert_eq ! (
3416+ state. channel_since( & channel_id) ,
3417+ Some ( membership_ts) ,
3418+ "channel_since should use the dynamic replay floor until an event is seen"
3419+ ) ;
3420+ }
3421+
33553422 // ── Membership dedup regression tests (M4) ───────────────────────────
33563423
33573424 /// Membership dedup must NOT contaminate per-channel `last_seen`.
0 commit comments