block/buzz · error · IngestError::Rejected

missing p tag

Error message

missing p tag

What it means

A kind 9000 PUT_USER event had no usable 'p' tag: extract_p_tag() requires a p tag whose content hex-decodes to exactly 32 bytes, and none matched. The p tag names the member being added/updated, so without it the relay cannot tell whose membership to mutate and rejects the event before storage.

Source

Thrown at crates/buzz-relay/src/handlers/side_effects.rs:359

            // member that preserves the role they already hold, and only defaults
            // to Member for a genuinely new member. Defaulting unconditionally to
            // Member made a bare self-targeted PUT_USER silently demote an owner.
            let role_str = extract_tag_value(event, "role");
            let requested_role = match role_str {
                Some(ref s) => match s.parse::<buzz_db::channel::MemberRole>() {
                    Ok(r) => Some(r),
                    Err(_) => return Err(anyhow::anyhow!("invalid role: {s}")),
                },
                None => None,
            };

            let members = state.db.get_members(tenant.community(), channel_id).await?;
            let actor_role: Option<buzz_db::channel::MemberRole> = members
                .iter()
                .find(|m| m.pubkey == actor_bytes)
                .and_then(|m| m.role.parse().ok());
            let target_pubkey =
                extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?;

            // PUT_USER: open channels allow any authenticated user; private channels
            // require the actor to be an existing active member. Any active member may
            // add an ordinary member, guest, or bot, but only owners/admins may grant
            // an elevated role.
            if channel.visibility == "private" {
                if actor_role.is_none() {
                    return Err(anyhow::anyhow!("actor not authorized"));
                }

                if requested_role.is_some_and(|role| role.is_elevated())
                    && !actor_role.is_some_and(|role| role.is_elevated())
                {
                    return Err(anyhow::anyhow!(
                        "only owners/admins may grant elevated roles"
                    ));
                }
            }

View on GitHub (pinned to f956e6fe06)

Solutions

  1. Add ["p", "<64-char hex pubkey>"] as the target of the membership change
  2. Convert npub → hex before building the tag (NIP-19 decode)
  3. Ensure the p tag is the first p tag and its value round-trips: hex::decode(...).len() == 32

Example fix

// before
EventBuilder::new(Kind::from(9000), "", [
    Tag::custom(TagKind::Custom("h"), vec![channel_uuid]),
    Tag::custom(TagKind::Custom("role"), vec!["member"]),
    Tag::custom(TagKind::Custom("p"), vec![npub_string]), // bech32, not hex
])

// after
let pk_hex = hex::encode(nip19::decode(&npub_string)?.1.to_bytes());
Tag::custom(TagKind::Custom("p"), vec![pk_hex])
Defensive patterns

Strategy: validation

Validate before calling

// Mirror extract_p_tag before publishing a 9000
fn valid_p_tag(tags: &[Tag]) -> bool {
    tags.iter().any(|t| {
        t.kind().to_string() == "p"
            && t.content()
                .and_then(|v| hex::decode(v).ok())
                .map(|b| b.len() == 32)
                .unwrap_or(false)
    })
}
assert!(valid_p_tag(&event.tags), "PUT_USER needs p = 64-hex pubkey");

Type guard

const isValidPTag = (tags: string[][]): boolean =>
  tags.some(t => t[0] === "p" && /^[0-9a-f]{64}$/.test(t[1] ?? ""));

Try / catch

match validate_admin_event(&tenant, 9000, &event, &state).await {
    Err(e) if e.to_string().contains("missing p tag") => {
        reject("PUT_USER requires [\"p\", \"<64-hex pubkey>\"]", &event.id)
    }
    other => other,
}

Prevention

When it happens

Trigger: PUT_USER published with only h and role tags; p tag value is an npub (bech32) instead of 64-char hex; p tag hex is truncated (not 32 bytes) or has a 0x prefix; tag kind serialized as 'P' (uppercase); multiple p tags where the first is malformed — extraction stops at the first p tag and returns None if it fails.

Common situations: Pasting NIP-19 npub strings from a UI into tag builders; key strings trimmed/corrupted in transit; fixtures reusing a placeholder like "deadbeef" that is not 64 hex chars.

Related errors


AI-assisted analysis of block/buzz@f956e6fe06 (2026-08-16). Data as JSON: /api/errors/5266242998b86b62. Report an issue: GitHub.