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}