Skip to main content

paykit_lib/allowance/
api.rs

1use tracing::instrument;
2
3use crate::{error::map_error, EncryptedLink, PaykitAppId, PrivateApplicationMessage, Result};
4
5use super::{
6    types::{
7        AllowanceAcceptance, AllowanceEnd, AllowanceEvent, AllowanceEventMessage,
8        AllowanceProposal, AllowanceRejection,
9    },
10    wire::{
11        parse_allowance_json, parse_event_header_ids, serialize_acceptance_json,
12        serialize_allowance_json, serialize_end_json, serialize_proposal_json,
13        serialize_rejection_json,
14    },
15};
16
17/// Parse a raw Private Application Message as an Allowance Event Message.
18///
19/// Returns `None` for non-Allowance kinds. Recognized malformed messages are
20/// returned with [`AllowanceEventMessage::is_valid`] set to `false`, while the
21/// raw JSON remains available for durable storage and future audit.
22pub fn parse_allowance_event_message(
23    message: &PrivateApplicationMessage,
24) -> Option<AllowanceEventMessage> {
25    let kind = message.known_kind()?;
26    let event = parse_allowance_json(kind, &message.raw_json)?;
27    let (event_id, allowance_id) = match &event {
28        Ok(event) => (
29            Some(event.event_id().clone()),
30            Some(event.allowance_id().clone()),
31        ),
32        Err(_) => parse_event_header_ids(&message.raw_json),
33    };
34    Some(AllowanceEventMessage {
35        kind,
36        app_id: serde_json::from_str::<serde_json::Value>(&message.raw_json)
37            .ok()
38            .and_then(|value| {
39                value
40                    .get("app_id")?
41                    .as_str()
42                    .and_then(|id| PaykitAppId::new(id).ok())
43            }),
44        event_id,
45        allowance_id,
46        raw_json: message.raw_json.clone(),
47        event: event.map_err(|error| error.to_string()),
48    })
49}
50
51/// Serialize a V1 Allowance Event Message to compact JSON.
52///
53/// Serialization rejects a complete message larger than the single-message
54/// `pubky-noise` plaintext limit.
55pub fn serialize_allowance_event(app_id: &PaykitAppId, event: &AllowanceEvent) -> Result<String> {
56    serialize_allowance_json(app_id, event)
57}
58
59/// Send a `paykit.allowance_proposal` Event Message.
60///
61/// The caller is responsible for using the exact authenticated Encrypted Link
62/// that binds the Allower and Allowee and for retaining the Event Message for
63/// retry and lifecycle derivation.
64#[instrument(skip(link, event))]
65pub async fn send_allowance_proposal(
66    link: &mut EncryptedLink,
67    app_id: &PaykitAppId,
68    event: &AllowanceProposal,
69) -> Result<()> {
70    send_json(
71        link,
72        serialize_proposal_json(app_id, event),
73        "send_allowance_proposal",
74    )
75    .await
76}
77
78/// Send a `paykit.allowance_acceptance` Event Message.
79///
80/// Call [`AllowanceAcceptance::validate_for_proposal`] first with the
81/// authenticated sender role when the proposal is available.
82#[instrument(skip(link, event))]
83pub async fn send_allowance_acceptance(
84    link: &mut EncryptedLink,
85    app_id: &PaykitAppId,
86    event: &AllowanceAcceptance,
87) -> Result<()> {
88    send_json(
89        link,
90        serialize_acceptance_json(app_id, event),
91        "send_allowance_acceptance",
92    )
93    .await
94}
95
96/// Send a `paykit.allowance_rejection` Event Message.
97///
98/// Call [`AllowanceRejection::validate_for_proposal`] first with the
99/// authenticated sender role when the proposal is available.
100#[instrument(skip(link, event))]
101pub async fn send_allowance_rejection(
102    link: &mut EncryptedLink,
103    app_id: &PaykitAppId,
104    event: &AllowanceRejection,
105) -> Result<()> {
106    send_json(
107        link,
108        serialize_rejection_json(app_id, event),
109        "send_allowance_rejection",
110    )
111    .await
112}
113
114/// Send a `paykit.allowance_end` Event Message.
115///
116/// Call the appropriate withdrawal or accepted-authority validation helper on
117/// [`AllowanceEnd`] when the causal events are available.
118#[instrument(skip(link, event))]
119pub async fn send_allowance_end(
120    link: &mut EncryptedLink,
121    app_id: &PaykitAppId,
122    event: &AllowanceEnd,
123) -> Result<()> {
124    send_json(
125        link,
126        serialize_end_json(app_id, event),
127        "send_allowance_end",
128    )
129    .await
130}
131
132async fn send_json(
133    link: &mut EncryptedLink,
134    json: Result<String>,
135    context: &'static str,
136) -> Result<()> {
137    let json = json.map_err(|error| map_error(context, error))?;
138    link.send_allowance_message(json.as_bytes())
139        .await
140        .map_err(|error| map_error(context, error))
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::{
147        allowance::test_fixtures::minimal_proposal, PrivateApplicationMessage, PrivateMessageKind,
148    };
149
150    fn proposal() -> AllowanceEvent {
151        AllowanceEvent::Proposal(minimal_proposal())
152    }
153
154    #[test]
155    fn test_parser_uses_raw_json_kind_and_redacts_debug() {
156        let json =
157            serialize_allowance_event(&crate::PaykitAppId::new("bitkit").unwrap(), &proposal())
158                .unwrap();
159        let message = PrivateApplicationMessage {
160            app_id: Some("bitkit".into()),
161
162            version: Some(1),
163            kind: Some(PrivateMessageKind::ReceiptAccess.as_str().to_string()),
164            raw_json: json,
165        };
166        let parsed = parse_allowance_event_message(&message).unwrap();
167        assert!(parsed.is_valid());
168        assert_eq!(parsed.kind(), PrivateMessageKind::AllowanceProposal);
169        assert_eq!(parsed.parsed_event(), Some(&proposal()));
170        let debug = format!("{parsed:?}");
171        assert!(!debug.contains("\"asset\":\"btc\""));
172        assert!(debug.contains("<redacted:"));
173    }
174
175    #[test]
176    fn test_parser_preserves_malformed_recognized_message_for_audit() {
177        let valid =
178            serialize_allowance_event(&crate::PaykitAppId::new("bitkit").unwrap(), &proposal())
179                .unwrap();
180        let raw_json = valid.replacen("{", "{\"private_sentinel\":true,", 1);
181        let message = PrivateApplicationMessage {
182            app_id: Some("bitkit".into()),
183
184            version: Some(1),
185            kind: Some(PrivateMessageKind::AllowanceProposal.as_str().to_string()),
186            raw_json: raw_json.clone(),
187        };
188
189        let parsed = parse_allowance_event_message(&message).unwrap();
190        assert!(!parsed.is_valid());
191        assert_eq!(parsed.event_id(), Some(proposal().event_id()));
192        assert_eq!(parsed.allowance_id(), Some(proposal().allowance_id()));
193        assert_eq!(parsed.raw_json(), raw_json);
194        assert!(!format!("{parsed:?}").contains("private_sentinel"));
195    }
196
197    #[test]
198    fn test_parser_flags_reused_causal_event_ids_as_malformed() {
199        let proposal = proposal();
200        let event_id = proposal.event_id().clone();
201        let kind = PrivateMessageKind::AllowanceAcceptance;
202        // Constructors refuse the reuse, so build the wire text by hand.
203        let raw_json = format!(
204            "{{\"version\":1,\"kind\":\"{}\",\"event_id\":\"{event_id}\",\"allowance_id\":\"{}\",\"proposal_event_id\":\"{event_id}\"}}",
205            kind.as_str(),
206            proposal.allowance_id()
207        );
208        let message = PrivateApplicationMessage {
209            app_id: Some("bitkit".into()),
210
211            version: Some(1),
212            kind: Some(kind.as_str().to_string()),
213            raw_json,
214        };
215
216        let parsed = parse_allowance_event_message(&message).unwrap();
217        assert!(!parsed.is_valid());
218        assert_eq!(parsed.kind(), kind);
219        assert_eq!(parsed.event_id(), Some(&event_id));
220        assert_eq!(parsed.allowance_id(), Some(proposal.allowance_id()));
221    }
222
223    #[test]
224    fn test_parser_ignores_non_allowance_kinds() {
225        let message = PrivateApplicationMessage {
226            app_id: Some("bitkit".into()),
227
228            version: Some(1),
229            kind: Some(PrivateMessageKind::PaymentRequest.as_str().to_string()),
230            raw_json: format!(
231                "{{\"version\":1,\"kind\":\"{}\"}}",
232                PrivateMessageKind::PaymentRequest.as_str()
233            ),
234        };
235        assert!(parse_allowance_event_message(&message).is_none());
236    }
237}