Skip to content

[design] Readonly-field DCE — opt-out annotation, compiler flag, or document workaround? #109

Description

@E-Jacko

Use case

Contracts that declare readonly fields for off-chain metadata — fields the contract author wants preserved in the on-chain script for downstream recovery (a certificate reference, a deploy-time tag, an off-chain index key) but the contract body doesn't itself read inside a method. The compiler's dead-code-elimination pass strips these fields out of the lowered script because they're "unused" from the compiler's perspective, breaking the downstream recovery flow.

A no-op binding workaround exists today (const _bind = this.fieldName; early in any method body prevents DCE), but it's undocumented and looks weird in contract source. This issue invites maintainer input on the right canonical mechanism — annotation, compiler flag, or just documenting the workaround.

How this surfaced

Surfaced while building contracts whose deploy-time metadata fields are intended to be parsed back from the on-chain script after deployment (so an indexer or audit tool can recover the metadata without an off-chain database). The compiler's DCE pass aggressively strips fields not referenced in any method body, removing the bytes the recovery code expected to find. Workaround: a no-op self-binding (const _bind = this.metadata;) early in a method body prevents DCE. It works, but only because it's a side-channel against the optimizer — and it looks like dead code to any reader unfamiliar with the trap.

Current behavior

The runar compiler's DCE pass strips readonly fields that aren't referenced in any method body. This is an aggressive code-size optimization — beneficial for compactness of the lowered Bitcoin Script, since unused readonly slots otherwise occupy bytes of the locking script for the contract's lifetime on chain.

The trap: when contract authors declare a readonly field for off-chain consumption (recoverable from the on-chain script later, but not referenced in the contract body itself), DCE removes it. Downstream recovery code that walks the script expecting the field returns garbage or fails to find the bytes.

No documented opt-out exists. The discoverable workaround is a no-op self-binding:

public class MyContract extends SmartContract {
  readonly metadataId: ByteString;  // off-chain index key, not read in any method

  constructor(metadataId: ByteString, ...) {
    super(...);
    this.metadataId = metadataId;
  }

  @method()
  public someMethod(...) {
    const _bind = this.metadataId;  // ← prevents DCE; reads weird; no docs
    // ... rest of method ...
  }
}

The binding emits a single read-then-discard pair in the lowered script, which is enough to mark the field "used" from the optimizer's POV and skip the DCE pass for that slot. It works, but it's undocumented, looks like dead code to any contract author who doesn't already know the trap, and several authors will rediscover it independently before someone writes it down.

Proposed shape (design question — open for redesign)

Several possible mechanisms, no strong preference. Inviting maintainer input on which is canonical:

Option 1 — field-level annotation

@embedAlways
readonly metadataId: ByteString;

Compiler skips DCE for any annotated field. Cleanly expresses author intent at the declaration site. Opt-in (existing code unchanged).

Option 2 — compiler flag

runc --preserve-readonly ...

Global opt-out per compilation. Useful when a contract has many such fields; clumsier when only one or two need preservation. Doesn't express author intent at the field level.

Option 3 — document the workaround upstream

Don't change the compiler; bless the const _bind = this.field; pattern with explicit documentation (README + contract-author guide). Zero compiler churn, but every new contract author still reads the workaround and asks "why?".

Option 4 — emit a compiler warning for stripped readonly fields

Even without an opt-out, surfacing the DCE behaviour at compile time ("warning: readonly field metadataId not referenced in any method body — eliminated by DCE; use [recommended-mechanism] to preserve") closes the discovery gap. Could be combined with any of options 1-3.

The PoC patches we maintained locally use option 3 (document the workaround) because it requires no compiler changes; but option 1 is more direct and option 4 is the cheapest defensive improvement. Open to maintainer's preference.

Alternatives considered

Shape Strengths Weaknesses
Annotation (@embedAlways) Cleanest authoring experience; intent expressed at field site; opt-in. Compiler change.
Compiler flag Coarse-grained; useful for many-field contracts. Global; doesn't express per-field intent.
Document the no-op binding workaround Zero compiler churn. Workaround looks weird; rediscovery cost recurs.
Compile warning for stripped readonlies Catches the trap defensively; cheap to add. Doesn't fix the underlying preservation question.
Don't change DCE; reject the use case Smallest scope. Punts on a legitimate authoring pattern.

Backwards compatibility

Whatever shape lands should be opt-in:

  • Existing contracts that don't declare readonly metadata fields see no change.
  • Existing contracts that rely on the current aggressive DCE behaviour (for script-size optimization) continue to get DCE on un-annotated fields.
  • The annotation / flag / warning is the new surface; the default stays aggressive.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions