πŸ“’ Update (March 2026): Sails v1.0.0-beta.1πŸ›₯ is now released! The Sails Header (v1) described in this post has shipped as part of this release, alongside IDL V2, new interface ID and ReflectHash specs. This article was written in December 2025 when the specification was still in development β€” it is now live. β†’ Release notes on GitHub


If you’ve ever tried to build an indexer or a block explorer on top of Vara, you know the problem: you see a message, you see a payload, and that’s it. Without the program’s metadata β€” the Wasm binary, the source, some side-channel ABI file β€” that payload is just bytes. You can’t tell a Token::Transfer from a Game::Attack.

The usual answer is β€œbuild it per-dApp”. Every frontend hardcodes its own decoding logic. Every indexer needs execution context. It works, but it doesn’t scale, and it means the ecosystem stays fragmented by default.

Sails Header v1 is our answer to that. It’s a 16-byte prefix on every message β€” no runtime changes, no consensus involvement, purely userspace β€” that turns opaque payloads into self-describing service calls.

The Optimistic Identification Trick

Before getting into the wire format, it’s worth explaining the most useful thing this enables, because it’s not obvious from the spec alone.

The Interface ID β€” an 8-byte field in the header β€” is a structural fingerprint of a service, computed deterministically at compile time from its functions, events, and base services. The key property: every program that implements the same interface gets the same ID. Not β€œsimilar” β€” identical.

This means an off-chain tool doesn’t need to verify bytecode or query a registry to know what kind of contract it’s talking to. If messages are coming in with the FungibleToken Interface ID, you can optimistically treat it as a token. A wallet can render a β€œTransfer” screen immediately. An indexer can filter for all NFT transfers across the whole network with a single field comparison.

                   Vara network message arrives

      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚   Header (16 bytes)          β”‚     Payload      β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚
                    β–Ό
       Interface ID = 0xa3f29c11…      ← known fingerprint
                    β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚                 β”‚
           β–Ό                 β–Ό
      πŸ’³ Wallet          πŸ” Indexer
    renders "Transfer"  filters "NFT transfers"
    UI instantly        network-wide, no ABI needed

It’s duck typing, but for smart contracts β€” and it works without any on-chain coordination.

What the Header Actually Looks Like

Each Sails message starts with a fixed 16-byte preamble:

Byte  0    1    2    3    4    5    6    7    8    9   10   11   12   13   14   15
     β”Œβ”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
     β”‚ 47 β”‚ 4D β”‚ 01 β”‚ 10 β”‚     Interface ID  (8 bytes)    β”‚ Entry ID β”‚  Rt  β”‚  00  β”‚
     β”‚'G' β”‚'M' β”‚ v1 β”‚len β”‚                                β”‚ (2 bytes)β”‚  Idx β”‚  rsv β”‚
     β””β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜
      ◄────────────►       ◄──────────────────────────────────────────────────────────►
          Magic                         routing fields                  then payload β†’
  • Magic GM β€” parsers skip non-Sails messages instantly
  • Interface ID β€” which service (e.g. FungibleToken, DaoVoting)
  • Entry ID β€” which method within that service
  • Route Index β€” which instance, if a program hosts multiple services
  • Reserved byte β€” always 0x00 in v1; reserved for future versions

Routing Gets Cheaper

There’s an underrated benefit on the contract side too. Here’s what routing looked like before versus now:

Before: string matching
─────────────────────────────────────────────────────────────
  payload β†’ read bytes β†’ allocate string β†’ strcmp()
                                               β”‚
                               "VFT.Transfer"? β†’ handler A
                               "VFT.Approve"?  β†’ handler B
                               "DAO.Vote"?     β†’ handler C

  Cost: heap allocations + byte-by-byte comparisons + typo risk


After: integer dispatch
─────────────────────────────────────────────────────────────
  header β†’ read 8 bytes (interface_id)
         β†’ read 2 bytes (entry_id)
         β†’ jump table

  0xa3f2… + 0x0002  ──►  handler B

  Cost: two integer comparisons, O(1), zero allocations

No string vulnerabilities, no gas wasted on parsing, no typos in method names causing silent failures.

Route Inference and the Zero Sentinel

