Skip to content
Merged
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: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 7 additions & 15 deletions crates/cow-venue/src/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,15 @@ mod tests {
use super::*;
use videre_test::{CodecVectors, Expectation};

use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource};
use crate::order::{BuyToken, SellToken};

fn order_body() -> OrderBody {
OrderBody {
sell_token: [0x11; 20],
buy_token: [0x22; 20],
receiver: None,
sell_amount: [0x01; 32],
buy_amount: [0x02; 32],
valid_to: 1_700_000_000,
app_data: [0x44; 32],
fee_amount: [0u8; 32],
kind: OrderKind::Sell,
partially_fillable: true,
sell_token_balance: SellTokenSource::Erc20,
buy_token_balance: BuyTokenDestination::Erc20,
}
OrderBody::sell(SellToken([0x11; 20]), [0x01; 32])
.for_at_least(BuyToken([0x22; 20]), [0x02; 32])
.valid_to(1_700_000_000)
.app_data([0x44; 32])
.partially_fillable()
.build()
}

fn composable_body() -> ComposableBody {
Expand Down
1 change: 1 addition & 0 deletions crates/cow-venue/src/classification_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ struct Document {

/// Why the shipped classification data could not be turned into a table.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ClassificationError {
/// The TOML did not parse or a field had the wrong type.
#[error("classification data is not valid TOML: {0}")]
Expand Down
26 changes: 11 additions & 15 deletions crates/cow-venue/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ mod tests {
submitted: SubmitLog,
}

impl videre_sdk::client::sealed::SealedTransport for SpyClient {}

impl VenueTransport for SpyClient {
async fn quote(&self, _venue: &VenueId, _body: Vec<u8>) -> Result<Quotation, VenueFault> {
unreachable!("quote not exercised")
Expand Down Expand Up @@ -78,21 +80,15 @@ mod tests {

fn sample_body() -> CowIntentBody {
use crate::body::CowIntent;
use crate::order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource};
CowIntentBody::V1(CowIntent::Order(OrderBody {
sell_token: [0x11; 20],
buy_token: [0x22; 20],
receiver: None,
sell_amount: [0x01; 32],
buy_amount: [0x02; 32],
valid_to: 1_700_000_000,
app_data: [0x44; 32],
fee_amount: [0u8; 32],
kind: OrderKind::Sell,
partially_fillable: true,
sell_token_balance: SellTokenSource::Erc20,
buy_token_balance: BuyTokenDestination::Erc20,
}))
use crate::order::{BuyToken, OrderBody, SellToken};
CowIntentBody::V1(CowIntent::Order(
OrderBody::sell(SellToken([0x11; 20]), [0x01; 32])
.for_at_least(BuyToken([0x22; 20]), [0x02; 32])
.valid_to(1_700_000_000)
.app_data([0x44; 32])
.partially_fillable()
.build(),
))
}

#[test]
Expand Down
4 changes: 3 additions & 1 deletion crates/cow-venue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ pub use body::{CowIntent, CowIntentBody};
#[cfg(feature = "body")]
pub use composable::ComposableBody;
#[cfg(feature = "body")]
pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource};
pub use order::{
BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource,
};

#[cfg(feature = "client")]
pub use classification::{ClassificationTable, classify, is_already_submitted};
Expand Down
229 changes: 229 additions & 0 deletions crates/cow-venue/src/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//! marker enums are canonical wire forms, not on-chain keccak markers,
//! so the adapter, not this type, owns the projection to and from chain.

use core::marker::PhantomData;

use borsh::{BorshDeserialize, BorshSerialize};

/// A 20-byte EVM address in wire form.
Expand All @@ -17,6 +19,28 @@ pub type Address = [u8; 20];
/// A 256-bit amount as its 32-byte big-endian representation.
pub type U256 = [u8; 32];

/// The token an order sells, typed so a builder call cannot swap
/// sides with the buy token.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SellToken(pub Address);

impl From<Address> for SellToken {
fn from(address: Address) -> Self {
Self(address)
}
}

/// The token an order buys, typed so a builder call cannot swap sides
/// with the sell token.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BuyToken(pub Address);

impl From<Address> for BuyToken {
fn from(address: Address) -> Self {
Self(address)
}
}

/// Which side of the trade is fixed.
#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum OrderKind {
Expand Down Expand Up @@ -79,6 +103,162 @@ pub struct OrderBody {
pub buy_token_balance: BuyTokenDestination,
}

impl OrderBody {
/// Start a sell order: `amount` of `token` is the fixed side.
#[must_use]
pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder<NeedsBuy> {
OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32])
}

/// Start a buy order: `amount` of `token` is the fixed side.
#[must_use]
pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder<NeedsSell> {
OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount)
}
}

