Skip to content
Open
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "st0x-pricing-types"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
license = "CAL-1.0-Combined-Work-Exception"
repository = "https://github.com/ST0x-Technology/st0x.pricing-types"
Expand Down
32 changes: 32 additions & 0 deletions docs/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,38 @@ pool) must refuse a zero ratio outright rather than skip the assertion: a real
vault NAV ratio is never zero, so zero-for-a-known-vault means something
upstream is broken and forwarding it would let the fill settle unprotected.

## Underlying (stock) rates

`PriceFrame` / `Quote` also carry `underlying_rate_base_to_quote` and
`underlying_rate_quote_to_base`: the directional rates for the vault's
underlying ERC4626 asset — the offchain stock. They are DEFINED as the served
vault rates un-scaled by `nav_ratio`, so a consumer that wants the vault price
can derive it atomically from the underlying and the vault's own
`convertToAssets(1 share)`
(`vault_price = underlying * convertToAssets(1
share)`) and reproduce the SERVED
vault rate, instead of trusting the signed vault rate — which sidesteps the
exact-match NAV gate as a DoS surface. They mirror `rate_base_to_quote` /
`rate_quote_to_base` in directionality and spread policy; the only difference is
the scaling.

Because the underlying is derived from the SERVED vault rate, it is
clamp-consistent, not necessarily the raw stock mark: when the model's no-cross
guard clamps the vault rate, the underlying reflects the clamped rate, so a
consumer deriving `underlying * convertToAssets` reproduces the clamped quote
and cannot reconstruct an unclamped, self-crossing one. Decimal-float division
is not a bit-exact inverse of multiplication, so this derivation reproduces the
served vault rate to Float precision (last digit), not necessarily bit-for-bit;
the underlying is the definition and the served rate is the target.

Underlying rates, the vault rates, and `nav_ratio` all come from a single
upstream mark read, so they are mutually consistent for that observation. When
`base` is not a vault token (`nav_ratio` zero) there is no separate underlying
and `underlying_rate_base_to_quote == rate_base_to_quote` (likewise for the
reverse direction). Frames from producers that predate the fields decode to the
all-zero Float via `#[serde(default)]`; a real stock rate is never all-zero, so
that value reads as "not carried".

## WebSocket framing

URL: `wss://<host>/ws`. The upgrade request must carry
Expand Down
156 changes: 149 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ pub type Symbol = String;
/// * `rate_quote_to_base`: amount of `base` you receive per 1 unit of
/// `quote` input.
///
/// The `underlying_rate_*` pair mirrors these two but prices the vault's
/// underlying ERC4626 asset (the offchain stock) instead of the vault
/// token `base`. It is DEFINED as the served vault rate un-scaled by the
/// NAV ratio, so a consumer deriving the vault price on-chain
/// (`underlying * live convertToAssets(1 share)`) reproduces the SERVED
/// vault rate — including any no-cross clamp the model applied — rather
/// than trusting a signed vault rate. See the field docs below.
///
/// Consumers must NOT invert one to derive the other — that would treat
/// the model's spread as if it were symmetric and discard the per-direction
/// decision. The only legitimate `1/x` happens at protocol-adapter
Expand Down Expand Up @@ -101,6 +109,35 @@ pub struct Quote {
/// the assertion.
#[serde(default)]
pub nav_ratio: WireU256,
/// Directional rate for the vault's underlying ERC4626 asset (the
/// offchain stock), `quote` per 1 unit of underlying. Same
/// directionality and spread policy as `rate_base_to_quote`. DEFINED
/// as the served (post-clamp) vault rate divided by the NAV ratio, so
/// that `underlying * live convertToAssets(1 share)` reproduces the
/// SERVED `rate_base_to_quote` — the exact rate the model published,
/// clamps included — and a consumer can derive the vault price
/// atomically instead of trusting the signed vault rate. Crucially
/// this is the CLAMP-CONSISTENT underlying, not necessarily the raw
/// stock mark: when the model's no-cross clamp binds, this rate
/// reflects the clamped vault rate, so deriving from it cannot
/// reconstruct an unclamped (self-crossing) quote. When `base` is not
/// a vault token (`nav_ratio` zero) there is no separate underlying and
/// this equals `rate_base_to_quote`. (Decimal-float division is not a
/// bit-exact inverse of multiplication, so the derived vault rate
/// matches the served one to Float precision, not necessarily
/// bit-for-bit.) Frames from producers that predate the field decode
/// to the zero Float via `#[serde(default)]`; that all-zero sentinel
/// means "not carried", distinct from a real (always non-zero) stock
/// rate.
#[serde(default)]
pub underlying_rate_base_to_quote: WireFloat,
/// Directional rate for the vault's underlying ERC4626 asset, units of
/// underlying per 1 unit of `quote` — the reverse-direction counterpart
/// of [`Self::underlying_rate_base_to_quote`], mirroring
/// `rate_quote_to_base`. See that field for the vault-derivation and
/// zero-sentinel semantics.
#[serde(default)]
pub underlying_rate_quote_to_base: WireFloat,
}

