Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand All @@ -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"
}));
});

Expand All @@ -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]
Expand Down
100 changes: 97 additions & 3 deletions crates/dto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<underlying>/status` and consumed by the liquidity
/// rebalance guard.
Expand All @@ -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)]
Comment thread
rouzwelt marked this conversation as resolved.
#[ts(as = "Option<VaultModeTag>", optional)]
pub vault_mode: VaultModeTag,
}

/// One entry in the `GET /tokenized-assets` list.
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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]
Comment thread
rouzwelt marked this conversation as resolved.
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::<VaultModeTag>(invalid.clone())
.is_err(),
"{invalid} must not deserialize as VaultModeTag"
);
}

assert_eq!(
serde_json::from_value::<VaultModeTag>(json!("vault_direct"))
.unwrap(),
VaultModeTag::VaultDirect
);
assert_eq!(
serde_json::from_value::<VaultModeTag>(json!("orchestrator"))
.unwrap(),
VaultModeTag::Orchestrator
);
}

// The wire format is snake_case: the PascalCase domain spelling (`Enabled`,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -224,5 +225,18 @@ mod tests {
["type"],
"string"
);
assert_eq!(schemas["VaultModeTag"]["type"], "string");
Comment thread
rouzwelt marked this conversation as resolved.
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"
);
}
}
Loading
Loading