mirror of
https://github.com/scsibug/nostr-rs-relay.git
synced 2024-11-25 01:59:08 -05:00
feat(NIP-26): allow searches for delegated public keys
Implements core NIP-26 delegated event functionality. Events can include a `delegation` tag that provides a signature and restrictions on which events can be delegated. Notable points on the implementation so far: * Schema has been upgraded to include an index and new column. * Basic rune parsing/evaluation to implement the example event in the NIP, but no more. * No special logic for deletion. * No migration logic for determining delegated authors for already-stored events.
This commit is contained in:
parent
274c61bb72
commit
72f8a1aa5c
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -1042,6 +1042,7 @@ dependencies = [
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"regex",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"secp256k1",
|
"secp256k1",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
@ -32,6 +32,7 @@ http = { version = "0.2" }
|
||||||
parse_duration = "2"
|
parse_duration = "2"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
const_format = "0.2.28"
|
const_format = "0.2.28"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
|
@ -27,6 +27,7 @@ mirrored on [GitHub](https://github.com/scsibug/nostr-rs-relay).
|
||||||
- [x] NIP-15: [End of Stored Events Notice](https://github.com/nostr-protocol/nips/blob/master/15.md)
|
- [x] NIP-15: [End of Stored Events Notice](https://github.com/nostr-protocol/nips/blob/master/15.md)
|
||||||
- [x] NIP-16: [Event Treatment](https://github.com/nostr-protocol/nips/blob/master/16.md)
|
- [x] NIP-16: [Event Treatment](https://github.com/nostr-protocol/nips/blob/master/16.md)
|
||||||
- [x] NIP-22: [Event `created_at` limits](https://github.com/nostr-protocol/nips/blob/master/22.md) (_future-dated events only_)
|
- [x] NIP-22: [Event `created_at` limits](https://github.com/nostr-protocol/nips/blob/master/22.md) (_future-dated events only_)
|
||||||
|
- [x] NIP-26: [Event Delegation](https://github.com/nostr-protocol/nips/blob/master/26.md)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
22
src/db.rs
22
src/db.rs
|
@ -142,12 +142,15 @@ pub async fn db_writer(
|
||||||
if next_event.is_none() {
|
if next_event.is_none() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// track if an event write occurred; this is used to
|
||||||
|
// update the rate limiter
|
||||||
let mut event_write = false;
|
let mut event_write = false;
|
||||||
let subm_event = next_event.unwrap();
|
let subm_event = next_event.unwrap();
|
||||||
let event = subm_event.event;
|
let event = subm_event.event;
|
||||||
let notice_tx = subm_event.notice_tx;
|
let notice_tx = subm_event.notice_tx;
|
||||||
// check if this event is authorized.
|
// check if this event is authorized.
|
||||||
if let Some(allowed_addrs) = whitelist {
|
if let Some(allowed_addrs) = whitelist {
|
||||||
|
// TODO: incorporate delegated pubkeys
|
||||||
// if the event address is not in allowed_addrs.
|
// if the event address is not in allowed_addrs.
|
||||||
if !allowed_addrs.contains(&event.pubkey) {
|
if !allowed_addrs.contains(&event.pubkey) {
|
||||||
info!(
|
info!(
|
||||||
|
@ -284,12 +287,13 @@ pub fn write_event(conn: &mut PooledConnection, e: &Event) -> Result<usize> {
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
// get relevant fields from event and convert to blobs.
|
// get relevant fields from event and convert to blobs.
|
||||||
let id_blob = hex::decode(&e.id).ok();
|
let id_blob = hex::decode(&e.id).ok();
|
||||||
let pubkey_blob = hex::decode(&e.pubkey).ok();
|
let pubkey_blob: Option<Vec<u8>> = hex::decode(&e.pubkey).ok();
|
||||||
|
let delegator_blob: Option<Vec<u8>> = e.delegated_by.as_ref().and_then(|d| hex::decode(d).ok());
|
||||||
let event_str = serde_json::to_string(&e).ok();
|
let event_str = serde_json::to_string(&e).ok();
|
||||||
// ignore if the event hash is a duplicate.
|
// ignore if the event hash is a duplicate.
|
||||||
let mut ins_count = tx.execute(
|
let mut ins_count = tx.execute(
|
||||||
"INSERT OR IGNORE INTO event (event_hash, created_at, kind, author, content, first_seen, hidden) VALUES (?1, ?2, ?3, ?4, ?5, strftime('%s','now'), FALSE);",
|
"INSERT OR IGNORE INTO event (event_hash, created_at, kind, author, delegated_by, content, first_seen, hidden) VALUES (?1, ?2, ?3, ?4, ?5, ?6, strftime('%s','now'), FALSE);",
|
||||||
params![id_blob, e.created_at, e.kind, pubkey_blob, event_str]
|
params![id_blob, e.created_at, e.kind, pubkey_blob, delegator_blob, event_str]
|
||||||
)?;
|
)?;
|
||||||
if ins_count == 0 {
|
if ins_count == 0 {
|
||||||
// if the event was a duplicate, no need to insert event or
|
// if the event was a duplicate, no need to insert event or
|
||||||
|
@ -439,16 +443,22 @@ fn query_from_filter(f: &ReqFilter) -> (String, Vec<Box<dyn ToSql>>) {
|
||||||
for auth in authvec {
|
for auth in authvec {
|
||||||
match hex_range(auth) {
|
match hex_range(auth) {
|
||||||
Some(HexSearch::Exact(ex)) => {
|
Some(HexSearch::Exact(ex)) => {
|
||||||
auth_searches.push("author=?".to_owned());
|
auth_searches.push("author=? OR delegated_by=?".to_owned());
|
||||||
|
params.push(Box::new(ex.clone()));
|
||||||
params.push(Box::new(ex));
|
params.push(Box::new(ex));
|
||||||
}
|
}
|
||||||
Some(HexSearch::Range(lower, upper)) => {
|
Some(HexSearch::Range(lower, upper)) => {
|
||||||
auth_searches.push("(author>? AND author<?)".to_owned());
|
auth_searches.push(
|
||||||
|
"(author>? AND author<?) OR (delegated_by>? AND delegated_by<?)".to_owned(),
|
||||||
|
);
|
||||||
|
params.push(Box::new(lower.clone()));
|
||||||
|
params.push(Box::new(upper.clone()));
|
||||||
params.push(Box::new(lower));
|
params.push(Box::new(lower));
|
||||||
params.push(Box::new(upper));
|
params.push(Box::new(upper));
|
||||||
}
|
}
|
||||||
Some(HexSearch::LowerOnly(lower)) => {
|
Some(HexSearch::LowerOnly(lower)) => {
|
||||||
auth_searches.push("author>?".to_owned());
|
auth_searches.push("author>? OR delegated_by>?".to_owned());
|
||||||
|
params.push(Box::new(lower.clone()));
|
||||||
params.push(Box::new(lower));
|
params.push(Box::new(lower));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|
416
src/delegation.rs
Normal file
416
src/delegation.rs
Normal file
|
@ -0,0 +1,416 @@
|
||||||
|
//! Event parsing and validation
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::event::Event;
|
||||||
|
use bitcoin_hashes::{sha256, Hash};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
|
use secp256k1::{schnorr, Secp256k1, VerifyOnly, XOnlyPublicKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
// This handles everything related to delegation, in particular the
|
||||||
|
// condition/rune parsing and logic.
|
||||||
|
|
||||||
|
// Conditions are poorly specified, so we will implement the minimum
|
||||||
|
// necessary for now.
|
||||||
|
|
||||||
|
// fields MUST be either "kind" or "created_at".
|
||||||
|
// operators supported are ">", "<", "=", "!".
|
||||||
|
// no operations on 'content' are supported.
|
||||||
|
|
||||||
|
// this allows constraints for:
|
||||||
|
// valid date ranges (valid from X->Y dates).
|
||||||
|
// specific kinds (publish kind=1,5)
|
||||||
|
// kind ranges (publish ephemeral events, kind>19999&kind<30001)
|
||||||
|
|
||||||
|
// for more complex scenarios (allow delegatee to publish ephemeral
|
||||||
|
// AND replacement events), it may be necessary to generate and use
|
||||||
|
// different condition strings, since we do not support grouping or
|
||||||
|
// "OR" logic.
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
/// Secp256k1 verification instance.
|
||||||
|
pub static ref SECP: Secp256k1<VerifyOnly> = Secp256k1::verification_only();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
|
||||||
|
pub enum Field {
|
||||||
|
Kind,
|
||||||
|
CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Field {
|
||||||
|
type Err = Error;
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
if value == "kind" {
|
||||||
|
Ok(Field::Kind)
|
||||||
|
} else if value == "created_at" {
|
||||||
|
Ok(Field::CreatedAt)
|
||||||
|
} else {
|
||||||
|
Err(Error::DelegationParseError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
|
||||||
|
pub enum Operator {
|
||||||
|
LessThan,
|
||||||
|
GreaterThan,
|
||||||
|
Equals,
|
||||||
|
NotEquals,
|
||||||
|
}
|
||||||
|
impl FromStr for Operator {
|
||||||
|
type Err = Error;
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
if value == "<" {
|
||||||
|
Ok(Operator::LessThan)
|
||||||
|
} else if value == ">" {
|
||||||
|
Ok(Operator::GreaterThan)
|
||||||
|
} else if value == "=" {
|
||||||
|
Ok(Operator::Equals)
|
||||||
|
} else if value == "!" {
|
||||||
|
Ok(Operator::NotEquals)
|
||||||
|
} else {
|
||||||
|
Err(Error::DelegationParseError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
|
||||||
|
pub struct ConditionQuery {
|
||||||
|
pub(crate) conditions: Vec<Condition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConditionQuery {
|
||||||
|
pub fn allows_event(&self, event: &Event) -> bool {
|
||||||
|
// check each condition, to ensure that the event complies
|
||||||
|
// with the restriction.
|
||||||
|
for c in &self.conditions {
|
||||||
|
if !c.allows_event(event) {
|
||||||
|
// any failing conditions invalidates the delegation
|
||||||
|
// on this event
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// delegation was permitted unconditionally, or all conditions
|
||||||
|
// were true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the delegator approved the delegation; return a ConditionQuery if so.
|
||||||
|
pub fn validate_delegation(
|
||||||
|
delegator: &str,
|
||||||
|
delegatee: &str,
|
||||||
|
cond_query: &str,
|
||||||
|
sigstr: &str,
|
||||||
|
) -> Option<ConditionQuery> {
|
||||||
|
// form the token
|
||||||
|
let tok = format!("nostr:delegation:{}:{}", delegatee, cond_query);
|
||||||
|
// form SHA256 hash
|
||||||
|
let digest: sha256::Hash = sha256::Hash::hash(tok.as_bytes());
|
||||||
|
let sig = schnorr::Signature::from_str(sigstr).unwrap();
|
||||||
|
if let Ok(msg) = secp256k1::Message::from_slice(digest.as_ref()) {
|
||||||
|
if let Ok(pubkey) = XOnlyPublicKey::from_str(delegator) {
|
||||||
|
let verify = SECP.verify_schnorr(&sig, &msg, &pubkey);
|
||||||
|
if verify.is_ok() {
|
||||||
|
// return the parsed condition query
|
||||||
|
cond_query.parse::<ConditionQuery>().ok()
|
||||||
|
} else {
|
||||||
|
debug!("client sent an delegation signature that did not validate");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("client sent malformed delegation pubkey");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("error converting delegation digest to secp256k1 message");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed delegation condition
|
||||||
|
/// see https://github.com/nostr-protocol/nips/pull/28#pullrequestreview-1084903800
|
||||||
|
/// An example complex condition would be: kind=1,2,3&created_at<1665265999
|
||||||
|
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
|
||||||
|
pub struct Condition {
|
||||||
|
pub(crate) field: Field,
|
||||||
|
pub(crate) operator: Operator,
|
||||||
|
pub(crate) values: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Condition {
|
||||||
|
/// Check if this condition allows the given event to be delegated
|
||||||
|
pub fn allows_event(&self, event: &Event) -> bool {
|
||||||
|
// determine what the right-hand side of the operator is
|
||||||
|
let resolved_field = match &self.field {
|
||||||
|
Field::Kind => event.kind,
|
||||||
|
Field::CreatedAt => event.created_at,
|
||||||
|
};
|
||||||
|
match &self.operator {
|
||||||
|
Operator::LessThan => {
|
||||||
|
// the less-than operator is only valid for single values.
|
||||||
|
if self.values.len() == 1 {
|
||||||
|
if let Some(v) = self.values.first() {
|
||||||
|
return resolved_field < *v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Operator::GreaterThan => {
|
||||||
|
// the greater-than operator is only valid for single values.
|
||||||
|
if self.values.len() == 1 {
|
||||||
|
if let Some(v) = self.values.first() {
|
||||||
|
return resolved_field > *v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Operator::Equals => {
|
||||||
|
// equals is interpreted as "must be equal to at least one provided value"
|
||||||
|
return self.values.iter().any(|&x| resolved_field == x);
|
||||||
|
}
|
||||||
|
Operator::NotEquals => {
|
||||||
|
// not-equals is interpreted as "must not be equal to any provided value"
|
||||||
|
// this is the one case where an empty list of values could be allowed; even though it is a pointless restriction.
|
||||||
|
return self.values.iter().all(|&x| resolved_field != x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_to_condition(cs: &str) -> Option<Condition> {
|
||||||
|
// a condition is a string (alphanum+underscore), an operator (<>=!), and values (num+comma)
|
||||||
|
lazy_static! {
|
||||||
|
static ref RE: Regex = Regex::new("([[:word:]]+)([<>=!]+)([,[[:digit:]]]*)").unwrap();
|
||||||
|
}
|
||||||
|
// match against the regex
|
||||||
|
let caps = RE.captures(cs)?;
|
||||||
|
let field = caps.get(1)?.as_str().parse::<Field>().ok()?;
|
||||||
|
let operator = caps.get(2)?.as_str().parse::<Operator>().ok()?;
|
||||||
|
// values are just comma separated numbers, but all must be parsed
|
||||||
|
let rawvals = caps.get(3)?.as_str();
|
||||||
|
let values = rawvals
|
||||||
|
.split_terminator(',')
|
||||||
|
.map(|n| n.parse::<u64>().ok())
|
||||||
|
.collect::<Option<Vec<_>>>()?;
|
||||||
|
// convert field string into Field
|
||||||
|
Some(Condition {
|
||||||
|
field,
|
||||||
|
operator,
|
||||||
|
values,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a condition query from a string slice
|
||||||
|
impl FromStr for ConditionQuery {
|
||||||
|
type Err = Error;
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
// split the string with '&'
|
||||||
|
let mut conditions = vec![];
|
||||||
|
let condstrs = value.split_terminator('&');
|
||||||
|
// parse each individual condition
|
||||||
|
for c in condstrs {
|
||||||
|
conditions.push(str_to_condition(c).ok_or(Error::DelegationParseError)?);
|
||||||
|
}
|
||||||
|
Ok(ConditionQuery { conditions })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// parse condition strings
|
||||||
|
#[test]
|
||||||
|
fn parse_empty() -> Result<()> {
|
||||||
|
// given an empty condition query, produce an empty vector
|
||||||
|
let empty_cq = ConditionQuery { conditions: vec![] };
|
||||||
|
let parsed = "".parse::<ConditionQuery>()?;
|
||||||
|
assert_eq!(parsed, empty_cq);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse field 'kind'
|
||||||
|
#[test]
|
||||||
|
fn test_kind_field_parse() -> Result<()> {
|
||||||
|
let field = "kind".parse::<Field>()?;
|
||||||
|
assert_eq!(field, Field::Kind);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// parse field 'created_at'
|
||||||
|
#[test]
|
||||||
|
fn test_created_at_field_parse() -> Result<()> {
|
||||||
|
let field = "created_at".parse::<Field>()?;
|
||||||
|
assert_eq!(field, Field::CreatedAt);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// parse unknown field
|
||||||
|
#[test]
|
||||||
|
fn unknown_field_parse() {
|
||||||
|
let field = "unk".parse::<Field>();
|
||||||
|
assert!(field.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse a full conditional query with an empty array
|
||||||
|
#[test]
|
||||||
|
fn parse_kind_equals_empty() -> Result<()> {
|
||||||
|
// given an empty condition query, produce an empty vector
|
||||||
|
let kind_cq = ConditionQuery {
|
||||||
|
conditions: vec![Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::Equals,
|
||||||
|
values: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let parsed = "kind=".parse::<ConditionQuery>()?;
|
||||||
|
assert_eq!(parsed, kind_cq);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// parse a full conditional query with a single value
|
||||||
|
#[test]
|
||||||
|
fn parse_kind_equals_singleval() -> Result<()> {
|
||||||
|
// given an empty condition query, produce an empty vector
|
||||||
|
let kind_cq = ConditionQuery {
|
||||||
|
conditions: vec![Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::Equals,
|
||||||
|
values: vec![1],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let parsed = "kind=1".parse::<ConditionQuery>()?;
|
||||||
|
assert_eq!(parsed, kind_cq);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// parse a full conditional query with multiple values
|
||||||
|
#[test]
|
||||||
|
fn parse_kind_equals_multival() -> Result<()> {
|
||||||
|
// given an empty condition query, produce an empty vector
|
||||||
|
let kind_cq = ConditionQuery {
|
||||||
|
conditions: vec![Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::Equals,
|
||||||
|
values: vec![1, 2, 4],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let parsed = "kind=1,2,4".parse::<ConditionQuery>()?;
|
||||||
|
assert_eq!(parsed, kind_cq);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// parse multiple conditions
|
||||||
|
#[test]
|
||||||
|
fn parse_multi_conditions() -> Result<()> {
|
||||||
|
// given an empty condition query, produce an empty vector
|
||||||
|
let cq = ConditionQuery {
|
||||||
|
conditions: vec![
|
||||||
|
Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::GreaterThan,
|
||||||
|
values: vec![10000],
|
||||||
|
},
|
||||||
|
Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::LessThan,
|
||||||
|
values: vec![20000],
|
||||||
|
},
|
||||||
|
Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::NotEquals,
|
||||||
|
values: vec![10001],
|
||||||
|
},
|
||||||
|
Condition {
|
||||||
|
field: Field::CreatedAt,
|
||||||
|
operator: Operator::LessThan,
|
||||||
|
values: vec![1665867123],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let parsed =
|
||||||
|
"kind>10000&kind<20000&kind!10001&created_at<1665867123".parse::<ConditionQuery>()?;
|
||||||
|
assert_eq!(parsed, cq);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn simple_event() -> Event {
|
||||||
|
Event {
|
||||||
|
id: "0".to_owned(),
|
||||||
|
pubkey: "0".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
|
created_at: 0,
|
||||||
|
kind: 0,
|
||||||
|
tags: vec![],
|
||||||
|
content: "".to_owned(),
|
||||||
|
sig: "0".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for condition logic on event w/ empty values
|
||||||
|
#[test]
|
||||||
|
fn condition_with_empty_values() {
|
||||||
|
let mut c = Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::GreaterThan,
|
||||||
|
values: vec![],
|
||||||
|
};
|
||||||
|
let e = simple_event();
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
c.operator = Operator::LessThan;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
c.operator = Operator::Equals;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
// Not Equals applied to an empty list *is* allowed
|
||||||
|
// (pointless, but logically valid).
|
||||||
|
c.operator = Operator::NotEquals;
|
||||||
|
assert!(c.allows_event(&e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for condition logic on event w/ single value
|
||||||
|
#[test]
|
||||||
|
fn condition_kind_gt_event_single() {
|
||||||
|
let c = Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::GreaterThan,
|
||||||
|
values: vec![10],
|
||||||
|
};
|
||||||
|
let mut e = simple_event();
|
||||||
|
// kind is not greater than 10, not allowed
|
||||||
|
e.kind = 1;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
// kind is greater than 10, allowed
|
||||||
|
e.kind = 100;
|
||||||
|
assert!(c.allows_event(&e));
|
||||||
|
// kind is 10, not allowed
|
||||||
|
e.kind = 10;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
}
|
||||||
|
// Check for condition logic on event w/ multi values
|
||||||
|
#[test]
|
||||||
|
fn condition_with_multi_values() {
|
||||||
|
let mut c = Condition {
|
||||||
|
field: Field::Kind,
|
||||||
|
operator: Operator::Equals,
|
||||||
|
values: vec![0, 10, 20],
|
||||||
|
};
|
||||||
|
let mut e = simple_event();
|
||||||
|
// Allow if event kind is in list for Equals
|
||||||
|
e.kind = 10;
|
||||||
|
assert!(c.allows_event(&e));
|
||||||
|
// Deny if event kind is not in list for Equals
|
||||||
|
e.kind = 11;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
// Deny if event kind is in list for NotEquals
|
||||||
|
e.kind = 10;
|
||||||
|
c.operator = Operator::NotEquals;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
// Allow if event kind is not in list for NotEquals
|
||||||
|
e.kind = 99;
|
||||||
|
c.operator = Operator::NotEquals;
|
||||||
|
assert!(c.allows_event(&e));
|
||||||
|
// Always deny if GreaterThan/LessThan for a list
|
||||||
|
c.operator = Operator::LessThan;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
c.operator = Operator::GreaterThan;
|
||||||
|
assert!(!c.allows_event(&e));
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,6 +50,8 @@ pub enum Error {
|
||||||
HyperError(hyper::Error),
|
HyperError(hyper::Error),
|
||||||
#[error("Hex encoding error")]
|
#[error("Hex encoding error")]
|
||||||
HexError(hex::FromHexError),
|
HexError(hex::FromHexError),
|
||||||
|
#[error("Delegation parse error")]
|
||||||
|
DelegationParseError,
|
||||||
#[error("Unknown/Undocumented")]
|
#[error("Unknown/Undocumented")]
|
||||||
UnknownError,
|
UnknownError,
|
||||||
}
|
}
|
||||||
|
|
81
src/event.rs
81
src/event.rs
|
@ -1,4 +1,5 @@
|
||||||
//! Event parsing and validation
|
//! Event parsing and validation
|
||||||
|
use crate::delegation::validate_delegation;
|
||||||
use crate::error::Error::*;
|
use crate::error::Error::*;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::nip05;
|
use crate::nip05;
|
||||||
|
@ -31,6 +32,8 @@ pub struct EventCmd {
|
||||||
pub struct Event {
|
pub struct Event {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub(crate) pubkey: String,
|
pub(crate) pubkey: String,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub(crate) delegated_by: Option<String>,
|
||||||
pub(crate) created_at: u64,
|
pub(crate) created_at: u64,
|
||||||
pub(crate) kind: u64,
|
pub(crate) kind: u64,
|
||||||
#[serde(deserialize_with = "tag_from_string")]
|
#[serde(deserialize_with = "tag_from_string")]
|
||||||
|
@ -83,6 +86,7 @@ impl From<EventCmd> for Result<Event> {
|
||||||
} else if ec.event.is_valid() {
|
} else if ec.event.is_valid() {
|
||||||
let mut e = ec.event;
|
let mut e = ec.event;
|
||||||
e.build_index();
|
e.build_index();
|
||||||
|
e.update_delegation();
|
||||||
Ok(e)
|
Ok(e)
|
||||||
} else {
|
} else {
|
||||||
Err(EventInvalid)
|
Err(EventInvalid)
|
||||||
|
@ -110,6 +114,50 @@ impl Event {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// is this event delegated (properly)?
|
||||||
|
// does the signature match, and are conditions valid?
|
||||||
|
// if so, return an alternate author for the event
|
||||||
|
pub fn delegated_author(&self) -> Option<String> {
|
||||||
|
// is there a delegation tag?
|
||||||
|
let delegation_tag: Vec<String> = self
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.len() == 4)
|
||||||
|
.filter(|x| x.get(0).unwrap() == "delegation")
|
||||||
|
.take(1)
|
||||||
|
.next()?
|
||||||
|
.to_vec(); // get first tag
|
||||||
|
|
||||||
|
//let delegation_tag = self.tag_values_by_name("delegation");
|
||||||
|
// delegation tags should have exactly 3 elements after the name (pubkey, condition, sig)
|
||||||
|
// the event is signed by the delagatee
|
||||||
|
let delegatee = &self.pubkey;
|
||||||
|
// the delegation tag references the claimed delagator
|
||||||
|
let delegator: &str = delegation_tag.get(1)?;
|
||||||
|
let querystr: &str = delegation_tag.get(2)?;
|
||||||
|
let sig: &str = delegation_tag.get(3)?;
|
||||||
|
|
||||||
|
// attempt to get a condition query; this requires the delegation to have a valid signature.
|
||||||
|
if let Some(cond_query) = validate_delegation(delegator, delegatee, querystr, sig) {
|
||||||
|
// The signature was valid, now we ensure the delegation
|
||||||
|
// condition is valid for this event:
|
||||||
|
if cond_query.allows_event(self) {
|
||||||
|
// since this is allowed, we will provide the delegatee
|
||||||
|
Some(delegator.into())
|
||||||
|
} else {
|
||||||
|
debug!("an event failed to satisfy delegation conditions");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("event had had invalid delegation signature");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update delegation status
|
||||||
|
fn update_delegation(&mut self) {
|
||||||
|
self.delegated_by = self.delegated_author();
|
||||||
|
}
|
||||||
/// Build an event tag index
|
/// Build an event tag index
|
||||||
fn build_index(&mut self) {
|
fn build_index(&mut self) {
|
||||||
// if there are no tags; just leave the index as None
|
// if there are no tags; just leave the index as None
|
||||||
|
@ -145,7 +193,7 @@ impl Event {
|
||||||
self.pubkey.chars().take(8).collect()
|
self.pubkey.chars().take(8).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve tag values
|
/// Retrieve tag initial values across all tags matching the name
|
||||||
pub fn tag_values_by_name(&self, tag_name: &str) -> Vec<String> {
|
pub fn tag_values_by_name(&self, tag_name: &str) -> Vec<String> {
|
||||||
self.tags
|
self.tags
|
||||||
.iter()
|
.iter()
|
||||||
|
@ -269,6 +317,7 @@ mod tests {
|
||||||
Event {
|
Event {
|
||||||
id: "0".to_owned(),
|
id: "0".to_owned(),
|
||||||
pubkey: "0".to_owned(),
|
pubkey: "0".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
|
@ -350,6 +399,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "999".to_owned(),
|
id: "999".to_owned(),
|
||||||
pubkey: "012345".to_owned(),
|
pubkey: "012345".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 501234,
|
created_at: 501234,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
|
@ -367,6 +417,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "999".to_owned(),
|
id: "999".to_owned(),
|
||||||
pubkey: "012345".to_owned(),
|
pubkey: "012345".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 501234,
|
created_at: 501234,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: vec![
|
tags: vec![
|
||||||
|
@ -388,11 +439,39 @@ mod tests {
|
||||||
assert_eq!(v, vec!["foo", "bar", "baz"]);
|
assert_eq!(v, vec!["foo", "bar", "baz"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_no_tag_select() {
|
||||||
|
let e = Event {
|
||||||
|
id: "999".to_owned(),
|
||||||
|
pubkey: "012345".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
|
created_at: 501234,
|
||||||
|
kind: 1,
|
||||||
|
tags: vec![
|
||||||
|
vec!["j".to_owned(), "abc".to_owned()],
|
||||||
|
vec!["e".to_owned(), "foo".to_owned()],
|
||||||
|
vec!["e".to_owned(), "baz".to_owned()],
|
||||||
|
vec![
|
||||||
|
"p".to_owned(),
|
||||||
|
"aaaa".to_owned(),
|
||||||
|
"ws://example.com".to_owned(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
content: "this is a test".to_owned(),
|
||||||
|
sig: "abcde".to_owned(),
|
||||||
|
tagidx: None,
|
||||||
|
};
|
||||||
|
let v = e.tag_values_by_name("x");
|
||||||
|
// asking for tags that don't exist just returns zero-length vector
|
||||||
|
assert_eq!(v.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_canonical_with_tags() {
|
fn event_canonical_with_tags() {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "999".to_owned(),
|
id: "999".to_owned(),
|
||||||
pubkey: "012345".to_owned(),
|
pubkey: "012345".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 501234,
|
created_at: 501234,
|
||||||
kind: 1,
|
kind: 1,
|
||||||
tags: vec![
|
tags: vec![
|
||||||
|
|
|
@ -35,7 +35,7 @@ impl From<config::Info> for RelayInfo {
|
||||||
description: i.description,
|
description: i.description,
|
||||||
pubkey: i.pubkey,
|
pubkey: i.pubkey,
|
||||||
contact: i.contact,
|
contact: i.contact,
|
||||||
supported_nips: Some(vec![1, 2, 9, 11, 12, 15, 16, 22]),
|
supported_nips: Some(vec![1, 2, 9, 11, 12, 15, 16, 22, 26]),
|
||||||
software: Some("https://git.sr.ht/~gheartsfield/nostr-rs-relay".to_owned()),
|
software: Some("https://git.sr.ht/~gheartsfield/nostr-rs-relay".to_owned()),
|
||||||
version: CARGO_PKG_VERSION.map(|x| x.to_owned()),
|
version: CARGO_PKG_VERSION.map(|x| x.to_owned()),
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ pub mod close;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod conn;
|
pub mod conn;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod delegation;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod hexrange;
|
pub mod hexrange;
|
||||||
|
|
|
@ -20,7 +20,7 @@ pragma mmap_size = 536870912; -- 512MB of mmap
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
/// Latest database version
|
/// Latest database version
|
||||||
pub const DB_VERSION: usize = 6;
|
pub const DB_VERSION: usize = 7;
|
||||||
|
|
||||||
/// Schema definition
|
/// Schema definition
|
||||||
const INIT_SQL: &str = formatcp!(
|
const INIT_SQL: &str = formatcp!(
|
||||||
|
@ -40,6 +40,7 @@ event_hash BLOB NOT NULL, -- 4-byte hash
|
||||||
first_seen INTEGER NOT NULL, -- when the event was first seen (not authored!) (seconds since 1970)
|
first_seen INTEGER NOT NULL, -- when the event was first seen (not authored!) (seconds since 1970)
|
||||||
created_at INTEGER NOT NULL, -- when the event was authored
|
created_at INTEGER NOT NULL, -- when the event was authored
|
||||||
author BLOB NOT NULL, -- author pubkey
|
author BLOB NOT NULL, -- author pubkey
|
||||||
|
delegated_by BLOB, -- delegator pubkey (NIP-26)
|
||||||
kind INTEGER NOT NULL, -- event kind
|
kind INTEGER NOT NULL, -- event kind
|
||||||
hidden INTEGER, -- relevant for queries
|
hidden INTEGER, -- relevant for queries
|
||||||
content TEXT NOT NULL -- serialized json of event object
|
content TEXT NOT NULL -- serialized json of event object
|
||||||
|
@ -49,6 +50,7 @@ content TEXT NOT NULL -- serialized json of event object
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS event_hash_index ON event(event_hash);
|
CREATE UNIQUE INDEX IF NOT EXISTS event_hash_index ON event(event_hash);
|
||||||
CREATE INDEX IF NOT EXISTS created_at_index ON event(created_at);
|
CREATE INDEX IF NOT EXISTS created_at_index ON event(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS author_index ON event(author);
|
CREATE INDEX IF NOT EXISTS author_index ON event(author);
|
||||||
|
CREATE INDEX IF NOT EXISTS delegated_by_index ON event(delegated_by);
|
||||||
CREATE INDEX IF NOT EXISTS kind_index ON event(kind);
|
CREATE INDEX IF NOT EXISTS kind_index ON event(kind);
|
||||||
|
|
||||||
-- Tag Table
|
-- Tag Table
|
||||||
|
@ -152,6 +154,9 @@ pub fn upgrade_db(conn: &mut PooledConnection) -> Result<()> {
|
||||||
if curr_version == 5 {
|
if curr_version == 5 {
|
||||||
curr_version = mig_5_to_6(conn)?;
|
curr_version = mig_5_to_6(conn)?;
|
||||||
}
|
}
|
||||||
|
if curr_version == 6 {
|
||||||
|
curr_version = mig_6_to_7(conn)?;
|
||||||
|
}
|
||||||
if curr_version == DB_VERSION {
|
if curr_version == DB_VERSION {
|
||||||
info!(
|
info!(
|
||||||
"All migration scripts completed successfully. Welcome to v{}.",
|
"All migration scripts completed successfully. Welcome to v{}.",
|
||||||
|
@ -348,3 +353,23 @@ fn mig_5_to_6(conn: &mut PooledConnection) -> Result<usize> {
|
||||||
info!("vacuumed DB after tags rebuild in {:?}", start.elapsed());
|
info!("vacuumed DB after tags rebuild in {:?}", start.elapsed());
|
||||||
Ok(6)
|
Ok(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mig_6_to_7(conn: &mut PooledConnection) -> Result<usize> {
|
||||||
|
info!("database schema needs update from 6->7");
|
||||||
|
// only change is adding a hidden column to events.
|
||||||
|
let upgrade_sql = r##"
|
||||||
|
ALTER TABLE event ADD delegated_by BLOB;
|
||||||
|
CREATE INDEX IF NOT EXISTS delegated_by_index ON event(delegated_by);
|
||||||
|
PRAGMA user_version = 7;
|
||||||
|
"##;
|
||||||
|
match conn.execute_batch(upgrade_sql) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("database schema upgraded v6 -> v7");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("update failed: {}", err);
|
||||||
|
panic!("database could not be upgraded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(7)
|
||||||
|
}
|
||||||
|
|
|
@ -217,6 +217,17 @@ impl ReqFilter {
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delegated_authors_match(&self, event: &Event) -> bool {
|
||||||
|
if let Some(delegated_pubkey) = &event.delegated_by {
|
||||||
|
self.authors
|
||||||
|
.as_ref()
|
||||||
|
.map(|vs| prefix_match(vs, delegated_pubkey))
|
||||||
|
.unwrap_or(true)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tag_match(&self, event: &Event) -> bool {
|
fn tag_match(&self, event: &Event) -> bool {
|
||||||
// get the hashset from the filter.
|
// get the hashset from the filter.
|
||||||
if let Some(map) = &self.tags {
|
if let Some(map) = &self.tags {
|
||||||
|
@ -248,7 +259,7 @@ impl ReqFilter {
|
||||||
&& self.since.map(|t| event.created_at > t).unwrap_or(true)
|
&& self.since.map(|t| event.created_at > t).unwrap_or(true)
|
||||||
&& self.until.map(|t| event.created_at < t).unwrap_or(true)
|
&& self.until.map(|t| event.created_at < t).unwrap_or(true)
|
||||||
&& self.kind_match(event.kind)
|
&& self.kind_match(event.kind)
|
||||||
&& self.authors_match(event)
|
&& (self.authors_match(event) || self.delegated_authors_match(event))
|
||||||
&& self.tag_match(event)
|
&& self.tag_match(event)
|
||||||
&& !self.force_no_match
|
&& !self.force_no_match
|
||||||
}
|
}
|
||||||
|
@ -308,6 +319,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "foo".to_owned(),
|
id: "foo".to_owned(),
|
||||||
pubkey: "abcd".to_owned(),
|
pubkey: "abcd".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -326,6 +338,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abcd".to_owned(),
|
id: "abcd".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -344,6 +357,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abcde".to_owned(),
|
id: "abcde".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -363,6 +377,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abc".to_owned(),
|
id: "abc".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 50,
|
created_at: 50,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -386,6 +401,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abc".to_owned(),
|
id: "abc".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 150,
|
created_at: 150,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -407,6 +423,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abc".to_owned(),
|
id: "abc".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 50,
|
created_at: 50,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -425,6 +442,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abc".to_owned(),
|
id: "abc".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 1001,
|
created_at: 1001,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -443,6 +461,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "abc".to_owned(),
|
id: "abc".to_owned(),
|
||||||
pubkey: "".to_owned(),
|
pubkey: "".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -461,6 +480,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "123".to_owned(),
|
id: "123".to_owned(),
|
||||||
pubkey: "abc".to_owned(),
|
pubkey: "abc".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -479,6 +499,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "123".to_owned(),
|
id: "123".to_owned(),
|
||||||
pubkey: "bcd".to_owned(),
|
pubkey: "bcd".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
@ -497,6 +518,7 @@ mod tests {
|
||||||
let e = Event {
|
let e = Event {
|
||||||
id: "123".to_owned(),
|
id: "123".to_owned(),
|
||||||
pubkey: "xyz".to_owned(),
|
pubkey: "xyz".to_owned(),
|
||||||
|
delegated_by: None,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
kind: 0,
|
kind: 0,
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
|
Loading…
Reference in New Issue
Block a user