Skip to content

fix(oracle): read and write oracle messages as standalone TLV records - #177

Merged
bennyhodl merged 2 commits into
masterfrom
oracle-tlv-record
Aug 13, 2026
Merged

fix(oracle): read and write oracle messages as standalone TLV records#177
bennyhodl merged 2 commits into
masterfrom
oracle-tlv-record

Conversation

@bennyhodl

Copy link
Copy Markdown
Owner

Summary

OracleAnnouncement is the one type in the DLC specification that has its own record type, is nested inside another message, and travels on its own. Those two roles pull in opposite directions: nested, its Writeable impl must write the body alone because SingleOracleInfo wraps the field in write_as_tlv; standalone, it needs the header because nothing else supplies one. One impl cannot do both, so there was no supported way to read an announcement that arrived by itself and every consumer hand-rolled the same two BigSize reads to strip the header.

This adds the missing second entry point instead of changing the impl. The on-disk contract format is untouched, so there is no data migration.

Changes

  • TlvType gives a record its type as an associated constant, which a reader holding only bytes can compare against. TlvRecord is a blanket impl over TlvType + Readable + Writeable supplying to_tlv_bytes, from_tlv_bytes, the hex forms, and from_tlv_bytes_or_legacy for stores holding a mix of both encodings. Declaring a record is one line of impl_dlc_tlv_record!, which derives Type from the same constant so the two cannot drift.
  • read_as_tlv now enforces the header. The record type must match the type being read, and the body must consume exactly its declared length. It previously discarded both, so a mistyped or truncated record decoded into a plausible value and left the reader mid-record, corrupting everything after it.
  • Fixed a real interop bug in kormir's nostr events. Announcements and attestations were published in the body form, unreadable by other DLC clients on the relay. They now carry the TLV form; the reader takes either, since relay history cannot be rewritten.
  • Removed write_oracle_event, which duplicated write_as_tlv behind a misleading TODO.
  • Added docs/oracle-message-serialization.md covering which form goes where and how to migrate a column holding standalone oracle bytes.
  • Added plans/custom-tlv-stream.md. Separate finding: DDK silently drops application TLV records appended to a message — appending 7 bytes to a SignDlc parses fine, then re-serializes 7 bytes shorter. node-dlc preserves these. The plan scopes the fix to the stateless ddk::contract module, where it needs no manager or migration work.

Note for the release notes

Two breaking changes no test can catch:

  1. read_as_tlv's bound moved from T: Type to T: TlvType — a compile break for direct callers.
  2. kormir's nostr kind 88/89 payloads changed to the TLV form. DDK reads both; an external client with a body-only decoder does not.

Testing

cargo test --workspace --all-features — 277 passing, 0 failures. Clippy and rustdoc clean.

Backward compatibility is pinned against bytes this crate did not write:

  • Production Magnolia announcement and attestation, and node-dlc's own enum and numeric announcement vectors — all parse, validate where signed, and re-encode byte-identical. The numeric one covers digit_decomposition_event_descriptor (55306), which previously had no foreign-byte coverage at all; every other fixture takes the 55302 branch.
  • stored_contracts_round_trip_byte_for_byte reads all six contract states from fixtures serialized by an earlier release and asserts every byte matches on write-back. This is the evidence that contract_data needs no migration.
  • legacy_body_form_nostr_events_still_decode covers the path an existing relay client hits after the kormir change.
  • json_representation_is_unchanged pins the camelCase JSON shape, which KormirOracleClient deserializes and nothing else guarded.

Reviewer note: the #[ignore]d splice_in_* execution tests do not terminate — verified they also time out at 25 minutes on unmodified master, so it is pre-existing and unrelated. The other 10 execution tests pass.

An oracle announcement is the one type in the specification that has its own
record type, is nested inside another message, and also travels on its own.
Its Writeable impl must stay body-only, because SingleOracleInfo wraps the
field in write_as_tlv and a self-written header would double-wrap every stored
contract. That left no supported way to read an announcement that arrived
alone, so consumers stripped the two BigSize header fields by hand.

Add TlvType, giving a record its type as an associated constant, and TlvRecord,
a blanket impl over TlvType + Readable + Writeable that supplies to_tlv_bytes,
from_tlv_bytes, the hex forms, and from_tlv_bytes_or_legacy for stores holding
a mix of both encodings. Declaring a record is now one line of
impl_dlc_tlv_record!, which derives Type from the same constant so the two
cannot drift apart.

read_as_tlv now enforces both halves of the header: the record type must match
the type being read, and the body must consume exactly its declared length. It
previously discarded both, so a mistyped or truncated record decoded into a
plausible value and left the reader mid-record.

Fix kormir's nostr events, which published announcements and attestations in
the body form and so were unreadable by other DLC clients. They now carry the
TLV form; the reader accepts either, since a relay's history cannot be
rewritten.

The stored contract format is unchanged. Announcements embedded in a contract
were always written through write_as_tlv and read back the same way, and
stored_contracts_round_trip_byte_for_byte pins that across all six contract
states, so no data migration is required.
@bennyhodl
bennyhodl merged commit 194f522 into master Aug 13, 2026
134 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants