daitchain

SPUR-IC receipt format Spec v1

SPUR-IC ("Inference Contract") is the over-the-wire receipt protocol used between a DAIT-chain tenant and a host running an x/state_channel session. HTTP framing patterns from x402 and RFC 8555 (ACME), payloads domain-specific to verifiable compute under TEE attestation.

The goal: every inference response carries enough cryptographic evidence that the tenant can independently (a) confirm the host ran the agreed-upon model inside an attested TEE, (b) bill against channel escrow without trusting the host, and (c) build a court-admissible receipt chain that can be replayed on-chain in a dispute.

1. Transport

SPUR-IC rides on top of any HTTP/1.1, HTTP/2, HTTP/3, or WebSocket transport. TLS is REQUIRED in production but is NOT a substitute for the SPUR-IC signature; the host's tee_pubkey is the trust anchor, not the TLS leaf.

For server-streamed inference (SSE, chunked JSON, WebSocket frames) the final framing message MUST contain the headers below. Intermediate chunks MAY repeat them but only the last is authoritative.

2. Required headers

HeaderValue
X-DAIT-Version1
X-DAIT-Channel-Id64-char lowercase hex of the 32-byte channel ID
X-DAIT-Receiptbase64url (no padding) of the canonical-encoded Receipt
X-DAIT-Statebase64url of the latest ChannelState, co-signed by the user if turn >= 1
Content-Typeapplication/json or text/event-stream

Optional headers

HeaderValue
X-DAIT-Tee-Quote-Typenras-jwt | tdx | sev-snp
X-DAIT-Model-Idechoes ChannelState.model_id for log readability
X-DAIT-Call-Seqdecimal string of Receipt.call_seq
X-DAIT-Settle-Hintcooperative | dispute-pending

3. Receipt schema

interface Receipt {
  channel_id:    Uint8Array; // 32 bytes
  call_seq:      number;     // monotonic per channel, starts at 1
  request_hash:  Uint8Array; // 32 bytes; sha256(canonical(prompt || params || nonce))
  response_hash: Uint8Array; // 32 bytes; sha256(canonical(model_output))
  model_hash:    Uint8Array; // 32 bytes; identifier of weights actually loaded
  tokens_in:     number;
  tokens_out:    number;
  price_uDAIT:   bigint;     // total cost of THIS call in uDAIT
  tee_quote:     Uint8Array; // raw bytes of the attestation quote
  tee_pubkey:    Uint8Array; // 33-byte secp256k1 compressed pubkey bound in REPORTDATA
  timestamp:     number;     // unix milliseconds
  sig_host:      Uint8Array; // 64 bytes; secp256k1 over canonical_receipt_minus_sig
}

request_hash and response_hash MUST be computed over the canonical JSON form of the application-level payload. The host's adapter is responsible for stable JSON canonicalization (RFC 8785 / JCS) prior to hashing.

4. ChannelState schema

interface ChannelState {
  channel_id:           Uint8Array; // 32 bytes, matches Receipt.channel_id
  host_addr:            string;     // bech32 dait1...
  user_addr:            string;     // bech32 dait1...
  model_id:             string;     // free-form, lower-kebab; e.g. "qwen2.5-32b-instruct"
  max_calls:            number;
  deadline:             number;     // unix milliseconds
  escrow_amount:        bigint;     // uDAIT locked at channel open
  spent_amount:         bigint;     // sum of Receipt.price_uDAIT seen so far
  call_count:           number;     // length of receipts merkle tree leaves
  receipts_merkle_root: Uint8Array; // 32 bytes; sha256-merkle of canonical receipts
  turn_num:             number;     // 0 = initial state at open; ++ each cosign
  sig_user:             Uint8Array; // 64 bytes (empty until user cosigns turn)
  sig_host:             Uint8Array; // 64 bytes
}

A state with sig_user.len === 0 is provisional. The user MUST counter-sign before the state can be presented in a dispute.

5. Canonical encoding

Two equivalent encodings MUST be supported by every implementation:

Implementations SHOULD use protobuf for X-DAIT-Receipt / X-DAIT-State header values. JCS is permitted only for non-header diagnostic copies.

6. Signature scheme

Domain separation:

Receipts:      sig_host = secp256k1.sign(sha256("DAIT-RCPT-v1\x00"  || canonical_receipt_minus_sig))
User cosign:   sig_user = secp256k1.sign(sha256("DAIT-STATE-v1\x00" || canonical_state_minus_sigs))
Host on state: sig_host = secp256k1.sign(sha256("DAIT-STATE-v1\x00" || canonical_state_minus_sigs))

The leading domain tag is mandatory: it prevents any cross-protocol re-binding between Receipts and ChannelStates.

7. Verification rules (client-side)

For every inbound response a tenant MUST:

  1. Parse X-DAIT-Receipt, decode protobuf.
  2. Reject if channel_id does not match the open channel.
  3. Reject if call_seq is not exactly last_seen + 1.
  4. Verify sig_host against tee_pubkey.
  5. Verify the TEE quote (tee_quote) chains to a root the tenant trusts AND that the quote's report-data field hashes to tee_pubkey.
  6. Recompute request_hash from the request the tenant just sent; reject on mismatch.
  7. Recompute response_hash from the body bytes; reject on mismatch.
  8. Update local ChannelState: bump call_count, add price_uDAIT to spent_amount, append receipt hash to the merkle tree, increment turn_num, sign, ship X-DAIT-State back on the next request.

Any failed step is a non-recoverable fault. The tenant SHOULD checkpoint the last good state, persist the offending receipt, and initiate a dispute via MsgChallengeChannel.

8. Versioning

X-DAIT-Version is the wire version. Major bumps when canonical encoding or signature domain tags change. Implementations MUST refuse versions they do not understand rather than silently proceeding.

9. Reference implementations

Both expose a verifyReceipt(headers, requestBytes, responseBytes, channelState) function that returns either an updated ChannelState or a typed error.

See also