Skip to main content

tenzro_types/
capital_intent.rs

1//! Capital Intent standard — agentic capital allocation over tokenized assets.
2//!
3//! See `docs/architecture/capital-intent.md`. This is a **new standard Tenzro
4//! introduces** that the market does not have: commerce intents (AP2 Intent
5//! Mandate), swap intents (CoW), cross-chain transfer intents (ERC-7683), and
6//! general wallet intents (ERC-7521) all exist — but none expresses a
7//! *regulated capital-allocation objective* over tokenized securities/assets.
8//!
9//! A [`CapitalIntent`] is the regulated-capital-markets analog of an AP2 Intent
10//! Mandate: it declares a financial **objective** (Acquire/Exit/Rebalance/
11//! Hedge/Yield) plus **risk + compliance constraints**, signed by the principal
12//! with the shared DID envelope. Solver agents compete to fulfil it; fulfilment
13//! runs as a saga (Execute → Verify → Compensate) using ERC-7683/CCIP for
14//! settlement legs, AP2 cart/payment mandates for authorization, `erc3643` for
15//! compliance gating, Proof-of-Reserve for backing, and ERC-8004 for solver
16//! scoring. Tenzro defines and enforces the standard + invariants; the solver
17//! agents, venue adapters, and UX are ecosystem.
18
19use serde::{Deserialize, Serialize};
20
21use crate::identity::KycTier;
22
23/// Regulatory regime an intent must be executed under. Gates which investors
24/// (by `IdentityClaim`) and venues are eligible at the `erc3643` transfer layer.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum RegRegime {
28    /// Regulation S — offers made outside the US (most tokenized-equity venues).
29    RegS,
30    /// Regulation D — US accredited-investor private placements.
31    RegD,
32    /// EU MiFID II.
33    MiFidII,
34    /// No regulatory restriction (e.g. a permissionless utility asset).
35    Unrestricted,
36}
37
38/// Side of a capital leg.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum Side {
42    Buy,
43    Sell,
44}
45
46/// A target weight in a rebalance objective.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct AssetWeight {
49    /// Tokenized-asset id (matches the `erc3643` / Proof-of-Reserve asset id).
50    pub asset_id: String,
51    /// Target portfolio weight in basis points (sum across targets should be 10_000).
52    pub weight_bps: u16,
53}
54
55/// The financial objective an intent expresses. Amounts are in the smallest unit
56/// of the quote asset (notional) or the asset itself (quantity).
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case", tag = "kind")]
59pub enum Objective {
60    /// Acquire up to `target_notional` of `asset_id`, paying at most `max_unit_price`.
61    Acquire {
62        asset_id: String,
63        target_notional: u128,
64        max_unit_price: u128,
65    },
66    /// Sell `quantity` of `asset_id`, accepting at least `min_unit_price`.
67    Exit {
68        asset_id: String,
69        quantity: u128,
70        min_unit_price: u128,
71    },
72    /// Rebalance the principal's holdings toward the given target weights.
73    Rebalance { targets: Vec<AssetWeight> },
74    /// Open a hedge of `notional` against `asset_id` using `instrument`.
75    Hedge {
76        asset_id: String,
77        notional: u128,
78        instrument: String,
79    },
80    /// Deploy `amount` of `asset_id` into a yield venue at >= `min_apy_bps`.
81    Yield {
82        asset_id: String,
83        amount: u128,
84        min_apy_bps: u32,
85    },
86}
87
88/// Risk / routing constraints the solver must respect.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct Constraints {
91    /// Maximum tolerated slippage in basis points.
92    pub max_slippage_bps: u16,
93    /// Unix-seconds deadline; the intent expires after this.
94    pub deadline_unix: i64,
95    /// Allowed execution venues (empty = any). Matched against solver plans.
96    #[serde(default)]
97    pub allowed_venues: Vec<String>,
98    /// Allowed settlement chains (CAIP-2-ish ids, empty = any).
99    #[serde(default)]
100    pub allowed_chains: Vec<String>,
101}
102
103/// Compliance requirements enforced at the `erc3643` transfer layer.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct ComplianceReq {
106    /// Regulatory regime the execution must satisfy.
107    pub reg_regime: RegRegime,
108    /// Minimum KYC tier the principal (and counterparties) must hold.
109    pub min_kyc_tier: KycTier,
110    /// Whether participation is restricted to accredited investors.
111    #[serde(default)]
112    pub accredited_only: bool,
113}
114
115/// Authorization envelope — the hard capital ceilings. References AP2 mandates
116/// and a delegation scope by id (kept decoupled so this crate does not depend on
117/// the AP2 / payments types); the node verifies the references at open time.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct Authorization {
120    /// AP2 cart/payment mandate id authorizing spend for the settlement legs.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub ap2_mandate_ref: Option<String>,
123    /// TDIP delegation-scope id bounding the executing agent.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub delegation_scope_ref: Option<String>,
126    /// Absolute ceiling on total notional this intent may spend (smallest unit).
127    pub max_total_notional: u128,
128}
129
130/// Settlement requirements for fulfilment proofs.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct SettlementReq {
133    /// Proof tag the solver must satisfy per leg (e.g. "tee", "zk", "oracle").
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub proof: Option<String>,
136    /// Optional preferred settlement route (e.g. a bridge / CCIP lane).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub preferred_route: Option<String>,
139}
140
141/// A signed capital intent — the regulated-capital-markets analog of an AP2
142/// Intent Mandate.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct CapitalIntent {
145    /// Caller-chosen unique id (e.g. "ci_abc123").
146    pub intent_id: String,
147    /// DID of the principal authorizing the intent.
148    pub principal_did: String,
149    /// The financial objective.
150    pub objective: Objective,
151    /// Risk / routing constraints.
152    pub constraints: Constraints,
153    /// Compliance requirements.
154    pub compliance: ComplianceReq,
155    /// Capital authorization ceilings.
156    pub authorization: Authorization,
157    /// Settlement / proof requirements.
158    pub settlement: SettlementReq,
159    /// Principal's DID-envelope signature over [`CapitalIntent::signing_payload`].
160    #[serde(default, skip_serializing_if = "Vec::is_empty")]
161    pub signature: Vec<u8>,
162}
163
164impl CapitalIntent {
165    /// Deterministic bytes the principal signs (everything except the signature),
166    /// so every surface (RPC, MCP, A2A) verifies an identical preimage. Layout is
167    /// length-prefixed and field-ordered for cross-language reproducibility.
168    pub fn signing_payload(&self) -> Vec<u8> {
169        fn push_str(buf: &mut Vec<u8>, s: &str) {
170            buf.extend_from_slice(&(s.len() as u32).to_be_bytes());
171            buf.extend_from_slice(s.as_bytes());
172        }
173        let mut buf = Vec::new();
174        buf.extend_from_slice(b"tenzro-capital-intent:v1");
175        push_str(&mut buf, &self.intent_id);
176        push_str(&mut buf, &self.principal_did);
177        // Objective discriminant + a stable rendering of its fields.
178        push_str(&mut buf, &serde_objective_tag(&self.objective));
179        buf.extend_from_slice(&self.authorization.max_total_notional.to_be_bytes());
180        buf.extend_from_slice(&self.constraints.deadline_unix.to_be_bytes());
181        buf.extend_from_slice(&(self.constraints.max_slippage_bps).to_be_bytes());
182        push_str(&mut buf, self.compliance.reg_regime.as_str());
183        push_str(&mut buf, self.compliance.min_kyc_tier.as_str());
184        buf.push(self.compliance.accredited_only as u8);
185        buf
186    }
187}
188
189impl RegRegime {
190    pub fn as_str(&self) -> &'static str {
191        match self {
192            RegRegime::RegS => "reg_s",
193            RegRegime::RegD => "reg_d",
194            RegRegime::MiFidII => "mifid_ii",
195            RegRegime::Unrestricted => "unrestricted",
196        }
197    }
198}
199
200/// Render an objective's identity (kind + primary asset/amount) into a stable
201/// string for the signing payload.
202fn serde_objective_tag(o: &Objective) -> String {
203    match o {
204        Objective::Acquire { asset_id, target_notional, max_unit_price } => {
205            format!("acquire:{asset_id}:{target_notional}:{max_unit_price}")
206        }
207        Objective::Exit { asset_id, quantity, min_unit_price } => {
208            format!("exit:{asset_id}:{quantity}:{min_unit_price}")
209        }
210        Objective::Rebalance { targets } => {
211            let mut s = String::from("rebalance");
212            for t in targets {
213                s.push_str(&format!(":{}={}", t.asset_id, t.weight_bps));
214            }
215            s
216        }
217        Objective::Hedge { asset_id, notional, instrument } => {
218            format!("hedge:{asset_id}:{notional}:{instrument}")
219        }
220        Objective::Yield { asset_id, amount, min_apy_bps } => {
221            format!("yield:{asset_id}:{amount}:{min_apy_bps}")
222        }
223    }
224}
225
226/// A solver's bid to fulfil an intent.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct CapitalQuote {
229    pub intent_id: String,
230    /// DID of the bidding solver agent (KYA-verified, ERC-8004-ranked).
231    pub solver_did: String,
232    /// Human/opaque description of the execution plan.
233    pub plan: String,
234    /// Quoted all-in price (smallest unit) to fulfil the objective.
235    pub price: u128,
236    /// Estimated time to completion, seconds.
237    pub eta_secs: u64,
238    pub quoted_at: i64,
239}
240
241/// Lifecycle status of a capital intent.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum CapitalIntentStatus {
245    /// Opened, awaiting solver quotes.
246    Open,
247    /// At least one quote received.
248    Quoting,
249    /// A solver was assigned; principal escrow locked.
250    Assigned,
251    /// Saga execution in flight.
252    Executing,
253    /// Legs executed; proofs being verified.
254    Verifying,
255    /// Completed — escrow released, receipt written.
256    Settled,
257    /// A leg failed; compensation in progress.
258    Compensating,
259    /// Terminally failed (after compensation).
260    Failed,
261    /// Deadline passed before fulfilment.
262    Expired,
263}
264
265impl CapitalIntentStatus {
266    pub fn is_terminal(&self) -> bool {
267        matches!(
268            self,
269            CapitalIntentStatus::Settled
270                | CapitalIntentStatus::Failed
271                | CapitalIntentStatus::Expired
272        )
273    }
274}
275
276/// Status of one settlement leg.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum LegStatus {
280    Planned,
281    Settled,
282    Compensated,
283    Failed,
284}
285
286/// A price quote from a venue, recorded on a leg to prove best execution.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct VenueQuote {
289    pub venue: String,
290    pub unit_price: u128,
291}
292
293/// True if `chosen` is the best price among `quotes` for the side (lowest for a
294/// buy, highest for a sell). Empty quotes → unprovable → false.
295pub fn best_execution_ok(side: Side, chosen: u128, quotes: &[VenueQuote]) -> bool {
296    if quotes.is_empty() {
297        return false;
298    }
299    match side {
300        Side::Buy => chosen <= quotes.iter().map(|q| q.unit_price).min().unwrap_or(chosen),
301        Side::Sell => chosen >= quotes.iter().map(|q| q.unit_price).max().unwrap_or(chosen),
302    }
303}
304
305/// One settlement leg executed while fulfilling an intent (a single venue trade
306/// + its ERC-7683 settlement + compliance check + backing proof).
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
308pub struct CapitalLeg {
309    pub venue: String,
310    pub asset_id: String,
311    pub side: Side,
312    pub quantity: u128,
313    pub unit_price: u128,
314    /// ERC-7683 order / settlement reference for this leg, once settled.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub settlement_ref: Option<String>,
317    /// Opaque fulfilment-proof reference (TEE/zk/oracle per `SettlementReq`).
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub proof: Option<String>,
320    /// Venue quotes the solver observed at execution (best-execution proof set).
321    #[serde(default)]
322    pub venue_quotes: Vec<VenueQuote>,
323    /// True if `unit_price` is best among `venue_quotes` for `side`.
324    #[serde(default)]
325    pub best_execution_verified: bool,
326    pub status: LegStatus,
327    pub updated_at: i64,
328}
329
330/// The persisted, on-chain-anchored state machine for a capital intent.
331/// Persisted under `CF_SETTLEMENTS` with the `capital_intent:` prefix.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct CapitalIntentRecord {
334    pub intent: CapitalIntent,
335    pub status: CapitalIntentStatus,
336    /// Assigned solver DID, once chosen.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub solver_did: Option<String>,
339    /// Solver bids received.
340    #[serde(default)]
341    pub quotes: Vec<CapitalQuote>,
342    /// Settlement legs executed during fulfilment.
343    #[serde(default)]
344    pub legs: Vec<CapitalLeg>,
345    /// Principal escrow id locked at assignment.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub escrow_id: Option<String>,
348    /// Notional filled so far (smallest unit).
349    #[serde(default)]
350    pub filled_notional: u128,
351    /// Final receipt hash (hex) once settled.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub receipt: Option<String>,
354    pub created_at: i64,
355    pub updated_at: i64,
356}
357
358impl CapitalIntentRecord {
359    /// True when every executed leg has settled (and at least one exists).
360    pub fn all_legs_settled(&self) -> bool {
361        !self.legs.is_empty()
362            && self.legs.iter().all(|l| l.status == LegStatus::Settled)
363    }
364
365    /// True when the intent has reached a terminal status.
366    pub fn is_terminal(&self) -> bool {
367        self.status.is_terminal()
368    }
369}