diff --git a/src/tools/dual-stacking-decode.ts b/src/tools/dual-stacking-decode.ts new file mode 100644 index 00000000..e8ecf759 --- /dev/null +++ b/src/tools/dual-stacking-decode.ts @@ -0,0 +1,31 @@ +/** + * cvToJSON tuple helpers for dual-stacking status. + * + * Wire shape: { type: "(tuple ...)", value: { KEY: { type, value } } } + * Clarity keys are often UPPER_SNAKE or kebab (MIN_APR, cycle-id), not camelCase. + * Fallback keys (min-apr, minApr) are defensive only — Hiro currently returns MIN_APR first. + */ + +export type TupleFieldMap = Record; + +export function tupleFields(raw: unknown): TupleFieldMap | null { + if (!raw || typeof raw !== "object") return null; + const obj = raw as { value?: unknown; type?: string }; + if (obj.value && typeof obj.value === "object" && !Array.isArray(obj.value)) { + return obj.value as TupleFieldMap; + } + // Already unwrapped map of fields + return obj as TupleFieldMap; +} + +export function fieldValue( + fields: TupleFieldMap | null, + ...keys: string[] +): number | undefined { + if (!fields) return undefined; + for (const k of keys) { + const v = fields[k]?.value; + if (v !== undefined && v !== null) return Number(v); + } + return undefined; +} diff --git a/src/tools/dual-stacking.tools.ts b/src/tools/dual-stacking.tools.ts index 63061a32..c5f51a7f 100644 --- a/src/tools/dual-stacking.tools.ts +++ b/src/tools/dual-stacking.tools.ts @@ -15,6 +15,7 @@ import { getHiroApi } from "../services/hiro-api.js"; import { callContract } from "../transactions/builder.js"; import { getExplorerTxUrl } from "../config/networks.js"; import { createJsonResponse, createErrorResponse } from "../utils/index.js"; +import { fieldValue, tupleFields } from "./dual-stacking-decode.js"; // --------------------------------------------------------------------------- // Constants @@ -131,49 +132,78 @@ Note: Dual Stacking is only available on mainnet.`, cycleOverviewRaw, ] = values; - // Parse APR data — returns {min-apr: uint, max-apr: uint} divided by 1_000_000 for % - let apr: { minApr: number; maxApr: number; unit: string; note: string } = { + // cvToJSON tuple helpers (shared dual-stacking-decode.ts) — real wire keys MIN_APR / cycle-id. + // Fallback keys min-apr/minApr kept only as defensive Hiro-shape fallbacks, not observed wire names. + + // Parse APR data — MIN_APR/MAX_APR uints divided by 1_000_000 for % + let apr: { minApr: number; maxApr: number; unit: string; note: string; multiplier?: number } = { minApr: 0, maxApr: 0, unit: "%", note: "Multiplier up to 10x with stacked STX", }; if (aprDataRaw && typeof aprDataRaw === "object") { - const aprObj = aprDataRaw as Record; - const minAprRaw = aprObj["min-apr"]?.value; - const maxAprRaw = aprObj["max-apr"]?.value; + const fields = tupleFields(aprDataRaw); + const minAprRaw = fieldValue(fields, "MIN_APR", "min-apr", "minApr"); + const maxAprRaw = fieldValue(fields, "MAX_APR", "max-apr", "maxApr"); + const multiplier = fieldValue(fields, "MULTIPLIER", "multiplier"); + const decoded = + minAprRaw !== undefined || maxAprRaw !== undefined; + if (!decoded && fields) { + warnings.push( + "get-apr-data: tuple decoded but MIN_APR/MAX_APR keys missing; check Clarity wire names" + ); + } apr = { - minApr: minAprRaw !== undefined ? Number(minAprRaw) / 1_000_000 : 0, - maxApr: maxAprRaw !== undefined ? Number(maxAprRaw) / 1_000_000 : 0, + minApr: minAprRaw !== undefined ? minAprRaw / 1_000_000 : 0, + maxApr: maxAprRaw !== undefined ? maxAprRaw / 1_000_000 : 0, unit: "%", note: "Multiplier up to 10x with stacked STX", + ...(multiplier !== undefined && { multiplier }), }; } - // Parse cycle overview — returns tuple with cycle-id, snapshot-index, snapshots-per-cycle + // Parse cycle overview — cycle-id, snapshot-index, snapshots-per-cycle let cycleOverview: { currentCycleId: number; snapshotIndex: number; snapshotsPerCycle: number; } = { currentCycleId: 0, snapshotIndex: 0, snapshotsPerCycle: 0 }; if (cycleOverviewRaw && typeof cycleOverviewRaw === "object") { - const co = cycleOverviewRaw as Record; + const fields = tupleFields(cycleOverviewRaw); + const cycleId = fieldValue(fields, "cycle-id", "cycleId", "currentCycleId"); + const snapshotIndex = fieldValue(fields, "snapshot-index", "snapshotIndex"); + const snapshotsPerCycle = fieldValue( + fields, + "snapshots-per-cycle", + "snapshotsPerCycle" + ); + if ( + cycleId === undefined && + snapshotIndex === undefined && + snapshotsPerCycle === undefined && + fields + ) { + warnings.push( + "current-overview-data: tuple decoded but cycle-id/snapshot keys missing; check Clarity wire names" + ); + } cycleOverview = { - currentCycleId: co["cycle-id"]?.value !== undefined ? Number(co["cycle-id"].value) : 0, - snapshotIndex: - co["snapshot-index"]?.value !== undefined ? Number(co["snapshot-index"].value) : 0, - snapshotsPerCycle: - co["snapshots-per-cycle"]?.value !== undefined - ? Number(co["snapshots-per-cycle"].value) - : 0, + currentCycleId: cycleId ?? 0, + snapshotIndex: snapshotIndex ?? 0, + snapshotsPerCycle: snapshotsPerCycle ?? 0, }; } - // Parse minimum enrollment amount + // Parse minimum enrollment amount (simple uint cvToJSON: { type, value }) let minimumEnrollmentSats = 0; if (minimumAmountRaw !== null && minimumAmountRaw !== undefined) { - const raw = minimumAmountRaw as { value?: string | number }; - minimumEnrollmentSats = raw.value !== undefined ? Number(raw.value) : 0; + if (typeof minimumAmountRaw === "number" || typeof minimumAmountRaw === "string") { + minimumEnrollmentSats = Number(minimumAmountRaw); + } else { + const raw = minimumAmountRaw as { value?: string | number }; + minimumEnrollmentSats = raw.value !== undefined ? Number(raw.value) : 0; + } } // Parse boolean enrollment flags. A failed read stays null (unknown) diff --git a/tests/unit/dual-stacking-tuple-decode.test.ts b/tests/unit/dual-stacking-tuple-decode.test.ts new file mode 100644 index 00000000..44615b9a --- /dev/null +++ b/tests/unit/dual-stacking-tuple-decode.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { deserializeCV, cvToJSON } from "@stacks/transactions"; +import { fieldValue, tupleFields } from "../../src/tools/dual-stacking-decode.js"; + +const APR_HEX = + "0c00000003074d41585f41505201000000000000000000000000004c4b40074d494e5f415052010000000000000000000000000007a1200a4d554c5449504c494552010000000000000000000000000000000a"; +const OVERVIEW_HEX = + "0c00000003086379636c652d696401000000000000000000000000000000070e736e617073686f742d696e646578010000000000000000000000000000000d13736e617073686f74732d7065722d6379636c65010000000000000000000000000000000e"; + +describe("dual stacking tuple decode (#611)", () => { + it("decodes get-apr-data MIN_APR/MAX_APR from cvToJSON using shipped helpers", () => { + const json = cvToJSON(deserializeCV(Buffer.from(APR_HEX, "hex"))); + // Bug: looking at top-level min-apr yields undefined/0 + expect((json as any)["min-apr"]).toBeUndefined(); + const fields = tupleFields(json); + expect(fieldValue(fields, "MIN_APR")! / 1_000_000).toBe(0.5); + expect(fieldValue(fields, "MAX_APR")! / 1_000_000).toBe(5); + expect(fieldValue(fields, "MULTIPLIER")).toBe(10); + }); + + it("decodes current-overview-data cycle-id fields", () => { + const json = cvToJSON(deserializeCV(Buffer.from(OVERVIEW_HEX, "hex"))); + const fields = tupleFields(json); + expect(fieldValue(fields, "cycle-id")).toBe(7); + expect(fieldValue(fields, "snapshot-index")).toBe(13); + expect(fieldValue(fields, "snapshots-per-cycle")).toBe(14); + }); +});