polyc-facts 2026.8.3

Shared semantic-fold library: decode-to-fact functions reused by every consumer that reads the event log, so a payment receipt or a tool call means the same thing everywhere it's read.
//! The verified wallet-link-lifecycle fold (`#2123`) — the SINGLE definition
//! of "a wallet-link lifecycle event that counts", the `wallet_link_lifecycle`
//! sibling of [`crate::refusals::verified_refusals`]. Every consumer that
//! wants to know which link/renew/revoke transitions actually happened (the
//! `wallet_link_lifecycle` query view today; any future forensics/ledger
//! consumer) reads through this fold, not a second, independently-written
//! decode of the signed JSON payload.
//!
//! A forged/tampered event, or one signed by a key outside
//! `trusted_signers`, is not "a lifecycle row with bad data" — the fold
//! returns `None` for it and the caller skips it, exactly the way
//! [`verified_refusals`](crate::refusals::verified_refusals) treats an
//! unverifiable refusal.

use polyc_eventlog::Event;
use polyc_proto::kinds;

/// The signature-verified, **trusted-signer allow-listed**
/// `wallet_link_lifecycle` events in a replayed partition, in journal order.
///
/// Drops any entry whose signature does not verify, whose embedded
/// `signed_by` key is not in `trusted_signers`, or whose signed `kind`
/// disagrees with [`kinds::WALLET_LINK_LIFECYCLE`] (the physical kind every
/// lifecycle event is filed under). Every lifecycle-reading consumer reads
/// through this fold, or through the `wallet_link_lifecycle` query view built
/// on top of it, so none can drift on which transitions they trust.
pub fn verified_wallet_link_lifecycle_events<'a>(
    events: &'a [Event],
    trusted_signers: &'a [Vec<u8>],
) -> impl Iterator<Item = polyc_crypto::approval::VerifiedWalletLinkLifecycle> + 'a {
    events.iter().filter_map(move |ev| {
        let (base, _) = kinds::parse(&ev.kind);
        if base != kinds::WALLET_LINK_LIFECYCLE {
            return None;
        }
        let verified = polyc_crypto::approval::verify_signed_wallet_link_lifecycle(
            &ev.payload,
            trusted_signers,
        )?;
        if verified.kind != kinds::WALLET_LINK_LIFECYCLE {
            return None;
        }
        Some(verified)
    })
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]

    use polyc_crypto::approval::{
        ApprovalSigner, WalletLinkLifecyclePayload, wallet_link_lifecycle_payload,
    };

    use super::*;

    fn signed_lifecycle_event(signer: &ApprovalSigner, transition: &str, subject: &str) -> Vec<u8> {
        let (payload, sig, pk) = wallet_link_lifecycle_payload(
            &WalletLinkLifecyclePayload {
                kind: kinds::WALLET_LINK_LIFECYCLE,
                transition,
                subject,
                wallet_address: "0x1111111111111111111111111111111111111111",
                currency: "0x2222222222222222222222222222222222222222",
                chain_id: "42431",
                limit_base_units: "5000000",
                limit_human: "5",
                period_secs: "86400",
                expiry_unix: "1780086400",
                recipients: "",
                conversation_id: "conv-1",
                timestamp: "1780000000",
            },
            signer,
        );
        let _ = (sig, pk);
        payload
    }

    #[test]
    fn verified_wallet_link_lifecycle_events_drops_unknown_signer() {
        let trusted = ApprovalSigner::from_seed(1);
        let untrusted = ApprovalSigner::from_seed(2);
        let events = vec![
            Event::new(
                "wallet_link_lifecycle".to_owned(),
                signed_lifecycle_event(&trusted, "linked", "persona-1"),
            ),
            Event::new(
                "wallet_link_lifecycle".to_owned(),
                signed_lifecycle_event(&untrusted, "linked", "persona-2"),
            ),
        ];
        let trusted_signers = vec![trusted.public_key_bytes()];
        let decoded: Vec<_> =
            verified_wallet_link_lifecycle_events(&events, &trusted_signers).collect();
        assert_eq!(decoded.len(), 1);
        assert_eq!(decoded[0].subject, "persona-1");
    }

    #[test]
    fn verified_wallet_link_lifecycle_events_filters_by_kind_base() {
        let signer = ApprovalSigner::from_seed(3);
        let events = vec![
            Event::new(
                kinds::WALLET_LINK_LIFECYCLE.to_owned(),
                signed_lifecycle_event(&signer, "linked", "persona-1"),
            ),
            Event::new("usage".to_owned(), vec![]),
        ];
        let trusted_signers = vec![signer.public_key_bytes()];
        let decoded: Vec<_> =
            verified_wallet_link_lifecycle_events(&events, &trusted_signers).collect();
        assert_eq!(decoded.len(), 1);
    }

    #[test]
    fn verified_wallet_link_lifecycle_events_drops_malformed_payload() {
        let events = vec![Event::new(
            kinds::WALLET_LINK_LIFECYCLE.to_owned(),
            vec![0xFF, 0xFE, 0xFD],
        )];
        let decoded: Vec<_> = verified_wallet_link_lifecycle_events(&events, &[]).collect();
        assert_eq!(decoded.len(), 0);
    }
}