Protocol Specification v0.6

Data Model

The v0.6 data model separates transport identity, durable call identity (opens in a new tab) and original event identity (opens in a new tab). A message UUID identifies signed transport bytes; a caller-qualified call identifier binds one complete admitted request; an original domain and event identify the financial obligation. A fresh UUID does not create permission to settle an original event again.

Four identities

A fresh UUID changes only the transport key; the call, event and cut identities stay the same.

A fresh UUID changes only the transport key; the call, event and cut identities stay the same. One signed request yields four identities from different fields. The transport key is the full UUID, deployment, source domain and principal of the Core. The call identifier is the principal and the call sequence word, the original event is the origin domain and origin event words, and the commit cut is the authority epoch of the Core and the revision word. A resend that changes only the UUID gets a new transport key and keeps the other three. Identity Fresh UUID Identifies TransportKey 1 new key 5 the exact signed bytes Core 11 uuid 16 bytes Core 3 deployment Core 4 source_domain Core 6 principal CallId 2 same one admitted request and its outcome Core 6 principal word 2 call_sequence OriginKey 3 same the financial obligation word 6 origin_domain word 7 origin_event CommitCut 4 same the state the request expects Core 8 authority_epoch word 4 revision
  1. 1

    Transport key

    The authenticated sender and the full 16-byte UUID. Other bytes under a bound key receive REPLAY_CONFLICT.

  2. 2

    Call identifier

    The authenticated principal is the namespace, and the caller supplies the sequence. A retry of the exact admitted call returns the existing outcome without another effect.

  3. 3

    Original event

    The origin domain and event identify the financial obligation, independent of the route. They do not depend on the transport message.

  4. 4

    Commit cut

    The authority epoch and the revision of the signed cut. Together they name the authoritative state the request expects for a new effect.

  5. 5

    Fresh UUID

    A resend that changes only the UUID binds a new transport key. It keeps the same call, event and cut, so it gives no permission to settle the event again.

  • The field a fresh UUID changes

Schematic: Core n is a Core field tag and word n an operand position of an ordinary, legal or spend call.

Figure 1: Fields bound by the transport key, call identifier, original event and commit cut, and what a fresh UUID changes

Envelope and identity#

NormativeBytes, test vectors and proofs

ValueRepresentationMeaning
version, purposeUnsigned varintsNegotiated wire version and signing purpose
Deployment, domains, principal, key, epoch, scope64-bit registered handlesConfigured identity and authority bindings
issued_seconds, ttl_secondsFixed64 secondsSigned delivery validity, separate from an appeal deadline
uuid16 bytesUUID version 4 with the RFC 4122 variant
Family and opcodeNumeric discriminatorsSelect the message body and exact operand layout
Revision and authority epochSigned operation cutExpected authoritative state for a new effect
Origin domain and eventExact pairRoute-independent identity of a financial obligation

An authority MUST bind a call identifier to the authenticated caller namespace and the entire admitted request, including its operation and any lock credential. The transport UUID and the original event key have separate meanings. Domains and principals carry no CAIP-2 (opens in a new tab) text or display address in this wire format.

The schema declares the Core fields with their tags and wire types. The registered handles are fixed64 fields and the UUID is a byte string:

