Skip to content

Retiring a contract keyword makes already-registered contracts unloadable; refuse it only on registration and update #5270

Open

Description

@PastaPastaPasta

Twice during the protocol version 14 betas, a keyword rename made data contracts that were already registered fail to parse everywhere: in Drive's own contract fetch and in the SDKs, with or without full validation. The first was refersTo.lookup / propertyAgreement in 4.2.0-beta.7 (#5197); the second was immutableAllowSetting in 5.0.0-beta.1 (#5217). Could a retired keyword be refused only when a contract is registered or updated, and still be read when a stored contract is loaded (or the stored bytes rewritten when the change activates)? And could that become a written rule before protocol version 14 reaches testnet and mainnet?

We hit this building Dash Forge, a git forge that runs on Platform.

We know protocol version 14 is unreleased and its tables are still editable in place (system_limits/v4.rs#L3-L9). Both PRs said what would happen, and both release notes told devnets to re-cut. This is not a complaint about the betas. We are asking for the discipline that will matter once a contract cannot be re-cut.

Expected Behavior

Current Behavior

Both refusals run on every parse:

  • refuse_replaced_reference_keywords refuses lookup, propertyAgreement and type: "listElement" "on every parse, so a contract written with them never loads with another meaning" (try_from_schema/mod.rs#L1039-L1075).
  • parse_immutable_keyword refuses any document type that carries immutableAllowSetting (common/mod.rs#L3106-L3133).
  • The generation 3 parser states this as policy: "its shape is enforced on both the validating and the stored path. No keyword gets stored-path leniency" (v3/mod.rs#L311-L327).
  • Drive loads a stored contract through that parser (versioned_deserialize_trusted(bytes, false, …), fetch_contract/v0/mod.rs#L78-L87). So every transition on such a contract ends as an internal error, as the comment at v3/mod.rs#L324-L327 says.
  • The owner cannot repair the contract with an update. The update's state validation first fetches the stored contract (data_contract_update/state/v0/mod.rs#L114-L120), and then validates the update against it (#L150-L154).

Reproduction. We kept snapshots of our contracts as fetched from two devnets. Each was fetched with one proved getDataContracts request and serialized with toBytes at protocol version 14:

We parsed each one with three published SDKs:

// node repro.mjs <dir with node_modules/@dashevo/wasm-sdk> <snapshot.json>
import fs from 'node:fs';
const [dir, snapFile] = process.argv.slice(2);
const mod = await import(dir + '/node_modules/@dashevo/wasm-sdk/dist/sdk.js');
await mod.default();
for (const [id, v] of Object.entries(JSON.parse(fs.readFileSync(snapFile, 'utf8')))) {
  for (const full of [false, true]) {
    try { mod.DataContract.fromBase64(v.bytes, full, v.platformVersion); console.log(id, full, 'OK'); }
    catch (e) { console.log(id, full, 'ERROR:', String(e?.message ?? e)); }
  }
}
Contract (all at protocol version 14) wasm-sdk 4.2.0-beta.5 4.2.0-beta.7 5.0.0-beta.1
A2KL77ngVM1ft1t1em2XKt1rWCBZANdAJMyfWrDGCcd1 (registered on beta.6, uses lookup) OK ERROR (a) ERROR (a)
C1zHeeG7EUudXdB5ZyDQnXVU35hrCfvd1fRCybXEqaPS (registered on beta.6, uses propertyAgreement) OK ERROR (b) ERROR (b)
6ktYsH3cpxC7FbazwtVWGiNuVb4TNE5YrHD1hNY8XqNx (registered on beta.7, uses immutableAllowSetting) (c) OK ERROR (d)

Each result is the same with full_validation false and true. The other contracts in both snapshots, DPNS among them, load in every SDK from the release they were registered on onward. In (c), beta.5 cannot read grammar added after it (skipIfAbsent value must be a boolean). An older client failing on newer grammar is expected, and we are not asking about that. The errors are:

(a) invalid contract structure: refersTo lookup was replaced by findBy: map every property of the unique index to its source, { "<index property>": <source> }, and leave the index name out; a propertyAgreement function goes in findBy too
(b) invalid contract structure: refersTo propertyAgreement was replaced by where, keyed by the referenced document's property: { "<referenced property>": "<referring property or $ownerId>" }
(d) invalid contract structure: document type "checkRun": `immutableAllowSetting` is replaced by a conditional `immutable` entry: { "property": "p", "when": { "present": "$old.p" } } freezes "p" once the stored document holds it

What it cost us:

  • On 2026-09-29, one of our devnets was upgraded in place from 4.2.0-beta.6 to 4.2.0-beta.7. After that, our two main contracts could not be loaded. We had to register new contracts, with new ids, on a new devnet.
  • Our next devnet was re-cut for 5.0.0-beta.1. Had it been upgraded in place, our third contract (12 document types) would have become unreadable, along with every document in it. Everything we had stored there went with the re-cut, including a repository mirror import that stood at 5,609 of 7,742 items.
  • No client version reads both forms. 4.2.0-beta.7 refuses the conditional immutable entry (every immutable entry must be a property name (a string)), and 5.0.0-beta.1 refuses immutableAllowSetting. So the SDK upgrade, the new contract and its new id had to ship as one release.
  • On mainnet there is no re-cut. The same change would strand every document in the affected contracts.

Possible Solution

Suggestions, roughly from smallest to largest:

  1. For pure renames, read the old spelling into the same declaration when loading. Keep refusing it at registration and update. The book says the meta-schema already refuses the old refersTo spellings there (10101, refers-to.md#L319). feat(dpp)!: refersTo finds a document with findBy and checks it with where #5197 notes that "the parsed declarations, and so validation, fees and execution, are the same". feat(platform)!: immutable properties frozen under a condition (PV14) #5217 gives the exact equivalent of immutableAllowSetting: ["p"], which is { "property": "p", "when": { "present": "$old.p" } }. Since the meaning is identical, a contract written the old way would not load "with another meaning". We may be missing a path that skips the meta-schema. The rationale at v3/mod.rs#L321-L323 mentions check_tx and client parsing. If such a path exists, a parser flag that only Drive's stored-contract fetch sets would keep the change narrow.
  2. When a keyword has to go, migrate rather than refuse. In the first block of the protocol version that retires a keyword, rewrite the stored contracts that use it, as transition_to_version_12 did. Rewrite index entries earlier versions laid out otherwise, then drop the stored-layout reads (PV15+) #5196 proposes the same approach for index entries.
  3. Write the rule down next to "Shipped generations are frozen" in coding-conventions.md. For example: "a parser change must load every contract the previous build admitted; retiring a keyword is a registration rule, plus a migration if needed". A "Stored contracts affected" section in the PR description, like "In-place changes to shipped generations", would make reviewers check for this.
  4. Check it in CI. Run a corpus of real registered contracts on every PR. We filed that separately as CI: load a corpus of real registered data contracts on every PR, and flag changes that break stored contracts #5271.

Alternatives Considered

  • Re-registering under the new grammar is what we did. The contract gets a new id, and its documents stay behind under the old contract, which can no longer be read. Every client has to change ids at the same moment.
  • Updating the contract to the new grammar is not possible, because the update path loads the stored contract first (above).
  • Pinning the older SDK works only until the network upgrades. After that, the old SDK cannot read anything registered in the new form.

Additional Context

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions