fix(relay): relax not_before to optional, add d-tag validation

The spec requires not_before on pending reminders only — terminal states
(done/cancelled) and bookmarks omit it. The validator incorrectly
required exactly one not_before on all kind:30300 events, blocking the
entire completion/cancellation lifecycle.

Also adds d-tag structure validation per spec: reject zero, empty, or
duplicate d tags.

Co-authored-by: Will Pfleger <will@pfleger.dev>
Signed-off-by: Will Pfleger <will@pfleger.dev>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-11 15:10:01 -04:00
committed by Will Pfleger
co-authored by Will Pfleger
parent 0b3bed62bf
commit 84b0dadee3
2 changed files with 164 additions and 21 deletions
+72 -15
View File
@@ -987,13 +987,16 @@ fn validate_not_before(tag_value: &str) -> Result<u64, &'static str> {
/// reaches NIP-33 parameterized replacement.
///
/// The relay never decrypts the reminder; it only enforces the public schedule
/// tags. A reminder MUST carry exactly one valid `not_before`, and — when an
/// optional NIP-40 `expiration` is present — `expiration` MUST be strictly after
/// `not_before` (an `expiration <= not_before` window would expire the reminder
/// before it ever became due).
/// tags. A reminder carries at most one `not_before` (omitted on terminal
/// states), and — when both `not_before` and an optional NIP-40 `expiration`
/// are present — `expiration` MUST be strictly after `not_before` (an
/// `expiration <= not_before` window would expire the reminder before it ever
/// became due).
fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
let mut not_before: Option<u64> = None;
let mut expiration: Option<&str> = None;
let mut d_count = 0u8;
let mut d_empty = false;
for tag in event.tags.iter() {
let parts = tag.as_slice();
@@ -1010,20 +1013,35 @@ fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
not_before = Some(validate_not_before(&parts[1])?);
}
"expiration" => expiration = Some(&parts[1]),
"d" => {
d_count = d_count.saturating_add(1);
if parts[1].is_empty() {
d_empty = true;
}
}
_ => {}
}
}
let not_before = not_before.ok_or("malformed not_before")?;
// d-tag: must have exactly one, non-empty
if d_count == 0 {
return Err("missing d tag");
}
if d_count > 1 {
return Err("duplicate d tag");
}
if d_empty {
return Err("empty d tag");
}
// `expiration` is optional and governed by NIP-40, not this validator.
// We enforce only the spec's ordering rule (line 130), and only when the
// value parses — an unparseable `expiration` is NIP-40's concern, so we
// must not assert a false ordering reason for it.
if let Some(exp) = expiration {
if let Ok(exp) = exp.parse::<u64>() {
if exp <= not_before {
return Err("expiration before not_before");
// `not_before` is optional — terminal states (done/cancelled) and bookmarks
// omit it. The ordering check only applies when both are present.
if let Some(nb) = not_before {
if let Some(exp) = expiration {
if let Ok(exp) = exp.parse::<u64>() {
if exp <= nb {
return Err("expiration before not_before");
}
}
}
}
@@ -2434,9 +2452,10 @@ mod tests {
}
#[test]
fn reminder_rejects_missing_not_before() {
fn reminder_accepts_missing_not_before() {
// Terminal states (done/cancelled) and bookmarks omit not_before
let ev = make_reminder(&[&["d", "abc"]]);
assert_eq!(validate_event_reminder(&ev), Err("malformed not_before"));
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
@@ -2500,4 +2519,42 @@ mod tests {
assert!(!requires_h_channel_scope(KIND_EVENT_REMINDER));
assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER));
}
#[test]
fn reminder_accepts_expiration_without_not_before() {
// A terminal/bookmark with expiration but no not_before is valid —
// no ordering check applies when not_before is absent.
let ev = make_reminder(&[&["d", "abc"], &["expiration", "1777542730"]]);
assert!(validate_event_reminder(&ev).is_ok());
}
#[test]
fn reminder_rejects_missing_d_tag() {
let ev = make_event_with_tags(
KIND_EVENT_REMINDER,
"ciphertext",
&[&["not_before", "1717000000"]],
);
assert_eq!(validate_event_reminder(&ev), Err("missing d tag"));
}
#[test]
fn reminder_rejects_empty_d_tag() {
let ev = make_event_with_tags(
KIND_EVENT_REMINDER,
"ciphertext",
&[&["d", ""], &["not_before", "1717000000"]],
);
assert_eq!(validate_event_reminder(&ev), Err("empty d tag"));
}
#[test]
fn reminder_rejects_duplicate_d_tag() {
let ev = make_event_with_tags(
KIND_EVENT_REMINDER,
"ciphertext",
&[&["d", "abc"], &["d", "def"], &["not_before", "1717000000"]],
);
assert_eq!(validate_event_reminder(&ev), Err("duplicate d tag"));
}
}
@@ -145,18 +145,17 @@ async fn test_reminder_accepted_with_valid_not_before() {
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_missing_not_before() {
async fn test_reminder_accepted_missing_not_before() {
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
// No not_before tag at all
// No not_before tag — valid for terminal states (done/cancelled) and bookmarks
let event = build_reminder(&keys, &d_tag, vec![]);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject missing not_before");
let (accepted, _msg) = submit_event_http(&client, &keys, &event).await;
assert!(
msg.contains("malformed not_before"),
"unexpected message: {msg}"
accepted,
"should accept missing not_before (bookmark/terminal)"
);
}
@@ -339,6 +338,93 @@ async fn test_reminder_accepted_with_malformed_expiration() {
);
}
// ── d-tag validation tests ──────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_missing_d_tag() {
let client = http_client();
let keys = Keys::generate();
// Build event without d tag
let tags = vec![Tag::parse(["not_before", "1717000000"]).unwrap()];
let event = EventBuilder::new(
Kind::Custom(KIND_EVENT_REMINDER),
"nip44-ciphertext-placeholder",
)
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject missing d tag");
assert!(msg.contains("missing d tag"), "unexpected message: {msg}");
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_empty_d_tag() {
let client = http_client();
let keys = Keys::generate();
let tags = vec![
Tag::parse(["d", ""]).unwrap(),
Tag::parse(["not_before", "1717000000"]).unwrap(),
];
let event = EventBuilder::new(
Kind::Custom(KIND_EVENT_REMINDER),
"nip44-ciphertext-placeholder",
)
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject empty d tag");
assert!(msg.contains("empty d tag"), "unexpected message: {msg}");
}
#[tokio::test]
#[ignore]
async fn test_reminder_rejected_duplicate_d_tag() {
let client = http_client();
let keys = Keys::generate();
let tags = vec![
Tag::parse(["d", "abc"]).unwrap(),
Tag::parse(["d", "def"]).unwrap(),
Tag::parse(["not_before", "1717000000"]).unwrap(),
];
let event = EventBuilder::new(
Kind::Custom(KIND_EVENT_REMINDER),
"nip44-ciphertext-placeholder",
)
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(!accepted, "should reject duplicate d tag");
assert!(msg.contains("duplicate d tag"), "unexpected message: {msg}");
}
#[tokio::test]
#[ignore]
async fn test_reminder_accepted_expiration_without_not_before() {
// Terminal/bookmark with expiration but no not_before — no ordering check applies
let client = http_client();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = build_reminder(
&keys,
&d_tag,
vec![Tag::parse(["expiration", "1777542730"]).unwrap()],
);
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
assert!(
accepted,
"expiration without not_before should be accepted: {msg}"
);
}
// ── Read-path filtering tests (HTTP bridge) ──────────────────────────────────
#[tokio::test]