Protocol BuffersCoreproto/oip.protoL15-L44Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
message Core {  optional uint64 version = 1;  optional uint64 purpose = 2;  optional fixed64 deployment = 3;  optional fixed64 source_domain = 4;  optional fixed64 target_domain = 5;  optional fixed64 principal = 6;  optional fixed64 key_id = 7;  optional fixed64 authority_epoch = 8;  optional fixed64 issued_seconds = 9;  optional fixed64 ttl_seconds = 10;  optional bytes uuid = 11;  optional uint64 family = 12;  optional uint64 primary = 13;  optional uint64 secondary = 14;  optional uint64 signature_scheme = 15;  // Exactly one member is present, and its tag is 15 plus the family.  oneof body {    Operation state_sync = 16;    Operation regulatory_action = 17;    Operation lock_management = 18;    Operation query = 19;    Operation ack = 20;    Operation heartbeat = 21;    Operation governance = 22;  }  optional uint64 atomicity = 23;  optional uint64 priority = 24;  optional fixed64 route_scope = 25;}

Transport key#

The receiver keys retained transport bytes by deployment, source domain, principal and the complete 16-byte UUID:

Transport key of a received framesrc/wire/admission.rsL112-L137Source (opens the lines on GitHub in a new tab)
#[derive(    Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,)]pub struct TransportKey {    deployment: u64,    source: u64,    principal: u64,    uuid: [u8; 16],}impl TransportKey {    pub fn for_core(core: &Core) -> Result<Self> {        let bad = WireError::InvalidFormat;        Ok(Self {            deployment: core.deployment.ok_or(bad)?,            source: core.source_domain.ok_or(bad)?,            principal: core.principal.ok_or(bad)?,            uuid: core                .uuid                .as_deref()                .ok_or(bad)?                .try_into()                .map_err(|_| bad)?,        })    }}

A shared vector row holds both languages to the whole UUID. Two UUIDs that differ only in their first octet give different keys:

TextComplete UUIDtests/vectors/wire-conformance.tsvL26-L26Source (opens the lines on GitHub in a new tab)

Columnsidoperationinputsexpectedevidence

complete-uuid-with-distinct-upper-octets	receive	uuid_a=000102030405400780090a0b0c0d0e0f;uuid_b=100102030405400780090a0b0c0d0e0f;low_octets_same=true	identity_keys_equal=false	authenticated_wire_message_id_def,accepted_core_bytes_preserve_all_original_fields

Call, event and cut identities#

The call identifier, the original event key and the commit cut (opens in a new tab) are separate types in the reference:

RustCall, event and cut identitiessrc/lib.rsL34-L54Source (opens the lines on GitHub in a new tab)
#[derive(    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,)]pub struct CallId {    pub namespace: Principal,    pub sequence: u64,}#[derive(    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,)]pub struct OriginKey {    pub domain: u64,    pub event: u64,}#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]pub struct CommitCut {    pub authority_epoch: u64,    pub revision: usize,}

Words, blobs and deadlines#

NormativeBytes, test vectors and proofs

Operation.words are unpacked fixed64 fields. Operation.blobs are length-delimited byte strings. Field Tables gives every position and accepted code. An optional value starts with an exact 0 or 1 presence word; a counted set has an exact count and strictly ascending members.

The schema declares the record once, and both generated bindings keep its tags, wire types and deadline alternatives:

Operationproto/oip.protoL46-L64Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
// An operation carries positional operands. The family of the enclosing body// member and the opcode fix the meaning and order of every entry in words and// blobs. The field-meaning table spec/operations.json lists, for each family// and opcode, the positions, enumerations and whether the reference// configuration accepts the operation.message Operation {  optional uint64 opcode = 1;  repeated fixed64 words = 2 [packed = false];  repeated bytes blobs = 3;  // The appeal deadline of a regulatory action (family 2). Operations of  // other families reject both members. In this version an absent member and  // appeal_deadline_indefinite both mean that no appeal deadline applies.  oneof appeal_deadline {    // Only the value true is canonical.    bool appeal_deadline_indefinite = 4;    // Unix time in UTC seconds.    fixed64 appeal_deadline_utc_seconds = 5;  }}

In Go the two deadline alternatives are wrapper types that carry tags 4 and 5:

GoDeadline alternatives in Gogo/wirepb/oip.pb.goL487-L495Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
type Operation_AppealDeadlineIndefinite struct {	// Only the value true is canonical.	AppealDeadlineIndefinite bool `protobuf:"varint,4,opt,name=appeal_deadline_indefinite,json=appealDeadlineIndefinite,oneof"`}type Operation_AppealDeadlineUtcSeconds struct {	// Unix time in UTC seconds.	AppealDeadlineUtcSeconds uint64 `protobuf:"fixed64,5,opt,name=appeal_deadline_utc_seconds,json=appealDeadlineUtcSeconds,oneof"`}

The operand reader takes a presence word only as 0 or 1, takes a set only with strictly ascending members, and requires every word to be consumed:

Presence words, sets and the end of the wordssrc/wire/operands.rsL165-L201Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
fn present(&mut self) -> Result<bool> {    match self.word()? {        0 => Ok(false),        1 => Ok(true),        _ => Err(BAD),    }}fn optional_origin(&mut self) -> Result<Option<OriginKey>> {    if self.present()? {        Ok(Some(self.origin()?))    } else {        Ok(None)    }}fn optional_word(&mut self) -> Result<Option<u64>> {    if self.present()? {        Ok(Some(self.word()?))    } else {        Ok(None)    }}fn take(&mut self, count: usize) -> Result<&[u64]> {    let (items, remaining) = self.0.split_at_checked(count).ok_or(BAD)?;    self.0 = remaining;    Ok(items)}fn set(&mut self) -> Result<BTreeSet<u64>> {    let count = self.size()?;    let items = self.take(count)?;    if items.windows(2).any(|pair| pair[0] >= pair[1]) {        return Err(BAD);    }    Ok(items.iter().copied().collect())}fn end(&self) -> Result<()> {    if self.0.is_empty() { Ok(()) } else { Err(BAD) }}

Amounts use exactly 32 big-endian bytes. The reference product accepts only the unsigned 64-bit range, so the first 24 bytes MUST be zero. Amount units are base units, not whole tokens. A decoder must reject a value outside the product range rather than truncate it.

The amount blob decodes only at exactly 32 bytes, and the conversion to the product range refuses any nonzero byte among the first 24:

Amountssrc/wire/codec.rsL374-L388Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub struct Unsigned256(pub [u8; 32]);impl Unsigned256 {    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {        Ok(Self(bytes.try_into().map_err(|_| BAD)?))    }    pub fn product_amount(self) -> Result<u64> {        if self.0[..24].iter().any(|&byte| byte != 0) {            return Err(BAD);        }        Ok(u64::from_be_bytes(self.0[24..].try_into().unwrap()))    }}
Appeal deadline formEncoded formProduct meaning in this edition
AbsentNeither alternative appearsNo appeal deadline
Indefiniteappeal_deadline_indefinite = trueNo appeal deadline
Finiteappeal_deadline_utc_secondsUTC seconds since the Unix epoch

The absent and indefinite forms remain different bytes even though this product gives them the same meaning. Non-regulatory layouts reject either deadline alternative.

Two shared vector rows pin both rules in Rust and Go. An all-ones 32-byte amount survives the wire and fails the product conversion, and the three deadline forms give three distinct encodings (distinct_bytes) that each decode back to their own form (distinct_semantics):

TextAmount range and deadline formstests/vectors/wire-conformance.tsvL5-L6Source (opens the lines on GitHub in a new tab)

Columnsidoperationinputsexpectedevidence

unsigned256-lossless-before-product-range-check	roundtrip	amount_hex=ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;length=32;product_amount_max=18446744073709551615	wire_roundtrip=true;product_conversion_rejected=true	exact_32_octet_unsigned_amount_has_no_accepted_alias,native_envelope_matches_defthree-deadline-presence-states	roundtrip	cases=absent,indefinite,finite_0	distinct_bytes=true;distinct_semantics=true	exact_deadline_roundtrip,wellformed_wire_body_roundtrip

Transfer binding and current authority#

NormativeBytes, test vectors and proofs

A transfer binds its source event, asset, funding owner, destination, recipient, amount and original authority epoch (opens in a new tab). A lock credential separately names the asset, current holder and generation. Owner and lock holder are different roles. The consuming endpoint checks the current authority and exact binding at the commit cut.

The binding names the original event, asset, owner, destination, recipient and amount. The lock credential is a separate record with its own holder, generation and epoch:

Binding and lock credentialsrc/lock_finality.rsL23-L58Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]pub struct Binding {    pub origin: OriginKey,    pub asset: AssetId,    pub owner: Principal,    pub destination: EndpointId,    pub recipient: Principal,    pub amount: u64,}impl Binding {    pub fn source(self) -> Account {        Account {            endpoint: self.origin.domain,            owner: self.owner,            asset: self.asset,        }    }    pub fn destination(self) -> Account {        Account {            endpoint: self.destination,            owner: self.recipient,            asset: self.asset,        }    }}#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]pub struct Credential {    pub asset: AssetId,    pub origin: OriginKey,    pub holder: Principal,    pub generation: u64,    pub epoch: u64,}

The source balance is the authoritative unallocated source pool at the selected domain and asset. Changing the recovery owner does not choose another source pool. Current and historical reads use the pool from their own checked cut.

The reference keeps four identities distinct:

  • Transport key: the authenticated sender and the full UUID.
  • Call identifier: the authenticated caller namespace and the call sequence.
  • Original event: the source domain and the source event.
  • Commit cut: the authority epoch and the product revision.

Ordinary, legal and spend calls read the call identifier, the commit cut and the original event from one shared prefix. In Rust the call namespace and the cut's epoch come from the signed Core, and the call sequence, the revision and the original event come from the words. Go reads the same words in the same order and takes the namespace from the Core; it reads the cut's epoch from the Core where it needs it, as in the admission (opens in a new tab) scope check:

Call prefixsrc/wire/operands.rsL322-L360Highlighted lines are discussed in the textSource (opens the lines on GitHub in a new tab)
fn native<'a>(core: &Core, op: &'a Operation) -> Result<(CallId, CallPayload, Words<'a>)> {    let mut words = Words(&op.words);    let message_id = words.word()?;    let caller = core.principal.ok_or(BAD)?;    let id = CallId {        namespace: caller,        sequence: words.word()?,    };    let session = words.word()?;    let cut = CommitCut {        authority_epoch: core.authority_epoch.ok_or(BAD)?,        revision: words.size()?,    };    let origin_epoch = words.word()?;    let binding = Binding {        origin: words.origin()?,        asset: words.word()?,        owner: words.word()?,        destination: words.word()?,        recipient: words.word()?,        amount: amount(op.blobs.first().ok_or(BAD)?)?,    };    Ok((        id,        CallPayload {            message_id,            origin_epoch,            endpoint: core.target_domain.ok_or(BAD)?,            caller,            session,            cut,            binding,            credential: None,            versions: Vec::new(),            command: Command::PublishPrimary,        },        words,    ))}

Supporting evidence#

NormativeBytes, test vectors and proofs

An authenticated source attestation, legal proof or owner endorsement must bind the same canonical fields that the product consumes. A host adapter supplies validated proof objects; it cannot replace the request with different data. The immutable origin epoch stays distinct from the current caller's session and epoch.

Architecture-level types#

DesignCarried from v0.5, without bytes or test vectors in this version

The display-oriented TypeScript interfaces of v0.5 are design descriptions. They are not the v0.6 serialized schema. Address resolution (opens in a new tab), Canton (opens in a new tab) party mappings and general condition expressions (opens in a new tab) have no wire fields in this edition. Use Migration from v0.5 when translating an existing design.


Edition history#

  • 0.6.0: Added this edition. The v0.5 specification remains available at its original addresses.