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
98 changes: 98 additions & 0 deletions crates/nexum-runtime/src/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,101 @@ mod value_flow_smoke {
assert!(amount.amount.is_empty());
}
}

/// Bindgen smoke for the `nexum:intent` package, mirroring the value-flow
/// smoke above: no host consumer exists yet (the pool router lands later),
/// so the package compiles under test only, through a throwaway world that
/// imports the pool interface and, transitively, the types interface and
/// its value-flow dependency. The test names every generated type, case,
/// and field by its plain Rust spelling, and a dummy `pool` host impl pins
/// the three function signatures, so a keyword collision or an accidental
/// signature change fails this build rather than a downstream binding.
#[cfg(test)]
mod intent_smoke {
wasmtime::component::bindgen!({
inline: "
package nexum:intent-smoke;
world smoke {
import nexum:intent/pool@0.1.0;
}
",
path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"],
});

use nexum::intent::types::{
AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError,
};
use nexum::value_flow::types::Settlement;

struct DummyPool;

impl nexum::intent::pool::Host for DummyPool {
fn submit(&mut self, _venue: String, _body: Vec<u8>) -> Result<SubmitOutcome, VenueError> {
Err(VenueError::UnknownVenue)
}

fn status(
&mut self,
_venue: String,
_receipt: Vec<u8>,
) -> Result<IntentStatus, VenueError> {
Err(VenueError::UnknownVenue)
}

fn cancel(&mut self, _venue: String, _receipt: Vec<u8>) -> Result<(), VenueError> {
Err(VenueError::UnknownVenue)
}
}

#[test]
fn identifiers_bind_unescaped() {
use nexum::intent::pool::Host;

let _ = AuthScheme::Eip712;
let _ = AuthScheme::Eip1271;
let _ = AuthScheme::Presign;
let _ = AuthScheme::OffchainSig;
let _ = AuthScheme::Unsigned;

let header = IntentHeader {
gives: Vec::new(),
wants: Vec::new(),
valid_until: None,
settlement: Settlement::EvmChain(1),
authorisation: AuthScheme::Eip712,
};
assert!(header.gives.is_empty() && header.wants.is_empty());

let _ = IntentStatus::Pending;
let _ = IntentStatus::Open;
let _ = IntentStatus::Settled(None);
let _ = IntentStatus::Failed(FailReason {
code: String::new(),
detail: String::new(),
});
let _ = IntentStatus::Expired;
let _ = IntentStatus::Cancelled;

let tx = UnsignedTx {
chain_id: 1,
to: Vec::new(),
value: Vec::new(),
input: Vec::new(),
};
let _ = SubmitOutcome::Accepted(Vec::new());
let _ = SubmitOutcome::RequiresSigning(tx);

let _ = VenueError::InvalidBody(String::new());
let _ = VenueError::InvalidReceipt;
let _ = VenueError::Rejected(String::new());
let _ = VenueError::Denied(String::new());
let _ = VenueError::Unsupported(String::new());
let _ = VenueError::Unavailable(String::new());
let _ = VenueError::InternalError(String::new());

let mut pool = DummyPool;
assert!(pool.submit(String::new(), Vec::new()).is_err());
assert!(pool.status(String::new(), Vec::new()).is_err());
assert!(pool.cancel(String::new(), Vec::new()).is_err());
}
}
26 changes: 26 additions & 0 deletions wit/nexum-intent/pool.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package nexum:intent@0.1.0;

/// The strategy-module face of the intent core. The host is a router plus
/// a policy checkpoint: it resolves the venue id to the installed adapter,
/// has the adapter derive the header, runs guard policy on it, and only
/// then forwards the call. Bodies are opaque bytes at this boundary; typing
/// is recovered guest-side by venue SDK crates and host-side by the
/// adapter's header derivation. Bodies carry their own routing: there is no
/// chain parameter, a multichain venue's body schema names the chain and
/// the derived header's settlement field exposes the choice to policy.
interface pool {
use types.{intent-status, receipt, submit-outcome, venue-error};

/// Submit an opaque intent body to the named venue. Success is either
/// the venue's receipt or requires-signing: a transaction the host
/// must sign and send before the intent exists.
submit: func(venue: string, body: list<u8>) -> result<submit-outcome, venue-error>;

/// Report where a previously submitted intent is in its life.
status: func(venue: string, receipt: receipt) -> result<intent-status, venue-error>;

/// Ask the venue to withdraw an intent. Success means the venue
/// accepted the cancellation, not that settlement can no longer
/// happen: an already in-flight settlement may still win the race.
cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>;
}
132 changes: 132 additions & 0 deletions wit/nexum-intent/types.wit
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package nexum:intent@0.1.0;

/// The venue-neutral intent ontology: what a deal gives and wants, how the
/// venue authorizes it, and how its life at the venue is reported. Built on
/// the nexum:value-flow vocabulary so a submission is described the same
/// way to strategy modules, venue adapters, and guard policy. The package
/// deliberately does not depend on nexum:host: embedding the host fault
/// type in venue-error would pin this contract's freeze cadence to host
/// versioning, so venue-error carries its own transport cases.
///
/// Identifier hygiene follows the value-flow rule: every id is checked
/// against WIT keywords and the reserved words of the nine binding-target
/// languages, preferring a two-word kebab id wherever a single word is a
/// keyword anywhere (`unsigned` not `none`, a Python keyword;

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.

none is not a Python keyword — Python's keyword is None (capitalised). The real risk is in Rust: wit-bindgen title-cases variant names, so a case named none would generate a Rust identifier None that shadows Option::None in the enum's namespace. The rationale is correct but the language cited is wrong.

Suggested change
/// keyword anywhere (`unsigned` not `none`, a Python keyword;
/// keyword anywhere (`unsigned` not `none` (Rust: bindgen title-cases variants,
/// making `none``None` which shadows `Option::None`);

