Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE groups
DROP COLUMN epoch_entered_at_ns;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- XIP-82: the delivery-service envelope timestamp of the message by
-- which this client entered the current MLS epoch (the epoch's commit,
-- or the welcome for members added at that epoch). NULL means the group
-- has not advanced epoch since this column landed; readers fall back to
-- created_at_ns (the initial epoch). Consumed by the external-commit
-- validator's expire_in_ns staleness bound.
ALTER TABLE groups
ADD COLUMN epoch_entered_at_ns BIGINT;
82 changes: 82 additions & 0 deletions crates/xmtp_db/src/encrypted_store/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ pub struct StoredGroup {
/// NULL if the pending-remove didn't receive an update yet
#[builder(default = None)]
pub has_pending_leave_request: Option<bool>,
/// XIP-82: envelope timestamp (ns) of the message by which this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium encrypted_store/group.rs:109

StoredGroup::epoch_entered_at_ns defaults to NULL for existing rows, but xmtp_welcome.rs creates groups with created_at_ns set to local now_ns() rather than the welcome message's envelope timestamp. This causes pre-migration groups to measure XIP-82 staleness from local insertion time instead of the actual epoch-entry message time, so external commits may be incorrectly accepted or rejected until the group advances epoch.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_db/src/encrypted_store/group.rs around line 109:

`StoredGroup::epoch_entered_at_ns` defaults to `NULL` for existing rows, but `xmtp_welcome.rs` creates groups with `created_at_ns` set to local `now_ns()` rather than the welcome message's envelope timestamp. This causes pre-migration groups to measure XIP-82 staleness from local insertion time instead of the actual epoch-entry message time, so external commits may be incorrectly accepted or rejected until the group advances epoch.

/// client entered the current MLS epoch — the epoch's commit, or
/// the welcome for members added at that epoch. NULL falls back to
/// `created_at_ns` (the initial epoch). Consumed by the
/// external-commit validator's `expire_in_ns` staleness bound.
#[builder(default = None)]
pub epoch_entered_at_ns: Option<i64>,
//todo: store member role?
}

Expand Down Expand Up @@ -369,6 +376,16 @@ pub trait QueryGroup {

fn get_groups_have_pending_leave_request(&self)
-> Result<Vec<Vec<u8>>, crate::ConnectionError>;

/// Records the envelope timestamp (ns) of the message by which this
/// client entered the current MLS epoch (XIP-82): written on every
/// successful commit merge and at welcome-join. Readers fall back
/// to `created_at_ns` when NULL (the initial epoch).
fn set_group_epoch_entered_at_ns(
&self,
group_id: &GroupId,
epoch_entered_at_ns: i64,
) -> Result<(), StorageError>;
}

impl<T> QueryGroup for &T
Expand Down Expand Up @@ -556,6 +573,14 @@ where
) -> Result<Vec<Vec<u8>>, crate::ConnectionError> {
(**self).get_groups_have_pending_leave_request()
}

fn set_group_epoch_entered_at_ns(
&self,
group_id: &GroupId,
epoch_entered_at_ns: i64,
) -> Result<(), StorageError> {
(**self).set_group_epoch_entered_at_ns(group_id, epoch_entered_at_ns)
}
}

impl<C: ConnectionExt> QueryGroup for DbConnection<C> {
Expand Down Expand Up @@ -1203,6 +1228,32 @@ impl<C: ConnectionExt> QueryGroup for DbConnection<C> {
Ok(())
}

fn set_group_epoch_entered_at_ns(
&self,
group_id: &GroupId,
epoch_entered_at_ns: i64,
) -> Result<(), StorageError> {
use crate::schema::groups::dsl;
// Monotonic: envelope time only moves forward over a group's
// life (commits merge in cursor order; a welcome re-entry
// postdates the removal it recovers from), so a smaller value
// here can only come from pathological reordering or
// corruption. Never move the epoch-entry point backward — a
// backdated value would widen the XIP-82 staleness window.
self.raw_query(|conn| {
diesel::update(
dsl::groups.find(group_id).filter(
dsl::epoch_entered_at_ns
.is_null()
.or(dsl::epoch_entered_at_ns.lt(epoch_entered_at_ns)),
),
)
.set(dsl::epoch_entered_at_ns.eq(Some(epoch_entered_at_ns)))
.execute(conn)
})?;
Ok(())
}

#[xmtp_common::db_span]
fn get_groups_have_pending_leave_request(
&self,
Expand Down Expand Up @@ -1588,6 +1639,37 @@ pub(crate) mod tests {
.await
}

#[xmtp_common::test]
async fn test_epoch_entered_at_ns_round_trip() {
with_connection(|conn| {
let test_group = generate_group(None);
test_group.store(&conn).unwrap();

// Fresh groups have no epoch-entry timestamp — readers fall
// back to created_at_ns (the initial epoch).
let fetched: StoredGroup = conn.fetch(&test_group.id).ok().flatten().unwrap();
assert_eq!(fetched.epoch_entered_at_ns, None);

conn.set_group_epoch_entered_at_ns(&test_group.id, 42_000)
.unwrap();
let fetched: StoredGroup = conn.fetch(&test_group.id).ok().flatten().unwrap();
assert_eq!(fetched.epoch_entered_at_ns, Some(42_000));

// Later epoch advances overwrite.
conn.set_group_epoch_entered_at_ns(&test_group.id, 43_000)
.unwrap();
let fetched: StoredGroup = conn.fetch(&test_group.id).ok().flatten().unwrap();
assert_eq!(fetched.epoch_entered_at_ns, Some(43_000));

// Monotonic: an out-of-order older timestamp never moves
// the epoch-entry point backward.
conn.set_group_epoch_entered_at_ns(&test_group.id, 42_500)
.unwrap();
let fetched: StoredGroup = conn.fetch(&test_group.id).ok().flatten().unwrap();
assert_eq!(fetched.epoch_entered_at_ns, Some(43_000));
})
}

#[xmtp_common::test]
fn test_new_group_has_correct_purpose() {
with_connection(|conn| {
Expand Down
1 change: 1 addition & 0 deletions crates/xmtp_db/src/encrypted_store/icebox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ mod tests {
commit_log_public_key: None,
is_commit_log_forked: None,
has_pending_leave_request: None,
epoch_entered_at_ns: None,
};
group.store(conn).unwrap();
group_id
Expand Down
1 change: 1 addition & 0 deletions crates/xmtp_db/src/encrypted_store/message_deletion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ mod tests {
commit_log_public_key: None,
is_commit_log_forked: None,
has_pending_leave_request: None,
epoch_entered_at_ns: None,
}
.store(conn)
.unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/xmtp_db/src/encrypted_store/schema_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ diesel::table! {
commit_log_public_key -> Nullable<Binary>,
is_commit_log_forked -> Nullable<Bool>,
has_pending_leave_request -> Nullable<Bool>,
epoch_entered_at_ns -> Nullable<BigInt>,
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/xmtp_db/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ mock! {
fn get_groups_have_pending_leave_request(
&self,
) -> Result<Vec<Vec<u8>>, crate::ConnectionError>;

fn set_group_epoch_entered_at_ns(
&self,
group_id: &GroupId,
epoch_entered_at_ns: i64,
) -> Result<(), StorageError>;
}

impl QueryGroupVersion for DbQuery {
Expand Down
Loading
Loading