From d509070a440549a9b1b03a77718c5ed8642970d7 Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Wed, 16 Sep 2026 20:00:35 +0545 Subject: [PATCH 1/2] feat(stacking): move the stacking skill to pox-5 pox-5 is the active PoX contract on mainnet (cycle 141+) and removed every pox-4 function the skill called. Port the pox-5 staking implementation from aibtcdev/aibtc-mcp-server#682: - StackingService rebuilt on pox-5: stake / stake-update / unstake with a signer manager, signer-set walk, claim-style detection, and sBTC reward pull + staker claim. Deny-mode post-conditions (staking lock amount, performs-PoX, sBTC only out of pox-5 / the manager). Pre-checks refuse before signing: prepare phase, already/not staking, unregistered signer, balance, 1-96 cycles. - Added over the MCP version: every write confirms pox-5 is still the network's active PoX contract, and a dependency seam replaces module mocks in tests. - CLI: get-pox-info, get-stacking-status, list-signers, stack-stx, extend-stacking, unstake-stx, get-rewards, claim-rewards; strict argument parsing; optional BTC payout calldata. - pillar-direct: Fast Pool stack and revoke refuse while pox-4 is not active (the Pillar wallet contract hardcodes pox-4). - btcAddressToPoxAddr in src/lib/utils/bitcoin.ts; POX_5 replaces POX_4. - @stacks/* bumped to ^7.6.0 for the staking and pox post-condition types. Closes #429. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- bun.lock | 17 +- package.json | 8 +- pillar/SKILL.md | 4 +- pillar/pillar-direct.ts | 34 +- skills.json | 16 +- src/lib/config/contracts.ts | 4 +- src/lib/services/stacking.service.test.ts | 322 ++++++++ src/lib/services/stacking.service.ts | 870 +++++++++++++++------- src/lib/utils/bitcoin.ts | 32 + stacking/AGENT.md | 86 +-- stacking/SKILL.md | 226 ++++-- stacking/stacking.ts | 564 ++++++++++---- 13 files changed, 1636 insertions(+), 549 deletions(-) create mode 100644 src/lib/services/stacking.service.test.ts diff --git a/README.md b/README.md index 15c362c5..90ed6f8d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Each skill is a self-contained directory with a `SKILL.md` (used by Claude Code | [defi-portfolio-scanner](./defi-portfolio-scanner/) | `defi-portfolio-scanner/defi-portfolio-scanner.ts` | Cross-protocol DeFi position aggregator — scans Bitflow HODLMM, Zest Protocol, ALEX DEX, and Styx Bridge for a given STX address; produces a risk-scored portfolio summary with USD estimates. Mainnet-only. | | [zest-auto-repay](./zest-auto-repay/) | `zest-auto-repay/zest-auto-repay.ts` | Autonomous Zest Protocol LTV guardian — monitors borrowing positions, detects liquidation risk, and executes safe repayments with enforced spend limits to protect collateral on Stacks mainnet. | | [hodlmm-move-liquidity](./hodlmm-move-liquidity/) | `hodlmm-move-liquidity/hodlmm-move-liquidity.ts` | HODLMM Move-Liquidity & Auto-Rebalancer — withdraw from drifted bins, re-deposit around the current active bin, and run an autonomous monitoring loop that keeps LP capital earning 24/7. Mainnet-only. | -| [stacking](./stacking/) | `stacking/stacking.ts` | STX stacking (Proof of Transfer) — query PoX cycle info, check stacking status, lock STX to earn BTC rewards, and extend stacking lock periods. | +| [stacking](./stacking/) | `stacking/stacking.ts` | PoX-5 STX staking — cycle state and staking status, signer managers, stake / update / unstake STX, and sBTC reward claims. | | [stacks-market](./stacks-market/) | `stacks-market/stacks-market.ts` | Prediction market trading on stacksmarket.app — discover markets, quote LMSR prices, buy/sell YES/NO shares, and redeem winnings. Mainnet-only. | | [stacking-lottery](./stacking-lottery/) | `stacking-lottery/stacking-lottery.ts` | Stacking lottery pots on stackspot.app — pool STX into pots that get stacked via PoX, VRF picks a random winner for sBTC rewards, and all participants get their STX back. Mainnet-only. | | [stackspot](./stackspot/) | `stackspot/stackspot.ts` | Stacking lottery pots on stackspot.app — pool STX into pots that get stacked via PoX, VRF picks a random winner for sBTC rewards, everyone gets STX back. Mainnet-only. | diff --git a/bun.lock b/bun.lock index 16aee9f6..6e3ce91e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@aibtc/skills", @@ -12,10 +13,10 @@ "@scure/bip32": "^1.6.2", "@scure/bip39": "^2.0.1", "@scure/btc-signer": "^2.0.1", - "@stacks/common": "^7.3.1", - "@stacks/encryption": "^7.3.1", - "@stacks/network": "^7.3.1", - "@stacks/transactions": "^7.3.1", + "@stacks/common": "^7.6.0", + "@stacks/encryption": "^7.6.0", + "@stacks/network": "^7.6.0", + "@stacks/transactions": "^7.6.0", "@stacks/wallet-sdk": "^7.2.0", "alex-sdk": "^3.2.1", "axios": "^1.13.2", @@ -59,15 +60,15 @@ "@stacks/auth": ["@stacks/auth@7.3.1", "", { "dependencies": { "@noble/secp256k1": "1.7.1", "@stacks/common": "^7.3.1", "@stacks/encryption": "^7.3.1", "@stacks/network": "^7.3.1", "@stacks/profile": "^7.3.1", "cross-fetch": "^3.1.5", "jsontokens": "^4.0.1" } }, "sha512-8zjQrnthhymJruSWYuP17IRx+c0k8LrOZYH9DRyaTzjEZ+pbvsRUM9v7hH1aGr2LG94BI9249qXpCtGWorVI+g=="], - "@stacks/common": ["@stacks/common@7.3.1", "", {}, "sha512-29ANTFcSSlXnGQlgDVWg7OQ74lgQhu3x8JkeN19Q+UE/1lbQrzcctgPHG74XHjWNp8NPBqskUYA8/HLgIKuKNQ=="], + "@stacks/common": ["@stacks/common@7.6.0", "", {}, "sha512-VUW8WDmZP4wXUNWVvpFerV+7LQ0PqIn8YOs8AjSUMA7ZOXIRLxJHoKxjfiHzJqazy7KlyryPW4kSNBCr7tLqhQ=="], "@stacks/connect": ["@stacks/connect@7.10.2", "", { "dependencies": { "@stacks/auth": "^7.0.0", "@stacks/common": "^7.0.0", "@stacks/connect-ui": "6.6.0", "@stacks/network": "^7.0.0", "@stacks/network-v6": "npm:@stacks/network@^6.16.0", "@stacks/profile": "^7.0.0", "@stacks/transactions": "^7.0.0", "@stacks/transactions-v6": "npm:@stacks/transactions@^6.16.0", "jsontokens": "^4.0.1" } }, "sha512-fQcdayBgq9XZnX4rqQxa//Gx9c0ycrmrZT9dZ01uHDlIr/ZxwU18d5A3hyYv4F7LQYQQkFr9htpVTlH0RSqWUw=="], "@stacks/connect-ui": ["@stacks/connect-ui@6.6.0", "", { "dependencies": { "@stencil/core": "^2.17.1" } }, "sha512-uc22RH99umYzB94h5LiKPtGu34IBGrwUb3TfijGb2ZMudaMCiv/Fr1jjZKfQW5MRmexnbAEmGZpFlQKinCcsUA=="], - "@stacks/encryption": ["@stacks/encryption@7.3.1", "", { "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@scure/bip39": "1.1.0", "@stacks/common": "^7.3.1", "base64-js": "^1.5.1", "bs58": "^5.0.0", "ripemd160-min": "^0.0.6", "varuint-bitcoin": "^1.1.2" } }, "sha512-hCY61gd4PVr5LUZKOuzWfPLmuPrIGEapd1LkMintToJ+F3R/x0T+iIJVnJf2Y1l0cJsc4Xxq/TWCBeEAfybScg=="], + "@stacks/encryption": ["@stacks/encryption@7.6.0", "", { "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@scure/bip39": "1.1.0", "@stacks/common": "^7.6.0", "base64-js": "^1.5.1", "bs58": "^5.0.0", "ripemd160-min": "^0.0.6", "varuint-bitcoin": "^1.1.2" } }, "sha512-1D2yazEcC5blc/xZkCPXQRbiAozXiVisLdxdFF3bVvNocUZ7yWkW7SOhhNve8RJ0Yf0XBkG6IbnBa3F1vyOyXA=="], - "@stacks/network": ["@stacks/network@7.3.1", "", { "dependencies": { "@stacks/common": "^7.3.1", "cross-fetch": "^3.1.5" } }, "sha512-dQjhcwkz8lihSYSCUMf7OYeEh/Eh0++NebDtXbIB3pHWTvNCYEH7sxhYTB1iyunurv31/QEi0RuWdlfXK/BjeA=="], + "@stacks/network": ["@stacks/network@7.6.0", "", { "dependencies": { "@stacks/common": "^7.6.0", "cross-fetch": "^3.1.5" } }, "sha512-Blm85nsEbvJoFIFaDp0QE9WMC0Hf+o3Yb0oIDwWu3PPgpVSdndb/bJQUeT6eJTs0ibS26A2E/5d5L0fb4Ff/lA=="], "@stacks/network-v6": ["@stacks/network@6.17.0", "", { "dependencies": { "@stacks/common": "^6.16.0", "cross-fetch": "^3.1.5" } }, "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw=="], @@ -77,7 +78,7 @@ "@stacks/storage": ["@stacks/storage@7.3.1", "", { "dependencies": { "@stacks/auth": "^7.3.1", "@stacks/common": "^7.3.1", "@stacks/encryption": "^7.3.1", "@stacks/network": "^7.3.1", "base64-js": "^1.5.1", "jsontokens": "^4.0.1" } }, "sha512-AvbZJkc97LZ0icpIWeZpUgtSQS3Nsd3M16GYAJcHDqsBdsCtlwoSzsEwrcwrxZ1CFsWMnz3Te9xCNqUjoQ+CcA=="], - "@stacks/transactions": ["@stacks/transactions@7.3.1", "", { "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@stacks/common": "^7.3.1", "@stacks/network": "^7.3.1", "c32check": "^2.0.0", "lodash.clonedeep": "^4.5.0" } }, "sha512-ufnC1BPrOKz5b5gxxdseP3vBrFq1+qx1L6t+J/QnjXULyWdkhtS+LBEqRw2bL5qNteMvU2GhqPgFtYQPzolGbw=="], + "@stacks/transactions": ["@stacks/transactions@7.6.0", "", { "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@stacks/common": "^7.6.0", "@stacks/network": "^7.6.0", "c32check": "^2.0.0", "lodash.clonedeep": "^4.5.0" } }, "sha512-s7F7eJtQVnZoB79j8pY9SLKhlvvdYZZD1NSDRWrFjzSJTDmxptL8c50S563WFja6KOhFOgg1jd+p0XWTxFAVyA=="], "@stacks/transactions-v6": ["@stacks/transactions@6.17.0", "", { "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@stacks/common": "^6.16.0", "@stacks/network": "^6.17.0", "c32check": "^2.0.0", "lodash.clonedeep": "^4.5.0" } }, "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g=="], diff --git a/package.json b/package.json index 50423c38..1edc4419 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,10 @@ "@scure/bip32": "^1.6.2", "@scure/bip39": "^2.0.1", "@scure/btc-signer": "^2.0.1", - "@stacks/common": "^7.3.1", - "@stacks/encryption": "^7.3.1", - "@stacks/network": "^7.3.1", - "@stacks/transactions": "^7.3.1", + "@stacks/common": "^7.6.0", + "@stacks/encryption": "^7.6.0", + "@stacks/network": "^7.6.0", + "@stacks/transactions": "^7.6.0", "@stacks/wallet-sdk": "^7.2.0", "alex-sdk": "^3.2.1", "axios": "^1.13.2", diff --git a/pillar/SKILL.md b/pillar/SKILL.md index 956b9c22..12e268df 100644 --- a/pillar/SKILL.md +++ b/pillar/SKILL.md @@ -421,7 +421,7 @@ Options: #### direct-stack-stx -Stack STX via Fast Pool or Stacking DAO. Agent-signed, no browser needed. +Stack STX via Fast Pool or Stacking DAO. Agent-signed, no browser needed. The Fast Pool path goes through pox-4 in the Pillar wallet contract and is refused while pox-4 is not the active PoX contract (PoX-5 is live on mainnet); use the `stacking` skill for PoX-5 staking. ``` bun run pillar/pillar-direct.ts direct-stack-stx --stx-amount --pool fast-pool|stacking-dao @@ -433,7 +433,7 @@ Options: #### direct-revoke-fast-pool -Revoke Fast Pool STX delegation. Agent-signed, no browser needed. +Revoke Fast Pool STX delegation. Agent-signed, no browser needed. Refused while pox-4 is not the active PoX contract (it calls pox-4 `revoke-delegate-stx`). ``` bun run pillar/pillar-direct.ts direct-revoke-fast-pool diff --git a/pillar/pillar-direct.ts b/pillar/pillar-direct.ts index c1227fcc..4fe4d0b1 100644 --- a/pillar/pillar-direct.ts +++ b/pillar/pillar-direct.ts @@ -1439,11 +1439,32 @@ program // direct-stack-stx // --------------------------------------------------------------------------- +/** + * The Pillar smart wallet's Fast Pool functions are hardcoded to pox-4 + * (`pox-4.allow-contract-caller` + `pox4-fast-pool-v3.delegate-stx`, and + * `pox-4.revoke-delegate-stx`). Once PoX has moved past pox-4 those calls abort + * on chain, so refuse before signing instead of burning gas on a sure failure. + * Mirrors aibtcdev/aibtc-mcp-server#682. + */ +async function assertFastPoolPoxActive(action: string): Promise { + const pox = await getHiroApi(NETWORK).getPoxInfo(); + if (!pox.contract_id.endsWith(".pox-4")) { + throw new Error( + `${action} is unavailable: the Pillar smart wallet's Fast Pool path calls pox-4, but the ` + + `active PoX contract is ${pox.contract_id}, so the transaction would abort on chain. ` + + `This needs a Pillar wallet contract update. For STX staking on PoX-5, use ` + + `bun run stacking/stacking.ts stack-stx with a signer manager (see list-signers).` + ); + } +} + program .command("direct-stack-stx") .description( "Stack STX from your Pillar smart wallet via Fast Pool or Stacking DAO. " + - "Agent-signed, no browser needed. Backend sponsors gas." + "Agent-signed, no browser needed. Backend sponsors gas. " + + "Fast Pool goes through pox-4 and is refused while pox-4 is not the active PoX contract " + + "(PoX-5 is live; use the stacking skill for PoX-5 staking)." ) .requiredOption( "--stx-amount ", @@ -1451,7 +1472,7 @@ program ) .requiredOption( "--pool ", - "Stacking pool: fast-pool (delegates to pox4-fast-pool-v3) or stacking-dao (deposits for stSTX)" + "Stacking pool: fast-pool (delegates to pox4-fast-pool-v3; refused while pox-4 is not active) or stacking-dao (deposits for stSTX)" ) .action(async (opts: { stxAmount: string; pool: string }) => { try { @@ -1466,6 +1487,10 @@ program throw new Error(`--pool must be one of: ${validPools.join(", ")}`); } + if (opts.pool === "fast-pool") { + await assertFastPoolPoxActive("Fast Pool stacking"); + } + const stxAmount = parseInt(opts.stxAmount, 10); const { keyService, session } = await requireActiveKey(); @@ -1524,7 +1549,8 @@ program program .command("direct-revoke-fast-pool") .description( - "Revoke Fast Pool STX delegation from your Pillar smart wallet. " + + "Revoke Fast Pool STX delegation from your Pillar smart wallet (pox-4 revoke-delegate-stx; " + + "refused while pox-4 is not the active PoX contract). " + "Agent-signed, no browser needed. STX stays locked until current PoX cycle ends." ) .action(async () => { @@ -1535,6 +1561,8 @@ program return; } + await assertFastPoolPoxActive("Revoking Fast Pool"); + const { keyService, session } = await requireActiveKey(); const authId = generateAuthId(); diff --git a/skills.json b/skills.json index c4f69e51..61de80e7 100644 --- a/skills.json +++ b/skills.json @@ -1,6 +1,6 @@ { "version": "0.43.1", - "generated": "2026-10-08T09:03:52.107Z", + "generated": "2026-10-08T11:41:17.190Z", "skills": [ { "name": "agent-lookup", @@ -2379,13 +2379,17 @@ }, { "name": "stacking", - "description": "STX stacking operations on Stacks — query PoX cycle info, check stacking status, lock STX to earn BTC rewards (stack-stx), and extend an existing stacking lock period. Write operations require an unlocked wallet.", + "description": "PoX-5 STX staking on Stacks — query cycle state and staking status, list signer managers, stake / update / unstake STX with a signer manager, and check or claim sBTC rewards. Write operations require an unlocked wallet.", "entry": "stacking/stacking.ts", "arguments": [ "get-pox-info", "get-stacking-status", + "list-signers", "stack-stx", - "extend-stacking" + "extend-stacking", + "unstake-stx", + "get-rewards", + "claim-rewards" ], "requires": [ "wallet" @@ -2401,8 +2405,12 @@ "mcpTools": [ "get_pox_info", "get_stacking_status", + "list_stacking_signers", "stack_stx", - "extend_stacking" + "extend_stacking", + "unstake_stx", + "get_stacking_rewards", + "claim_stacking_rewards" ] }, { diff --git a/src/lib/config/contracts.ts b/src/lib/config/contracts.ts index dee56693..1af6d95a 100644 --- a/src/lib/config/contracts.ts +++ b/src/lib/config/contracts.ts @@ -16,7 +16,7 @@ export const MAINNET_CONTRACTS = { BNS: "SP000000000000000000002Q6VF78.bns", // Stacking - POX_4: "SP000000000000000000002Q6VF78.pox-4", + POX_5: "SP000000000000000000002Q6VF78.pox-5", // ALEX DEX (SDK handles most operations, but we need pool contract for queries) ALEX_AMM_POOL: "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.amm-swap-pool-v1-1", @@ -166,7 +166,7 @@ export const TESTNET_CONTRACTS = { BNS: "ST000000000000000000002AMW42H.bns", // Stacking - POX_4: "ST000000000000000000002AMW42H.pox-4", + POX_5: "ST000000000000000000002AMW42H.pox-5", // ERC-8004 Identity & Reputation IDENTITY_REGISTRY: "ST3YT0XW92E6T2FE59B2G5N2WNNFSBZ6MZKQS5D18.identity-registry-v2", diff --git a/src/lib/services/stacking.service.test.ts b/src/lib/services/stacking.service.test.ts new file mode 100644 index 00000000..c8575d90 --- /dev/null +++ b/src/lib/services/stacking.service.test.ts @@ -0,0 +1,322 @@ +/** + * Ported from aibtcdev/aibtc-mcp-server tests/services/stacking.test.ts (#682), + * using the service's dependency seam instead of module mocks, plus coverage + * for the active-PoX-contract guard this repo adds. + */ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { Cl, type ClarityValue, cvToJSON, deserializeCV, serializeCV } from "@stacks/transactions"; +import { + PoxVersionUnsupportedError, + StackingService, + buildPayoutCalldata, + type StackingServiceDeps, +} from "./stacking.service.js"; +import { btcAddressToPoxAddr } from "../utils/bitcoin.js"; + +const reads = new Map(); +let burnHeight = 0; +let activePox = "SP000000000000000000002Q6VF78.pox-5"; +let balance = { balance: "10000000000", locked: "0", burnchain_unlock_height: 0 }; +let contractInterface: unknown = { functions: [] }; + +const hiro = { + callReadOnlyFunction: mock(async (_contract: string, fn: string, args: ClarityValue[]) => { + const key = `${fn}:${args.map((a) => JSON.stringify(cvToJSON(a))).join(",")}`; + const value = reads.get(key) ?? reads.get(fn); + if (!value) return { okay: false, cause: `unmocked read ${key}` }; + return { okay: true, result: `0x${serializeCV(value)}` }; + }), + getCoreApiInfo: mock(async () => ({ burn_block_height: burnHeight })), + getStxBalance: mock(async () => balance), + getContractInterface: mock(async () => contractInterface), + getPoxInfo: mock(async () => ({ contract_id: activePox })), +} as unknown as NonNullable; + +const callContract = mock(async (..._args: unknown[]) => ({ txid: "0xabc", rawTx: "00" })); + +function service(): StackingService { + return new StackingService("mainnet", { + hiro, + callContract: callContract as unknown as NonNullable, + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function lastCall(index = 0): any { + return (callContract.mock.calls[index] as unknown[])[1]; +} + +const STAKER = "SP2V64EB40ZBQBV55A294ABWM53G4T5S9PKKPYGKV"; +const MANAGER = "SP8HK160YD5GHXP69VGA0TC7AQJ1X4CDW3XVERSE.xverse-signer-manager-2"; +const OTHER_MANAGER = "SP3ZA8J49HPS7M3KD7EB01Y0ZAJS7VJS2NG87MDGN.planbetter-signer-manager"; +const account = { address: STAKER, privateKey: "00", network: "mainnet" } as never; + +// Mainnet parameters: cycle 143 spans 966350..968449, prepare phase from 968350. +const FIRST = 666050; +const LENGTH = 2100; + +function setStaker(info: { amount: bigint; first: number; cycles: number; signer: string } | null) { + reads.set( + `get-staker-info:${JSON.stringify(cvToJSON(Cl.principal(STAKER)))}`, + info + ? Cl.some( + Cl.tuple({ + "amount-ustx": Cl.uint(info.amount), + "first-reward-cycle": Cl.uint(info.first), + "num-cycles": Cl.uint(info.cycles), + signer: Cl.principal(info.signer), + }) + ) + : Cl.none() + ); +} + +beforeEach(() => { + reads.clear(); + callContract.mockClear(); + reads.set( + "get-pox-info", + Cl.ok( + Cl.tuple({ + "min-amount-ustx": Cl.uint(50_000_000_000n), + "reward-cycle-id": Cl.uint(143), + "prepare-cycle-length": Cl.uint(100), + "first-burnchain-block-height": Cl.uint(FIRST), + "reward-cycle-length": Cl.uint(LENGTH), + "total-liquid-supply-ustx": Cl.uint(1n), + }) + ) + ); + reads.set("get-bond-membership", Cl.none()); + reads.set("get-signer-info", Cl.some(Cl.bufferFromHex("02".padEnd(66, "0")))); + burnHeight = 967282; + activePox = "SP000000000000000000002Q6VF78.pox-5"; + balance = { balance: "10000000000", locked: "0", burnchain_unlock_height: 0 }; + contractInterface = { functions: [] }; + setStaker(null); +}); + +describe("getPoxState", () => { + test("derives the cycle and prepare phase the way pox-5 does", async () => { + expect(await service().getPoxState()).toMatchObject({ + rewardCycle: 143, + nextCycleStartHeight: 968450, + preparePhaseStartHeight: 968350, + inPreparePhase: false, + }); + burnHeight = 968350; + expect((await service().getPoxState()).inPreparePhase).toBe(true); + }); +}); + +describe("stake", () => { + test("stakes with the signer manager, current burn height and a staking post-condition", async () => { + const result = await service().stake(account, { + signerManager: MANAGER, + amountUstx: 2_780_000_000n, + numCycles: 96, + }); + + const options = lastCall(); + expect(options.contractName).toBe("pox-5"); + expect(options.functionName).toBe("stake"); + expect(options.functionArgs.map((a: ClarityValue) => cvToJSON(a).value)).toEqual([ + MANAGER, + "2780000000", + "96", + "967282", + null, + ]); + expect(options.postConditionMode).toBe(2); + expect(options.postConditions).toEqual([ + { type: "staking-postcondition", address: STAKER, condition: "eq", amount: "2780000000" }, + ]); + expect(result.unlockCycle).toBe(240); + expect(result.unlockBurnHeight).toBe(FIRST + 240 * LENGTH); + }); + + test("passes payout calldata as (some buff)", async () => { + const calldata = buildPayoutCalldata({ version: 4, hashbytesHex: "11".repeat(20) }, 3000n); + await service().stake(account, { signerManager: MANAGER, amountUstx: 1_000_000n, numCycles: 1, signerCalldata: calldata }); + expect(cvToJSON(lastCall().functionArgs[4]).type).toBe(`(optional (buff ${calldata.length}))`); + }); + + test("refuses during the prepare phase without signing", async () => { + burnHeight = 968400; + await expect(service().stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 1 })).rejects.toThrow( + /prepare phase/ + ); + expect(callContract).not.toHaveBeenCalled(); + }); + + test("refuses when already staking", async () => { + setStaker({ amount: 5n, first: 144, cycles: 2, signer: MANAGER }); + await expect(service().stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 1 })).rejects.toThrow( + /already staking/ + ); + expect(callContract).not.toHaveBeenCalled(); + }); + + test("refuses an unregistered signer manager", async () => { + reads.set("get-signer-info", Cl.none()); + await expect(service().stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 1 })).rejects.toThrow( + /not a registered pox-5 signer manager/ + ); + }); + + test("refuses more STX than the account holds", async () => { + await expect( + service().stake(account, { signerManager: MANAGER, amountUstx: 20_000_000_000n, numCycles: 1 }) + ).rejects.toThrow(/Insufficient STX/); + }); + + test("rejects an out-of-range lock period", async () => { + await expect(service().stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 97 })).rejects.toThrow( + /between 1 and 96/ + ); + }); +}); + +describe("active PoX contract guard", () => { + test("every write refuses without signing when pox-5 is not the active contract", async () => { + activePox = "SP000000000000000000002Q6VF78.pox-6"; + setStaker({ amount: 5n, first: 144, cycles: 10, signer: MANAGER }); + const svc = service(); + const writes = [ + () => svc.stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 1 }), + () => svc.updateStake(account, { cyclesToExtend: 1, amountIncreaseUstx: 0n }), + () => svc.unstake(account), + () => svc.pullSignerRewards(account, MANAGER, 143, 1n), + () => svc.claimStakerRewards(account, MANAGER, 143, "staker-arg"), + ]; + for (const write of writes) { + await expect(write()).rejects.toBeInstanceOf(PoxVersionUnsupportedError); + } + expect(callContract).not.toHaveBeenCalled(); + }); + + test("refuses when the active contract cannot be read", async () => { + (hiro.getPoxInfo as unknown as ReturnType).mockImplementationOnce(async () => { + throw new Error("boom"); + }); + await expect(service().stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 1 })).rejects.toThrow( + /Could not confirm the active PoX contract/ + ); + expect(callContract).not.toHaveBeenCalled(); + }); +}); + +describe("updateStake", () => { + test("names the current signer as old-signer-manager and locks the new total", async () => { + setStaker({ amount: 64_686_000_000n, first: 144, cycles: 10, signer: MANAGER }); + balance = { balance: "70000000000", locked: "64686000000", burnchain_unlock_height: 0 }; + const result = await service().updateStake(account, { + signerManager: OTHER_MANAGER, + cyclesToExtend: 1, + amountIncreaseUstx: 1_000_000_000n, + }); + + const options = lastCall(); + expect(options.functionName).toBe("stake-update"); + expect(options.functionArgs.map((a: ClarityValue) => cvToJSON(a).value)).toEqual([ + OTHER_MANAGER, + MANAGER, + "1", + "1000000000", + null, + ]); + expect(options.postConditions[0]).toMatchObject({ + type: "staking-postcondition", + condition: "eq", + amount: "65686000000", + }); + expect(result.unlockCycle).toBe(155); + }); + + test("refuses an increase larger than the unlocked balance", async () => { + setStaker({ amount: 5n, first: 144, cycles: 2, signer: MANAGER }); + balance = { balance: "100", locked: "90", burnchain_unlock_height: 0 }; + await expect(service().updateStake(account, { cyclesToExtend: 0, amountIncreaseUstx: 50n })).rejects.toThrow( + /Insufficient unlocked STX/ + ); + }); + + test("refuses a no-op update", async () => { + setStaker({ amount: 5n, first: 144, cycles: 2, signer: MANAGER }); + await expect(service().updateStake(account, { cyclesToExtend: 0, amountIncreaseUstx: 0n })).rejects.toThrow( + /Nothing to update/ + ); + }); +}); + +describe("unstake", () => { + test("unstakes from the current signer with a pox post-condition", async () => { + setStaker({ amount: 5n, first: 144, cycles: 10, signer: MANAGER }); + const result = await service().unstake(account); + + const options = lastCall(); + expect(options.functionName).toBe("unstake"); + expect(cvToJSON(options.functionArgs[0]).value).toBe(MANAGER); + expect(options.postConditions[0]).toMatchObject({ type: "pox-postcondition", condition: "will-perform" }); + expect(result.unlockCycle).toBe(144); + }); + + test("refuses when the stake already unlocks next cycle", async () => { + setStaker({ amount: 5n, first: 143, cycles: 1, signer: MANAGER }); + await expect(service().unstake(account)).rejects.toThrow(/would not unlock it sooner/); + }); +}); + +describe("signer managers", () => { + test("reads the claim path from the manager's interface", async () => { + const svc = service(); + const iface = (args: string[]) => ({ + functions: [ + { + name: "claim-staker-rewards", + access: "public", + args: args.map((name) => ({ name, type: "uint128" })), + outputs: { type: "" }, + }, + ], + }); + contractInterface = iface(["staker", "reward-cycle", "bond-index"]); + expect(await svc.getClaimStyle(MANAGER)).toBe("staker-arg"); + contractInterface = iface(["reward-cycle", "bond-index"]); + expect(await svc.getClaimStyle(MANAGER)).toBe("caller"); + contractInterface = { functions: [] }; + expect(await svc.getClaimStyle(OTHER_MANAGER)).toBe("none"); + }); + + test("pulls STX-staker rewards with an empty bond list, then claims for the staker", async () => { + const svc = service(); + await svc.pullSignerRewards(account, MANAGER, 143, 500n); + await svc.claimStakerRewards(account, MANAGER, 143, "staker-arg"); + + const pull = lastCall(0); + const claim = lastCall(1); + expect(pull.functionName).toBe("claim-rewards"); + expect(cvToJSON(pull.functionArgs[0]).value).toEqual([]); + expect(pull.postConditions[0]).toMatchObject({ + condition: "gte", + amount: "500", + address: "SP000000000000000000002Q6VF78.pox-5", + }); + expect(claim.functionName).toBe("claim-staker-rewards"); + expect(claim.functionArgs.map((a: ClarityValue) => cvToJSON(a).value)).toEqual([STAKER, "143", null]); + // Nothing may leave the caller: the only condition is on the manager. + expect(claim.postConditions).toHaveLength(1); + expect(claim.postConditions[0].address).toBe(MANAGER); + }); +}); + +describe("payout calldata", () => { + test("encodes { pox-addr, max-fee } from a Bitcoin address", () => { + const poxAddr = btcAddressToPoxAddr("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "mainnet"); + expect(poxAddr.version).toBe(4); + const decoded = cvToJSON(deserializeCV(Buffer.from(buildPayoutCalldata(poxAddr, 2500n)).toString("hex"))); + expect(decoded.value["max-fee"].value).toBe("2500"); + expect(decoded.value["pox-addr"].value.version.value).toBe("0x04"); + expect(decoded.value["pox-addr"].value.hashbytes.value).toBe(`0x${poxAddr.hashbytesHex}`); + }); +}); diff --git a/src/lib/services/stacking.service.ts b/src/lib/services/stacking.service.ts index 3c1380eb..2903798d 100644 --- a/src/lib/services/stacking.service.ts +++ b/src/lib/services/stacking.service.ts @@ -1,84 +1,449 @@ +/** + * PoX-5 stacking. + * + * PoX-5 replaced pox-4 at reward cycle 141 on mainnet, and it is not a rename: + * + * - Every stake names a SIGNER MANAGER, a contract implementing pox-5's + * `signer-manager-trait`. There is no pool delegation (`delegate-stx`) and no + * PoX reward address on the stake itself; the manager's `validate-stake!` + * decides who may join and reads any payout preferences out of + * `signer-calldata`. + * - Rewards are paid in sBTC to the signer manager, not the staker. Getting a + * staker paid takes the manager pulling its share out of pox-5 + * (`manager.claim-rewards`) and then paying the staker + * (`manager.claim-staker-rewards`). Each manager implements that second step + * its own way, and some pay out off-chain, so the claim path is read from the + * manager's interface rather than assumed. + * - `stake`, `stake-update` and `unstake` are refused during the prepare phase + * (the last `prepare-cycle-length` blocks of a cycle). + * + * Locking STX is not a transfer, so these calls carry pox-5's own post-condition + * types (a staking lock amount, or "performs PoX") rather than STX transfer + * conditions. + * + * Every write first confirms pox-5 is still the network's active PoX contract, + * so a future PoX version refuses before signing instead of aborting on chain. + * + * Ported from aibtcdev/aibtc-mcp-server#682; keep the two in step. + */ + import { ClarityValue, - uintCV, - tupleCV, + Pc, + PostConditionMode, bufferCV, + contractPrincipalCV, + cvToJSON, + hexToCV, + listCV, noneCV, - someCV, principalCV, - hexToCV, - cvToValue, + serializeCV, + someCV, + tupleCV, + uintCV, } from "@stacks/transactions"; -import { HiroApiService, getHiroApi, PoxInfo } from "./hiro-api.js"; +import { HiroApiService, getHiroApi } from "./hiro-api.js"; import { getContracts, parseContractId, type Network } from "../config/index.js"; import { callContract, type Account, type TransferResult } from "../transactions/builder.js"; -import { createStxPostCondition } from "../transactions/post-conditions.js"; + +// ============================================================================ +// Constants mirrored from pox-5 +// ============================================================================ + +/** pox-5 `MAX_NUM_CYCLES`. */ +export const MAX_STAKE_CYCLES = 96; + +/** Hard stop when walking the signer-set linked list. */ +const MAX_SIGNERS_WALKED = 200; + // ============================================================================ // Types // ============================================================================ -export interface StackingStatus { - stacked: boolean; - amountMicroStx: string; - amountStx: string; +export interface PoxState { + contractId: string; + burnHeight: number; + rewardCycle: number; + firstBurnHeight: number; + rewardCycleLength: number; + prepareCycleLength: number; + /** Burn height where the next reward cycle begins. */ + nextCycleStartHeight: number; + /** Burn height where this cycle's prepare phase begins. */ + preparePhaseStartHeight: number; + inPreparePhase: boolean; + /** Minimum a signer needs delegated to join the signer set (not a per-staker minimum). */ + signerSetMinUstx: bigint; + totalLiquidSupplyUstx: bigint; +} + +export interface StakerInfo { + amountUstx: bigint; firstRewardCycle: number; - lockPeriod: number; - unlockHeight: number; - poxAddress?: string; - /** The PoX contract the network currently runs (from /v2/pox). */ - activePoxContract?: string; - /** pox-5 only: the signer the STX is staked with. */ - signer?: string; - /** Set when the result comes with a caveat the caller should surface. */ - warning?: string; + numCycles: number; + /** First cycle in which the STX is unlocked. */ + unlockCycle: number; + unlockBurnHeight: number; + signerManager: string; +} + +export interface BondMembership { + bondIndex: number; + amountUstx: bigint; + amountSats: bigint; + isL1Lock: boolean; + signerManager: string; +} + +export interface StakingStatus { + address: string; + pox: PoxState; + staking: StakerInfo | null; + bond: BondMembership | null; + account: { + balanceUstx: bigint; + lockedUstx: bigint; + unlockedUstx: bigint; + burnchainUnlockHeight: number; + }; +} + +export interface SignerSetEntry { + signerManager: string; + delegatedUstx: bigint; +} + +/** + * How a signer manager pays its stakers, read from its public interface. + * - `staker-arg`: `claim-staker-rewards(staker, reward-cycle, bond-index)`, callable by anyone + * - `caller`: `claim-staker-rewards(reward-cycle, bond-index)`, pays the caller + * - `none`: no on-chain staker claim (the manager pays off-chain, or not at all) + */ +export type ClaimStyle = "staker-arg" | "caller" | "none"; + +export interface StakeOptions { + signerManager: string; + amountUstx: bigint; + numCycles: number; + signerCalldata?: Uint8Array; +} + +export interface UpdateStakeOptions { + /** New signer manager; defaults to the current one. */ + signerManager?: string; + cyclesToExtend: number; + amountIncreaseUstx: bigint; + signerCalldata?: Uint8Array; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Decode a Clarity value to plain JS: tuples to objects, lists to arrays, + * optionals to value-or-null, responses to their inner value, uints to strings. + * (`cvToValue` only unwraps the outermost layer.) + */ +function plain(cv: ClarityValue): unknown { + const unwrap = (node: unknown): unknown => { + if (node === null || typeof node !== "object") return node; + if (Array.isArray(node)) return node.map(unwrap); + const n = node as { type?: string; value?: unknown }; + if (typeof n.type === "string" && "value" in n) { + if (n.type.startsWith("(optional") && n.value === null) return null; + return unwrap(n.value); + } + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, unwrap(v)])); + }; + return unwrap(cvToJSON(cv)); +} + +function toNumber(v: unknown): number { + return Number(v as bigint | number | string); +} + +function toBigInt(v: unknown): bigint { + return BigInt(v as bigint | number | string); +} + +function assertContractId(id: string, label: string): void { + if (!/^S[PMTN][0-9A-Z]{28,40}\.[a-zA-Z][a-zA-Z0-9-_]{0,39}$/.test(id)) { + throw new Error(`${label} must be a contract id like SP....name, got "${id}"`); + } +} + +function contractCV(id: string): ClarityValue { + const { address, name } = parseContractId(id); + return contractPrincipalCV(address, name); +} + +function calldataCV(calldata?: Uint8Array): ClarityValue { + if (!calldata) return noneCV(); + if (calldata.length > 500) { + throw new Error(`signer calldata is ${calldata.length} bytes; pox-5 accepts at most 500`); + } + return someCV(bufferCV(calldata)); } /** - * Raised by write operations when the network no longer runs pox-4. pox-5 - * (Epoch 4.0) removed stack-stx, stack-extend, stack-increase, delegate-stx - * and revoke-delegate-stx in favour of signer-manager staking (stake, - * stake-update, unstake), so building these calls would only broadcast a - * transaction that aborts and still costs the fee. + * Payout calldata in the shape reference signer managers decode: + * `{ pox-addr: { version, hashbytes }, max-fee }`. Managers derived from the + * pox-5 reference implementation use it to pay rewards to a Bitcoin address via + * an sBTC withdrawal, capped at `max-fee` sats of withdrawal fee. Whether a given + * manager accepts it is up to that manager's `validate-stake!`. */ +export function buildPayoutCalldata( + poxAddr: { version: number; hashbytesHex: string }, + maxFeeSats: bigint +): Uint8Array { + const serialized = serializeCV( + tupleCV({ + "pox-addr": tupleCV({ + version: bufferCV(Uint8Array.from([poxAddr.version])), + hashbytes: bufferCV(Buffer.from(poxAddr.hashbytesHex, "hex")), + }), + "max-fee": uintCV(maxFeeSats), + }) + ); + return typeof serialized === "string" + ? Uint8Array.from(Buffer.from(serialized, "hex")) + : serialized; +} + + +/** Raised by writes when the network's active PoX contract is not the one this service targets. */ export class PoxVersionUnsupportedError extends Error { - constructor(public readonly activePoxContract: string) { + constructor( + public readonly activePoxContract: string, + public readonly supportedPoxContract: string + ) { super( `Stacking writes are disabled: this network's active PoX contract is ${activePoxContract}, ` + - `but this skill only supports pox-4. pox-5 replaced stack-stx / stack-extend / stack-increase / ` + - `delegate-stx / revoke-delegate-stx with signer-manager staking (stake, stake-update, unstake), ` + - `which this skill does not implement yet. No transaction was sent.` + `but this skill targets ${supportedPoxContract}. No transaction was sent.` ); this.name = "PoxVersionUnsupportedError"; } } // ============================================================================ -// Stacking Service +// Service // ============================================================================ +/** Test seam: replace the Stacks API client and the contract-call builder. */ +export interface StackingServiceDeps { + hiro?: Pick< + HiroApiService, + "callReadOnlyFunction" | "getCoreApiInfo" | "getStxBalance" | "getContractInterface" | "getPoxInfo" + >; + callContract?: typeof callContract; +} + export class StackingService { - private hiro: HiroApiService; - private contracts: ReturnType; + private hiro: NonNullable; + private call: typeof callContract; + private poxContract: string; + + constructor( + private network: Network, + deps: StackingServiceDeps = {} + ) { + this.hiro = deps.hiro ?? getHiroApi(network); + this.call = deps.callContract ?? callContract; + this.poxContract = getContracts(network).POX_5; + } - constructor(private network: Network) { - this.hiro = getHiroApi(network); - this.contracts = getContracts(network); + get contractId(): string { + return this.poxContract; } - /** - * Get current PoX (Proof of Transfer) info - */ - async getPoxInfo(): Promise { - return this.hiro.getPoxInfo(); + private async read( + contractId: string, + functionName: string, + args: ClarityValue[] = [] + ): Promise { + const sender = parseContractId(this.poxContract).address; + const result = await this.hiro.callReadOnlyFunction(contractId, functionName, args, sender); + if (!result.okay || !result.result) { + throw new Error(`${contractId}::${functionName} failed: ${result.cause ?? "no result"}`); + } + return plain(hexToCV(result.result)); } - /** - * Refuse a write unless the network's active PoX contract is the pox-4 this - * service builds calls for. Fails closed: if the active contract cannot be - * read, nothing is signed. - */ - private async assertPox4Active(): Promise { + private readPox(functionName: string, args: ClarityValue[] = []): Promise { + return this.read(this.poxContract, functionName, args); + } + + // -------------------------------------------------------------------------- + // Reads + // -------------------------------------------------------------------------- + + async getPoxState(): Promise { + const [info, core] = await Promise.all([ + this.readPox("get-pox-info") as Promise>, + this.hiro.getCoreApiInfo(), + ]); + const firstBurnHeight = toNumber(info["first-burnchain-block-height"]); + const rewardCycleLength = toNumber(info["reward-cycle-length"]); + const prepareCycleLength = toNumber(info["prepare-cycle-length"]); + const burnHeight = core.burn_block_height; + // Mirrors pox-5 burn-height-to-reward-cycle / reward-cycle-to-burn-height. + const rewardCycle = Math.floor((burnHeight - firstBurnHeight) / rewardCycleLength); + const nextCycleStartHeight = firstBurnHeight + (rewardCycle + 1) * rewardCycleLength; + const preparePhaseStartHeight = nextCycleStartHeight - prepareCycleLength; + + return { + contractId: this.poxContract, + burnHeight, + rewardCycle, + firstBurnHeight, + rewardCycleLength, + prepareCycleLength, + nextCycleStartHeight, + preparePhaseStartHeight, + inPreparePhase: burnHeight >= preparePhaseStartHeight, + signerSetMinUstx: toBigInt(info["min-amount-ustx"]), + totalLiquidSupplyUstx: toBigInt(info["total-liquid-supply-ustx"]), + }; + } + + private rewardCycleStartHeight(pox: PoxState, cycle: number): number { + return pox.firstBurnHeight + cycle * pox.rewardCycleLength; + } + + async getStakerInfo(address: string, pox?: PoxState): Promise { + const raw = (await this.readPox("get-staker-info", [principalCV(address)])) as Record< + string, + unknown + > | null; + if (!raw) return null; + const state = pox ?? (await this.getPoxState()); + const firstRewardCycle = toNumber(raw["first-reward-cycle"]); + const numCycles = toNumber(raw["num-cycles"]); + const unlockCycle = firstRewardCycle + numCycles; + return { + amountUstx: toBigInt(raw["amount-ustx"]), + firstRewardCycle, + numCycles, + unlockCycle, + unlockBurnHeight: this.rewardCycleStartHeight(state, unlockCycle), + signerManager: String(raw["signer"]), + }; + } + + async getBondMembership(address: string): Promise { + const raw = (await this.readPox("get-bond-membership", [principalCV(address)])) as Record< + string, + unknown + > | null; + if (!raw) return null; + return { + bondIndex: toNumber(raw["bond-index"]), + amountUstx: toBigInt(raw["amount-ustx"]), + amountSats: toBigInt(raw["amount-sats"]), + isL1Lock: Boolean(raw["is-l1-lock"]), + signerManager: String(raw["signer"]), + }; + } + + async getStakingStatus(address: string): Promise { + const pox = await this.getPoxState(); + const [staking, bond, balance] = await Promise.all([ + this.getStakerInfo(address, pox), + this.getBondMembership(address), + this.hiro.getStxBalance(address), + ]); + const balanceUstx = BigInt(balance.balance); + const lockedUstx = BigInt(balance.locked); + return { + address, + pox, + staking, + bond, + account: { + balanceUstx, + lockedUstx, + unlockedUstx: balanceUstx - lockedUstx, + burnchainUnlockHeight: balance.burnchain_unlock_height, + }, + }; + } + + /** Whether pox-5 has a signer key registered for this manager. */ + async isRegisteredSigner(signerManager: string): Promise { + const key = await this.readPox("get-signer-info", [contractCV(signerManager)]); + return key !== null; + } + + /** Walk pox-5's signer-set linked list for a reward cycle. */ + async getSignerSet(cycle: number): Promise { + const entries: SignerSetEntry[] = []; + let next = (await this.readPox("get-signer-set-first-item-for-cycle", [ + uintCV(cycle), + ])) as string | null; + + while (next !== null && entries.length < MAX_SIGNERS_WALKED) { + const signer = next; + const delegated = await this.readPox("get-amount-delegated-for-signer", [ + principalCV(signer), + uintCV(cycle), + ]); + entries.push({ signerManager: signer, delegatedUstx: toBigInt(delegated) }); + next = (await this.readPox("get-signer-set-next-item-for-cycle", [ + principalCV(signer), + uintCV(cycle), + ])) as string | null; + } + return entries; + } + + async getClaimStyle(signerManager: string): Promise { + const iface = await this.hiro.getContractInterface(signerManager); + const fn = iface.functions.find( + (f) => f.name === "claim-staker-rewards" && f.access === "public" + ); + if (!fn) return "none"; + const names = fn.args.map((a) => a.name); + if (names.length === 3 && names[0] === "staker" && names[1] === "reward-cycle") { + return "staker-arg"; + } + if (names.length === 2 && names[0] === "reward-cycle") { + return "caller"; + } + return "none"; + } + + /** sBTC the staker has earned from this signer for a cycle and not yet claimed (before manager fees). */ + async getStakerUnclaimedRewards( + signerManager: string, + rewardCycle: number, + staker: string + ): Promise { + return toBigInt( + await this.readPox("get-earned-staker-rewards", [ + principalCV(signerManager), + uintCV(rewardCycle), + noneCV(), + principalCV(staker), + ]) + ); + } + + /** sBTC pox-5 still holds for the signer manager for a cycle (not yet pulled by the manager). */ + async getSignerUnpulledRewards(signerManager: string, rewardCycle: number): Promise { + return toBigInt( + await this.readPox("get-earned", [principalCV(signerManager), uintCV(rewardCycle), noneCV()]) + ); + } + + // -------------------------------------------------------------------------- + // Writes + // -------------------------------------------------------------------------- + + /** Refuse to sign unless pox-5 is the network's active PoX contract. */ + private async assertPox5Active(): Promise { let active: string; try { active = (await this.hiro.getPoxInfo()).contract_id; @@ -88,256 +453,251 @@ export class StackingService { `(${error instanceof Error ? error.message : String(error)}). No transaction was sent.` ); } - if (active !== this.contracts.POX_4) { - throw new PoxVersionUnsupportedError(active || "(unknown)"); + if (active !== this.poxContract) { + throw new PoxVersionUnsupportedError(active || "(unknown)", this.poxContract); } } - /** - * Get stacking status for an address - * Note: Returns whether the address is stacking, but detailed amounts require proper CV parsing - */ - async getStackingStatus(address: string): Promise { - let active: string | undefined; - try { - active = (await this.hiro.getPoxInfo()).contract_id; - } catch { - // Unknown: fall back to the pox-4 read below. + private assertNotPreparePhase(pox: PoxState, action: string): void { + if (pox.inPreparePhase) { + throw new Error( + `pox-5 refuses ${action} during the prepare phase. Burn height ${pox.burnHeight} is inside ` + + `the prepare phase that started at ${pox.preparePhaseStartHeight}; try again at or after ` + + `burn height ${pox.nextCycleStartHeight} (reward cycle ${pox.rewardCycle + 1}).` + ); } - if (active && active !== this.contracts.POX_4) { - return this.getPox5StakingStatus(address, active); + } + + async stake(account: Account, options: StakeOptions): Promise { + const { signerManager, amountUstx, numCycles, signerCalldata } = options; + assertContractId(signerManager, "signerManager"); + if (amountUstx <= 0n) throw new Error("amount must be greater than zero"); + if (!Number.isInteger(numCycles) || numCycles < 1 || numCycles > MAX_STAKE_CYCLES) { + throw new Error(`numCycles must be an integer between 1 and ${MAX_STAKE_CYCLES}`); } - try { - const result = await this.hiro.callReadOnlyFunction( - this.contracts.POX_4, - "get-stacker-info", - [{ type: "principal", value: address } as unknown as ClarityValue], - address + await this.assertPox5Active(); + const status = await this.getStakingStatus(account.address); + const { pox } = status; + this.assertNotPreparePhase(pox, "stake"); + if (status.staking) { + throw new Error( + `${account.address} is already staking ${status.staking.amountUstx} uSTX with ` + + `${status.staking.signerManager}. Use extend_stacking to extend, increase or switch signer.` ); - - if (result.okay && result.result) { - const isStacked = result.result.includes("some"); - return { - stacked: isStacked, - amountMicroStx: "0", // Requires CV parsing - amountStx: "0", - firstRewardCycle: 0, - lockPeriod: 0, - unlockHeight: 0, - }; - } - } catch { - // Stacker info not found } + // pox-5 counts locked + unlocked STX (a bond rolling over into a stake is still locked). + if (status.account.balanceUstx < amountUstx) { + throw new Error( + `Insufficient STX: ${status.account.balanceUstx} uSTX total, ${amountUstx} uSTX requested.` + ); + } + if (!(await this.isRegisteredSigner(signerManager))) { + throw new Error( + `${signerManager} is not a registered pox-5 signer manager. Use list_stacking_signers to pick one.` + ); + } + + const { address, name } = parseContractId(this.poxContract); + const result = await this.call(account, { + contractAddress: address, + contractName: name, + functionName: "stake", + functionArgs: [ + contractCV(signerManager), + uintCV(amountUstx), + uintCV(numCycles), + // Any height in the current cycle makes the next cycle the first reward cycle. + uintCV(pox.burnHeight), + calldataCV(signerCalldata), + ], + postConditionMode: PostConditionMode.Deny, + postConditions: [Pc.principal(account.address).willSendEq(amountUstx).ustxToLock()], + }); + const unlockCycle = pox.rewardCycle + 1 + numCycles; return { - stacked: false, - amountMicroStx: "0", - amountStx: "0", - firstRewardCycle: 0, - lockPeriod: 0, - unlockHeight: 0, + ...result, + pox, + unlockCycle, + unlockBurnHeight: this.rewardCycleStartHeight(pox, unlockCycle), }; } - /** - * Status under pox-5, read from `get-staker-info`: - * (optional { amount-ustx, first-reward-cycle, num-cycles, signer }). - * Anything unexpected throws rather than reporting "not stacking". - */ - private async getPox5StakingStatus(address: string, poxContract: string): Promise { - const result = await this.hiro.callReadOnlyFunction( - poxContract, - "get-staker-info", - [principalCV(address)], - address - ); - if (!result.okay || !result.result) { - throw new Error(`${poxContract} get-staker-info failed: ${result.cause ?? "no result"}`); + async updateStake( + account: Account, + options: UpdateStakeOptions + ): Promise { + const { cyclesToExtend, amountIncreaseUstx, signerCalldata } = options; + if (!Number.isInteger(cyclesToExtend) || cyclesToExtend < 0) { + throw new Error("cyclesToExtend must be a non-negative integer"); } - const info = cvToValue(hexToCV(result.result)) as - | { value?: Record } - | null; - const tuple = info?.value; - const base = { activePoxContract: poxContract, unlockHeight: 0 }; - if (!tuple) { - return { ...base, stacked: false, amountMicroStx: "0", amountStx: "0", firstRewardCycle: 0, lockPeriod: 0 }; + if (amountIncreaseUstx < 0n) throw new Error("amountIncrease must not be negative"); + + await this.assertPox5Active(); + const status = await this.getStakingStatus(account.address); + const { pox } = status; + const current = status.staking; + if (!current) { + throw new Error(`${account.address} is not staking. Use stack_stx to start.`); + } + this.assertNotPreparePhase(pox, "stake-update"); + + const signerManager = options.signerManager ?? current.signerManager; + assertContractId(signerManager, "signerManager"); + if ( + cyclesToExtend === 0 && + amountIncreaseUstx === 0n && + signerManager === current.signerManager && + !signerCalldata + ) { + throw new Error("Nothing to update: pass cyclesToExtend, amountIncrease, a new signerManager or payout calldata."); + } + + const unlockCycle = current.unlockCycle + cyclesToExtend; + // pox-5 recomputes num-cycles from the next cycle to the unlock cycle. + const numCycles = unlockCycle - pox.rewardCycle - 1; + if (numCycles < 1 || numCycles > MAX_STAKE_CYCLES) { + throw new Error( + `The lock would run ${numCycles} cycles from the next cycle; pox-5 allows 1 to ${MAX_STAKE_CYCLES}.` + ); } - const amount = BigInt(tuple["amount-ustx"]?.value ?? "0"); + if (status.account.unlockedUstx < amountIncreaseUstx) { + throw new Error( + `Insufficient unlocked STX: ${status.account.unlockedUstx} uSTX unlocked, ${amountIncreaseUstx} uSTX requested.` + ); + } + if (signerManager !== current.signerManager && !(await this.isRegisteredSigner(signerManager))) { + throw new Error( + `${signerManager} is not a registered pox-5 signer manager. Use list_stacking_signers to pick one.` + ); + } + + const newAmountUstx = current.amountUstx + amountIncreaseUstx; + const { address, name } = parseContractId(this.poxContract); + const result = await this.call(account, { + contractAddress: address, + contractName: name, + functionName: "stake-update", + functionArgs: [ + contractCV(signerManager), + contractCV(current.signerManager), + uintCV(cyclesToExtend), + uintCV(amountIncreaseUstx), + calldataCV(signerCalldata), + ], + postConditionMode: PostConditionMode.Deny, + // The staking condition is on the resulting total lock, not the increment. + postConditions: [Pc.principal(account.address).willSendEq(newAmountUstx).ustxToLock()], + }); + return { - ...base, - stacked: true, - amountMicroStx: amount.toString(), - amountStx: formatUstx(amount), - firstRewardCycle: Number(tuple["first-reward-cycle"]?.value ?? 0), - lockPeriod: Number(tuple["num-cycles"]?.value ?? 0), - signer: tuple.signer?.value, - warning: "unlockHeight is not reported for pox-5 positions.", + ...result, + previous: current, + newAmountUstx, + signerManager, + unlockCycle, + unlockBurnHeight: this.rewardCycleStartHeight(pox, unlockCycle), }; } + async unstake( + account: Account + ): Promise { + await this.assertPox5Active(); + const status = await this.getStakingStatus(account.address); + const { pox } = status; + const current = status.staking; + if (!current) { + throw new Error(`${account.address} is not staking.`); + } + this.assertNotPreparePhase(pox, "unstake"); + if (current.unlockCycle <= pox.rewardCycle + 1) { + throw new Error( + `This stake already unlocks at the start of cycle ${current.unlockCycle} ` + + `(burn height ${current.unlockBurnHeight}); unstaking would not unlock it sooner.` + ); + } - /** - * Stack STX tokens - */ - async stack( - account: Account, - amount: bigint, - poxAddress: { version: number; hashbytes: string }, - startBurnHeight: number, - lockPeriod: number - ): Promise { - await this.assertPox4Active(); - const { address: contractAddress, name: contractName } = parseContractId(this.contracts.POX_4); - - const functionArgs: ClarityValue[] = [ - uintCV(amount), - tupleCV({ - version: bufferCV(Buffer.from([poxAddress.version])), - hashbytes: bufferCV(Buffer.from(poxAddress.hashbytes, "hex")), - }), - uintCV(startBurnHeight), - uintCV(lockPeriod), - ]; - - // Add post condition: sender must lock exactly `amount` of STX - const postCondition = createStxPostCondition( - account.address, - "eq", - amount - ); - - return callContract(account, { - contractAddress, - contractName, - functionName: "stack-stx", - functionArgs, - postConditions: [postCondition], + const { address, name } = parseContractId(this.poxContract); + const result = await this.call(account, { + contractAddress: address, + contractName: name, + functionName: "unstake", + functionArgs: [contractCV(current.signerManager)], + postConditionMode: PostConditionMode.Deny, + postConditions: [Pc.origin().willPerformPox()], }); - } - /** - * Extend stacking period - */ - async extendStacking( - account: Account, - extendCount: number, - poxAddress: { version: number; hashbytes: string } - ): Promise { - await this.assertPox4Active(); - const { address: contractAddress, name: contractName } = parseContractId(this.contracts.POX_4); - - const functionArgs: ClarityValue[] = [ - uintCV(extendCount), - tupleCV({ - version: bufferCV(Buffer.from([poxAddress.version])), - hashbytes: bufferCV(Buffer.from(poxAddress.hashbytes, "hex")), - }), - ]; - - // No assets moved from sender (extends existing lock period) - return callContract(account, { - contractAddress, - contractName, - functionName: "stack-extend", - functionArgs, - postConditions: [], - }); + const unlockCycle = pox.rewardCycle + 1; + return { + ...result, + previous: current, + unlockCycle, + unlockBurnHeight: this.rewardCycleStartHeight(pox, unlockCycle), + }; } /** - * Increase stacking amount + * Have the signer manager pull its STX-staking rewards for a cycle out of pox-5. + * Permissionless on reference managers. An empty bond-period list claims the + * STX-staker bucket only, which is all an STX staker's payout draws on. */ - async increaseStacking( + async pullSignerRewards( account: Account, - increaseAmount: bigint + signerManager: string, + rewardCycle: number, + unpulledSats: bigint ): Promise { - await this.assertPox4Active(); - const { address: contractAddress, name: contractName } = parseContractId(this.contracts.POX_4); - - const functionArgs: ClarityValue[] = [uintCV(increaseAmount)]; - - // Add post condition: sender must lock exactly `increaseAmount` of additional STX - const postCondition = createStxPostCondition( - account.address, - "eq", - increaseAmount - ); - - return callContract(account, { - contractAddress, - contractName, - functionName: "stack-increase", - functionArgs, - postConditions: [postCondition], + await this.assertPox5Active(); + const { address, name } = parseContractId(signerManager); + const sbtc = getContracts(this.network).SBTC_TOKEN as `${string}.${string}`; + return this.call(account, { + contractAddress: address, + contractName: name, + functionName: "claim-rewards", + functionArgs: [ + listCV([]), + uintCV(rewardCycle), + ], + postConditionMode: PostConditionMode.Deny, + // pox-5 pays the manager; more may have accrued by the time this mines. + postConditions: [Pc.principal(this.poxContract as `${string}.${string}`).willSendGte(unpulledSats).ft(sbtc, "sbtc-token")], }); } - /** - * Delegate STX to a stacking pool - */ - async delegateStx( + /** Pay this staker their share of a cycle's rewards through the signer manager. */ + async claimStakerRewards( account: Account, - amount: bigint, - delegateTo: string, - untilBurnHeight?: number, - poxAddress?: { version: number; hashbytes: string } + signerManager: string, + rewardCycle: number, + style: Exclude ): Promise { - await this.assertPox4Active(); - const { address: contractAddress, name: contractName } = parseContractId(this.contracts.POX_4); - - const functionArgs: ClarityValue[] = [ - uintCV(amount), - { type: "principal", value: delegateTo } as unknown as ClarityValue, - untilBurnHeight ? someCV(uintCV(untilBurnHeight)) : noneCV(), - poxAddress - ? someCV(tupleCV({ - version: bufferCV(Buffer.from([poxAddress.version])), - hashbytes: bufferCV(Buffer.from(poxAddress.hashbytes, "hex")), - })) - : noneCV(), - ]; - - // No assets moved from sender (delegation is permission, not transfer) - return callContract(account, { - contractAddress, - contractName, - functionName: "delegate-stx", - functionArgs, - postConditions: [], - }); - } - - /** - * Revoke delegation - */ - async revokeDelegation(account: Account): Promise { - await this.assertPox4Active(); - const { address: contractAddress, name: contractName } = parseContractId(this.contracts.POX_4); - - // No assets moved from sender (revokes delegation permission) - return callContract(account, { - contractAddress, - contractName, - functionName: "revoke-delegate-stx", - functionArgs: [], - postConditions: [], + await this.assertPox5Active(); + const { address, name } = parseContractId(signerManager); + const sbtc = getContracts(this.network).SBTC_TOKEN as `${string}.${string}`; + const args = + style === "staker-arg" + ? [principalCV(account.address), uintCV(rewardCycle), noneCV()] + : [uintCV(rewardCycle), noneCV()]; + return this.call(account, { + contractAddress: address, + contractName: name, + functionName: "claim-staker-rewards", + functionArgs: args, + postConditionMode: PostConditionMode.Deny, + // The manager sends sBTC to the staker (or into an sBTC withdrawal to their + // BTC address). Fees and earlier settled balances make the exact amount + // manager-specific; nothing may leave the caller. + postConditions: [Pc.principal(signerManager as `${string}.${string}`).willSendGte(1).ft(sbtc, "sbtc-token")], }); } - } // ============================================================================ // Helper Functions // ============================================================================ -function formatUstx(ustx: bigint): string { - const whole = ustx / 1_000_000n; - const frac = (ustx % 1_000_000n).toString().padStart(6, "0").replace(/0+$/, ""); - return frac ? `${whole}.${frac}` : whole.toString(); -} - let _stackingServiceInstance: StackingService | null = null; export function getStackingService(network: Network): StackingService { diff --git a/src/lib/utils/bitcoin.ts b/src/lib/utils/bitcoin.ts index 56e054c9..589d3250 100644 --- a/src/lib/utils/bitcoin.ts +++ b/src/lib/utils/bitcoin.ts @@ -427,3 +427,35 @@ export function deriveTaprootKeyPair( internalPubKeyBytes, }; } + +/** + * Decode a Bitcoin address into the `{ version, hashbytes }` pox-addr tuple used by + * Stacks contracts (sBTC withdrawals, PoX signer-manager payout addresses). + */ +export function btcAddressToPoxAddr( + address: string, + network: Network +): { version: number; hashbytesHex: string } { + const decoded = btc + .Address(network === "mainnet" ? btc.NETWORK : btc.TEST_NETWORK) + .decode(address); + + if (decoded.type === "tr" && decoded.pubkey) { + return { version: 0x06, hashbytesHex: Buffer.from(decoded.pubkey).toString("hex") }; + } + + switch (decoded.type) { + case "pkh": + return { version: 0x00, hashbytesHex: Buffer.from(decoded.hash).toString("hex") }; + case "sh": + return { version: 0x01, hashbytesHex: Buffer.from(decoded.hash).toString("hex") }; + case "wpkh": + return { version: 0x04, hashbytesHex: Buffer.from(decoded.hash).toString("hex") }; + case "wsh": + return { version: 0x05, hashbytesHex: Buffer.from(decoded.hash).toString("hex") }; + default: + throw new Error( + "Unsupported BTC address type. Supported: P2PKH, P2SH, P2WPKH, P2WSH, P2TR." + ); + } +} diff --git a/stacking/AGENT.md b/stacking/AGENT.md index 05d08ae8..46ff98d5 100644 --- a/stacking/AGENT.md +++ b/stacking/AGENT.md @@ -1,75 +1,75 @@ --- name: stacking-agent skill: stacking -description: STX stacking operations on Stacks — query PoX cycle info, check stacking status, lock STX to earn BTC rewards, and extend an existing stacking lock period. +description: PoX-5 STX staking on Stacks — cycle state, staking status, signer managers, stake / update / unstake, and sBTC reward claims. --- # Stacking Agent -This agent handles Proof of Transfer (PoX) stacking operations on the Stacks blockchain. Stacking locks STX tokens for a specified number of reward cycles to earn Bitcoin rewards. Read operations (`get-pox-info`, `get-stacking-status`) work without a wallet. Write operations (`stack-stx`, `extend-stacking`) require an unlocked wallet and a valid Bitcoin reward address. +Handles STX staking on PoX-5. A stake locks STX with a **signer manager** contract for a number of reward cycles; rewards accrue in sBTC to the manager and are claimed per cycle. Read subcommands need no wallet. Writes need an unlocked wallet and always confirm pox-5 is the active PoX contract before signing. ## Prerequisites -- **Check `activePoxContract` from `get-pox-info` first.** If it is not `...pox-4` (mainnet runs pox-5 since Epoch 4.0), write operations are unsupported and refuse without sending a transaction; only the read operations are usable - -- Wallet unlocked via `bun run wallet/wallet.ts unlock` (for `stack-stx` and `extend-stacking` only) -- Sufficient STX balance to meet the minimum stacking threshold (check with `get-pox-info`) -- A Bitcoin reward address expressed as version byte + hashbytes hex (not base58check) -- `--start-burn-height` must fall within the prepare phase of the current PoX cycle -- `--lock-period` between 1 and 12 cycles; each mainnet cycle is approximately 2 weeks +- Wallet unlocked via `bun run wallet/wallet.ts unlock` (for `stack-stx`, `extend-stacking`, `unstake-stx`, `claim-rewards`) +- `NETWORK=mainnet` for mainnet staking (default is testnet) +- STX for the lock amount plus the transaction fee; `extend-stacking` increases draw on **unlocked** STX only +- A signer manager contract id, chosen with `list-signers` ## Decision Logic | Goal | Subcommand | |------|-----------| -| Check current cycle, min stacking amount, timing | `get-pox-info` — returns cycle data and BTC block timing | -| Verify if an address is currently stacking | `get-stacking-status` — returns lock amount, unlock height, cycles | -| Lock STX to earn BTC rewards | `stack-stx` — requires BTC reward address and burn height | -| Extend an existing stacking commitment | `extend-stacking` — adds cycles to current lock period | +| Cycle, burn height, prepare-phase timing | `get-pox-info` | +| Is an address staking, with whom, until when | `get-stacking-status` | +| Pick a signer manager | `list-signers` (add `--with-payout-info` to see who supports on-chain claims) | +| Start staking | `stack-stx` | +| Extend, add STX, switch manager, change payout | `extend-stacking` (any combination in one call) | +| Stop early | `unstake-stx` (unlocks at the start of next cycle) | +| See claimable rewards for a cycle | `get-rewards` | +| Collect rewards for a cycle | `claim-rewards` | ## Safety Checks -- Before `stack-stx`: run `get-pox-info` to confirm `minAmountUstx` — stacking below the threshold is rejected -- Before `stack-stx`: verify `--start-burn-height` is within `nextCycle.prepare_phase_start_block_height` window -- Before `stack-stx`: STX will be locked for the full `--lock-period` — funds cannot be withdrawn until `unlockHeight` -- Before `extend-stacking`: confirm current stacking is active via `get-stacking-status`; can only extend, not cancel -- Bitcoin address version values: `0`=P2PKH, `1`=P2SH, `4`=P2WPKH, `5`=P2WSH, `6`=P2TR -- Bitcoin hashbytes length: 20 bytes (hex 40 chars) for P2PKH/P2SH/P2WPKH; 32 bytes (hex 64 chars) for P2WSH/P2TR +- Before any write: `get-pox-info`; if `inPreparePhase` is true, wait until `nextCycleStartHeight` — writes are refused during the prepare phase +- Before `stack-stx`: `get-stacking-status` must show `staking: false`; otherwise use `extend-stacking` +- Before `stack-stx`: confirm the manager's own terms (allowlists, minimums, required `--btc-reward-address`). A stake the manager refuses aborts on chain and still costs the fee +- `--num-cycles` locks STX for up to 96 cycles (~4 years); STX cannot be moved until `unlockBurnHeight` unless `unstake-stx` is called, which still waits for the next cycle +- `claim-rewards` may send **two** transactions (manager pull, then staker claim); check `get-rewards` first and only claim when `unclaimedSatsBeforeFees` > 0 and `stakerClaim` is not `none` +- Never pass both `--btc-reward-address` and `--signer-calldata-hex` ## Error Handling | Error message | Cause | Fix | |--------------|-------|-----| -| "Stacking writes are disabled: this network's active PoX contract is ...pox-5" | pox-5 replaced the pox-4 stacking functions this skill calls | Do not retry; stacking via this skill is unavailable until pox-5 staking support lands | +| "pox-5 refuses ... during the prepare phase" | Burn height is in the last 100 blocks of the cycle | Retry at or after the burn height named in the error | +| "... is already staking ... Use extend_stacking" | Address has a stake | Use `extend-stacking` | +| "... is not staking" | No stake to update / unstake / look up | Use `stack-stx`, or pass `--signer-manager` for past rewards | +| "... is not a registered pox-5 signer manager" | Wrong or unregistered contract id | Pick one from `list-signers` | +| "Insufficient STX" / "Insufficient unlocked STX" | Balance too low | Fund the wallet or reduce the amount | +| "Nothing to update" | `extend-stacking` with no changes | Pass at least one change | +| "unstaking would not unlock it sooner" | Stake already ends next cycle | No action needed | +| "has no on-chain staker claim" | Manager pays off-chain | Do not retry; contact the manager | +| "No unclaimed rewards" | Nothing earned or already claimed for that cycle | Check another cycle with `get-rewards` | +| "Stacking writes are disabled: this network's active PoX contract is ..." | A newer PoX contract replaced pox-5 | Do not retry; the skill needs updating | | "Could not confirm the active PoX contract from the Stacks API" | `/v2/pox` unreachable; writes fail closed | Retry later | -| "No active wallet found. Specify --wallet-id." | Wallet session expired or not unlocked | Run `bun run wallet/wallet.ts unlock` | -| "--pox-address-version must be a non-negative integer" | Invalid version byte passed | Use 0, 1, 4, 5, or 6 matching your BTC address type | -| "--start-burn-height must be a positive integer" | Non-integer or zero burn height | Pass a valid positive BTC block height | -| "--lock-period must be an integer between 1 and 12" | Lock period out of range | Use 1–12 cycles | -| "--extend-count must be an integer between 1 and 12" | Extend count out of range | Use 1–12 additional cycles | ## Output Handling -- `get-pox-info`: use `nextCycle.prepare_phase_start_block_height` as `--start-burn-height` for `stack-stx` -- `get-pox-info`: `minAmountUstx` is the minimum to pass to `--amount` in `stack-stx` -- `get-stacking-status`: `stacked: true` confirms active stacking; `unlockHeight` is the BTC block when STX unlocks -- `stack-stx`: `txid` and `explorerUrl` confirm the lock transaction; `lockPeriod` and `startBurnHeight` echo inputs -- `extend-stacking`: `txid` confirms the extension; `extendCount` echoes the number of additional cycles added +- `get-pox-info`: `inPreparePhase`, `nextCycleStartHeight` gate writes +- `get-stacking-status`: `staking`, `stake.signerManager`, `stake.unlockBurnHeight`, `balance.unlockedUstx` +- `list-signers`: `signers[].signerManager` feeds `--signer-manager` +- Writes: `txid` and `explorerUrl`; `unlockCycle` / `unlockBurnHeight` give the new unlock point +- `get-rewards`: `unclaimedSatsBeforeFees`, `stakerClaim`, `next` +- `claim-rewards`: `txid` of the staker claim, `managerPull.txid` when a pull was sent first ## Example Invocations ```bash -# Get current PoX cycle info and minimum stacking amount -bun run stacking/stacking.ts get-pox-info - -# Check stacking status for the active wallet -bun run stacking/stacking.ts get-stacking-status - -# Stack 100,000 STX for 6 cycles using a P2WPKH Bitcoin address -bun run stacking/stacking.ts stack-stx \ - --amount 100000000000 \ - --pox-address-version 4 \ - --pox-address-hashbytes <20-byte-hex> \ - --start-burn-height \ - --lock-period 6 +NETWORK=mainnet bun run stacking/stacking.ts get-pox-info +NETWORK=mainnet bun run stacking/stacking.ts list-signers --with-payout-info +NETWORK=mainnet bun run stacking/stacking.ts stack-stx \ + --signer-manager SP8HK160YD5GHXP69VGA0TC7AQJ1X4CDW3XVERSE.xverse-signer-manager-2 \ + --amount 1000000000 --num-cycles 6 +NETWORK=mainnet bun run stacking/stacking.ts get-rewards --reward-cycle 144 +NETWORK=mainnet bun run stacking/stacking.ts claim-rewards --reward-cycle 144 ``` diff --git a/stacking/SKILL.md b/stacking/SKILL.md index 274ffecf..0461ba3b 100644 --- a/stacking/SKILL.md +++ b/stacking/SKILL.md @@ -1,25 +1,30 @@ --- name: stacking -description: "STX stacking operations on Stacks — query PoX cycle info, check stacking status, lock STX to earn BTC rewards (stack-stx), and extend an existing stacking lock period. Write operations require an unlocked wallet." +description: "PoX-5 STX staking on Stacks — query cycle state and staking status, list signer managers, stake / update / unstake STX with a signer manager, and check or claim sBTC rewards. Write operations require an unlocked wallet." metadata: author: "whoabuddy" author-agent: "Trustless Indra" user-invocable: "false" - arguments: "get-pox-info | get-stacking-status | stack-stx | extend-stacking" + arguments: "get-pox-info | get-stacking-status | list-signers | stack-stx | extend-stacking | unstake-stx | get-rewards | claim-rewards" entry: "stacking/stacking.ts" - mcp-tools: "get_pox_info, get_stacking_status, stack_stx, extend_stacking" + mcp-tools: "get_pox_info, get_stacking_status, list_stacking_signers, stack_stx, extend_stacking, unstake_stx, get_stacking_rewards, claim_stacking_rewards" requires: "wallet" tags: "l2, write, requires-funds" --- # Stacking Skill -> **pox-5 (Epoch 4.0) is active on mainnet.** pox-5 removed `stack-stx`, `stack-extend`, `stack-increase`, `delegate-stx` and `revoke-delegate-stx` in favour of signer-manager staking (`stake`, `stake-update`, `unstake`), which this skill does not implement yet. On any network whose active PoX contract is not pox-4, `stack-stx` and `extend-stacking` refuse with `PoxVersionUnsupportedError` before signing anything. `get-pox-info` reports `activePoxContract`, and `get-stacking-status` reads pox-5's `get-staker-info` (adding `activePoxContract`, `signer` and a `warning`; `lockPeriod` is pox-5's `num-cycles`). +STX staking on **PoX-5** (active on mainnet since reward cycle 141). PoX-5 is not a rename of pox-4: -Provides Proof of Transfer (PoX) stacking operations on the Stacks blockchain. Stacking locks STX tokens for a specified number of reward cycles to earn Bitcoin rewards. +- Every stake names a **signer manager**, a contract implementing pox-5's `signer-manager-trait`. There is no pool delegation (`delegate-stx`) and no reward address on the stake itself; the manager decides who may join and reads any payout preferences from optional **signer calldata**. +- Rewards are paid in **sBTC to the signer manager**. A staker is paid when the manager pulls its share from pox-5 (`claim-rewards`) and then pays the staker (`claim-staker-rewards`). Managers implement that second step differently, and some pay off-chain. +- `stake`, `stake-update` and `unstake` are refused during the **prepare phase** (the last 100 blocks of each cycle). +- Locks are not transfers: transactions carry pox-5's own post-conditions (a staking lock amount, or "performs PoX") in Deny mode. -- **get-pox-info** and **get-stacking-status** — Read-only, no wallet required. -- **stack-stx** and **extend-stacking** — Write operations, require an unlocked wallet. +Every write first confirms pox-5 is still the network's active PoX contract and refuses before signing otherwise. + +- **get-pox-info**, **get-stacking-status**, **list-signers**, **get-rewards** — read-only, no wallet required (status and rewards default to the active wallet's address). +- **stack-stx**, **extend-stacking**, **unstake-stx**, **claim-rewards** — write operations, require an unlocked wallet. ## Usage @@ -31,7 +36,7 @@ bun run stacking/stacking.ts [options] ### get-pox-info -Get current Proof of Transfer (PoX) cycle information, including current and next cycle details, minimum stacking amount, and cycle lengths. +Current PoX-5 state. ``` bun run stacking/stacking.ts get-pox-info @@ -40,125 +45,188 @@ bun run stacking/stacking.ts get-pox-info Output: ```json { - "network": "testnet", - "currentCycle": { - "id": 88, - "min_threshold_ustx": 50000000000, - "stacked_ustx": 1200000000000, - "is_pox_active": true - }, - "nextCycle": { - "id": 89, - "min_threshold_ustx": 50000000000, - "min_increment_ustx": 5000000000, - "stacked_ustx": 0, - "prepare_phase_start_block_height": 3450, - "blocks_until_prepare_phase": 25, - "reward_phase_start_block_height": 3500, - "blocks_until_reward_phase": 75, - "ustx_until_pox_rejection": 0 - }, - "minAmountUstx": 50000000000, + "network": "mainnet", + "contract": "SP000000000000000000002Q6VF78.pox-5", + "burnHeight": 967284, + "rewardCycle": 143, + "nextCycleStartHeight": 968450, + "preparePhaseStartHeight": 968350, + "inPreparePhase": false, + "firstBurnHeight": 666050, "rewardCycleLength": 2100, "prepareCycleLength": 100, - "currentBurnchainBlockHeight": 3425, - "totalLiquidSupplyUstx": 1400000000000000 + "signerSetMinUstx": "50000000000", + "totalLiquidSupplyUstx": "1867006091924579" } ``` -### get-stacking-status +`signerSetMinUstx` is the minimum a **signer** needs delegated to join the signer set, not a per-staker minimum. -Check if an address is currently stacking STX. +### get-stacking-status ``` bun run stacking/stacking.ts get-stacking-status [--address ] ``` +Output: +```json +{ + "address": "SP1PSZZYFH9H5M81KGBV2XTFQBR3HQ17HJW7Y4ZK2", + "network": "mainnet", + "staking": true, + "stake": { + "signerManager": "SP8HK160YD5GHXP69VGA0TC7AQJ1X4CDW3XVERSE.xverse-signer-manager-2", + "amountUstx": "500000000", + "amount": "500.000000 STX", + "firstRewardCycle": 144, + "numCycles": 96, + "unlockCycle": 240, + "unlockBurnHeight": 1170050 + }, + "bond": null, + "balance": { + "totalUstx": "560494462", + "lockedUstx": "500000000", + "unlockedUstx": "60494462", + "burnchainUnlockHeight": 1170050 + }, + "pox": { "contract": "SP000000000000000000002Q6VF78.pox-5", "burnHeight": 967284, "rewardCycle": 143, "nextCycleStartHeight": 968450, "preparePhaseStartHeight": 968350, "inPreparePhase": false } +} +``` + +`bond` is set when the address is in a pox-5 protocol bond (`bondIndex`, `amountUstx`, `amountSats`, `isL1Lock`, `signerManager`). + +### list-signers + +Signer managers in the signer set for a reward cycle (default: the next cycle, which a new stake joins), sorted by delegated STX. + +``` +bun run stacking/stacking.ts list-signers [--reward-cycle ] [--with-payout-info] +``` + Options: -- `--address` (optional) — Stacks address to check (uses active wallet if omitted) +- `--reward-cycle` (optional) — cycle to list +- `--with-payout-info` (optional) — also read each manager's staker-claim style: `staker-arg` / `caller` (on-chain claim works) or `none` (pays off-chain). One extra request per signer. -Output: +Output (truncated): ```json { - "address": "SP2...", - "network": "testnet", - "stacked": true, - "amountMicroStx": "100000000000", - "amountStx": "100000", - "firstRewardCycle": 85, - "lockPeriod": 3, - "unlockHeight": 6300 + "network": "mainnet", + "rewardCycle": 144, + "count": 26, + "signers": [ + { "signerManager": "SP1N8F8BBBC60XF6HJBNJHKPRGJ7WZBRGNDJX4YDR.signer-manager", "delegatedUstx": "85815090000000", "delegated": "85815090.000000 STX" } + ], + "note": "Managers can restrict who may stake ..." } ``` -### stack-stx +Only signers at or above the signer-set minimum appear. Other registered managers can still be staked with by contract id. -Lock STX tokens to earn Bitcoin rewards via Proof of Transfer. Requires an unlocked wallet with sufficient STX. +### stack-stx -The Bitcoin reward address must be provided as a version byte and hash. For P2PKH (legacy Bitcoin address), version is `0`. For P2SH, version is `1`. For P2WPKH (native SegWit), version is `4`. +Lock STX with a signer manager. The lock starts next reward cycle. ``` bun run stacking/stacking.ts stack-stx \ + --signer-manager \ --amount \ - --pox-address-version \ - --pox-address-hashbytes \ - --start-burn-height \ - --lock-period + --num-cycles <1-96> \ + [--btc-reward-address
[--max-withdrawal-fee-sats ] | --signer-calldata-hex ] ``` Options: -- `--amount` (required) — Amount of STX to stack in micro-STX (1 STX = 1,000,000 micro-STX). Must meet the minimum stacking threshold. -- `--pox-address-version` (required) — Bitcoin address version byte: `0` (P2PKH), `1` (P2SH), `4` (P2WPKH), `5` (P2WSH), `6` (P2TR) -- `--pox-address-hashbytes` (required) — Bitcoin address hash bytes as a hex string (20 bytes for P2PKH/P2SH/P2WPKH, 32 bytes for P2WSH/P2TR) -- `--start-burn-height` (required) — Bitcoin block height at which stacking begins (must be in a prepare phase or the first block of a reward phase) -- `--lock-period` (required) — Number of reward cycles to lock STX (1–12) +- `--signer-manager` (required) — signer manager contract id (see `list-signers`) +- `--amount` (required) — micro-STX to lock (1 STX = 1,000,000 micro-STX) +- `--num-cycles` (required) — reward cycles to lock, 1–96 (one cycle ≈ 2 weeks) +- `--btc-reward-address` (optional) — Bitcoin address for rewards, encoded as `{ pox-addr, max-fee }` calldata, the shape reference managers (e.g. Xverse, Fast Pool) use to pay via an sBTC withdrawal. Some managers require it, some ignore it. +- `--max-withdrawal-fee-sats` (optional) — max sBTC withdrawal fee per payout with `--btc-reward-address` (default 3000) +- `--signer-calldata-hex` (optional) — raw calldata (≤500 bytes) for managers with a custom format; not combinable with `--btc-reward-address` + +Refused before signing when: in the prepare phase, already staking (use `extend-stacking`), the manager is not a registered pox-5 signer, or the amount exceeds the address's STX balance. Output: ```json { "success": true, - "txid": "abc123...", - "stacker": "SP2...", - "amount": "100000000000", - "lockPeriod": 3, - "startBurnHeight": 850000, - "network": "testnet", - "explorerUrl": "https://explorer.hiro.so/txid/abc123...?chain=testnet" + "txid": "0x...", + "explorerUrl": "https://explorer.hiro.so/txid/0x...?chain=mainnet", + "staker": "SP...", + "signerManager": "SP8HK160YD5GHXP69VGA0TC7AQJ1X4CDW3XVERSE.xverse-signer-manager-2", + "amountUstx": "2780000000", + "amount": "2780.000000 STX", + "firstRewardCycle": 144, + "numCycles": 96, + "unlockCycle": 240, + "unlockBurnHeight": 1170050, + "rewardPayout": "sBTC (if the manager supports it)", + "network": "mainnet" } ``` ### extend-stacking -Extend an existing stacking lock period by additional reward cycles. Must already be stacking. Requires an unlocked wallet. +Update an existing stake (`stake-update`): extend, add STX, switch signer manager, or change payout calldata, in any combination. ``` bun run stacking/stacking.ts extend-stacking \ - --extend-count \ - --pox-address-version \ - --pox-address-hashbytes + [--cycles-to-extend ] [--amount-increase ] [--signer-manager ] \ + [--btc-reward-address
[--max-withdrawal-fee-sats ] | --signer-calldata-hex ] ``` -Options: -- `--extend-count` (required) — Number of additional reward cycles to lock (1–12) -- `--pox-address-version` (required) — Bitcoin address version byte (same as used when initially stacking) -- `--pox-address-hashbytes` (required) — Bitcoin address hash bytes as a hex string (same as used when initially stacking) +Refused when not staking, in the prepare phase, nothing would change, the increase exceeds the **unlocked** balance, the new manager is not registered, or the lock would run more than 96 cycles past the next cycle. + +Output: `txid`, `explorerUrl`, `previous` (the stake before), `signerManager`, `newAmountUstx`, `newAmount`, `unlockCycle`, `unlockBurnHeight`. + +### unstake-stx + +Stop a stake early (`unstake`). STX stays locked through the current cycle and unlocks at the start of the next. + +``` +bun run stacking/stacking.ts unstake-stx +``` + +Refused when not staking, in the prepare phase, or when the stake already unlocks next cycle. + +Output: `txid`, `explorerUrl`, `previous`, `unlockCycle`, `unlockBurnHeight`. + +### get-rewards + +sBTC earned from a signer manager for one cycle and not yet claimed. + +``` +bun run stacking/stacking.ts get-rewards --reward-cycle [--address ] [--signer-manager ] +``` Output: ```json { - "success": true, - "txid": "abc123...", - "stacker": "SP2...", - "extendCount": 2, - "network": "testnet", - "explorerUrl": "https://explorer.hiro.so/txid/abc123...?chain=testnet" + "address": "SP...", + "network": "mainnet", + "rewardCycle": 143, + "signerManager": "SP8HK160YD5GHXP69VGA0TC7AQJ1X4CDW3XVERSE.xverse-signer-manager-2", + "unclaimedSatsBeforeFees": "0", + "managerUnpulledSats": "0", + "stakerClaim": "staker-arg", + "next": "Nothing to claim for this cycle." } ``` +### claim-rewards + +Claim one cycle's sBTC rewards through the signer manager. If the manager has not pulled that cycle's rewards from pox-5 yet, this first sends the manager's permissionless `claim-rewards` (a second transaction paid by this wallet), then the staker claim. + +``` +bun run stacking/stacking.ts claim-rewards --reward-cycle [--signer-manager ] +``` + +Refused when the manager has no on-chain staker claim or nothing is unclaimed. Post-conditions allow sBTC only out of pox-5 (the pull) and the manager (the payout); nothing may leave the caller. + +Output: `txid`, `explorerUrl`, `unclaimedSatsBeforeFees`, `managerPull` (`{ txid, explorerUrl }` or `null`), and a `note` when two transactions were sent. + ## Notes -- The minimum stacking amount varies by cycle. Use `get-pox-info` to check `minAmountUstx` before calling `stack-stx`. -- Bitcoin reward addresses are specified as version + hashbytes (raw hash, not base58check encoded). To derive these from a Bitcoin address, use a library or the `wallet` skill's `get-taproot-address` for Taproot addresses. -- `lock-period` of 1–12 cycles is valid. Each cycle is typically ~2 weeks on mainnet. -- `start-burn-height` must fall within the prepare phase of the current PoX cycle. Check `nextCycle.prepare_phase_start_block_height` from `get-pox-info`. -- Wallet operations require an unlocked wallet (use `bun run wallet/wallet.ts unlock` first). +- Ported from the aibtc MCP server's pox-5 stacking tools (aibtcdev/aibtc-mcp-server#682); the transaction arguments and post-conditions match mainnet `stake`, `stake-update` and `unstake` transactions. +- Managers can restrict who may stake (allowlists, minimums, required calldata). A stake the manager refuses aborts on chain and still costs the fee, so check the manager's terms first. +- Wallet operations require an unlocked wallet (`bun run wallet/wallet.ts unlock`). +- Pillar's `direct-stack-stx --pool fast-pool` and `direct-revoke-fast-pool` go through pox-4 in the Pillar wallet contract and are refused while pox-4 is not active; use this skill for PoX-5 staking. diff --git a/stacking/stacking.ts b/stacking/stacking.ts index c6906f27..fb8ef407 100644 --- a/stacking/stacking.ts +++ b/stacking/stacking.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun /** * Stacking skill CLI - * Proof of Transfer (PoX) stacking operations: query PoX info, check stacking status, lock STX, extend lock period + * PoX-5 staking: query cycle state and staking status, list signer managers, + * stake / update / unstake STX, and check or claim sBTC rewards. * * Usage: bun run stacking/stacking.ts [options] */ @@ -9,9 +10,113 @@ import { Command } from "commander"; import { NETWORK, getExplorerTxUrl } from "../src/lib/config/networks.js"; import { getAccount, getWalletAddress } from "../src/lib/services/x402.service.js"; -import { getStackingService } from "../src/lib/services/stacking.service.js"; +import { + MAX_STAKE_CYCLES, + buildPayoutCalldata, + getStackingService, + type PoxState, + type StakerInfo, +} from "../src/lib/services/stacking.service.js"; +import { btcAddressToPoxAddr } from "../src/lib/utils/bitcoin.js"; import { printJson, handleError } from "../src/lib/utils/cli.js"; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stx(ustx: bigint): string { + const whole = ustx / 1_000_000n; + const frac = (ustx % 1_000_000n).toString().padStart(6, "0"); + return `${whole}.${frac} STX`; +} + +function parseIntStrict(raw: string, flag: string, min: number, max = Number.MAX_SAFE_INTEGER): number { + const s = String(raw ?? "").trim(); + if (!/^\d+$/.test(s) || Number(s) < min || Number(s) > max) { + const range = max === Number.MAX_SAFE_INTEGER ? `>= ${min}` : `from ${min} to ${max}`; + throw new Error(`${flag} must be an integer ${range}, got "${raw}"`); + } + return Number(s); +} + +function parseUstx(raw: string, flag: string, allowZero: boolean): bigint { + const s = String(raw ?? "").trim(); + if (!/^\d+$/.test(s) || (!allowZero && BigInt(s) === 0n)) { + throw new Error(`${flag} must be ${allowZero ? "a non-negative" : "a positive"} integer amount in micro-STX, got "${raw}"`); + } + return BigInt(s); +} + +function poxSummary(pox: PoxState) { + return { + contract: pox.contractId, + burnHeight: pox.burnHeight, + rewardCycle: pox.rewardCycle, + nextCycleStartHeight: pox.nextCycleStartHeight, + preparePhaseStartHeight: pox.preparePhaseStartHeight, + inPreparePhase: pox.inPreparePhase, + }; +} + +function stakerSummary(info: StakerInfo) { + return { + signerManager: info.signerManager, + amountUstx: info.amountUstx.toString(), + amount: stx(info.amountUstx), + firstRewardCycle: info.firstRewardCycle, + numCycles: info.numCycles, + unlockCycle: info.unlockCycle, + unlockBurnHeight: info.unlockBurnHeight, + }; +} + +interface PayoutOptions { + btcRewardAddress?: string; + maxWithdrawalFeeSats?: string; + signerCalldataHex?: string; +} + +function addPayoutOptions(command: Command): Command { + return command + .option( + "--btc-reward-address
", + "Bitcoin address to receive rewards, encoded as { pox-addr, max-fee } signer calldata (the shape reference " + + "signer managers use to pay via an sBTC withdrawal). Some managers require it, some ignore it." + ) + .option( + "--max-withdrawal-fee-sats ", + "Max sBTC withdrawal fee per payout when --btc-reward-address is set (default 3000)" + ) + .option( + "--signer-calldata-hex ", + "Advanced: raw signer calldata (hex, max 500 bytes). Cannot be combined with --btc-reward-address." + ); +} + +function resolveCalldata(opts: PayoutOptions): Uint8Array | undefined { + if (opts.btcRewardAddress && opts.signerCalldataHex) { + throw new Error("Pass either --btc-reward-address or --signer-calldata-hex, not both."); + } + if (opts.maxWithdrawalFeeSats !== undefined && !opts.btcRewardAddress) { + throw new Error("--max-withdrawal-fee-sats only applies with --btc-reward-address."); + } + if (opts.btcRewardAddress) { + const maxFee = + opts.maxWithdrawalFeeSats === undefined + ? 3000n + : BigInt(parseIntStrict(opts.maxWithdrawalFeeSats, "--max-withdrawal-fee-sats", 0)); + return buildPayoutCalldata(btcAddressToPoxAddr(opts.btcRewardAddress, NETWORK), maxFee); + } + if (opts.signerCalldataHex) { + const hex = opts.signerCalldataHex.replace(/^0x/, ""); + if (!/^([0-9a-fA-F]{2})+$/.test(hex)) { + throw new Error("--signer-calldata-hex must be a non-empty, even-length hex string."); + } + return Uint8Array.from(Buffer.from(hex, "hex")); + } + return undefined; +} + // --------------------------------------------------------------------------- // Program // --------------------------------------------------------------------------- @@ -21,9 +126,9 @@ const program = new Command(); program .name("stacking") .description( - "PoX stacking operations: query cycle info, check stacking status, lock STX for BTC rewards, and extend stacking lock period" + "PoX-5 staking: cycle state, staking status, signer managers, stake / update / unstake STX, and sBTC rewards" ) - .version("0.1.0"); + .version("0.2.0"); // --------------------------------------------------------------------------- // get-pox-info @@ -32,23 +137,20 @@ program program .command("get-pox-info") .description( - "Get current Proof of Transfer (PoX) cycle information. Returns current and next cycle details, minimum stacking amount, and cycle lengths." + "Current PoX-5 state: reward cycle, burn height, next cycle start, and whether the prepare phase is active " + + "(stake, extend and unstake are refused during it)." ) .action(async () => { try { - const stackingService = getStackingService(NETWORK); - const poxInfo = await stackingService.getPoxInfo(); - + const pox = await getStackingService(NETWORK).getPoxState(); printJson({ network: NETWORK, - activePoxContract: poxInfo.contract_id, - currentCycle: poxInfo.current_cycle, - nextCycle: poxInfo.next_cycle, - minAmountUstx: poxInfo.min_amount_ustx, - rewardCycleLength: poxInfo.reward_cycle_length, - prepareCycleLength: poxInfo.prepare_cycle_length, - currentBurnchainBlockHeight: poxInfo.current_burnchain_block_height, - totalLiquidSupplyUstx: poxInfo.total_liquid_supply_ustx, + ...poxSummary(pox), + firstBurnHeight: pox.firstBurnHeight, + rewardCycleLength: pox.rewardCycleLength, + prepareCycleLength: pox.prepareCycleLength, + signerSetMinUstx: pox.signerSetMinUstx.toString(), + totalLiquidSupplyUstx: pox.totalLiquidSupplyUstx.toString(), }); } catch (error) { handleError(error); @@ -62,22 +164,35 @@ program program .command("get-stacking-status") .description( - "Check if an address is currently stacking STX. Returns stacking status, amount, and lock period details." - ) - .option( - "--address
", - "Stacks address to check (uses active wallet if omitted)" + "PoX-5 staking status for an address: locked amount, signer manager, lock period, unlock cycle and burn " + + "height, protocol bond membership, and locked/unlocked STX balance." ) + .option("--address
", "Stacks address to check (uses active wallet if omitted)") .action(async (opts: { address?: string }) => { try { - const stackingService = getStackingService(NETWORK); - const walletAddress = opts.address || (await getWalletAddress()); - const status = await stackingService.getStackingStatus(walletAddress); - + const address = opts.address || (await getWalletAddress()); + const status = await getStackingService(NETWORK).getStakingStatus(address); printJson({ - address: walletAddress, + address, network: NETWORK, - ...status, + staking: status.staking !== null, + stake: status.staking ? stakerSummary(status.staking) : null, + bond: status.bond + ? { + bondIndex: status.bond.bondIndex, + signerManager: status.bond.signerManager, + amountUstx: status.bond.amountUstx.toString(), + amountSats: status.bond.amountSats.toString(), + isL1Lock: status.bond.isL1Lock, + } + : null, + balance: { + totalUstx: status.account.balanceUstx.toString(), + lockedUstx: status.account.lockedUstx.toString(), + unlockedUstx: status.account.unlockedUstx.toString(), + burnchainUnlockHeight: status.account.burnchainUnlockHeight, + }, + pox: poxSummary(status.pox), }); } catch (error) { handleError(error); @@ -85,150 +200,303 @@ program }); // --------------------------------------------------------------------------- -// stack-stx +// list-signers // --------------------------------------------------------------------------- program - .command("stack-stx") + .command("list-signers") .description( - "Lock STX tokens for stacking to earn BTC rewards. " + - "Requires a Bitcoin reward address (version + hashbytes) and a burn height within the prepare phase. " + - "Requires an unlocked wallet." - ) - .requiredOption( - "--amount ", - "Amount of STX to stack in micro-STX (1 STX = 1,000,000 micro-STX)" - ) - .requiredOption( - "--pox-address-version ", - "Bitcoin address version byte: 0 (P2PKH), 1 (P2SH), 4 (P2WPKH), 5 (P2WSH), 6 (P2TR)" + "List PoX-5 signer managers in the signer set for a reward cycle (default: next cycle) with the STX " + + "delegated to each. A signer manager is what stack-stx stakes with." ) - .requiredOption( - "--pox-address-hashbytes ", - "Bitcoin address hash bytes as a hex string (20 bytes for P2PKH/P2SH, 32 bytes for P2WSH/P2TR)" - ) - .requiredOption( - "--start-burn-height ", - "Bitcoin block height at which stacking begins (must be in a prepare phase)" - ) - .requiredOption( - "--lock-period ", - "Number of reward cycles to lock STX (1-12)" - ) - .action( - async (opts: { - amount: string; - poxAddressVersion: string; - poxAddressHashbytes: string; - startBurnHeight: string; - lockPeriod: string; - }) => { - try { - const version = parseInt(opts.poxAddressVersion, 10); - if (isNaN(version) || version < 0) { - throw new Error( - "--pox-address-version must be a non-negative integer (0=P2PKH, 1=P2SH, 4=P2WPKH, 5=P2WSH, 6=P2TR)" - ); - } - - const startBurnHeight = parseInt(opts.startBurnHeight, 10); - if (isNaN(startBurnHeight) || startBurnHeight <= 0) { - throw new Error("--start-burn-height must be a positive integer"); - } - - const lockPeriod = parseInt(opts.lockPeriod, 10); - if (isNaN(lockPeriod) || lockPeriod < 1 || lockPeriod > 12) { - throw new Error("--lock-period must be an integer between 1 and 12"); - } - - const stackingService = getStackingService(NETWORK); - const account = await getAccount(); - const result = await stackingService.stack( - account, - BigInt(opts.amount), - { version, hashbytes: opts.poxAddressHashbytes }, - startBurnHeight, - lockPeriod - ); + .option("--reward-cycle ", "Reward cycle to list (default: the next cycle, which a new stake joins)") + .option("--with-payout-info", "Also read how each manager pays stakers (one extra request per signer)", false) + .action(async (opts: { rewardCycle?: string; withPayoutInfo: boolean }) => { + try { + const service = getStackingService(NETWORK); + const pox = await service.getPoxState(); + const cycle = + opts.rewardCycle === undefined ? pox.rewardCycle + 1 : parseIntStrict(opts.rewardCycle, "--reward-cycle", 1); + const signers = (await service.getSignerSet(cycle)).sort((a, b) => + b.delegatedUstx > a.delegatedUstx ? 1 : b.delegatedUstx < a.delegatedUstx ? -1 : 0 + ); - printJson({ - success: true, - txid: result.txid, - stacker: account.address, - amount: opts.amount, - lockPeriod, - startBurnHeight, - network: NETWORK, - explorerUrl: getExplorerTxUrl(result.txid, NETWORK), + const rows = []; + for (const s of signers) { + rows.push({ + signerManager: s.signerManager, + delegatedUstx: s.delegatedUstx.toString(), + delegated: stx(s.delegatedUstx), + ...(opts.withPayoutInfo && { stakerClaim: await service.getClaimStyle(s.signerManager) }), }); - } catch (error) { - handleError(error); } + + printJson({ + network: NETWORK, + rewardCycle: cycle, + count: rows.length, + signers: rows, + ...(opts.withPayoutInfo && { + stakerClaimKey: { + "staker-arg": "on-chain; claim-rewards works", + caller: "on-chain; claim-rewards works (paid to the caller)", + none: "no on-chain staker claim; the manager pays off-chain or not at all", + }, + }), + note: + "Managers can restrict who may stake (allowlists, minimums, required payout calldata). Check a " + + "manager's own terms before staking; a refused stake aborts on chain. Only signers at or above the " + + "signer-set minimum appear here; other registered managers can still be staked with by contract id.", + }); + } catch (error) { + handleError(error); } - ); + }); + +// --------------------------------------------------------------------------- +// stack-stx +// --------------------------------------------------------------------------- + +addPayoutOptions( + program + .command("stack-stx") + .description( + `Lock STX in PoX-5 with a signer manager to earn sBTC rewards. The lock starts next reward cycle and lasts ` + + `--num-cycles cycles (1-${MAX_STAKE_CYCLES}). Refused during the prepare phase and if already staking ` + + `(use extend-stacking). Requires an unlocked wallet.` + ) + .requiredOption( + "--signer-manager ", + "Signer manager contract id (see list-signers), e.g. SP8HK160YD5GHXP69VGA0TC7AQJ1X4CDW3XVERSE.xverse-signer-manager-2" + ) + .requiredOption("--amount ", "Amount to lock, in micro-STX (1 STX = 1,000,000 micro-STX)") + .requiredOption("--num-cycles ", `Reward cycles to lock (1-${MAX_STAKE_CYCLES}; one cycle is about two weeks)`) +).action(async (opts: PayoutOptions & { signerManager: string; amount: string; numCycles: string }) => { + try { + const amountUstx = parseUstx(opts.amount, "--amount", false); + const numCycles = parseIntStrict(opts.numCycles, "--num-cycles", 1, MAX_STAKE_CYCLES); + const signerCalldata = resolveCalldata(opts); + const account = await getAccount(); + const result = await getStackingService(NETWORK).stake(account, { + signerManager: opts.signerManager, + amountUstx, + numCycles, + signerCalldata, + }); + + printJson({ + success: true, + txid: result.txid, + explorerUrl: getExplorerTxUrl(result.txid, NETWORK), + staker: account.address, + signerManager: opts.signerManager, + amountUstx: amountUstx.toString(), + amount: stx(amountUstx), + firstRewardCycle: result.pox.rewardCycle + 1, + numCycles, + unlockCycle: result.unlockCycle, + unlockBurnHeight: result.unlockBurnHeight, + rewardPayout: opts.btcRewardAddress + ? `BTC to ${opts.btcRewardAddress} (if the manager supports it)` + : opts.signerCalldataHex + ? "custom signer calldata" + : "sBTC (if the manager supports it)", + network: NETWORK, + }); + } catch (error) { + handleError(error); + } +}); // --------------------------------------------------------------------------- // extend-stacking // --------------------------------------------------------------------------- +addPayoutOptions( + program + .command("extend-stacking") + .description( + `Update an existing PoX-5 stake (stake-update): extend the lock, add STX, switch signer manager, or change ` + + `payout calldata, in any combination. Refused during the prepare phase. The lock may not run more than ` + + `${MAX_STAKE_CYCLES} cycles past the next cycle. Requires an unlocked wallet.` + ) + .option("--cycles-to-extend ", "Cycles to add to the current unlock cycle", "0") + .option("--amount-increase ", "Additional micro-STX to lock", "0") + .option("--signer-manager ", "New signer manager contract id (defaults to the current one)") +).action( + async (opts: PayoutOptions & { cyclesToExtend: string; amountIncrease: string; signerManager?: string }) => { + try { + const cyclesToExtend = parseIntStrict(opts.cyclesToExtend, "--cycles-to-extend", 0, MAX_STAKE_CYCLES); + const amountIncreaseUstx = parseUstx(opts.amountIncrease, "--amount-increase", true); + const signerCalldata = resolveCalldata(opts); + const account = await getAccount(); + const result = await getStackingService(NETWORK).updateStake(account, { + signerManager: opts.signerManager, + cyclesToExtend, + amountIncreaseUstx, + signerCalldata, + }); + + printJson({ + success: true, + txid: result.txid, + explorerUrl: getExplorerTxUrl(result.txid, NETWORK), + staker: account.address, + previous: stakerSummary(result.previous), + signerManager: result.signerManager, + newAmountUstx: result.newAmountUstx.toString(), + newAmount: stx(result.newAmountUstx), + unlockCycle: result.unlockCycle, + unlockBurnHeight: result.unlockBurnHeight, + network: NETWORK, + }); + } catch (error) { + handleError(error); + } + } +); + +// --------------------------------------------------------------------------- +// unstake-stx +// --------------------------------------------------------------------------- + program - .command("extend-stacking") + .command("unstake-stx") .description( - "Extend an existing stacking lock period by additional reward cycles. " + - "Must already be stacking. Requires an unlocked wallet." - ) - .requiredOption( - "--extend-count ", - "Number of additional reward cycles to lock (1-12)" + "Stop a PoX-5 stake early (pox-5 unstake). The STX stays locked through the current reward cycle and " + + "unlocks at the start of the next one. Refused during the prepare phase. Requires an unlocked wallet." ) - .requiredOption( - "--pox-address-version ", - "Bitcoin address version byte (same as used when initially stacking)" + .action(async () => { + try { + const account = await getAccount(); + const result = await getStackingService(NETWORK).unstake(account); + printJson({ + success: true, + txid: result.txid, + explorerUrl: getExplorerTxUrl(result.txid, NETWORK), + staker: account.address, + previous: stakerSummary(result.previous), + unlockCycle: result.unlockCycle, + unlockBurnHeight: result.unlockBurnHeight, + network: NETWORK, + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// get-rewards +// --------------------------------------------------------------------------- + +program + .command("get-rewards") + .description( + "sBTC rewards an address has earned from its signer manager for one reward cycle and not yet claimed " + + "(before manager fees), whether the manager still has to pull them from pox-5, and whether the manager " + + "supports an on-chain staker claim." ) - .requiredOption( - "--pox-address-hashbytes ", - "Bitcoin address hash bytes as a hex string (same as used when initially stacking)" + .requiredOption("--reward-cycle ", "Reward cycle to check") + .option("--address
", "Stacks address (uses active wallet if omitted)") + .option("--signer-manager ", "Signer manager to check (defaults to the address's current one)") + .action(async (opts: { rewardCycle: string; address?: string; signerManager?: string }) => { + try { + const rewardCycle = parseIntStrict(opts.rewardCycle, "--reward-cycle", 0); + const service = getStackingService(NETWORK); + const staker = opts.address || (await getWalletAddress()); + const manager = opts.signerManager ?? (await service.getStakerInfo(staker))?.signerManager; + if (!manager) { + throw new Error( + `${staker} is not currently staking; pass --signer-manager to check rewards from a past signer.` + ); + } + const [earnedSats, unpulledSats, claimStyle] = await Promise.all([ + service.getStakerUnclaimedRewards(manager, rewardCycle, staker), + service.getSignerUnpulledRewards(manager, rewardCycle), + service.getClaimStyle(manager), + ]); + printJson({ + address: staker, + network: NETWORK, + rewardCycle, + signerManager: manager, + unclaimedSatsBeforeFees: earnedSats.toString(), + managerUnpulledSats: unpulledSats.toString(), + stakerClaim: claimStyle, + next: + earnedSats === 0n + ? "Nothing to claim for this cycle." + : claimStyle === "none" + ? "This manager has no on-chain staker claim; it pays stakers off-chain." + : "Run claim-rewards with this --reward-cycle.", + }); + } catch (error) { + handleError(error); + } + }); + +// --------------------------------------------------------------------------- +// claim-rewards +// --------------------------------------------------------------------------- + +program + .command("claim-rewards") + .description( + "Claim PoX-5 sBTC rewards for one reward cycle through the signer manager. If the manager has not yet " + + "pulled that cycle's rewards from pox-5, this first sends the manager's permissionless claim-rewards (a " + + "second transaction, paid by this wallet), then the staker claim. Not available for managers that pay " + + "off-chain. Requires an unlocked wallet." ) - .action( - async (opts: { - extendCount: string; - poxAddressVersion: string; - poxAddressHashbytes: string; - }) => { - try { - const extendCount = parseInt(opts.extendCount, 10); - if (isNaN(extendCount) || extendCount < 1 || extendCount > 12) { - throw new Error("--extend-count must be an integer between 1 and 12"); - } - - const version = parseInt(opts.poxAddressVersion, 10); - if (isNaN(version) || version < 0) { - throw new Error( - "--pox-address-version must be a non-negative integer" - ); - } - - const stackingService = getStackingService(NETWORK); - const account = await getAccount(); - const result = await stackingService.extendStacking( - account, - extendCount, - { version, hashbytes: opts.poxAddressHashbytes } + .requiredOption("--reward-cycle ", "Reward cycle to claim") + .option("--signer-manager ", "Signer manager to claim from (defaults to the wallet's current one)") + .action(async (opts: { rewardCycle: string; signerManager?: string }) => { + try { + const rewardCycle = parseIntStrict(opts.rewardCycle, "--reward-cycle", 0); + const service = getStackingService(NETWORK); + const account = await getAccount(); + const manager = opts.signerManager ?? (await service.getStakerInfo(account.address))?.signerManager; + if (!manager) { + throw new Error( + `${account.address} is not currently staking; pass --signer-manager to claim from a past signer.` ); + } - printJson({ - success: true, - txid: result.txid, - stacker: account.address, - extendCount, - network: NETWORK, - explorerUrl: getExplorerTxUrl(result.txid, NETWORK), - }); - } catch (error) { - handleError(error); + const [earnedSats, unpulledSats, claimStyle] = await Promise.all([ + service.getStakerUnclaimedRewards(manager, rewardCycle, account.address), + service.getSignerUnpulledRewards(manager, rewardCycle), + service.getClaimStyle(manager), + ]); + if (claimStyle === "none") { + throw new Error(`${manager} has no on-chain staker claim (claim-staker-rewards); it pays stakers off-chain.`); + } + if (earnedSats === 0n) { + throw new Error(`No unclaimed rewards for ${account.address} from ${manager} in cycle ${rewardCycle}.`); + } + + let managerPull: { txid: string; explorerUrl: string } | null = null; + if (unpulledSats > 0n) { + const pulled = await service.pullSignerRewards(account, manager, rewardCycle, unpulledSats); + managerPull = { txid: pulled.txid, explorerUrl: getExplorerTxUrl(pulled.txid, NETWORK) }; } + + const claim = await service.claimStakerRewards(account, manager, rewardCycle, claimStyle); + printJson({ + success: true, + network: NETWORK, + rewardCycle, + signerManager: manager, + unclaimedSatsBeforeFees: earnedSats.toString(), + managerPull, + txid: claim.txid, + explorerUrl: getExplorerTxUrl(claim.txid, NETWORK), + ...(managerPull && { + note: "Two transactions were sent in nonce order: the manager's pull, then the staker claim.", + }), + }); + } catch (error) { + handleError(error); } - ); + }); // --------------------------------------------------------------------------- // Parse From 01665a65c67fb5a9b2c5f868c63de25d6b98abfa Mon Sep 17 00:00:00 2001 From: biwasbhandari Date: Thu, 8 Oct 2026 17:36:32 +0545 Subject: [PATCH 2/2] fix(stacking): claim before manager pull, keep BTC payout on extend, pin claim nonces Review follow-ups for the pox-5 port, verified against mainnet pox-5 and the live signer-manager contracts: - get-rewards / claim-rewards: pox-5's get-earned-staker-rewards reads the signer's rewards-per-token, which only advances when the manager pulls, so it reported 0 and claim-rewards refused before ever sending the pull. Project the claimable amount with pox-5's compute-earned-rewards over the cycle's global rewards-per-token. - extend-stacking: stake-update forwards calldata to the manager's validate-stake!, and Xverse/Fast Pool delete the stored BTC payout address when it is none. Require --btc-reward-address, --signer-calldata-hex or an explicit --sbtc-payout. - claim-rewards: pin consecutive nonces for the pull + claim pair. - Take the sBTC contract for reward post-conditions from /v2/pox pox_5_sbtc_contract (differs from the default sBTC on testnet). - Refuse writes within 3 burn blocks of the prepare phase. - Error messages name CLI subcommands, not MCP tool names. Co-Authored-By: Claude Opus 5.5 --- skills.json | 2 +- src/lib/services/hiro-api.ts | 2 + src/lib/services/stacking.service.test.ts | 54 +++++++++++++ src/lib/services/stacking.service.ts | 98 +++++++++++++++++++---- stacking/AGENT.md | 6 +- stacking/SKILL.md | 12 +-- stacking/stacking.ts | 40 +++++++-- 7 files changed, 187 insertions(+), 27 deletions(-) diff --git a/skills.json b/skills.json index 61de80e7..9bd04bb6 100644 --- a/skills.json +++ b/skills.json @@ -1,6 +1,6 @@ { "version": "0.43.1", - "generated": "2026-10-08T11:41:17.190Z", + "generated": "2026-10-08T11:51:32.921Z", "skills": [ { "name": "agent-lookup", diff --git a/src/lib/services/hiro-api.ts b/src/lib/services/hiro-api.ts index b6e83b75..37730e9d 100644 --- a/src/lib/services/hiro-api.ts +++ b/src/lib/services/hiro-api.ts @@ -158,6 +158,8 @@ export interface MempoolTransaction { export interface PoxInfo { contract_id: string; + /** sBTC token contract pox-5 pays rewards in (not always the network's default sBTC). */ + pox_5_sbtc_contract?: string; pox_activation_threshold_ustx: number; first_burnchain_block_height: number; current_burnchain_block_height: number; diff --git a/src/lib/services/stacking.service.test.ts b/src/lib/services/stacking.service.test.ts index c8575d90..d8a2a073 100644 --- a/src/lib/services/stacking.service.test.ts +++ b/src/lib/services/stacking.service.test.ts @@ -308,6 +308,60 @@ describe("signer managers", () => { expect(claim.postConditions).toHaveLength(1); expect(claim.postConditions[0].address).toBe(MANAGER); }); + + test("passes explicit nonces through to both transactions", async () => { + const svc = service(); + await svc.pullSignerRewards(account, MANAGER, 143, 500n, 7n); + await svc.claimStakerRewards(account, MANAGER, 143, "staker-arg", 8n); + expect(lastCall(0).nonce).toBe(7n); + expect(lastCall(1).nonce).toBe(8n); + }); + + test("uses the sBTC contract pox-5 reports, not the network default", async () => { + const testnetSbtc = "SN3VMHXEN64ZZF71JQ5VESXDWTR301XTTXGF4J8F1.sbtc-token"; + (hiro.getPoxInfo as unknown as ReturnType).mockImplementation(async () => ({ + contract_id: activePox, + pox_5_sbtc_contract: testnetSbtc, + })); + try { + const svc = service(); + await svc.pullSignerRewards(account, MANAGER, 143, 500n); + await svc.claimStakerRewards(account, MANAGER, 143, "staker-arg"); + expect(lastCall(0).postConditions[0].asset).toBe(`${testnetSbtc}::sbtc-token`); + expect(lastCall(1).postConditions[0].asset).toBe(`${testnetSbtc}::sbtc-token`); + } finally { + (hiro.getPoxInfo as unknown as ReturnType).mockImplementation(async () => ({ + contract_id: activePox, + })); + } + }); + + test("projects claimable rewards from the cycle's global rewards-per-token before the manager pulls", async () => { + // Before the pull the signer's rewards-per-token is still 0, so pox-5's own + // get-earned-staker-rewards reports nothing. + reads.set("get-earned-staker-rewards", Cl.uint(0)); + reads.set("get-staker-shares-staked-for-cycle", Cl.uint(521_000_000n)); + reads.set("get-rewards-per-token-for-cycle", Cl.uint(646_291_401_690n)); + reads.set("get-staker-rewards-per-token-settled-for-cycle", Cl.uint(0)); + reads.set("get-staker-unclaimed-rewards-for-cycle", Cl.uint(0)); + reads.set("compute-earned-rewards", Cl.uint(336)); + + expect(await service().getStakerClaimableRewards(MANAGER, 144, STAKER)).toBe(336n); + const computeCall = (hiro.callReadOnlyFunction as unknown as ReturnType).mock.calls.find( + (c) => c[1] === "compute-earned-rewards" + ) as [string, string, ClarityValue[]]; + expect(computeCall[2].map((a) => cvToJSON(a).value)).toEqual(["521000000", "646291401690", "0", "0"]); + }); +}); + +describe("prepare phase margin", () => { + test("refuses a write a few blocks before the prepare phase", async () => { + burnHeight = 968348; // prepare phase starts at 968350 + await expect(service().stake(account, { signerManager: MANAGER, amountUstx: 1n, numCycles: 1 })).rejects.toThrow( + /could be mined inside it/ + ); + expect(callContract).not.toHaveBeenCalled(); + }); }); describe("payout calldata", () => { diff --git a/src/lib/services/stacking.service.ts b/src/lib/services/stacking.service.ts index 2903798d..ab36bc3a 100644 --- a/src/lib/services/stacking.service.ts +++ b/src/lib/services/stacking.service.ts @@ -56,6 +56,8 @@ export const MAX_STAKE_CYCLES = 96; /** Hard stop when walking the signer-set linked list. */ const MAX_SIGNERS_WALKED = 200; +// Burn blocks before the prepare phase within which writes are refused. +const PREPARE_PHASE_MARGIN_BLOCKS = 3; // ============================================================================ @@ -431,6 +433,54 @@ export class StackingService { ); } + /** + * What the staker can claim once the manager has pulled the cycle's rewards. + * + * `get-earned-staker-rewards` reads the signer's rewards-per-token, which + * pox-5 only advances when the manager calls `claim-rewards`. Until that pull + * it reports 0 (or a stale amount) even with rewards waiting. The pull sets + * the signer's rewards-per-token to the cycle's global value, so this runs + * pox-5's own `compute-earned-rewards` against that global value instead. + */ + async getStakerClaimableRewards( + signerManager: string, + rewardCycle: number, + staker: string + ): Promise { + const [shares, globalRpt, rptPaid, pending] = await Promise.all([ + this.readPox("get-staker-shares-staked-for-cycle", [ + principalCV(staker), + uintCV(rewardCycle), + noneCV(), + principalCV(signerManager), + ]), + this.readPox("get-rewards-per-token-for-cycle", [uintCV(rewardCycle), noneCV()]), + this.readPox("get-staker-rewards-per-token-settled-for-cycle", [ + principalCV(signerManager), + uintCV(rewardCycle), + noneCV(), + principalCV(staker), + ]), + this.readPox("get-staker-unclaimed-rewards-for-cycle", [ + principalCV(signerManager), + uintCV(rewardCycle), + noneCV(), + principalCV(staker), + ]), + ]); + if (toBigInt(globalRpt) < toBigInt(rptPaid)) { + return this.getStakerUnclaimedRewards(signerManager, rewardCycle, staker); + } + return toBigInt( + await this.readPox("compute-earned-rewards", [ + uintCV(toBigInt(shares)), + uintCV(toBigInt(globalRpt)), + uintCV(toBigInt(rptPaid)), + uintCV(toBigInt(pending)), + ]) + ); + } + /** sBTC pox-5 still holds for the signer manager for a cycle (not yet pulled by the manager). */ async getSignerUnpulledRewards(signerManager: string, rewardCycle: number): Promise { return toBigInt( @@ -442,20 +492,29 @@ export class StackingService { // Writes // -------------------------------------------------------------------------- - /** Refuse to sign unless pox-5 is the network's active PoX contract. */ - private async assertPox5Active(): Promise { - let active: string; + /** + * Refuse to sign unless pox-5 is the network's active PoX contract. Returns + * the sBTC contract pox-5 pays rewards in: on testnet it differs from the + * network's default sBTC, and a Deny-mode post-condition on the wrong token + * aborts every reward transfer. + */ + private async assertPox5Active(): Promise<{ sbtcContract: `${string}.${string}` }> { + let info: Awaited>; try { - active = (await this.hiro.getPoxInfo()).contract_id; + info = await this.hiro.getPoxInfo(); } catch (error) { throw new Error( `Could not confirm the active PoX contract from the Stacks API ` + `(${error instanceof Error ? error.message : String(error)}). No transaction was sent.` ); } + const active = info.contract_id; if (active !== this.poxContract) { throw new PoxVersionUnsupportedError(active || "(unknown)", this.poxContract); } + const sbtcContract = (info.pox_5_sbtc_contract || + getContracts(this.network).SBTC_TOKEN) as `${string}.${string}`; + return { sbtcContract }; } private assertNotPreparePhase(pox: PoxState, action: string): void { @@ -466,6 +525,15 @@ export class StackingService { `burn height ${pox.nextCycleStartHeight} (reward cycle ${pox.rewardCycle + 1}).` ); } + // A transaction sent just before the prepare phase can be mined inside it + // and abort, so leave a few blocks of margin. + if (pox.burnHeight >= pox.preparePhaseStartHeight - PREPARE_PHASE_MARGIN_BLOCKS) { + throw new Error( + `pox-5 refuses ${action} during the prepare phase, which starts at burn height ` + + `${pox.preparePhaseStartHeight}; at ${pox.burnHeight} this transaction could be mined inside it. ` + + `Try again at or after burn height ${pox.nextCycleStartHeight} (reward cycle ${pox.rewardCycle + 1}).` + ); + } } async stake(account: Account, options: StakeOptions): Promise { @@ -483,7 +551,7 @@ export class StackingService { if (status.staking) { throw new Error( `${account.address} is already staking ${status.staking.amountUstx} uSTX with ` + - `${status.staking.signerManager}. Use extend_stacking to extend, increase or switch signer.` + `${status.staking.signerManager}. Use extend-stacking to extend, increase or switch signer.` ); } // pox-5 counts locked + unlocked STX (a bond rolling over into a stake is still locked). @@ -494,7 +562,7 @@ export class StackingService { } if (!(await this.isRegisteredSigner(signerManager))) { throw new Error( - `${signerManager} is not a registered pox-5 signer manager. Use list_stacking_signers to pick one.` + `${signerManager} is not a registered pox-5 signer manager. Use list-signers to pick one.` ); } @@ -539,7 +607,7 @@ export class StackingService { const { pox } = status; const current = status.staking; if (!current) { - throw new Error(`${account.address} is not staking. Use stack_stx to start.`); + throw new Error(`${account.address} is not staking. Use stack-stx to start.`); } this.assertNotPreparePhase(pox, "stake-update"); @@ -569,7 +637,7 @@ export class StackingService { } if (signerManager !== current.signerManager && !(await this.isRegisteredSigner(signerManager))) { throw new Error( - `${signerManager} is not a registered pox-5 signer manager. Use list_stacking_signers to pick one.` + `${signerManager} is not a registered pox-5 signer manager. Use list-signers to pick one.` ); } @@ -647,12 +715,13 @@ export class StackingService { account: Account, signerManager: string, rewardCycle: number, - unpulledSats: bigint + unpulledSats: bigint, + nonce?: bigint ): Promise { - await this.assertPox5Active(); + const { sbtcContract: sbtc } = await this.assertPox5Active(); const { address, name } = parseContractId(signerManager); - const sbtc = getContracts(this.network).SBTC_TOKEN as `${string}.${string}`; return this.call(account, { + ...(nonce !== undefined && { nonce }), contractAddress: address, contractName: name, functionName: "claim-rewards", @@ -671,16 +740,17 @@ export class StackingService { account: Account, signerManager: string, rewardCycle: number, - style: Exclude + style: Exclude, + nonce?: bigint ): Promise { - await this.assertPox5Active(); + const { sbtcContract: sbtc } = await this.assertPox5Active(); const { address, name } = parseContractId(signerManager); - const sbtc = getContracts(this.network).SBTC_TOKEN as `${string}.${string}`; const args = style === "staker-arg" ? [principalCV(account.address), uintCV(rewardCycle), noneCV()] : [uintCV(rewardCycle), noneCV()]; return this.call(account, { + ...(nonce !== undefined && { nonce }), contractAddress: address, contractName: name, functionName: "claim-staker-rewards", diff --git a/stacking/AGENT.md b/stacking/AGENT.md index 46ff98d5..fb017ce3 100644 --- a/stacking/AGENT.md +++ b/stacking/AGENT.md @@ -30,23 +30,25 @@ Handles STX staking on PoX-5. A stake locks STX with a **signer manager** contra ## Safety Checks -- Before any write: `get-pox-info`; if `inPreparePhase` is true, wait until `nextCycleStartHeight` — writes are refused during the prepare phase +- Before any write: `get-pox-info`; if `inPreparePhase` is true, wait until `nextCycleStartHeight` — writes are refused during the prepare phase and within 3 burn blocks before it - Before `stack-stx`: `get-stacking-status` must show `staking: false`; otherwise use `extend-stacking` - Before `stack-stx`: confirm the manager's own terms (allowlists, minimums, required `--btc-reward-address`). A stake the manager refuses aborts on chain and still costs the fee - `--num-cycles` locks STX for up to 96 cycles (~4 years); STX cannot be moved until `unlockBurnHeight` unless `unstake-stx` is called, which still waits for the next cycle - `claim-rewards` may send **two** transactions (manager pull, then staker claim); check `get-rewards` first and only claim when `unclaimedSatsBeforeFees` > 0 and `stakerClaim` is not `none` - Never pass both `--btc-reward-address` and `--signer-calldata-hex` +- `extend-stacking` requires a payout choice every time: if the staker is paid to a BTC address, pass the same `--btc-reward-address` again; most managers delete the stored address on an update without calldata. Use `--sbtc-payout` only when sBTC payout is intended ## Error Handling | Error message | Cause | Fix | |--------------|-------|-----| | "pox-5 refuses ... during the prepare phase" | Burn height is in the last 100 blocks of the cycle | Retry at or after the burn height named in the error | -| "... is already staking ... Use extend_stacking" | Address has a stake | Use `extend-stacking` | +| "... is already staking ... Use extend-stacking" | Address has a stake | Use `extend-stacking` | | "... is not staking" | No stake to update / unstake / look up | Use `stack-stx`, or pass `--signer-manager` for past rewards | | "... is not a registered pox-5 signer manager" | Wrong or unregistered contract id | Pick one from `list-signers` | | "Insufficient STX" / "Insufficient unlocked STX" | Balance too low | Fund the wallet or reduce the amount | | "Nothing to update" | `extend-stacking` with no changes | Pass at least one change | +| "extend-stacking sends payout calldata ..." | No payout choice given | Re-pass `--btc-reward-address`, or `--sbtc-payout` if sBTC is intended | | "unstaking would not unlock it sooner" | Stake already ends next cycle | No action needed | | "has no on-chain staker claim" | Manager pays off-chain | Do not retry; contact the manager | | "No unclaimed rewards" | Nothing earned or already claimed for that cycle | Check another cycle with `get-rewards` | diff --git a/stacking/SKILL.md b/stacking/SKILL.md index 0461ba3b..dc8050f2 100644 --- a/stacking/SKILL.md +++ b/stacking/SKILL.md @@ -143,7 +143,7 @@ Options: - `--max-withdrawal-fee-sats` (optional) — max sBTC withdrawal fee per payout with `--btc-reward-address` (default 3000) - `--signer-calldata-hex` (optional) — raw calldata (≤500 bytes) for managers with a custom format; not combinable with `--btc-reward-address` -Refused before signing when: in the prepare phase, already staking (use `extend-stacking`), the manager is not a registered pox-5 signer, or the amount exceeds the address's STX balance. +Refused before signing when: in the prepare phase (or within 3 burn blocks of it), already staking (use `extend-stacking`), the manager is not a registered pox-5 signer, or the amount exceeds the address's STX balance. Output: ```json @@ -171,10 +171,12 @@ Update an existing stake (`stake-update`): extend, add STX, switch signer manage ``` bun run stacking/stacking.ts extend-stacking \ [--cycles-to-extend ] [--amount-increase ] [--signer-manager ] \ - [--btc-reward-address
[--max-withdrawal-fee-sats ] | --signer-calldata-hex ] + (--btc-reward-address
[--max-withdrawal-fee-sats ] | --signer-calldata-hex | --sbtc-payout) ``` -Refused when not staking, in the prepare phase, nothing would change, the increase exceeds the **unlocked** balance, the new manager is not registered, or the lock would run more than 96 cycles past the next cycle. +**A payout choice is required on every update.** pox-5 passes `stake-update`'s calldata to the manager's `validate-stake!` each time, and reference managers (Xverse, Fast Pool) treat no calldata as "delete the stored BTC payout address"; others reject it. To keep a BTC payout, pass the same `--btc-reward-address` again. `--sbtc-payout` sends no calldata on purpose. + +Refused when no payout choice is given, when not staking, in the prepare phase, nothing would change, the increase exceeds the **unlocked** balance, the new manager is not registered, or the lock would run more than 96 cycles past the next cycle. Output: `txid`, `explorerUrl`, `previous` (the stake before), `signerManager`, `newAmountUstx`, `newAmount`, `unlockCycle`, `unlockBurnHeight`. @@ -192,7 +194,7 @@ Output: `txid`, `explorerUrl`, `previous`, `unlockCycle`, `unlockBurnHeight`. ### get-rewards -sBTC earned from a signer manager for one cycle and not yet claimed. +sBTC claimable from a signer manager for one cycle. pox-5's `get-earned-staker-rewards` only catches up when the manager pulls the cycle's rewards, so this projects the amount the staker can claim after that pull (pox-5's own `compute-earned-rewards` over the cycle's global rewards-per-token). ``` bun run stacking/stacking.ts get-rewards --reward-cycle [--address ] [--signer-manager ] @@ -220,7 +222,7 @@ Claim one cycle's sBTC rewards through the signer manager. If the manager has no bun run stacking/stacking.ts claim-rewards --reward-cycle [--signer-manager ] ``` -Refused when the manager has no on-chain staker claim or nothing is unclaimed. Post-conditions allow sBTC only out of pox-5 (the pull) and the manager (the payout); nothing may leave the caller. +Refused when the manager has no on-chain staker claim or nothing is claimable (projected as in `get-rewards`, so rewards the manager has not pulled yet still count). When two transactions are sent they use consecutive explicit nonces. The sBTC post-conditions use the token pox-5 reports in `/v2/pox` (`pox_5_sbtc_contract`), which differs from the default sBTC on testnet. Post-conditions allow sBTC only out of pox-5 (the pull) and the manager (the payout); nothing may leave the caller. Output: `txid`, `explorerUrl`, `unclaimedSatsBeforeFees`, `managerPull` (`{ txid, explorerUrl }` or `null`), and a `note` when two transactions were sent. diff --git a/stacking/stacking.ts b/stacking/stacking.ts index fb8ef407..8e02c9e7 100644 --- a/stacking/stacking.ts +++ b/stacking/stacking.ts @@ -10,6 +10,7 @@ import { Command } from "commander"; import { NETWORK, getExplorerTxUrl } from "../src/lib/config/networks.js"; import { getAccount, getWalletAddress } from "../src/lib/services/x402.service.js"; +import { getHiroApi } from "../src/lib/services/hiro-api.js"; import { MAX_STAKE_CYCLES, buildPayoutCalldata, @@ -323,12 +324,34 @@ addPayoutOptions( .option("--cycles-to-extend ", "Cycles to add to the current unlock cycle", "0") .option("--amount-increase ", "Additional micro-STX to lock", "0") .option("--signer-manager ", "New signer manager contract id (defaults to the current one)") + .option( + "--sbtc-payout", + "Send no payout calldata, so rewards are paid as sBTC. Managers that store a BTC payout address delete it " + + "on a stake-update without calldata, and some managers reject it. One of this, --btc-reward-address or " + + "--signer-calldata-hex is required." + ) ).action( - async (opts: PayoutOptions & { cyclesToExtend: string; amountIncrease: string; signerManager?: string }) => { + async ( + opts: PayoutOptions & { cyclesToExtend: string; amountIncrease: string; signerManager?: string; sbtcPayout?: boolean } + ) => { try { const cyclesToExtend = parseIntStrict(opts.cyclesToExtend, "--cycles-to-extend", 0, MAX_STAKE_CYCLES); const amountIncreaseUstx = parseUstx(opts.amountIncrease, "--amount-increase", true); const signerCalldata = resolveCalldata(opts); + // pox-5 hands stake-update's calldata to the manager's validate-stake! on + // every update. Reference managers (Xverse, Fast Pool) treat `none` as + // "delete the stored BTC payout address", so an extend that omits it + // silently switches a BTC-payout staker to sBTC. Make the choice explicit. + if (signerCalldata && opts.sbtcPayout) { + throw new Error("--sbtc-payout cannot be combined with --btc-reward-address or --signer-calldata-hex."); + } + if (!signerCalldata && !opts.sbtcPayout) { + throw new Error( + "extend-stacking sends payout calldata to the signer manager on every update, and most managers " + + "treat no calldata as \"delete my BTC payout address\". Pass --btc-reward-address
(or " + + "--signer-calldata-hex) to keep or set a BTC payout, or --sbtc-payout to be paid in sBTC." + ); + } const account = await getAccount(); const result = await getStackingService(NETWORK).updateStake(account, { signerManager: opts.signerManager, @@ -411,7 +434,7 @@ program ); } const [earnedSats, unpulledSats, claimStyle] = await Promise.all([ - service.getStakerUnclaimedRewards(manager, rewardCycle, staker), + service.getStakerClaimableRewards(manager, rewardCycle, staker), service.getSignerUnpulledRewards(manager, rewardCycle), service.getClaimStyle(manager), ]); @@ -462,7 +485,7 @@ program } const [earnedSats, unpulledSats, claimStyle] = await Promise.all([ - service.getStakerUnclaimedRewards(manager, rewardCycle, account.address), + service.getStakerClaimableRewards(manager, rewardCycle, account.address), service.getSignerUnpulledRewards(manager, rewardCycle), service.getClaimStyle(manager), ]); @@ -474,12 +497,19 @@ program } let managerPull: { txid: string; explorerUrl: string } | null = null; + let claimNonce: bigint | undefined; if (unpulledSats > 0n) { - const pulled = await service.pullSignerRewards(account, manager, rewardCycle, unpulledSats); + // Two transactions back to back: pin consecutive nonces, since the API + // can still report the first one's nonce as next right after broadcast. + const pullNonce = BigInt( + (await getHiroApi(NETWORK).getNonceInfo(account.address)).possible_next_nonce + ); + const pulled = await service.pullSignerRewards(account, manager, rewardCycle, unpulledSats, pullNonce); managerPull = { txid: pulled.txid, explorerUrl: getExplorerTxUrl(pulled.txid, NETWORK) }; + claimNonce = pullNonce + 1n; } - const claim = await service.claimStakerRewards(account, manager, rewardCycle, claimStyle); + const claim = await service.claimStakerRewards(account, manager, rewardCycle, claimStyle, claimNonce); printJson({ success: true, network: NETWORK,