/// Builder state: the buy-side limit is unset.
pub enum NeedsBuy {}
/// Builder state: the sell-side limit is unset.
pub enum NeedsSell {}
/// Builder state: the expiry is unset.
pub enum NeedsValidTo {}
/// Builder state: every required field is set.
pub enum Ready {}

/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or
/// [`OrderBody::buy`] fixes the kind and its side, the counter-side
/// limit and the expiry are compile-time required, and the optionals
/// default (self-receive, zero `app_data` and `fee_amount`,
/// fill-or-kill, ERC-20 balances).
#[derive(Clone, Debug)]
pub struct OrderBuilder<S> {
body: OrderBody,
state: PhantomData<S>,
}

impl<S> OrderBuilder<S> {
const fn start(
kind: OrderKind,
sell_token: Address,
sell_amount: U256,
buy_token: Address,
buy_amount: U256,
) -> Self {
Self {
body: OrderBody {
sell_token,
buy_token,
receiver: None,
sell_amount,
buy_amount,
valid_to: 0,
app_data: [0; 32],
fee_amount: [0; 32],
kind,
partially_fillable: false,
sell_token_balance: SellTokenSource::Erc20,
buy_token_balance: BuyTokenDestination::Erc20,
},
state: PhantomData,
}
}

const fn into_state<T>(self) -> OrderBuilder<T> {
OrderBuilder {
body: self.body,
state: PhantomData,
}
}
}

impl OrderBuilder<NeedsBuy> {
/// Demand at least `amount` of `token` in return.
#[must_use]
pub const fn for_at_least(
mut self,
token: BuyToken,
amount: U256,
) -> OrderBuilder<NeedsValidTo> {
self.body.buy_token = token.0;
self.body.buy_amount = amount;
self.into_state()
}
}

impl OrderBuilder<NeedsSell> {
/// Spend at most `amount` of `token`.
#[must_use]
pub const fn for_at_most(
mut self,
token: SellToken,
amount: U256,
) -> OrderBuilder<NeedsValidTo> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OrderBuilder<S> typestate (three phantom marker types plus duplicated for_at_least/for_at_most methods) may be more machinery than the actual constraint needs. OrderBody's required fields (sell/buy side+amount, valid_to) are flat and mutually independent — nothing about CoW's wire format requires a specific call order, only that all required fields end up set. Typestate pays off when order-of-calls is semantically constrained (e.g. must-sign-before-submit); here it's really just "don't forget a required field," which a simpler Result-returning builder or required-args-in-constructor + optional setters would give with less API surface. Not a bug, just worth a second look at whether the complexity earns its keep.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed still present at HEAD: OrderBuilder<S> with PhantomData, four state markers (NeedsBuy, NeedsSell, NeedsValidTo, Ready), per-state impl blocks, and build gated on Ready. Tracked in #545 (M8).

Agreed the invariant here is "do not forget a required field" rather than a genuine call-order constraint, so the state machine is expressing something stronger than the domain requires. One refinement to the alternatives: of the two you list, required-args-in-constructor plus optional setters is the one worth pursuing, because it keeps the same compile-time completeness guarantee while dropping the phantom states and the duplicated method surface. The Result-returning builder trades that guarantee for runtime failure, which is the wrong direction for an order type where a missing required field is exactly what you want caught at compile time. #545 frames it as that choice, with "keep it and say why in the rustdoc" as the explicit third option so this does not get re-opened later.

self.body.sell_token = token.0;
self.body.sell_amount = amount;
self.into_state()
}
}

impl OrderBuilder<NeedsValidTo> {
/// Expire at `valid_to` (Unix seconds).
#[must_use]
pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder<Ready> {
self.body.valid_to = valid_to;
self.into_state()
}
}

