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
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:
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
Compiler skips DCE for any annotated field. Cleanly expresses author intent at the declaration site. Opt-in (existing code unchanged).
Option 2 — compiler flag
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
metadataIdnot 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
@embedAlways)Backwards compatibility
Whatever shape lands should be opt-in:
Related
runar broadcast and get_transaction: contract-layer surface; unrelated but same SDK family.SdkValue::EmptySig for OR-CHECKSIG: another contract-author-facing design question on the same SDK. The two issues both invite maintainer design preference on author-facing surfaces.