fix(oracle): read and write oracle messages as standalone TLV records - #177
Merged
Conversation
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.
Ari4ka
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
OracleAnnouncementis 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, itsWriteableimpl must write the body alone becauseSingleOracleInfowraps the field inwrite_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 twoBigSizereads 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
TlvTypegives a record its type as an associated constant, which a reader holding only bytes can compare against.TlvRecordis a blanket impl overTlvType + Readable + Writeablesupplyingto_tlv_bytes,from_tlv_bytes, the hex forms, andfrom_tlv_bytes_or_legacyfor stores holding a mix of both encodings. Declaring a record is one line ofimpl_dlc_tlv_record!, which derivesTypefrom the same constant so the two cannot drift.read_as_tlvnow 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.write_oracle_event, which duplicatedwrite_as_tlvbehind a misleading TODO.docs/oracle-message-serialization.mdcovering which form goes where and how to migrate a column holding standalone oracle bytes.plans/custom-tlv-stream.md. Separate finding: DDK silently drops application TLV records appended to a message — appending 7 bytes to aSignDlcparses fine, then re-serializes 7 bytes shorter. node-dlc preserves these. The plan scopes the fix to the statelessddk::contractmodule, where it needs no manager or migration work.Note for the release notes
Two breaking changes no test can catch:
read_as_tlv's bound moved fromT: TypetoT: TlvType— a compile break for direct callers.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:
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_bytereads all six contract states from fixtures serialized by an earlier release and asserts every byte matches on write-back. This is the evidence thatcontract_dataneeds no migration.legacy_body_form_nostr_events_still_decodecovers the path an existing relay client hits after the kormir change.json_representation_is_unchangedpins the camelCase JSON shape, whichKormirOracleClientdeserializes and nothing else guarded.Reviewer note: the
#[ignore]dsplice_in_*execution tests do not terminate — verified they also time out at 25 minutes on unmodifiedmaster, so it is pre-existing and unrelated. The other 10 execution tests pass.