Skip to content

fix(dual-stacking): decode Clarity tuples from cvToJSON's nested shape (#611)#620

Merged
biwasxyz merged 1 commit into
mainfrom
fix/611-dual-stacking-tuple-decode
Jul 20, 2026
Merged

fix(dual-stacking): decode Clarity tuples from cvToJSON's nested shape (#611)#620
biwasxyz merged 1 commit into
mainfrom
fix/611-dual-stacking-tuple-decode

Conversation

@biwasxyz

Copy link
Copy Markdown
Collaborator

Fixes #611.

Root cause

cvToJSON renders a Clarity tuple as { type: "(tuple ...)", value: { FIELD: { type, value } } } — fields sit one level down under .value. The status handler indexed the top-level object directly, so every tuple field resolved to undefined and the ?? 0 fallbacks turned it into an authoritative-looking 0.

There was a second fault on the APR read: the wrapper looked up min-apr/max-apr, but the contract's on-wire keys are uppercase. Confirmed against the deployed interface:

get-apr-data          -> (tuple (MAX_APR uint) (MIN_APR uint) (MULTIPLIER uint))
current-overview-data -> (tuple (cycle-id uint) (snapshot-index uint) (snapshots-per-cycle uint))

minimumEnrollmentSats was unaffected because a bare uint really is {type, value} — matching the reporter's observation that simple types survive and tuples zero out.

Also fixed

dual_stacking_get_rewards has the same nesting bug. reward-amount-for-cycle-and-address returns (optional uint128), so Number(raw.value) was Number({type,value})NaNrewardSats: null on every actual reward, while none collapsed to 0. Now unwrapped properly; none reports null (no reward recorded) rather than a zero reward.

Behavior change

Fields that fail to decode are now null with a warnings[] entry naming the read and the missing keys, instead of 0. Per the issue: a misleading 0% APR is worse than a visible failure, since an agent asking "should I enroll?" reasonably concludes no. apr.multiplier is now surfaced.

Verification

Live mainnet run of the patched handler:

{
  "minimumEnrollmentSats": 10000,
  "apr": { "minApr": 0.5, "maxApr": 5, "multiplier": 10, "unit": "%" },
  "cycleOverview": { "currentCycleId": 7, "snapshotIndex": 13, "snapshotsPerCycle": 14 }
}

Matches the expected-values table in the issue. The pre-existing is-enrolled-this-cycle / AtBlockUnavailable warning is unchanged.

The existing tests fed a bare uint fixture for both tuple reads, which is why this shipped green — fixtures are now real mainnet tuple hex, plus coverage for the wrong-key path and both optional branches. 537 tests pass.

#611)

cvToJSON renders a tuple as {type, value: {FIELD: {type, value}}}. The status
handler read fields off the top-level object and used the on-chain camelCase
output names, so every tuple field resolved to undefined and the `?? 0`
fallbacks reported 0% APR and cycle 0. Contract keys are MIN_APR/MAX_APR/
MULTIPLIER (uppercase) and cycle-id/snapshot-index/snapshots-per-cycle.

- Add tupleFields/tupleUint helpers that hop into .value and record misses
- Surface undecodable fields as null plus a warnings[] entry instead of 0
- Expose MULTIPLIER, and derive the APR note from it
- Fix the same nesting bug in dual_stacking_get_rewards, where the contract's
  (optional uint) return made Number({type,value}) produce NaN; none now
  reports null rather than a zero reward
- Return null for minimumEnrollmentSats when the read fails, not 0

Tests previously fed a bare uint for both tuple reads, which is why this
shipped; fixtures are now real mainnet tuple hex.

@arc0btc arc0btc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes the tuple-decoding bug from #611 — good root-cause diagnosis. cvToJSON nests tuple/optional fields one level under .value, and the old code read fields off the top-level object, so every min-apr/max-apr/cycle-overview field silently resolved to undefined and the ?? 0 fallback made it look like a confident, wrong "0% APR". That's exactly the kind of thing that would make an agent bail on a good enrollment decision.

What works well:

  • tupleFields/tupleUint/optionalUint are small, focused, and each has a comment explaining the exact cvToJSON shape they're unwrapping — that context will save the next person a debugging session.
  • Fixing the second bug (uppercase on-wire keys MIN_APR/MAX_APR vs the previous lowercase-hyphen lookup) is a good catch that wouldn't have been found without checking the deployed contract interface directly.
  • Recording missing fields into a warnings[] array instead of collapsing to 0 matches the pattern already used for the boolean enrollment flags in this same file (parseBoolean returning null) — good internal consistency.
  • The optional uint handling for dual_stacking_get_rewards (distinguishing none from a real zero reward) is a correctness fix in its own right, not just a nice-to-have.
  • Test fixtures switched from the old bare-uint stand-ins to real mainnet tuple/optional hex, plus a dedicated "wrong key" test that reproduces the original failure mode. That's the right way to close the gap that let this ship green originally.

[question] note field can go stale when multiplier fails to decode (src/tools/dual-stacking.tools.ts, the apr block)
note: \Multiplier up to ${multiplier ?? 10}x with stacked STX`falls back to hardcoded10when the multiplier tuple field itself couldn't be decoded — the same "plausible-looking wrong number" pattern the rest of this PR is fixing everywhere else. Since awarningsentry already fires for undecoded fields, would it be worth suppressing/qualifying the note text in that case too (e.g. omit the specific multiplier or say "unavailable")? Not blocking sincemultiplieritself does surface asnullin the structured field, just flagging the prose could still mislead a reader who skips straight tonote`.

Operational context: We don't currently call dual_stacking_status or dual_stacking_get_rewards from our own sensors/skills (our sBTC yield exposure so far is via the Zest supply path, not Dual Stacking), so no first-hand production data to add here — but the warnings[]-over-silent-0 pattern is one we rely on elsewhere in our own code and it's good to see it applied consistently in this server too.

Code quality notes: No reuse/efficiency concerns — the three helper functions are appropriately small and each used in more than one place. Nothing to simplify further.

Nice fix, and the test coverage (wrong-key path + both optional branches) gives good confidence this won't regress silently again.

@biwasxyz
biwasxyz merged commit 16e56ab into main Jul 20, 2026
5 checks passed
@biwasxyz
biwasxyz deleted the fix/611-dual-stacking-tuple-decode branch July 20, 2026 10:15
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.

dual_stacking_status: apr and cycleOverview tuple fields decode as zeros despite raw Clarity calls returning real values (v1.64.x follow-up to #554)

2 participants