Skip to content

fix(cluster-tool): parse a bare slug cell as a slug, not a decimal - #101

Merged
heifner merged 2 commits into
masterfrom
fix/slug-decoder-canonical-string
Sep 17, 2026
Merged

heifner merged 2 commits into
masterfrom
fix/slug-decoder-canonical-string

Conversation

@heifner

@heifner heifner commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

The merged-consumer prerequisite wire-sysio#619 needs, raised as a P2 on that PR: "Please update the merged consumer to mirror the carrier rule documented here … before merging this PR."

The defect

slugValue's string arm ran Number(value) first and fell back to SlugName.from only when that produced NaN. So a code that looks numeric was read as a decimal:

"ETH"  -> SlugName.from("ETH")   ✓      "7"    -> 7      ✗  (should be 149533581377536)
"WIRE" -> SlugName.from("WIRE")  ✓      "101"  -> 101    ✗
""     -> 0                      ✓      "1E3"  -> 1000   ✗
                                        "0X10" -> 16     ✗

The slug alphabet contains digits, so every one of those is a legitimate registry code. decodeSlugString's own JSDoc conceded the ambiguity was unresolvable by string-sniffing and named the fix: "split this decoder by the field's ABI carrier, after which this helper and both string arms below are deleted." That is what this does.

The split — per FIELD, not per shape

slugValue serves slug_name-typed ABI fields:

  • Bare string — the depot's ABI builtin renders the decoded slug, so it is parsed as a slug, never as a decimal. "" is the zero sentinel.
  • { value } wrapper — the transitional shape a pre-builtin depot emits, carrying the packed u64 directly, so its inner value stays numeric. This arm goes when no depot emits the wrapper.
  • Bare number — an already-packed value.

packedSlugValue serves code fields declared uint64, which are a genuinely different carrier: OperatorAction.chain_code / reserve_code are uint64 in attestations.proto, reachable through operators.recent_actions. Those never render as a spelling — they arrive as a number, or as a quoted DECIMAL once past 0xffffffff, which every real chain code exceeds ("ETH" is 23373212024832). flow-batch-operator-termination is the one call site that reads one, and it now uses the packed decoder.

That per-field split is the whole point: a decimal spelling and a slug spelling are indistinguishable by shape, so only the field's declared type can decide. decodeSlugString is deleted.

I classified all 44 call sites against the contract headers to find that one: every other reads a sysio::slug_name row field (opreg::balances/wtdwqueue, uwrit::locks/uwreqs, reserv::reserves, tokens), which is {value} pre-#619 and a canonical string after — so those are correct under either merge order.

The wrapper arm is now checked

fc::json quotes a uint64 above 0xffffffff, so the wrapper legitimately arrives as a decimal string — but only ever as digits. Number() would coerce "ETH" to NaN and truncate "1.5" rather than reject them, so the arm now requires an unsigned decimal and throws otherwise. This deliberately mirrors the depot's own checked_packed_value (fc/slug_name.hpp, added in #619 for the same reason), so both sides of the wire refuse the same shapes.

Throwing rather than returning NaN follows the contract already documented on slugValue: NaN never equals itself, so a NaN slug silently matches zero rows in a filter and surfaces minutes later as a poll timeout instead of at the decode that caused it.

Behaviour this intentionally changes

Two assertions on master pinned the old compromise and are inverted here, deliberately:

  • slugValue("101") was pinned to 101; it is now SlugName.from("101").
  • slugValue({ value: "ETH" }) was pinned to decode as a slug; the wrapper holds a packed u64, so it now throws.

A top-level decimal is no longer a carrier at all — slugValue("84606581215232") throws, because a slug is at most 8 symbols.

Verification

slugUtils 17/17. Full cluster-tool unit suite 1505/1506 (run before the packed-carrier commit; the three files it touches are covered by the 17).

The one failure — SystemContractSteps.test.ts, "Unknown sysio.system action: setscorecfg" — is pre-existing and unrelated: the sibling wire-libraries-ts/packages/sdk-core has setscorecfg in src/ (dated 2026-09-14) but its built lib/cjs output is from 2026-08-14, so the symbol is absent from what resolves at build time. The same staleness makes pnpm build red on master here (setoutpost, SysioChainsOutpostAddrsType, SysioSystemSetscorecfgAction, rank_score). That test file references nothing this change touches, and this diff is two files.

eslint clean on both.

Split slugValue by ABI carrier, which is what the old string-sniffing
decodeSlugString could not do: a bare string is a slug_name field and is
parsed as a SLUG, while the transitional { value } wrapper holds a packed
u64 and stays numeric. Preferring the decimal reading mis-decoded every
digit-only or JS-numeric-syntax code — "7", "101", "1E3", "0X10".

The wrapper arm now rejects anything but an unsigned decimal, mirroring
the depot's checked_packed_value (fc/slug_name.hpp) so both sides of the
wire refuse the same shapes.

Change-Id: I604b0c660b501cc340368af08d8149ad55d65f60
OperatorAction.chain_code is uint64 in attestations.proto, not a slug_name
ABI field, so it renders as a number — or a quoted decimal above
0xffffffff, which every real chain code exceeds. Reading it as a slug
spelling threw. flow-batch-operator-termination is the one call site that
reaches it, via operators.recent_actions.

packedSlugValue takes that carrier; slugValue keeps the slug_name fields.
Only the field's declared type can tell a decimal spelling from a slug
spelling, which is why the split is per-field rather than per-shape.

Change-Id: I428d2e0136eab8fb42edf3567933c3a3cc062ac2
@heifner
heifner merged commit bd2745e into master Sep 17, 2026
2 checks passed
@heifner
heifner deleted the fix/slug-decoder-canonical-string branch September 17, 2026 15:21
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