//! The verified-receipt fold — the SINGLE definition of "a receipt that
//! counts", shared by every receipt-summing call site (the dashboard spend
//! rollup, the committed-spend budget gate, wallet history, and the trace
//! projector's payment step) so none can drift on which receipts they
//! trust.
//!
//! The payment-receipt fold is the concrete example of what happens without
//! one: the trace projector
//! used to read `payment_receipt`/`outbound_payment_receipt` payloads with a
//! raw, unverified `serde_json::from_slice` while every accounting path
//! verified the signature first — the same fact, folded twice, one
//! (silently) wrong. Moving the fold here and routing every consumer,
//! including the trace projector, through it closes that gap by
//! construction: there is exactly one place that decides whether a payment
//! receipt settled.
use polyc_eventlog_model::Event;
use polyc_proto::kinds;
/// The signature-verified, **trusted-signer allow-listed** receipts of kind
/// `base` in a replayed partition, in journal order.
///
/// Filters to `base` and drops any entry whose signature does not verify, OR
/// whose embedded `signed_by` key is not in `trusted_signers`, OR (v2
/// receipts only) whose signed `kind` disagrees with `base`, the physical
/// event kind it is filed under — a forged/tampered payload, one self-signed
/// by an unknown key, or a validly-signed receipt re-filed under the other
/// direction's kind, is silently skipped rather than counted. A v1 receipt
/// (no signed `kind`, `VerifiedReceipt::kind` empty) has nothing to
/// cross-check and is kept as before. Every dollar-counting consumer (the
/// spend rollup, the committed-spend budget gate, wallet history, the trace
/// projector's payment step) reads receipts through this fold, or through
/// [`verified_outbound_receipts`], so none can drift on which receipts they
/// trust.
pub fn verified_receipts<'a>(
events: &'a [Event],
base: &'a str,
trusted_signers: &'a [Vec<u8>],
) -> impl Iterator<Item = polyc_crypto::approval::VerifiedReceipt> + 'a {
events.iter().filter_map(move |ev| {
let (event_base, _) = kinds::parse(&ev.kind);
if event_base != base {
return None;
}
let receipt = polyc_crypto::approval::verify_signed_receipt(&ev.payload, trusted_signers)?;
// v1 receipts sign no `kind` (empty string) — nothing to cross-check,
// unchanged behavior. v2 receipts sign the kind they were filed
// under; a signature that verifies but names the OTHER direction's
// kind means a valid receipt was re-filed under the wrong physical
// event, and must be dropped exactly like a signature failure —
// direction is derived from the physical kind downstream (see
// `polyc_query::decode::payments`), so a silent mismatch here would
// misclassify a payment's direction.
if !receipt.kind.is_empty() && receipt.kind != event_base {
return None;
}
Some(receipt)
})
}
/// The signature-verified, trusted-signer allow-listed `outbound_payment_receipt`s
/// in a replayed partition, in journal order.
///
/// The direction the control plane pays a `paid_fetch` on a persona's
/// behalf — see [`verified_receipts`] for the shared trust definition every
/// receipt consumer reads through.
pub fn verified_outbound_receipts<'a>(
events: &'a [Event],
trusted_signers: &'a [Vec<u8>],
) -> impl Iterator<Item = polyc_crypto::approval::VerifiedReceipt> + 'a {
verified_receipts(events, kinds::OUTBOUND_PAYMENT_RECEIPT, trusted_signers)
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs, clippy::unwrap_used)]
use polyc_crypto::approval::{ApprovalSigner, ReceiptPayload, receipt_payload};
use super::*;
/// `amount`/`currency` carry the DIRECTION's production shape, not one
/// shared shape (#1739): an inbound `payment_receipt` stores a decimal
/// figure in the settlement currency, an outbound
/// `outbound_payment_receipt` stores the settlement token's base units.
/// A fixture that wrote a dollar string under the outbound kind is what
/// let a bare integer parse look correct against the whole suite.
fn signed_receipt_of_kind(signer: &ApprovalSigner, kind: &str, tool_call_id: &str) -> Vec<u8> {
let (amount, currency) = if kind == kinds::OUTBOUND_PAYMENT_RECEIPT {
("1500000", "0xtoken")
} else {
("1.50", "USD")
};
let (payload, sig, pk) = receipt_payload(
&ReceiptPayload {
kind,
reference: "tx-1",
amount,
currency,
recipient: "0xrecipient",
method: "tempo",
timestamp: "2026-07-20T00:00:00Z",
tool_call_id,
approval_pos: "1",
approved_args_hash: "hash",
subject: "persona-1",
payer_kind: "linked_wallet",
paying_account: "0xpayer",
},
signer,
);
let _ = (sig, pk);
payload
}
fn signed_outbound_receipt(signer: &ApprovalSigner, tool_call_id: &str) -> Vec<u8> {
signed_receipt_of_kind(signer, kinds::OUTBOUND_PAYMENT_RECEIPT, tool_call_id)
}
/// A v1 receipt (no `version`/`kind` field) is legacy-signed over only
/// the six settlement facts; nothing in the payload names a direction, so
/// there is nothing to cross-check against the physical event kind. The
/// fold must keep it exactly as before this fix.
fn signed_v1_receipt() -> Vec<u8> {
// Frozen vector signed by `ApprovalSigner::from_seed(99)`. Tests must
// not turn a role signer back into a generic signing oracle merely to
// manufacture a legacy schema no production writer may mint now.
br#"{"reference":"tx-old","amount":"0.02","currency":"USDC","recipient":"0xr","method":"tempo","timestamp":"2026-06-01T00:00:00Z","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"0b2d980be185a6154d34da71e1ab6226d6dfc65bcd331fcf87b1b190765c4734301a3adececb1f2a80cf38007bc37d61dd4768eed4460a8326032b026a043407"}"#.to_vec()
}
/// A v2 receipt persisted before payer attribution existed: kind +
/// binding tuple signed, no payer fields at all. Frozen vector signed by
/// `ApprovalSigner::from_seed(99)` (same bytes `polyc_crypto`'s
/// `GOLDEN_V2_RECEIPT` pins) — checked in, never regenerated.
fn signed_v2_receipt() -> Vec<u8> {
br#"{"version":2,"kind":"outbound_payment_receipt","reference":"tx-frozen-v2","amount":"10000","currency":"0xToken","recipient":"0xrecipient","method":"tempo","timestamp":"2026-06-02T00:00:00Z","tool_call_id":"call-frozen","approval_pos":"42","approved_args_hash":"abcd1234","subject":"conv-frozen","signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"4f73999b3885188a976c4a2da45c0fc5b6917102fd5a17a10efbea7c9dc9c13216602de44b52388f6811d07ad3a9225288528828f05d436e8c1d3241e472c809"}"#.to_vec()
}
#[test]
fn verified_outbound_receipts_drops_unknown_signer() {
let trusted = ApprovalSigner::from_seed(1);
let untrusted = ApprovalSigner::from_seed(2);
let events = vec![
Event::new(
"outbound_payment_receipt:trusted".to_owned(),
signed_outbound_receipt(&trusted, "call-1"),
),
Event::new(
"outbound_payment_receipt:untrusted".to_owned(),
signed_outbound_receipt(&untrusted, "call-2"),
),
];
let trusted_signers = vec![trusted.public_key_bytes()];
let receipts: Vec<_> = verified_outbound_receipts(&events, &trusted_signers).collect();
assert_eq!(receipts.len(), 1);
assert_eq!(receipts[0].tool_call_id, "call-1");
}
#[test]
fn verified_receipts_filters_by_kind_base() {
let signer = ApprovalSigner::from_seed(3);
let events = vec![
Event::new(
"outbound_payment_receipt".to_owned(),
signed_outbound_receipt(&signer, "call-1"),
),
Event::new("payment_receipt".to_owned(), vec![]),
];
let trusted_signers = vec![signer.public_key_bytes()];
let receipts: Vec<_> =
verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
assert_eq!(receipts.len(), 1);
}
/// #1409: a v2 receipt validly signed by a trusted signer, whose signed
/// `kind` disagrees with the PHYSICAL event kind it is filed under, must
/// be dropped exactly like a signature failure — direction is derived
/// from the physical kind downstream, so a mismatch here would
/// misclassify a payment. A matching receipt is kept.
#[test]
fn verified_receipts_drops_signed_kind_mismatched_with_physical_kind() {
let signer = ApprovalSigner::from_seed(4);
let trusted_signers = vec![signer.public_key_bytes()];
// Signed kind says outbound, but physically filed under the inbound
// `payment_receipt` kind.
let mismatched = signed_receipt_of_kind(&signer, kinds::OUTBOUND_PAYMENT_RECEIPT, "call-1");
// Signed kind matches the physical kind it's filed under.
let matching = signed_receipt_of_kind(&signer, kinds::PAYMENT_RECEIPT, "call-2");
let events = vec![
Event::new(kinds::PAYMENT_RECEIPT.to_owned(), mismatched),
Event::new(kinds::PAYMENT_RECEIPT.to_owned(), matching),
];
let receipts: Vec<_> =
verified_receipts(&events, kinds::PAYMENT_RECEIPT, &trusted_signers).collect();
assert_eq!(
receipts.len(),
1,
"the kind-mismatched receipt must be dropped, the matching one kept"
);
assert_eq!(receipts[0].tool_call_id, "call-2");
}
/// A v1 receipt (no signed `kind`) has nothing to cross-check and must be
/// unaffected by the kind cross-check — it is kept regardless of which
/// physical kind it is filed under, same as before this fix.
#[test]
fn verified_receipts_keeps_v1_receipt_regardless_of_physical_kind() {
let signer = ApprovalSigner::from_seed(99);
let trusted_signers = vec![signer.public_key_bytes()];
let events = vec![Event::new(
kinds::OUTBOUND_PAYMENT_RECEIPT.to_owned(),
signed_v1_receipt(),
)];
let receipts: Vec<_> =
verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
assert_eq!(
receipts.len(),
1,
"a v1 receipt has no signed kind to cross-check"
);
assert_eq!(receipts[0].version, 1);
assert!(receipts[0].kind.is_empty());
}
/// A v2 receipt predates payer attribution: the fold must still keep it
/// (its signature and binding tuple are unaffected by the v3 field
/// addition) and report payer as explicit unknown, never inferred.
#[test]
fn verified_receipts_folds_frozen_v2_receipt_with_payer_unknown() {
let signer = ApprovalSigner::from_seed(99);
let trusted_signers = vec![signer.public_key_bytes()];
let events = vec![Event::new(
kinds::OUTBOUND_PAYMENT_RECEIPT.to_owned(),
signed_v2_receipt(),
)];
let receipts: Vec<_> =
verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
assert_eq!(receipts.len(), 1, "a frozen v2 receipt must still fold");
assert_eq!(receipts[0].version, 2);
assert!(receipts[0].payer_kind.is_empty());
assert!(receipts[0].paying_account.is_empty());
}
/// A v3 receipt folds with its payer attribution intact.
#[test]
fn verified_receipts_folds_v3_receipt_with_payer_attribution() {
let signer = ApprovalSigner::from_seed(5);
let trusted_signers = vec![signer.public_key_bytes()];
let events = vec![Event::new(
kinds::OUTBOUND_PAYMENT_RECEIPT.to_owned(),
signed_outbound_receipt(&signer, "call-1"),
)];
let receipts: Vec<_> =
verified_receipts(&events, kinds::OUTBOUND_PAYMENT_RECEIPT, &trusted_signers).collect();
assert_eq!(receipts.len(), 1);
assert_eq!(receipts[0].payer_kind, "linked_wallet");
assert_eq!(receipts[0].paying_account, "0xpayer");
}
}