The route_id field has a subtle design worth knowing if you’re building tooling.

0x00 isn’t β€œthe first route” β€” it’s a sentinel that means figure it out if there’s no ambiguity. If the target program has exactly one instance of the given interface_id, the receiver resolves the route automatically. If there are multiple instances, the message is rejected as ambiguous. This sidesteps a footgun where zero would silently route to the wrong service.

Non-zero values are explicit: they map to named route instances via a per-program manifest. The spec deliberately leaves that manifest format up to implementations β€” it can be embedded in the IDL, distributed alongside the binary, or hardcoded in the client.

One consequence for off-chain tools: when you see route_id = 0x00, you can’t assume there’s only one service. Check first.

How the Interface ID Is Computed

The fingerprint is built from three layers, hashed together with Keccak256 at compile time:

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  FUNCTIONS  (sorted lexicographically)                   β”‚
  β”‚                                                          β”‚
  β”‚  transfer(to: ActorId, amount: u128) β†’ Result<(), Error> β”‚
  β”‚    β†’ type=Command, name, args via ReflectHash, T + E     β”‚
  β”‚                                                          β”‚
  β”‚  balance_of(account: ActorId) β†’ u128                     β”‚
  β”‚    β†’ type=Query, name, args via ReflectHash, return type β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  EVENTS                                                  β”‚
  β”‚                                                          β”‚
  β”‚  enum Event { Transfer { from, to, value }, Approval {}} β”‚
  β”‚  adding a new variant changes the ID β†’ signals upgrade   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  BASE SERVICES                                           β”‚
  β”‚                                                          β”‚
  β”‚  extends BaseService  β†’  mix in its Interface ID         β”‚
  β”‚  proves which exact version of the base is implemented   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚
                           β–Ό
                  Keccak256( F β€– E β€– B )
                           β”‚
                      take [0..8]
                           β”‚
                           β–Ό
               Interface ID = 0xa3f29c11e8d04f02

A few details worth noting:

  • Result<T, E> β€” both T and E are hashed. Error handling is part of the interface contract, not an afterthought.
  • Protocol wrappers like CommandReply<T> are stripped before hashing β€” the ID reflects business logic, not transport.
  • Base service IDs are mixed in recursively, creating a chain of trust in 8 bytes.
  • The service name is deliberately excluded. You can rename a service without breaking the wire protocol β€” the ID only changes when the actual structure changes.

That last point is an intentional tradeoff: stability over nominality. Two services named differently but structurally identical will share an ID. Whether that’s a feature or a footgun depends on your use case, but for tooling it means you can’t infer the name from the ID alone β€” you still need the IDL for that.

Commands/queries and events also have independent entry_id spaces, both starting at zero. Keep that in mind when decoding: entry_id = 1 means different things depending on whether you’re looking at a function call or an event.

Zero Runtime Cost

For Rust: all of this happens in build.rs. The compiler embeds the IDs as constants. At runtime the program writes a static byte array. There’s no hashing at message time, no gas spent on it.

The Path Forward

The base header is fixed at 16 bytes, but header_len (byte 3) can go higher. Extensions use a TLV format β€” type_id (u8), flags (u8), length (u16), data β€” appended immediately after the base fields. Unknown type_ids are skipped by their declared length, so future extensions are forward-compatible by design. type_id = 0 is reserved and must never appear on the wire. The obvious first candidate is correlation IDs for tracing async message chains.

The reserved byte at offset 15 is always 0x00 in v1. Future versions may repurpose it β€” receivers should reject v1 headers where it’s non-zero.

The versioning and extension design was deliberate: define something stable enough to build tooling on, flexible enough not to need replacing in two years.

Key Technical Specs

FieldSizeDescription
Magic2 bytes0x47 0x4D (β€œGM”)
Version1 byte0x01
Header Len1 byteOffset to payload (default 0x10)
Interface ID8 bytesKeccak256(Functions β€– Events β€– BaseServices)[0..8]
Entry ID2 bytesLexicographically sorted method index
Route Index1 byteInstance identifier (0x00 = infer if unambiguous)
Reserved1 byteMust be 0x00 in v1

Ready to build? Check out the Sails Repository to get started.