Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/tools/dual-stacking-decode.ts
Original file line number Diff line number Diff line change
@@ -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<string, { value?: string | number }>;

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;
}
68 changes: 49 additions & 19 deletions src/tools/dual-stacking.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, { value?: string | number }>;
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<string, { value?: string | number }>;
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)
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/dual-stacking-tuple-decode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});