From 2f21987fb04a7da97763825e7e04ff4d41493755 Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Tue, 28 Jul 2026 19:10:55 +0000 Subject: [PATCH] feat: orchestrator mint api --- crates/client/src/lib.rs | 9 ++- crates/dto/src/lib.rs | 100 +++++++++++++++++++++++++++- src/openapi.rs | 14 ++++ src/tokenized_asset/api.rs | 132 ++++++++++++++++++++++++++++++++++--- 4 files changed, 242 insertions(+), 13 deletions(-) diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index a10f6118..55b74df2 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -136,7 +136,7 @@ impl IssuanceClient { mod tests { use httpmock::prelude::*; use serde_json::json; - use st0x_issuance_dto::TokenizedAssetStatus; + use st0x_issuance_dto::{TokenizedAssetStatus, VaultModeTag}; use super::*; @@ -157,7 +157,8 @@ mod tests { .header(API_KEY_HEADER, "test-key"); then.status(200).json_body(json!({ "underlying": "SGOV", - "status": "frozen" + "status": "frozen", + "vault_mode": "orchestrator" })); }); @@ -170,6 +171,10 @@ mod tests { mock.assert(); assert_eq!(status.underlying, UnderlyingSymbol::new("SGOV").unwrap()); assert_eq!(status.status, TokenizedAssetStatus::Frozen); + // The mode tag is what st0x.liquidity switches its mint flow on — + // this is the cross-repo path, so the client must surface it, not + // merely tolerate it. + assert_eq!(status.vault_mode, VaultModeTag::Orchestrator); } #[tokio::test] diff --git a/crates/dto/src/lib.rs b/crates/dto/src/lib.rs index f0efdb11..583eb873 100644 --- a/crates/dto/src/lib.rs +++ b/crates/dto/src/lib.rs @@ -310,6 +310,28 @@ pub enum TokenizedAssetStatus { Frozen, } +/// Which minting path the issuance bot uses for an asset. +/// +/// The liquidity bot's cue for which assets need a signed `MintAuthV1` +/// delivered before their mints can submit: `Orchestrator` assets do, +/// `VaultDirect` assets do not. Deliberately omits the orchestrator address — +/// consumers only need the tag; the issuance bot's config stays the single +/// source of truth for addresses during the cutover. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, +)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum VaultModeTag { + /// Mints deposit directly into the vault; no recipient authorization. + /// The default: a server that predates the field can only vault-direct. + #[default] + VaultDirect, + /// Mints go through the ST0xOrchestrator and require a recipient + /// authorization before submission. + Orchestrator, +} + /// Per-asset status, returned by /// `GET /tokenized-assets//status` and consumed by the liquidity /// rebalance guard. @@ -318,6 +340,14 @@ pub enum TokenizedAssetStatus { pub struct TokenizedAssetStatusResponse { pub underlying: UnderlyingSymbol, pub status: TokenizedAssetStatus, + /// Additive: absent in responses from servers that predate the field, + /// which only ever mint vault-direct — so the default is truthful. The + /// TS binding mirrors that absence-tolerance as an optional field + /// (`vault_mode?`), so a consumer generated from it handles the + /// rolling-deploy window where a pre-field server omits the value. + #[serde(default)] + #[ts(as = "Option", optional)] + pub vault_mode: VaultModeTag, } /// One entry in the `GET /tokenized-assets` list. @@ -366,6 +396,7 @@ pub fn export_bindings(out_dir: &Path) -> Result<(), ts_rs::ExportError> { AssetKey::export_all_to(out_dir)?; TokenizedAssetDetailResponse::export_all_to(out_dir)?; TokenizedAssetStatus::export_all_to(out_dir)?; + VaultModeTag::export_all_to(out_dir)?; TokenizedAssetStatusResponse::export_all_to(out_dir)?; TokenizedAssetResponse::export_all_to(out_dir)?; TokenizedAssetsListResponse::export_all_to(out_dir)?; @@ -540,23 +571,70 @@ mod tests { let response = TokenizedAssetStatusResponse { underlying: UnderlyingSymbol::new("SGOV").unwrap(), status: TokenizedAssetStatus::Frozen, + vault_mode: VaultModeTag::Orchestrator, }; assert_eq!( serde_json::to_value(&response).unwrap(), - json!({"underlying": "SGOV", "status": "frozen"}) + json!({ + "underlying": "SGOV", + "status": "frozen", + "vault_mode": "orchestrator" + }) ); } #[test] fn status_response_deserializes_from_wire() { + let response: TokenizedAssetStatusResponse = + serde_json::from_value(json!({ + "underlying": "SGOV", + "status": "enabled", + "vault_mode": "vault_direct" + })) + .unwrap(); + + assert_eq!(response.underlying, UnderlyingSymbol::new("SGOV").unwrap()); + assert_eq!(response.status, TokenizedAssetStatus::Enabled); + assert_eq!(response.vault_mode, VaultModeTag::VaultDirect); + } + + /// A response from a server that predates `vault_mode` still parses — + /// and defaults to `VaultDirect`, the only mode such a server can mint. + #[test] + fn status_response_without_vault_mode_defaults_to_vault_direct() { let response: TokenizedAssetStatusResponse = serde_json::from_value( json!({"underlying": "SGOV", "status": "enabled"}), ) .unwrap(); - assert_eq!(response.underlying, UnderlyingSymbol::new("SGOV").unwrap()); - assert_eq!(response.status, TokenizedAssetStatus::Enabled); + assert_eq!(response.vault_mode, VaultModeTag::VaultDirect); + } + + /// The wire format is snake_case, mirroring `TokenizedAssetStatus`: the + /// PascalCase domain spelling and unknown variants must fail loudly. + #[test] + fn vault_mode_tag_rejects_non_snake_case_and_unknown_variants() { + for invalid in + [json!("VaultDirect"), json!("Orchestrator"), json!("direct")] + { + assert!( + serde_json::from_value::(invalid.clone()) + .is_err(), + "{invalid} must not deserialize as VaultModeTag" + ); + } + + assert_eq!( + serde_json::from_value::(json!("vault_direct")) + .unwrap(), + VaultModeTag::VaultDirect + ); + assert_eq!( + serde_json::from_value::(json!("orchestrator")) + .unwrap(), + VaultModeTag::Orchestrator + ); } // The wire format is snake_case: the PascalCase domain spelling (`Enabled`, @@ -741,6 +819,22 @@ mod tests { "TokenizedAssetStatus must be an \"enabled\" | \"frozen\" union in TS:\n{status_enum_ts}" ); + // Optional (`vault_mode?`), mirroring the serde default: a pre-field + // server omits the value, and a generated consumer must not assume + // it is always present during that rolling-deploy window. + assert!( + status_ts.contains("vault_mode?: VaultModeTag"), + "vault_mode must be an OPTIONAL reference to the VaultModeTag \ + union in TS:\n{status_ts}" + ); + let vault_mode_ts = + std::fs::read_to_string(out_dir.join("VaultModeTag.ts")).unwrap(); + assert!( + vault_mode_ts.contains("\"vault_direct\"") + && vault_mode_ts.contains("\"orchestrator\""), + "VaultModeTag must be a \"vault_direct\" | \"orchestrator\" union in TS:\n{vault_mode_ts}" + ); + // `Network` is a closed enum, so ts_rs must emit a string-literal union // (`"base"`), not the bare `string` alias the old transparent newtype // produced — the dashboard switches on this exact wire string, so a diff --git a/src/openapi.rs b/src/openapi.rs index dfd41d00..9a10bbbd 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -40,6 +40,7 @@ expressed as an OpenAPI scheme." st0x_issuance_dto::TokenizedAssetDetailResponse, st0x_issuance_dto::TokenizedAssetStatusResponse, st0x_issuance_dto::TokenizedAssetStatus, + st0x_issuance_dto::VaultModeTag, st0x_issuance_dto::AddTokenizedAssetRequest, st0x_issuance_dto::AddTokenizedAssetResponse, st0x_issuance_dto::UnderlyingSymbol, @@ -224,5 +225,18 @@ mod tests { ["type"], "string" ); + assert_eq!(schemas["VaultModeTag"]["type"], "string"); + assert_eq!( + schemas["VaultModeTag"]["enum"], + serde_json::json!(["vault_direct", "orchestrator"]) + ); + // The parent must actually carry the field: registering the enum + // component alone would keep the assertions above green even if a + // derive regression dropped `vault_mode` from the status response. + assert_eq!( + schemas["TokenizedAssetStatusResponse"]["properties"]["vault_mode"] + ["$ref"], + "#/components/schemas/VaultModeTag" + ); } } diff --git a/src/tokenized_asset/api.rs b/src/tokenized_asset/api.rs index 9e04b99d..ae54f4ab 100644 --- a/src/tokenized_asset/api.rs +++ b/src/tokenized_asset/api.rs @@ -6,7 +6,7 @@ use sqlx::{Pool, Sqlite}; use st0x_issuance_dto::{ AddTokenizedAssetRequest, AddTokenizedAssetResponse, AssetKey, TokenizedAssetDetailResponse, TokenizedAssetResponse, - TokenizedAssetStatusResponse, TokenizedAssetsListResponse, + TokenizedAssetStatusResponse, TokenizedAssetsListResponse, VaultModeTag, }; use std::collections::BTreeMap; use std::sync::Arc; @@ -18,8 +18,20 @@ use super::{ }; use crate::auth::{InternalAuth, IssuerAuth}; use crate::chain::ConfiguredNetworks; +use crate::config::{Config, VaultMode}; use crate::underlying::load_freeze_status; +impl From for VaultModeTag { + fn from(mode: VaultMode) -> Self { + match mode { + VaultMode::VaultDirect => Self::VaultDirect, + // The tag deliberately drops the orchestrator address — the + // liquidity bot only needs to know an authorization is required. + VaultMode::Orchestrator { .. } => Self::Orchestrator, + } + } +} + fn merge_token_listing( views: Vec, ) -> Vec { @@ -141,12 +153,13 @@ pub(crate) async fn get_tokenized_asset( ), security(("internal_api_key" = [])) )] -#[tracing::instrument(skip(_auth, pool))] +#[tracing::instrument(skip(_auth, pool, config))] #[get("/tokenized-assets//status")] pub(crate) async fn get_tokenized_asset_status( underlying: &str, _auth: InternalAuth, pool: &rocket::State>, + config: &rocket::State, ) -> Result, Status> { let underlying = UnderlyingSymbol::new(underlying) .map_err(|_| Status::UnprocessableEntity)?; @@ -177,7 +190,13 @@ pub(crate) async fn get_tokenized_asset_status( Status::InternalServerError })?; - Ok(Json(TokenizedAssetStatusResponse { underlying, status: status.into() })) + let vault_mode = config.vault_mode_for(&underlying).into(); + + Ok(Json(TokenizedAssetStatusResponse { + underlying, + status: status.into(), + vault_mode, + })) } #[tracing::instrument(skip(_auth, pool))] @@ -311,13 +330,14 @@ mod tests { use rocket::routes; use serde_json::{Value, json}; use sqlx::sqlite::SqlitePoolOptions; + use std::collections::HashMap; use tracing_test::traced_test; use url::Url; use super::*; use crate::alpaca::service::AlpacaConfig; use crate::auth::{FailedAuthRateLimiter, test_auth_config}; - use crate::config::{Config, Environment, LogLevel}; + use crate::config::{Config, Environment, LogLevel, VaultModeConfig}; use crate::test_utils::logs_contain_at; use crate::tokenized_asset::{ AssetKey, Network, TokenSymbol, TokenizedAsset, TokenizedAssetCommand, @@ -1033,7 +1053,11 @@ mod tests { before.into_json().await.expect("valid JSON response"); assert_eq!( before_body, - json!({ "underlying": "AAPL", "status": "enabled" }) + json!({ + "underlying": "AAPL", + "status": "enabled", + "vault_mode": "vault_direct" + }) ); let underlying = UnderlyingSymbol::new("AAPL").unwrap(); @@ -1066,7 +1090,11 @@ mod tests { after.into_json().await.expect("valid JSON response"); assert_eq!( after_body, - json!({ "underlying": "AAPL", "status": "frozen" }) + json!({ + "underlying": "AAPL", + "status": "frozen", + "vault_mode": "vault_direct" + }) ); // Unfreezing must flip the status back to `enabled` — the other half of @@ -1098,7 +1126,11 @@ mod tests { unfrozen.into_json().await.expect("valid JSON response"); assert_eq!( unfrozen_body, - json!({ "underlying": "AAPL", "status": "enabled" }) + json!({ + "underlying": "AAPL", + "status": "enabled", + "vault_mode": "vault_direct" + }) ); } @@ -1202,6 +1234,83 @@ mod tests { ); } + /// Under a mixed config the status endpoint reports each asset's own + /// mode tag: the per-asset orchestrator override for AAPL, the + /// vault-direct default for MSFT — the liquidity bot's cue for which + /// assets need a `MintAuthV1` before their mints can submit. + #[tokio::test] + async fn test_get_status_reports_vault_mode_per_asset_under_mixed_config() { + let pool = migrated_in_memory_pool().await; + let store = setup_tokenized_asset_store(&pool).await; + + for (underlying, token) in [("AAPL", "tAAPL"), ("MSFT", "tMSFT")] { + let underlying = UnderlyingSymbol::new(underlying).unwrap(); + let key = AssetKey::new(underlying.clone(), Network::Base); + store + .send( + &key, + TokenizedAssetCommand::Add { + underlying, + token: TokenSymbol::new(token), + network: Network::Base, + vault: address!( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ), + }, + ) + .await + .expect("Failed to add asset"); + } + + let config = Config { + vault_mode_config: VaultModeConfig::new( + HashMap::from([( + "AAPL".to_string(), + VaultMode::Orchestrator { + address: address!( + "0xdddddddddddddddddddddddddddddddddddddddd" + ), + }, + )]), + VaultMode::VaultDirect, + ), + ..test_config() + }; + + let rocket = rocket::build() + .manage(config) + .manage(FailedAuthRateLimiter::new().unwrap()) + .manage(pool) + .mount("/", routes![get_tokenized_asset_status]); + let client = rocket::local::asynchronous::Client::tracked(rocket) + .await + .expect("valid rocket instance"); + + for (underlying, expected_mode) in + [("AAPL", "orchestrator"), ("MSFT", "vault_direct")] + { + let response = client + .get(format!("/tokenized-assets/{underlying}/status")) + .header(internal_api_key()) + .remote("127.0.0.1:8000".parse().unwrap()) + .dispatch() + .await; + + assert_eq!(response.status(), Status::Ok); + let body: Value = + response.into_json().await.expect("valid JSON response"); + assert_eq!( + body, + json!({ + "underlying": underlying, + "status": "enabled", + "vault_mode": expected_mode + }), + "unexpected status body for {underlying}" + ); + } + } + #[traced_test] #[tokio::test] async fn test_get_status_db_error_returns_500() { @@ -1332,7 +1441,14 @@ mod tests { let body: Value = response.into_json().await.expect("valid JSON response"); - assert_eq!(body, json!({ "underlying": "AAPL", "status": "enabled" })); + assert_eq!( + body, + json!({ + "underlying": "AAPL", + "status": "enabled", + "vault_mode": "vault_direct" + }) + ); } // The detail endpoint shares `load_asset_by_underlying`, so a non-live