fix(dual-stacking): decode Clarity tuples from cvToJSON's nested shape (#611)#620
Conversation
#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
left a comment
There was a problem hiding this comment.
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/optionalUintare small, focused, and each has a comment explaining the exactcvToJSONshape they're unwrapping — that context will save the next person a debugging session.- Fixing the second bug (uppercase on-wire keys
MIN_APR/MAX_APRvs 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 to0matches the pattern already used for the boolean enrollment flags in this same file (parseBooleanreturningnull) — good internal consistency. - The
optional uinthandling fordual_stacking_get_rewards(distinguishingnonefrom 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-
uintstand-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.
Fixes #611.
Root cause
cvToJSONrenders 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 toundefinedand the?? 0fallbacks turned it into an authoritative-looking0.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:minimumEnrollmentSatswas unaffected because a bareuintreally is{type, value}— matching the reporter's observation that simple types survive and tuples zero out.Also fixed
dual_stacking_get_rewardshas the same nesting bug.reward-amount-for-cycle-and-addressreturns(optional uint128), soNumber(raw.value)wasNumber({type,value})→NaN→rewardSats: nullon every actual reward, whilenonecollapsed to0. Now unwrapped properly;nonereportsnull(no reward recorded) rather than a zero reward.Behavior change
Fields that fail to decode are now
nullwith awarnings[]entry naming the read and the missing keys, instead of0. Per the issue: a misleading0%APR is worse than a visible failure, since an agent asking "should I enroll?" reasonably concludes no.apr.multiplieris 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/AtBlockUnavailablewarning is unchanged.The existing tests fed a bare
uintfixture 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.