/// A coherent point-in-time snapshot of multiple assets for a venue.
Expand Down Expand Up @@ -149,6 +186,15 @@ pub struct PriceFrame {
/// for the exact semantics and the zero sentinel.
#[serde(default)]
pub nav_ratio: WireU256,
/// Underlying-asset directional rate — see
/// [`Quote::underlying_rate_base_to_quote`] for the vault-derivation
/// and zero-sentinel semantics.
#[serde(default)]
pub underlying_rate_base_to_quote: WireFloat,
/// Reverse-direction underlying rate — see
/// [`Quote::underlying_rate_quote_to_base`].
#[serde(default)]
pub underlying_rate_quote_to_base: WireFloat,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -253,6 +299,8 @@ mod tests {
model_version: "0.1.0".into(),
source_ts_unix_ms: 1_714_999_970_000,
nav_ratio: nav_ratio_pattern(),
underlying_rate_base_to_quote: WireFloat::from_bytes([0x44; 32]),
underlying_rate_quote_to_base: WireFloat::from_bytes([0x45; 32]),
});
let buf = cbor(&frame);
let back: ServerFrame = from_cbor(&buf);
Expand All @@ -266,6 +314,14 @@ mod tests {
assert_eq!(p.rate_base_to_quote, WireFloat::from_bytes([0x42; 32]));
assert_eq!(p.rate_quote_to_base, WireFloat::from_bytes([0x43; 32]));
assert_eq!(p.nav_ratio, nav_ratio_pattern());
assert_eq!(
p.underlying_rate_base_to_quote,
WireFloat::from_bytes([0x44; 32])
);
assert_eq!(
p.underlying_rate_quote_to_base,
WireFloat::from_bytes([0x45; 32])
);
}
_ => panic!("wrong variant"),
}
Expand All @@ -283,11 +339,43 @@ mod tests {
expiry_unix_ms: 1_715_000_030_000,
source_ts_unix_ms: 1_714_999_970_000,
nav_ratio: nav_ratio_pattern(),
underlying_rate_base_to_quote: WireFloat::from_bytes([0x44; 32]),
underlying_rate_quote_to_base: WireFloat::from_bytes([0x45; 32]),
};
let back: Quote = from_cbor(&cbor(&quote));
assert_eq!(back.nav_ratio.0, nav_ratio_pattern().0);
}

#[test]
fn quote_round_trip_preserves_underlying_rates_exactly() {
// Distinct patterns per field so a swapped, truncated, or dropped
// underlying rate cannot pass. The underlying rates are the stock
// price the model priced against and downstream may derive the
// vault price from, so any lossy round-trip is a correctness bug.
let quote = Quote {
asset: "wtCOIN".into(),
chain_id: 8453,
base: WireAddress::from_bytes([0x11; 20]),
quote: WireAddress::from_bytes([0x22; 20]),
rate_base_to_quote: WireFloat::from_bytes([0x42; 32]),
rate_quote_to_base: WireFloat::from_bytes([0x43; 32]),
expiry_unix_ms: 1_715_000_030_000,
source_ts_unix_ms: 1_714_999_970_000,
nav_ratio: nav_ratio_pattern(),
underlying_rate_base_to_quote: WireFloat::from_bytes([0x44; 32]),
underlying_rate_quote_to_base: WireFloat::from_bytes([0x45; 32]),
};
let back: Quote = from_cbor(&cbor(&quote));
assert_eq!(
back.underlying_rate_base_to_quote,
WireFloat::from_bytes([0x44; 32])
);
assert_eq!(
back.underlying_rate_quote_to_base,
WireFloat::from_bytes([0x45; 32])
);
}