/// `internal-error` not `internal`, a Swift declaration keyword).
interface types {
use nexum:value-flow/types@0.1.0.{asset-amount, settlement};

/// How an intent is authorized at its venue.
variant auth-scheme {
/// An EIP-712 typed-data signature by host-held keys. The only
/// scheme that reaches the identity checkpoint at submit time.
eip712,
/// An EIP-1271 contract signature: consent happened on-chain when
/// the commitment was created, so the host signs nothing here.
eip1271,
/// A pre-signed authorization recorded at the settlement contract
/// ahead of submission.
presign,
/// A venue-defined off-chain signature scheme.
offchain-sig,
/// No authorization travels with the body.
unsigned,
}

/// The adapter-derived description of an intent body: the stable
/// ontology guard policy runs on. Policy has teeth on `gives` (what
/// leaves the user's control); `wants` is display-grade because the
/// host can rarely verify the counterparty's obligation and must not
/// pretend to.
record intent-header {
/// Value leaving the user's control.
gives: list<asset-amount>,
/// Value expected in return. Display-grade, not host-verified.
wants: list<asset-amount>,
/// Expiry in milliseconds since the Unix epoch, UTC; absent means
/// the venue's own default lifetime applies.
valid-until: option<u64>,
Comment on lines +46 to +48

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.

Unit mismatch with the block clock modules actually receive. Tick.epoch_s — the block-time value available to every strategy via on_block / on_tick — is in seconds (explicitly named _s; supervisor tests use 1_700_000_000). valid-until is in milliseconds, so a module comparing expiry against the current block time must divide by 1000 with no hint from either type. Either rename this field valid-until-ms to make the unit visible at the use site, or switch to seconds to match the block clock convention.

Suggested change
/// Expiry in milliseconds since the Unix epoch, UTC; absent means
/// the venue's own default lifetime applies.
valid-until: option<u64>,
/// Expiry in seconds since the Unix epoch, UTC; absent means
/// the venue's own default lifetime applies.
valid-until: option<u64>,

/// Where the deal settles.
settlement: settlement,
/// How the venue authorizes the intent.
authorisation: auth-scheme,
}

/// Venue-scoped stable identifier for a submitted intent (for CoW,
/// the 56-byte order UID). Opaque to the host and to policy.
type receipt = list<u8>;

/// Why an intent failed terminally, as reported by the venue.
record fail-reason {
/// Venue-scoped machine-readable code, stable enough for a
/// module to match on.
code: string,
/// Human-readable detail for logs and the consent surface.
detail: string,
}

/// Where an intent is in its life at the venue.
variant intent-status {
/// Accepted for processing but not yet live at the venue.
pending,
/// Live at the venue and eligible for settlement.
open,
/// Settled. The payload is venue-defined settlement proof (for an
/// EVM venue, typically the settlement transaction hash).
settled(option<list<u8>>),
Comment on lines +74 to +76

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 option wrapper is unexplained. Readers need to know when absence is expected (not just what Some means).

Suggested change
/// Settled. The payload is venue-defined settlement proof (for an
/// EVM venue, typically the settlement transaction hash).
settled(option<list<u8>>),
/// Settled. `some` carries venue-defined settlement proof (for an
/// EVM venue, typically the tx hash); `none` when the venue confirms
/// settlement but issues no on-chain proof (e.g. a fully off-chain venue).
settled(option<list<u8>>),

/// Terminally failed.
failed(fail-reason),
/// Reached its expiry without settling.
expired,
/// Withdrawn before settlement.
cancelled,
}

/// An EVM call the host must sign and send for the intent to exist
/// on-chain. The adapter only describes the call: the host routes it
/// through the guard's host-signed class, fills the gas and fee
/// fields, and signs, so adapters still structurally cannot move
/// value. Always a call to existing code; adapters cannot deploy.
record unsigned-tx {
/// Chain the transaction must land on.
chain-id: u64,
/// 20-byte address of the contract to call.
to: list<u8>,
/// Native value, big-endian unsigned; an empty list is zero.
value: list<u8>,
/// ABI-encoded calldata.
input: list<u8>,
}

/// What a successful submit produced. A variant from day one: an
/// on-chain-settlement venue (an ethflow-style order) has no receipt
/// to give until a transaction is signed, and bolting that on later
/// would break every deployed module.
variant submit-outcome {
/// The venue holds the intent; the receipt is its stable id.
accepted(receipt),
/// Settlement requires a host-signed on-chain transaction first.
requires-signing(unsigned-tx),
}

/// Failure of a pool or adapter call.
variant venue-error {
/// No installed adapter answers to the venue id.
unknown-venue,
/// Body bytes failed to decode against the venue's published
/// schema: malformed bytes or an unknown outer version.
invalid-body(string),
/// The receipt was not issued by this venue or is malformed.
invalid-receipt,
/// The venue refused the intent under its own rules.
rejected(string),
/// Guard policy refused the egress before it reached the venue.
denied(string),
Comment on lines +123 to +124

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.

denied is a host policy decision, not a venue response — it arrives over the same error channel as adapter errors (invalid-body) and venue refusals (rejected), making it easy to accidentally retry. The caller MUST NOT retry on denied; the other error variants may warrant retry.

Suggested change
/// Guard policy refused the egress before it reached the venue.
denied(string),
/// Guard policy refused the egress before it reached the venue;
/// this is a host decision, not a venue response. Callers must not retry.
denied(string),

/// The venue or adapter does not support the operation.
unsupported(string),
/// Transport or venue infrastructure failure; retry later.
unavailable(string),
/// Adapter or router failure that is not the caller's fault.
internal-error(string),
}
}
Loading