impl OrderBuilder<Ready> {
/// Deliver the buy token to `receiver` instead of the owner.
#[must_use]
pub const fn receiver(mut self, receiver: Address) -> Self {
self.body.receiver = Some(receiver);
self
}

/// Set the 32-byte on-chain app-data hash.
#[must_use]
pub const fn app_data(mut self, app_data: [u8; 32]) -> Self {
self.body.app_data = app_data;
self
}

/// Set the fee taken in the sell token.
#[must_use]
pub const fn fee_amount(mut self, fee_amount: U256) -> Self {
self.body.fee_amount = fee_amount;
self
}

/// Allow the order to fill partially.
#[must_use]
pub const fn partially_fillable(mut self) -> Self {
self.body.partially_fillable = true;
self
}

/// Source the sell token from `source`.
#[must_use]
pub const fn sell_token_balance(mut self, source: SellTokenSource) -> Self {
self.body.sell_token_balance = source;
self
}

/// Deliver the buy token to `destination`.
#[must_use]
pub const fn buy_token_balance(mut self, destination: BuyTokenDestination) -> Self {
self.body.buy_token_balance = destination;
self
}

/// The finished body.
#[must_use]
pub const fn build(self) -> OrderBody {
self.body
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -104,6 +284,55 @@ mod tests {
}
}

#[test]
fn sell_builder_matches_the_literal() {
let built = OrderBody::sell(SellToken([0x11; 20]), sample().sell_amount)
.for_at_least(BuyToken([0x22; 20]), [0xff; 32])
.valid_to(0xffff_ffff)
.receiver([0x33; 20])
.app_data([0x44; 32])
.build();
assert_eq!(built, sample());
}

#[test]
fn buy_builder_fixes_the_buy_side() {
let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32])
.for_at_most(SellToken([0x11; 20]), [0x01; 32])
.valid_to(100)
.partially_fillable()
.sell_token_balance(SellTokenSource::External)
.buy_token_balance(BuyTokenDestination::Internal)
.fee_amount([0x05; 32])
.build();
assert_eq!(built.kind, OrderKind::Buy);
assert_eq!(built.sell_token, [0x11; 20]);
assert_eq!(built.buy_token, [0x22; 20]);
assert_eq!(built.sell_amount, [0x01; 32]);
assert_eq!(built.buy_amount, [0xff; 32]);
assert_eq!(built.valid_to, 100);
assert!(built.partially_fillable);
assert_eq!(built.sell_token_balance, SellTokenSource::External);
assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal);
assert_eq!(built.fee_amount, [0x05; 32]);
assert_eq!(built.receiver, None);
}

#[test]
fn builder_defaults_are_the_wire_defaults() {
let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32])
.for_at_least(BuyToken([0x22; 20]), [0x02; 32])
.valid_to(1)
.build();
assert_eq!(built.receiver, None);
assert_eq!(built.app_data, [0; 32]);
assert_eq!(built.fee_amount, [0; 32]);
assert!(!built.partially_fillable);
assert_eq!(built.sell_token_balance, SellTokenSource::Erc20);
assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20);
assert_eq!(built.kind, OrderKind::Sell);
}

#[test]
fn order_body_borsh_round_trips() {
let body = sample();
Expand Down
3 changes: 3 additions & 0 deletions crates/nexum-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ tokio.workspace = true
# Task lifecycle and graceful shutdown; the sole crate that raw-spawns
# tokio tasks. Every engine task routes through its executor.
nexum-tasks = { path = "../nexum-tasks" }
# Single-source capability and fault-label vocabularies; the registry's
# core interface set is emitted from its table.
nexum-world = { path = "../nexum-world" }

# Manifest parsing.
serde.workspace = true
Expand Down
4 changes: 4 additions & 0 deletions crates/nexum-runtime/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,8 @@ mod tests {
linked: Arc<AtomicUsize>,
}

impl crate::sealed::SealedRuntime for ExtPreset {}

impl RuntimePreset for ExtPreset {
type Types = CoreRuntime;
type ChainBuilder = ProviderPoolBuilder;
Expand Down Expand Up @@ -740,6 +742,8 @@ mod tests {
logs: LogPipeline,
}

impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {}

impl RuntimePreset for PrebuiltLogsPreset {
type Types = CoreRuntime;
type ChainBuilder = ProviderPoolBuilder;
Expand Down
1 change: 1 addition & 0 deletions crates/nexum-runtime/src/host/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ impl Liveness {

/// A guest call failed outside the component's typed error space.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ActorFault {
/// The pre-call refuel failed; the guest was never entered.
#[error("refuel failed: {0}")]
Expand Down
Loading
Loading