#[test]
fn price_frame_without_nav_ratio_decodes_to_zero_sentinel() {
// A frame from a producer that predates `nav_ratio` has no such
Expand All @@ -306,6 +394,8 @@ mod tests {
model_version: "0.1.0".into(),
source_ts_unix_ms: 1_714_999_970_000,
nav_ratio: nav_ratio_pattern(),
underlying_rate_base_to_quote: WireFloat::from_bytes([0x44; 32]),
underlying_rate_quote_to_base: WireFloat::from_bytes([0x45; 32]),
});
let value: ciborium::Value = from_cbor(&cbor(&frame));
let ciborium::Value::Map(mut entries) = value else {
Expand All @@ -324,6 +414,56 @@ mod tests {
}
}

#[test]
fn price_frame_without_underlying_rates_decodes_to_zero_default() {
// A frame from a producer that predates the underlying rates has no
// such map keys. Strip both from a freshly-encoded frame to get that
// exact wire shape, then decode: each must default to the all-zero
// WireFloat ("not carried"), leaving the rest of the frame intact.
let frame = ServerFrame::Price(PriceFrame {
asset: "COIN".into(),
venue: Venue::Bebop,
chain_id: 8453,
base: WireAddress::from_bytes([0x11; 20]),
quote: WireAddress::from_bytes([0x22; 20]),
rate_base_to_quote: WireFloat::from_bytes([0x42; 32]),
rate_quote_to_base: WireFloat::from_bytes([0x43; 32]),
expiry_unix_ms: 1_715_000_030_000,
model_version: "0.1.0".into(),
source_ts_unix_ms: 1_714_999_970_000,
nav_ratio: nav_ratio_pattern(),
underlying_rate_base_to_quote: WireFloat::from_bytes([0x44; 32]),
underlying_rate_quote_to_base: WireFloat::from_bytes([0x45; 32]),
});
let value: ciborium::Value = from_cbor(&cbor(&frame));
let ciborium::Value::Map(mut entries) = value else {
panic!("ServerFrame::Price must encode as a CBOR map");
};
let before = entries.len();
entries.retain(|(k, _)| {
!matches!(
k.as_text(),
Some("underlying_rate_base_to_quote" | "underlying_rate_quote_to_base")
)
});
assert_eq!(
entries.len(),
before - 2,
"both underlying rate keys must be present"
);
let back: ServerFrame = from_cbor(&cbor(&ciborium::Value::Map(entries)));
match back {
ServerFrame::Price(p) => {
assert_eq!(p.underlying_rate_base_to_quote, WireFloat::default());
assert_eq!(p.underlying_rate_quote_to_base, WireFloat::default());
// The rest of the frame is untouched by the missing keys.
assert_eq!(p.rate_base_to_quote, WireFloat::from_bytes([0x42; 32]));
assert_eq!(p.nav_ratio, nav_ratio_pattern());
}
_ => panic!("wrong variant"),
}
}

#[test]
fn client_frame_round_trip_subscribe() {
let frame = ClientFrame::Subscribe(SubscribeFrame {
Expand All @@ -343,12 +483,12 @@ mod tests {

#[test]
fn price_frame_wire_size_bounded() {
// Sanity check against wire bloat. ~291 bytes with the canonical
// pair (chain_id + two 20-byte addresses) and the 34-byte
// `nav_ratio` byte string plus their CBOR map keys; the 360
// ceiling leaves headroom for a long model_version (e.g. a git
// sha). A sharp regression past this means a stringly-typed
// field crept in.
// Sanity check against wire bloat. ~420 bytes with the canonical
// pair (chain_id + two 20-byte addresses), the 34-byte `nav_ratio`
// byte string, and the two 34-byte `underlying_rate_*` byte strings
// plus their CBOR map keys; the 500 ceiling leaves headroom for a
// long model_version (e.g. a git sha). A sharp regression past this
// means a stringly-typed field crept in.
let frame = ServerFrame::Price(PriceFrame {
asset: "COIN".into(),
venue: Venue::Bebop,
Expand All @@ -361,10 +501,12 @@ mod tests {
model_version: "0.1.0".into(),
source_ts_unix_ms: 1_714_999_970_000,
nav_ratio: nav_ratio_pattern(),
underlying_rate_base_to_quote: WireFloat::from_bytes([0x44; 32]),
underlying_rate_quote_to_base: WireFloat::from_bytes([0x45; 32]),
});
let buf = cbor(&frame);
assert!(
buf.len() < 360,
buf.len() < 500,
"frame ballooned to {} bytes; cbor = {:02x?}",
buf.len(),
buf
Expand Down
Loading