polyc-payments 2026.9.0

Machine Payments Protocol (MPP/Tempo) integration for polychrome: the control-plane composition/glue layer over the standalone outbound, inbound, wallet-delegation, egress, and spend-policy primitive crates, plus the payment proxy/wallet views.
//! Machine Payments Protocol (MPP / Tempo) integration for polychrome.
//!
//! This is the polychrome-specific composition layer: the control-plane
//! payment proxy chokepoint, the wallet self-service view renderers, and the
//! env-driven [`config`] that assembles the standalone `polyc-*` payment
//! primitive crates into the shape polychrome's control plane needs (see
//! issue #717's crate map). It stays `publish = false` — a third party
//! building their own agent depends on the primitive crates directly instead:
//!
//! - **Outbound** 402-gated payment client (`TempoProvider`-backed, pays a
//!   `PAYMENT-REQUIRED` challenge via the MPP `PaymentMiddleware`/`PaymentExt`
//!   path, plus the per-call spend-cap wrapper, chain-id pinning guard,
//!   settlement-token balance reads, and verified explorer-link resolution) now
//!   lives in the standalone `polyc-payments-client` crate; [`PaymentsConfig`](config::PaymentsConfig)
//!   composes its `OutboundConfig` from its own broader configuration and
//!   delegates to it ([`PaymentsConfig::resolve_client`](config::PaymentsConfig::resolve_client),
//!   [`PaymentsConfig::resolve_outbound_client`](config::PaymentsConfig::resolve_outbound_client)).
//! - **Inbound**: 402 challenge issuance and credential verification lives in
//!   the standalone `polyc-payments-server` crate. This deployment mounts no
//!   inbound gate, so this crate composes nothing for it. A consumer that
//!   wants the inbound server depends on `polyc-payments-server` directly.
//!
//! The control-plane payment proxy ([`proxy`]) gates every paid fetch
//! (approval binding, SSRF, spend cap, budget) and renders the caller-facing
//! wallet self-service views ([`view`]); `polyc_wallet_delegation::provision`
//! mints and provisions a delegated key when a caller links a wallet.
//! Configuration for all of this is loaded from the environment via
//! [`config`].
//!
//! The full non-custodial Tempo wallet-delegation lifecycle — `keys.toml`
//! parsing, TIP-1053 witness binding, scoped-key provisioning, and secret
//! custody — lives in the standalone `polyc-wallet-delegation` crate; this
//! crate composes it into polychrome's own control-plane proxy and wallet
//! self-service views.
//!
//! The default network is Tempo's Moderato testnet
//! ([`MODERATO_RPC_URL`], chain id [`MODERATO_CHAIN_ID`]).

/// Settlement-amount units: dollars ↔ base units + reader-facing rendering.
pub mod amount;
/// Configuration loaded from the environment for both payment directions.
pub mod config;
/// Control-plane payment proxy core (approval-bind + SSRF + budget + fetch).
pub mod proxy;
/// Pure wallet self-service views (state machine + tool-result renderings).
pub mod view;

/// Force-register this crate's Prometheus settlement-amount counter.
///
/// Makes `polychrome_settlement_amount_unreadable_total` appear in a
/// `/metrics` scrape — with every bounded `direction` × `reason` child
/// zero-valued — before any receipt has failed to read, so an unread amount
/// shows up as a rate on an existing series rather than a series appearing
/// from nowhere. Idempotent (backed by a `OnceLock`); call once at process
/// startup, alongside any other crate's own `init_metrics`.
pub fn init_metrics() {
    amount::force();
}

/// Chain id of the Tempo Moderato testnet.
///
/// Used to build the inbound server challenge; outbound validates the chain id
/// from the challenge rather than asserting this value.
pub const MODERATO_CHAIN_ID: u64 = 42431;

/// Default JSON-RPC endpoint for the Tempo Moderato testnet.
pub const MODERATO_RPC_URL: &str = "https://rpc.moderato.tempo.xyz";

/// Default block-explorer base for the Tempo Moderato testnet.
///
/// The mainnet `explore.tempo.xyz` host does NOT index testnet transactions or
/// addresses, so any payer/settlement link must default to this testnet host.
/// The single source both the inbound config default and the harness payer link
/// resolve from.
pub const MODERATO_EXPLORER_URL: &str = "https://explore.testnet.tempo.xyz";

/// Returns the default RPC endpoint used when no override is configured.
#[must_use]
pub const fn provider_rpc_url() -> &'static str {
    MODERATO_RPC_URL
}

/// Hard ceiling on how long a caller-delegated access key may stay valid, in
/// seconds (one year).
///
/// Polychrome is **non-custodial**: a linked wallet's funds stay in the caller's
/// own onchain wallet, and the control plane holds only a spend- and
/// call-scoped *delegation* the caller authorized from their passkey — never a
/// balance. This ceiling is what keeps that delegation from becoming
/// open-ended: every minted authorization expires at or before `now + this`, so
/// authority the caller granted lapses on its own and must be re-authorized by a
/// fresh passkey approval (re-link). Minting clamps to this bound and the
/// passkey can only sign an expiry at or under the minted one, so no
/// configuration or client can hold spend authority indefinitely.
///
/// A year is long enough that a linked wallet keeps working across normal use
/// without a re-link. The bound, not its length, carries the non-custodial
/// property. A caller who wants a shorter key sets
/// `SpendPolicy.max_lifetime_secs`, which narrows the minted lifetime and can
/// never widen it.
///
/// The length still matters for one case. An unlink stops local spend at once,
/// because the control plane deletes its own copy of the key. The onchain
/// authorization outlives that deletion. It stands until it expires, unless
/// the person signs the hard-revoke ceremony (TIP-1011 `revokeKey`) from their
/// own wallet. The control plane holds no root key and cannot submit that
/// revocation itself. So this ceiling also sets how long an unlinked but
/// un-revoked key stays spendable onchain.
pub const MAX_DELEGATION_LIFETIME_SECS: u64 = 365 * 86_400;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn crate_links() {
        // Exists only to prove `cargo test -p polyc-payments` compiles and
        // runs — i.e. the whole mpp+tempo+alloy dependency tree resolves under
        // the workspace lock. Touch a const so the assertion is non-trivial.
        assert_eq!(MODERATO_CHAIN_ID, 42431);
    }

    // The ceiling is the value this deployment ships, not merely some value
    // under a bound. A revert to the old seven-day ceiling passes every other
    // test in the workspace, so pin the number itself.
    #[test]
    fn the_non_custodial_ceiling_is_one_year() {
        assert_eq!(MAX_DELEGATION_LIFETIME_SECS, 365 * 86_400);
    }

    #[test]
    fn moderato_constants() {
        assert_eq!(provider_rpc_url(), MODERATO_RPC_URL);
        assert_eq!(MODERATO_RPC_URL, "https://rpc.moderato.tempo.xyz");
        assert_eq!(MODERATO_CHAIN_ID, 42431);
    }
}