diff --git a/contrib/core-contract-tests/package-lock.json b/contrib/core-contract-tests/package-lock.json index 50ded82d38..f15153b12e 100644 --- a/contrib/core-contract-tests/package-lock.json +++ b/contrib/core-contract-tests/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@hirosystems/clarinet-sdk": "^2.4.1", "@stacks/clarunit": "0.0.1", - "@stacks/transactions": "^6.12.0", + "@stacks/stacking": "^6.13.2", + "@stacks/transactions": "^6.13.0", "chokidar-cli": "^3.0.0", "fast-check": "^3.15.1", "typescript": "^5.4.2", @@ -448,9 +449,9 @@ } }, "node_modules/@hirosystems/clarinet-sdk-wasm": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-2.4.0.tgz", - "integrity": "sha512-qApXWsnWRtQcj5BsqoKd+AsEtDURA5CJQcRxgCAVjyRSjkbGJXxNgrW9oRnIkfIIKJ6D5mV7JGrr8CQ8BSJ/tg==" + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-2.4.2.tgz", + "integrity": "sha512-85RrDiqrfup/h7XLqysdm/J4csmimCRTXHnCiD+4HyKHVhgr7HWL7sGEGpGfThjPxukjV8A+b2GF2x9Rufpz9g==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", @@ -1219,23 +1220,23 @@ } }, "node_modules/@stacks/common": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.10.0.tgz", - "integrity": "sha512-6x5Z7AKd9/kj3+DYE9xIDIkFLHihBH614i2wqrZIjN02WxVo063hWSjIlUxlx8P4gl6olVzlOy5LzhLJD9OP0A==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.13.0.tgz", + "integrity": "sha512-wwzyihjaSdmL6NxKvDeayy3dqM0L0Q2sawmdNtzJDi0FnXuJGm5PeapJj7bEfcI9XwI7Bw5jZoC6mCn9nc5YIw==", "dependencies": { "@types/bn.js": "^5.1.0", "@types/node": "^18.0.4" } }, "node_modules/@stacks/encryption": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.12.0.tgz", - "integrity": "sha512-CubE51pHrcxx3yA+xapevPgA9UDleIoEaUZ06/9uD91B42yvTg37HyS8t06rzukU9q+X7Cv2I/+vbuf4nJIo8g==", + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.13.1.tgz", + "integrity": "sha512-y5IFX3/nGI3fCk70gE0JwH70GpshD8RhUfvhMLcL96oNaec1cCdj1ZUiQupeicfYTHuraaVBYU9xLls4TRmypg==", "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@scure/bip39": "1.1.0", - "@stacks/common": "^6.10.0", + "@stacks/common": "^6.13.0", "@types/node": "^18.0.4", "base64-js": "^1.5.1", "bs58": "^5.0.0", @@ -1244,25 +1245,26 @@ } }, "node_modules/@stacks/network": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.11.3.tgz", - "integrity": "sha512-c4ClCU/QUwuu8NbHtDKPJNa0M5YxauLN3vYaR0+S4awbhVIKFQSxirm9Q9ckV1WBh7FtD6u2S0x+tDQGAODjNg==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.13.0.tgz", + "integrity": "sha512-Ss/Da4BNyPBBj1OieM981fJ7SkevKqLPkzoI1+Yo7cYR2df+0FipIN++Z4RfpJpc8ne60vgcx7nJZXQsiGhKBQ==", "dependencies": { - "@stacks/common": "^6.10.0", + "@stacks/common": "^6.13.0", "cross-fetch": "^3.1.5" } }, "node_modules/@stacks/stacking": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@stacks/stacking/-/stacking-6.12.0.tgz", - "integrity": "sha512-XBxwbaCGRPnjpjspb3CBXrlZl6xR+gghLMz9PQNPdpuIbBDFa0SGeHgqjtpVU+2DVL4UyBx8PVsAWtlssyVGng==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@stacks/stacking/-/stacking-6.13.2.tgz", + "integrity": "sha512-4h1UQuL2+Xdra9zMqzUElvKG9X9fenuNE7hD9sIqyxyLFxeQ7gRqczmTYPsmaj4wY5004JNj+efzGJ0VmpOcAA==", "dependencies": { + "@noble/hashes": "1.1.5", "@scure/base": "1.1.1", - "@stacks/common": "^6.10.0", - "@stacks/encryption": "^6.12.0", - "@stacks/network": "^6.11.3", + "@stacks/common": "^6.13.0", + "@stacks/encryption": "^6.13.1", + "@stacks/network": "^6.13.0", "@stacks/stacks-blockchain-api-types": "^0.61.0", - "@stacks/transactions": "^6.12.0", + "@stacks/transactions": "^6.13.1", "bs58": "^5.0.0" } }, @@ -1283,14 +1285,14 @@ "integrity": "sha512-yPOfTUboo5eA9BZL/hqMcM71GstrFs9YWzOrJFPeP4cOO1wgYvAcckgBRbgiE3NqeX0A7SLZLDAXLZbATuRq9w==" }, "node_modules/@stacks/transactions": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.12.0.tgz", - "integrity": "sha512-gRP3SfTaAIoTdjMvOiLrMZb/senqB8JQlT5Y4C3/CiHhiprYwTx7TbOCSa7WsNOU99H4aNfHvatmymuggXQVkA==", + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.13.1.tgz", + "integrity": "sha512-PWw2I+2Fj3CaFYQIoVcqQN6E2qGHNhFv03nuR0CxMq0sx8stPgYZbdzUlnlBcJQdsFiHrw3sPeqnXDZt+Hg5YQ==", "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", - "@stacks/common": "^6.10.0", - "@stacks/network": "^6.11.3", + "@stacks/common": "^6.13.0", + "@stacks/network": "^6.13.0", "c32check": "^2.0.0", "lodash.clonedeep": "^4.5.0" } diff --git a/contrib/core-contract-tests/package.json b/contrib/core-contract-tests/package.json index 7ba3ba62e2..fe3dee2eb5 100644 --- a/contrib/core-contract-tests/package.json +++ b/contrib/core-contract-tests/package.json @@ -13,7 +13,8 @@ "dependencies": { "@hirosystems/clarinet-sdk": "^2.4.1", "@stacks/clarunit": "0.0.1", - "@stacks/transactions": "^6.12.0", + "@stacks/stacking": "^6.13.2", + "@stacks/transactions": "^6.13.0", "chokidar-cli": "^3.0.0", "fast-check": "^3.15.1", "typescript": "^5.4.2", diff --git a/contrib/core-contract-tests/tests/pox-4/pox-4.stateful-prop.test.ts b/contrib/core-contract-tests/tests/pox-4/pox-4.stateful-prop.test.ts new file mode 100644 index 0000000000..15f4d4ddc0 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox-4.stateful-prop.test.ts @@ -0,0 +1,162 @@ +import { it } from "vitest"; +import { initSimnet } from "@hirosystems/clarinet-sdk"; +import { Real, Stub } from "./pox_CommandModel.ts"; + +import { + getPublicKeyFromPrivate, + publicKeyToBtcAddress, +} from "@stacks/encryption"; +import { StacksDevnet } from "@stacks/network"; +import { + createStacksPrivateKey, + getAddressFromPrivateKey, + TransactionVersion, +} from "@stacks/transactions"; +import { StackingClient } from "@stacks/stacking"; + +import fc from "fast-check"; +import { PoxCommands } from "./pox_Commands.ts"; + +import fs from "fs"; +import path from "path"; + +it("statefully interacts with PoX-4", async () => { + // SUT stands for "System Under Test". + const sut: Real = { + network: await initSimnet(), + }; + + const wallets = [ + [ + "wallet_1", + "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801", + ], + [ + "wallet_2", + "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101", + ], + [ + "wallet_3", + "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901", + ], + [ + "wallet_4", + "f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701", + ], + [ + "wallet_5", + "3eccc5dac8056590432db6a35d52b9896876a3d5cbdea53b72400bc9c2099fe801", + ], + [ + "wallet_6", + "7036b29cb5e235e5fd9b09ae3e8eec4404e44906814d5d01cbca968a60ed4bfb01", + ], + [ + "wallet_7", + "b463f0df6c05d2f156393eee73f8016c5372caa0e9e29a901bb7171d90dc4f1401", + ], + [ + "wallet_8", + "6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01", + ], + [ + "wallet_9", + "de433bdfa14ec43aa1098d5be594c8ffb20a31485ff9de2923b2689471c401b801", + ], + ].map((wallet) => { + const label = wallet[0]; + const prvKey = wallet[1]; + const pubKey = getPublicKeyFromPrivate(prvKey); + const devnet = new StacksDevnet(); + const initialUstxBalance = 100_000_000_000_000; + const signerPrvKey = createStacksPrivateKey(prvKey); + const signerPubKey = getPublicKeyFromPrivate(signerPrvKey.data); + const btcAddress = publicKeyToBtcAddress(pubKey); + const stxAddress = getAddressFromPrivateKey( + prvKey, + TransactionVersion.Testnet, + ); + + return { + label, + stxAddress, + btcAddress, + signerPrvKey, + signerPubKey, + stackingClient: new StackingClient(stxAddress, devnet), + ustxBalance: initialUstxBalance, + isStacking: false, + hasDelegated: false, + lockedAddresses: [], + amountToCommit: 0, + poolMembers: [], + delegatedTo: "", + delegatedMaxAmount: 0, + delegatedUntilBurnHt: 0, + delegatedPoxAddress: "", + amountLocked: 0, + amountUnlocked: initialUstxBalance, + unlockHeight: 0, + firstLockedRewardCycle: 0, + allowedContractCaller: "", + callerAllowedBy: [], + committedRewCycleIndexes: [], + }; + }); + + // Track the number of times each command is run, so we can see if all the + // commands are run at least once. + const statistics = fs.readdirSync(path.join(__dirname)).filter((file) => + file.startsWith("pox_") && file.endsWith(".ts") && + file !== "pox_CommandModel.ts" && file !== "pox_Commands.ts" + ).map((file) => file.slice(4, -3)); // Remove "pox_" prefix and ".ts" suffix. + + // This is the initial state of the model. + const model = new Stub( + new Map(wallets.map((wallet) => [wallet.stxAddress, wallet])), + new Map(wallets.map((wallet) => [wallet.stxAddress, { + ustxBalance: 100_000_000_000_000, + isStacking: false, + isStackingSolo: false, + hasDelegated: false, + lockedAddresses: [], + amountToCommit: 0, + poolMembers: [], + delegatedTo: "", + delegatedMaxAmount: 0, + delegatedUntilBurnHt: 0, + delegatedPoxAddress: "", + amountLocked: 0, + amountUnlocked: 100_000_000_000_000, + unlockHeight: 0, + firstLockedRewardCycle: 0, + allowedContractCaller: "", + callerAllowedBy: [], + committedRewCycleIndexes: [], + }])), + new Map(statistics.map((commandName) => [commandName, 0])), + ); + + simnet.setEpoch("3.0"); + + fc.assert( + fc.property( + PoxCommands(model.wallets, model.stackers, sut.network), + (cmds) => { + const initialState = () => ({ model: model, real: sut }); + fc.modelRun(initialState, cmds); + }, + ), + { + // Defines the number of test iterations to run; default is 100. + numRuns: 1000, + // Adjusts the level of detail in test reports. Default is 0 (minimal). + // At level 2, reports include extensive details, helpful for deep + // debugging. This includes not just the failing case and its seed, but + // also a comprehensive log of all executed steps and their outcomes. + verbose: 2, + }, + ); + + model.reportCommandRuns(); +}); diff --git a/contrib/core-contract-tests/tests/pox-4/pox_AllowContractCallerCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_AllowContractCallerCommand.ts new file mode 100644 index 0000000000..dad1a381a5 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_AllowContractCallerCommand.ts @@ -0,0 +1,132 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { expect } from "vitest"; +import { + boolCV, + Cl, + ClarityType, + OptionalCV, + UIntCV, +} from "@stacks/transactions"; + +/** + * The `AllowContractCallerCommand` authorizes a `contract-caller` to call + * stacking methods. Normally, stacking methods can only be invoked by direct + * transactions (i.e., the tx-sender issues a direct contract-call to the + * stacking methods). By issuing an allowance, the tx-sender may call stacking + * methods through the allowed contract. + * + * There are no constraints for running this command. + */ +export class AllowContractCallerCommand implements PoxCommand { + readonly wallet: Wallet; + readonly allowanceTo: Wallet; + readonly allowUntilBurnHt: OptionalCV; + + /** + * Constructs an `AllowContractCallerCommand` that authorizes a + * `contract-caller` to call stacking methods. + * + * @param wallet - Represents the Stacker's wallet. + * @param allowanceTo - Represents the authorized `contract-caller` (i.e., a + * stacking pool). + * @param allowUntilBurnHt - The burn block height until which the + * authorization is valid. + */ + constructor( + wallet: Wallet, + allowanceTo: Wallet, + allowUntilBurnHt: OptionalCV, + ) { + this.wallet = wallet; + this.allowanceTo = allowanceTo; + this.allowUntilBurnHt = allowUntilBurnHt; + } + + check(): boolean { + // There are no constraints for running this command. + return true; + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + // Act + const allowContractCaller = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "allow-contract-caller", + [ + // (caller principal) + Cl.principal(this.allowanceTo.stxAddress), + // (until-burn-ht (optional uint)) + this.allowUntilBurnHt, + ], + this.wallet.stxAddress, + ); + + // Assert + expect(allowContractCaller.result).toBeOk(boolCV(true)); + + // Get the wallets involved from the model and update it with the new state. + const wallet = model.stackers.get(this.wallet.stxAddress)!; + const callerAllowedBefore = wallet.allowedContractCaller; + + const callerAllowedBeforeState = model.stackers.get(callerAllowedBefore) || + null; + + if (callerAllowedBeforeState) { + // Remove the allower from the ex-allowed caller's allowance list. + + const walletIndexInsideAllowedByList = callerAllowedBeforeState + .callerAllowedBy.indexOf( + this.wallet.stxAddress, + ); + + expect(walletIndexInsideAllowedByList).toBeGreaterThan(-1); + + callerAllowedBeforeState.callerAllowedBy.splice( + walletIndexInsideAllowedByList, + 1, + ); + } + + const callerToAllow = model.stackers.get(this.allowanceTo.stxAddress)!; + // Update model so that we know this wallet has authorized a contract-caller. + + wallet.allowedContractCaller = this.allowanceTo.stxAddress; + callerToAllow.callerAllowedBy.push(this.wallet.stxAddress); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.wallet.label}`, + "allow-contract-caller", + this.allowanceTo.label, + "until", + optionalCVToString(this.allowUntilBurnHt), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.wallet.stxAddress} allow-contract-caller ${this.allowanceTo.stxAddress} until burn ht ${ + optionalCVToString(this.allowUntilBurnHt) + }`; + } +} + +const optionalCVToString = (optional: OptionalCV): string => + optional.type === ClarityType.OptionalSome + ? (optional.value as UIntCV).value.toString() + : "none"; diff --git a/contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts b/contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts new file mode 100644 index 0000000000..6d4d582b58 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts @@ -0,0 +1,212 @@ +import fc from "fast-check"; + +import { Simnet } from "@hirosystems/clarinet-sdk"; +import { + ClarityValue, + cvToValue, + StacksPrivateKey, +} from "@stacks/transactions"; +import { StackingClient } from "@stacks/stacking"; +import { + FIRST_BURNCHAIN_BLOCK_HEIGHT, + REWARD_CYCLE_LENGTH, +} from "./pox_Commands"; + +export type StxAddress = string; +export type BtcAddress = string; +export type CommandTag = string; + +export class Stub { + readonly wallets: Map; + readonly statistics: Map; + readonly stackers: Map; + stackingMinimum: number; + nextRewardSetIndex: number; + lastRefreshedCycle: number; + burnBlockHeight: number; + + constructor( + wallets: Map, + stackers: Map, + statistics: Map, + ) { + this.wallets = wallets; + this.statistics = statistics; + this.stackers = stackers; + this.stackingMinimum = 0; + this.nextRewardSetIndex = 0; + this.lastRefreshedCycle = 0; + this.burnBlockHeight = 0; + } + + trackCommandRun(commandName: string) { + const count = this.statistics.get(commandName) || 0; + this.statistics.set(commandName, count + 1); + } + + reportCommandRuns() { + console.log("Command run method execution counts:"); + this.statistics.forEach((count, commandName) => { + console.log(`${commandName}: ${count}`); + }); + } + + refreshStateForNextRewardCycle(real: Real) { + const burnBlockHeightResult = real.network.runSnippet("burn-block-height"); + const burnBlockHeight = Number( + cvToValue(burnBlockHeightResult as ClarityValue), + ); + const lastRefreshedCycle = this.lastRefreshedCycle; + const currentRewCycle = Math.floor( + (Number(burnBlockHeight) - FIRST_BURNCHAIN_BLOCK_HEIGHT) / + REWARD_CYCLE_LENGTH, + ); + + // The `this.burnBlockHeight` instance member is used for logging purposes. + // However, it's not used in the actual implementation of the model and all + // usages below use the `burnBlockHeight` local variable. + this.burnBlockHeight = burnBlockHeight; + + if (lastRefreshedCycle < currentRewCycle) { + this.nextRewardSetIndex = 0; + + this.wallets.forEach((w) => { + let updatedAmountToCommit = 0; + const wallet = this.stackers.get(w.stxAddress)!; + + // Get the wallet's ex-delegators by comparing their delegatedUntilBurnHt + // to the current burn block height (only if the wallet is a delegatee). + const expiredDelegators = wallet.poolMembers.filter((stackerAddress) => + this.stackers.get(stackerAddress)!.delegatedUntilBurnHt < + burnBlockHeight + ); + + // Get the operator's pool stackers that no longer have partially commited + // STX for the next reward cycle by comparing their unlock height to + // the next reward cycle's first block (only if the wallet is an operator). + const stackersToRemoveAmountToCommit = wallet.lockedAddresses.filter(( + stackerAddress, + ) => + this.stackers.get(stackerAddress)!.unlockHeight <= + burnBlockHeight + REWARD_CYCLE_LENGTH + ); + + // Get the operator's ex-pool stackers by comparing their unlockHeight to + // the current burn block height (only if the wallet is an operator). + const expiredStackers = wallet.lockedAddresses.filter( + (stackerAddress) => + this.stackers.get(stackerAddress)!.unlockHeight <= + burnBlockHeight, + ); + + // For each remaining pool stacker (if any), increase the operator's + // amountToCommit (partial-stacked) for the next cycle by the + // stacker's amountLocked. + wallet.lockedAddresses.forEach((stacker) => { + const stackerWallet = this.stackers.get(stacker)!; + updatedAmountToCommit += stackerWallet?.amountLocked; + }); + + // Update the operator's amountToCommit (partial-stacked). + wallet.amountToCommit = updatedAmountToCommit; + + // Remove the expired delegators from the delegatee's poolMembers list. + expiredDelegators.forEach((expDelegator) => { + const expDelegatorIndex = wallet.poolMembers.indexOf(expDelegator); + wallet.poolMembers.splice(expDelegatorIndex, 1); + }); + + // Remove the expired stackers from the operator's lockedAddresses list. + expiredStackers.forEach((expStacker) => { + const expStackerIndex = wallet.lockedAddresses.indexOf(expStacker); + wallet.lockedAddresses.splice(expStackerIndex, 1); + }); + + // For each pool stacker that no longer have partially commited STX for + // the next reward cycle, decrement the operator's amountToCommit + // (partial-stacked) by the stacker's amountLocked. + stackersToRemoveAmountToCommit.forEach((expStacker) => { + const expStackerWallet = this.stackers.get(expStacker)!; + wallet.amountToCommit -= expStackerWallet.amountLocked; + }); + + // Check the wallet's stack expiry and update the state accordingly. + if ( + wallet.unlockHeight > 0 && wallet.unlockHeight <= burnBlockHeight + ) { + wallet.isStacking = false; + wallet.isStackingSolo = false; + wallet.amountUnlocked += wallet.amountLocked; + wallet.amountLocked = 0; + wallet.unlockHeight = 0; + wallet.firstLockedRewardCycle = 0; + } // If the wallet is solo stacking and its stack won't expire in the + // next reward cycle, increment the model's nextRewardSetIndex (the + // next empty reward slot) + else if ( + wallet.unlockHeight > 0 && + wallet.unlockHeight > burnBlockHeight + REWARD_CYCLE_LENGTH && + wallet.isStackingSolo + ) { + this.nextRewardSetIndex++; + } + wallet.committedRewCycleIndexes = []; + }); + this.lastRefreshedCycle = currentRewCycle; + } + } +} + +export type Real = { + network: Simnet; +}; + +export type Wallet = { + label: string; + stxAddress: string; + btcAddress: string; + signerPrvKey: StacksPrivateKey; + signerPubKey: string; + stackingClient: StackingClient; +}; + +export type Stacker = { + ustxBalance: number; + isStacking: boolean; + isStackingSolo: boolean; + hasDelegated: boolean; + lockedAddresses: StxAddress[]; + amountToCommit: number; + poolMembers: StxAddress[]; + delegatedTo: StxAddress; + delegatedMaxAmount: number; + delegatedUntilBurnHt: number; + delegatedPoxAddress: BtcAddress; + amountLocked: number; + amountUnlocked: number; + unlockHeight: number; + firstLockedRewardCycle: number; + allowedContractCaller: StxAddress; + callerAllowedBy: StxAddress[]; + committedRewCycleIndexes: number[]; +}; + +export type PoxCommand = fc.Command; + +export const logCommand = (...items: (string | undefined)[]) => { + // Ensure we only render up to the first 10 items for brevity. + const renderItems = items.slice(0, 10); + const columnWidth = 23; // Standard width for each column after the first two. + const halfColumns = Math.floor(columnWidth / 2); + + // Pad columns to their widths: half for the first two, full for the rest. + const prettyPrint = renderItems.map((content, index) => + // Check if the index is less than 2 (i.e., first two items). + content + ? (index < 2 ? content.padEnd(halfColumns) : content.padEnd(columnWidth)) + : (index < 2 ? "".padEnd(halfColumns) : "".padEnd(columnWidth)) + ); + prettyPrint.push("\n"); + + process.stdout.write(prettyPrint.join("")); +}; diff --git a/contrib/core-contract-tests/tests/pox-4/pox_Commands.ts b/contrib/core-contract-tests/tests/pox-4/pox_Commands.ts new file mode 100644 index 0000000000..4767f91b70 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_Commands.ts @@ -0,0 +1,387 @@ +import fc from "fast-check"; +import { Real, Stacker, Stub, StxAddress, Wallet } from "./pox_CommandModel"; +import { GetStackingMinimumCommand } from "./pox_GetStackingMinimumCommand"; +import { GetStxAccountCommand } from "./pox_GetStxAccountCommand"; +import { StackStxCommand } from "./pox_StackStxCommand"; +import { DelegateStxCommand } from "./pox_DelegateStxCommand"; +import { DelegateStackStxCommand } from "./pox_DelegateStackStxCommand"; +import { Simnet } from "@hirosystems/clarinet-sdk"; +import { Cl, cvToValue, OptionalCV, UIntCV } from "@stacks/transactions"; +import { RevokeDelegateStxCommand } from "./pox_RevokeDelegateStxCommand"; +import { AllowContractCallerCommand } from "./pox_AllowContractCallerCommand"; +import { DelegateStackIncreaseCommand } from "./pox_DelegateStackIncreaseCommand"; +import { DelegateStackExtendCommand } from "./pox_DelegateStackExtendCommand"; +import { StackAggregationCommitAuthCommand } from "./pox_StackAggregationCommitAuthCommand"; +import { StackAggregationCommitSigCommand } from "./pox_StackAggregationCommitSigCommand"; +import { StackAggregationCommitIndexedSigCommand } from "./pox_StackAggregationCommitIndexedSigCommand"; +import { StackAggregationCommitIndexedAuthCommand } from "./pox_StackAggregationCommitIndexedAuthCommand"; +import { StackAggregationIncreaseCommand } from "./pox_StackAggregationIncreaseCommand"; +import { DisallowContractCallerCommand } from "./pox_DisallowContractCallerCommand"; + +export function PoxCommands( + wallets: Map, + stackers: Map, + network: Simnet, +): fc.Arbitrary>> { + const cmds = [ + // GetStackingMinimumCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + }).map(( + r: { + wallet: Wallet; + }, + ) => + new GetStackingMinimumCommand( + r.wallet, + ) + ), + // StackStxCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + authId: fc.nat(), + period: fc.integer({ min: 1, max: 12 }), + margin: fc.integer({ min: 1, max: 9 }), + }).map(( + r: { + wallet: Wallet; + authId: number; + period: number; + margin: number; + }, + ) => + new StackStxCommand( + r.wallet, + r.authId, + r.period, + r.margin, + ) + ), + // DelegateStxCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + delegateTo: fc.constantFrom(...wallets.values()), + untilBurnHt: fc.integer({ min: 1 }), + amount: fc.bigInt({ min: 0n, max: 100_000_000_000_000n }), + }).map(( + r: { + wallet: Wallet; + delegateTo: Wallet; + untilBurnHt: number; + amount: bigint; + }, + ) => + new DelegateStxCommand( + r.wallet, + r.delegateTo, + r.untilBurnHt, + r.amount, + ) + ), + // StackAggregationCommitAuthCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + authId: fc.nat(), + }).map(( + r: { + wallet: Wallet; + authId: number; + }, + ) => + new StackAggregationCommitAuthCommand( + r.wallet, + r.authId, + ) + ), + // StackAggregationCommitSigCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + authId: fc.nat(), + }).map(( + r: { + wallet: Wallet; + authId: number; + }, + ) => + new StackAggregationCommitSigCommand( + r.wallet, + r.authId, + ) + ), + // StackAggregationCommitIndexedAuthCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + authId: fc.nat(), + }).map(( + r: { + wallet: Wallet; + authId: number; + }, + ) => + new StackAggregationCommitIndexedAuthCommand( + r.wallet, + r.authId, + ) + ), + // StackAggregationCommitIndexedSigCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + authId: fc.nat(), + }).map(( + r: { + wallet: Wallet; + authId: number; + }, + ) => + new StackAggregationCommitIndexedSigCommand( + r.wallet, + r.authId, + ) + ), + // StackAggregationIncreaseCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + authId: fc.nat(), + }).chain((r) => { + const operator = stackers.get(r.wallet.stxAddress)!; + const committedRewCycleIndexesOrFallback = + operator.committedRewCycleIndexes.length > 0 + ? operator.committedRewCycleIndexes + : [-1]; + return fc.record({ + rewardCycleIndex: fc.constantFrom( + ...committedRewCycleIndexesOrFallback, + ), + }).map((cycleIndex) => ({ ...r, ...cycleIndex })); + }).map(( + r: { + wallet: Wallet; + rewardCycleIndex: number; + authId: number; + }, + ) => + new StackAggregationIncreaseCommand( + r.wallet, + r.rewardCycleIndex, + r.authId, + ) + ), + // RevokeDelegateStxCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + }).map(( + r: { + wallet: Wallet; + }, + ) => + new RevokeDelegateStxCommand( + r.wallet, + ) + ), + // DelegateStackStxCommand + fc.record({ + operator: fc.constantFrom(...wallets.values()), + startBurnHt: fc.integer({ + min: currentCycleFirstBlock(network), + max: nextCycleFirstBlock(network), + }), + period: fc.integer({ min: 1, max: 12 }), + }).chain((r) => { + const operator = stackers.get(r.operator.stxAddress)!; + // Determine available stackers based on the operator + const availableStackers = operator.poolMembers.length > 0 + ? operator.poolMembers + : [r.operator.stxAddress]; + + return fc.record({ + stacker: fc.constantFrom(...availableStackers), + }).map((stacker) => ({ + ...r, + stacker: wallets.get(stacker.stacker)!, + })).chain((resultWithStacker) => { + return fc.record({ + unlockBurnHt: fc.constant( + currentCycleFirstBlock(network) + + 1050 * (resultWithStacker.period + 1), + ), + }).map((additionalProps) => ({ + ...resultWithStacker, + ...additionalProps, + })); + }).chain((resultWithUnlockHeight) => { + return fc.record({ + amount: fc.bigInt({ + min: 0n, + max: BigInt( + stackers.get(resultWithUnlockHeight.stacker.stxAddress)! + .delegatedMaxAmount, + ), + }), + }).map((amountProps) => ({ + ...resultWithUnlockHeight, + ...amountProps, + })); + }); + }).map((finalResult) => { + return new DelegateStackStxCommand( + finalResult.operator, + finalResult.stacker, + finalResult.period, + finalResult.amount, + finalResult.unlockBurnHt, + ); + }), + // DelegateStackIncreaseCommand + fc.record({ + operator: fc.constantFrom(...wallets.values()), + increaseBy: fc.nat(), + }) + .chain((r) => { + const operator = stackers.get(r.operator.stxAddress)!; + const delegatorsList = operator.poolMembers; + + const availableStackers = delegatorsList.filter((delegator) => { + const delegatorWallet = stackers.get(delegator)!; + return delegatorWallet.unlockHeight > nextCycleFirstBlock(network); + }); + + const availableStackersOrFallback = availableStackers.length === 0 + ? [r.operator.stxAddress] + : availableStackers; + + return fc + .record({ + stacker: fc.constantFrom(...availableStackersOrFallback), + }) + .map((stacker) => ({ + ...r, + stacker: wallets.get(stacker.stacker)!, + })); + }) + .map((final) => { + return new DelegateStackIncreaseCommand( + final.operator, + final.stacker, + final.increaseBy, + ); + }), + // DelegateStackExtendCommand + fc.record({ + operator: fc.constantFrom(...wallets.values()), + extendCount: fc.integer({ min: 1, max: 11 }), + }).chain((r) => { + const operator = stackers.get(r.operator.stxAddress)!; + const delegatorsList = operator.poolMembers; + const availableStackers = delegatorsList.filter((delegator) => { + const delegatorWallet = stackers.get(delegator)!; + return delegatorWallet.unlockHeight > nextCycleFirstBlock(network); + }); + + const availableStackersOrFallback = availableStackers.length === 0 + ? [r.operator.stxAddress] + : availableStackers; + + return fc.record({ + stacker: fc.constantFrom(...availableStackersOrFallback), + currentCycle: fc.constant(currentCycle(network)), + }) + .map((additionalProps) => ({ + ...r, + stacker: wallets.get(additionalProps.stacker)!, + currentCycle: additionalProps.currentCycle, + })); + }).map((final) => + new DelegateStackExtendCommand( + final.operator, + final.stacker, + final.extendCount, + final.currentCycle, + ) + ), + // AllowContractCallerCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + allowanceTo: fc.constantFrom(...wallets.values()), + alllowUntilBurnHt: fc.oneof( + fc.constant(Cl.none()), + fc.integer({ min: 1 }).map((value) => Cl.some(Cl.uint(value))), + ), + }) + .map( + (r: { + wallet: Wallet; + allowanceTo: Wallet; + alllowUntilBurnHt: OptionalCV; + }) => + new AllowContractCallerCommand( + r.wallet, + r.allowanceTo, + r.alllowUntilBurnHt, + ), + ), + // DisallowContractCallerCommand + fc.record({ + stacker: fc.constantFrom(...wallets.values()), + callerToDisallow: fc.constantFrom(...wallets.values()), + }).map( + (r: { + stacker: Wallet; + callerToDisallow: Wallet; + }) => + new DisallowContractCallerCommand( + r.stacker, + r.callerToDisallow, + ), + ), + // GetStxAccountCommand + fc.record({ + wallet: fc.constantFrom(...wallets.values()), + }).map(( + r: { + wallet: Wallet; + }, + ) => + new GetStxAccountCommand( + r.wallet, + ) + ), + ]; + + // More on size: https://github.com/dubzzz/fast-check/discussions/2978 + // More on cmds: https://github.com/dubzzz/fast-check/discussions/3026 + return fc.commands(cmds, { size: "xsmall" }); +} + +export const REWARD_CYCLE_LENGTH = 1050; + +export const FIRST_BURNCHAIN_BLOCK_HEIGHT = 0; + +export const currentCycle = (network: Simnet) => + Number(cvToValue( + network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "current-pox-reward-cycle", + [], + "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM", + ).result, + )); + +export const currentCycleFirstBlock = (network: Simnet) => + Number(cvToValue( + network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "reward-cycle-to-burn-height", + [Cl.uint(currentCycle(network))], + "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM", + ).result, + )); + +const nextCycleFirstBlock = (network: Simnet) => + Number(cvToValue( + network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "reward-cycle-to-burn-height", + [Cl.uint(currentCycle(network) + 1)], + "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM", + ).result, + )); diff --git a/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackExtendCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackExtendCommand.ts new file mode 100644 index 0000000000..cfd385cf5a --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackExtendCommand.ts @@ -0,0 +1,168 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { assert, expect } from "vitest"; +import { Cl, ClarityType, isClarityType } from "@stacks/transactions"; +import { + FIRST_BURNCHAIN_BLOCK_HEIGHT, + REWARD_CYCLE_LENGTH, +} from "./pox_Commands.ts"; + +/** + * The `DelegateStackExtendCommand` allows a pool operator to + * extend an active stacking lock, issuing a "partial commitment" + * for the extended-to cycles. + * + * This method extends stacker's current lockup for an additional + * extend-count and partially commits those new cycles to `pox-addr`. + * + * Constraints for running this command include: + * - Stacker must have locked uSTX. + * - The Operator has to currently be delegated by the Stacker. + * - The new lock period must be less than or equal to 12. + */ +export class DelegateStackExtendCommand implements PoxCommand { + readonly operator: Wallet; + readonly stacker: Wallet; + readonly extendCount: number; + readonly currentCycle: number; + + /** + * Constructs a `DelegateStackExtendCommand` to extend the unlock + * height as a Pool Operator on behalf of a Stacker. + * + * @param operator - Represents the Pool Operator's wallet. + * @param stacker - Represents the STacker's wallet. + * @param extendCount - Represents the cycles to be expended. + * @param currentCycle - Represents the current PoX reward cycle. + */ + constructor( + operator: Wallet, + stacker: Wallet, + extendCount: number, + currentCycle: number, + ) { + this.operator = operator; + this.stacker = stacker; + this.extendCount = extendCount; + this.currentCycle = currentCycle; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - Stacker must have locked uSTX. + // - The Stacker's uSTX must have been locked by the Operator. + // - The Operator has to currently be delegated by the Stacker. + // - The new lock period must be less than or equal to 12. + + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const stackerWallet = model.stackers.get(this.stacker.stxAddress)!; + + const firstRewardCycle = + this.currentCycle > stackerWallet.firstLockedRewardCycle + ? this.currentCycle + : stackerWallet.firstLockedRewardCycle; + const firstExtendCycle = Math.floor( + (stackerWallet.unlockHeight - FIRST_BURNCHAIN_BLOCK_HEIGHT) / + REWARD_CYCLE_LENGTH, + ); + const lastExtendCycle = firstExtendCycle + this.extendCount - 1; + const totalPeriod = lastExtendCycle - firstRewardCycle + 1; + const newUnlockHeight = + REWARD_CYCLE_LENGTH * (firstRewardCycle + totalPeriod - 1) + + FIRST_BURNCHAIN_BLOCK_HEIGHT; + const stackedAmount = stackerWallet.amountLocked; + + return ( + stackerWallet.amountLocked > 0 && + stackerWallet.hasDelegated === true && + stackerWallet.isStacking === true && + stackerWallet.delegatedTo === this.operator.stxAddress && + stackerWallet.delegatedUntilBurnHt >= newUnlockHeight && + stackerWallet.delegatedMaxAmount >= stackedAmount && + operatorWallet.poolMembers.includes(this.stacker.stxAddress) && + operatorWallet.lockedAddresses.includes(this.stacker.stxAddress) && + totalPeriod <= 12 + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + const stackerWallet = model.stackers.get(this.stacker.stxAddress)!; + + // Act + const delegateStackExtend = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "delegate-stack-extend", + [ + // (stacker principal) + Cl.principal(this.stacker.stxAddress), + // (pox-addr { version: (buff 1), hashbytes: (buff 32) }) + poxAddressToTuple(stackerWallet.delegatedPoxAddress), + // (extend-count uint) + Cl.uint(this.extendCount), + ], + this.operator.stxAddress, + ); + + const { result: firstExtendCycle } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "burn-height-to-reward-cycle", + [Cl.uint(stackerWallet.unlockHeight)], + this.operator.stxAddress, + ); + assert(isClarityType(firstExtendCycle, ClarityType.UInt)); + + const lastExtendCycle = Number(firstExtendCycle.value) + this.extendCount - + 1; + + const { result: extendedUnlockHeight } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "reward-cycle-to-burn-height", + [Cl.uint(lastExtendCycle + 1)], + this.operator.stxAddress, + ); + assert(isClarityType(extendedUnlockHeight, ClarityType.UInt)); + const newUnlockHeight = extendedUnlockHeight.value; + + // Assert + expect(delegateStackExtend.result).toBeOk( + Cl.tuple({ + stacker: Cl.principal(this.stacker.stxAddress), + "unlock-burn-height": Cl.uint(newUnlockHeight), + }), + ); + + // Get the Stacker's wallet from the model and update it with the new state. + // Update model so that we know this wallet's unlock height was extended. + stackerWallet.unlockHeight = Number(newUnlockHeight); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label} Ӿ ${this.stacker.label}`, + "delegate-stack-extend", + "extend count", + this.extendCount.toString(), + "new unlock height", + stackerWallet.unlockHeight.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} delegate-stack-extend extend count ${this.extendCount}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackIncreaseCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackIncreaseCommand.ts new file mode 100644 index 0000000000..b9ec4a837c --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackIncreaseCommand.ts @@ -0,0 +1,138 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl } from "@stacks/transactions"; + +/** + * The DelegateStackIncreaseCommand allows a pool operator to + * increase an active stacking lock, issuing a "partial commitment" + * for the increased cycles. + * + * This method increases stacker's current lockup and partially + * commits the additional STX to `pox-addr`. + * + * Constraints for running this command include: + * - The Stacker must have locked uSTX. + * - The Operator has to currently be delegated by the Stacker. + * - The increase amount must be greater than 0. + * - Stacker's unlocked uSTX amount must be greater than or equal + * to the value of the increase amount. + * - Stacker's maximum delegated amount must be greater than or equal + * to the final locked amount. + * - The Operator must have locked the Stacker's previously locked funds. + */ +export class DelegateStackIncreaseCommand implements PoxCommand { + readonly operator: Wallet; + readonly stacker: Wallet; + readonly increaseBy: number; + + /** + * Constructs a DelegateStackIncreaseCommand to increase the uSTX amount + * previously locked on behalf of a Stacker. + * + * @param operator - Represents the Pool Operator's wallet. + * @param stacker - Represents the Stacker's wallet. + * @param increaseBy - Represents the locked amount to be increased by. + */ + constructor(operator: Wallet, stacker: Wallet, increaseBy: number) { + this.operator = operator; + this.stacker = stacker; + this.increaseBy = increaseBy; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Stacker must have locked uSTX. + // - The Operator has to currently be delegated by the Stacker. + // - The increase amount must be greater than 0. + // - Stacker's unlocked uSTX amount must be greater than or equal + // to the value of the increase amount. + // - Stacker's maximum delegated amount must be greater than or equal + // to the final locked amount. + // - The Operator must have locked the Stacker's previously locked funds. + + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const stackerWallet = model.stackers.get(this.stacker.stxAddress)!; + + return ( + stackerWallet.amountLocked > 0 && + stackerWallet.hasDelegated === true && + stackerWallet.isStacking === true && + this.increaseBy > 0 && + operatorWallet.poolMembers.includes(this.stacker.stxAddress) && + stackerWallet.amountUnlocked >= this.increaseBy && + stackerWallet.delegatedMaxAmount >= + this.increaseBy + stackerWallet.amountLocked && + operatorWallet.lockedAddresses.indexOf(this.stacker.stxAddress) > -1 + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + const stackerWallet = model.stackers.get(this.stacker.stxAddress)!; + const prevLocked = stackerWallet.amountLocked; + const newTotalLocked = prevLocked + this.increaseBy; + // Act + const delegateStackIncrease = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "delegate-stack-increase", + [ + // (stacker principal) + Cl.principal(this.stacker.stxAddress), + // (pox-addr { version: (buff 1), hashbytes: (buff 32) }) + poxAddressToTuple(stackerWallet.delegatedPoxAddress), + // (increase-by uint) + Cl.uint(this.increaseBy), + ], + this.operator.stxAddress, + ); + + // Assert + expect(delegateStackIncrease.result).toBeOk( + Cl.tuple({ + stacker: Cl.principal(this.stacker.stxAddress), + "total-locked": Cl.uint(newTotalLocked), + }), + ); + + // Get the Stacker's wallet from the model and update it with the new state. + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + // Update model so that we know this stacker has increased the stacked amount. + // Update locked and unlocked fields in the model. + stackerWallet.amountLocked = newTotalLocked; + stackerWallet.amountUnlocked = stackerWallet.amountUnlocked - + this.increaseBy; + operatorWallet.amountToCommit += this.increaseBy; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label} Ӿ ${this.stacker.label}`, + "delegate-stack-increase", + "increased by", + this.increaseBy.toString(), + "previously locked", + prevLocked.toString(), + "total locked", + stackerWallet.amountLocked.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} delegate-stack-increase by ${this.increaseBy}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackStxCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackStxCommand.ts new file mode 100644 index 0000000000..456983807f --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStackStxCommand.ts @@ -0,0 +1,191 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { assert, expect } from "vitest"; +import { + Cl, + ClarityType, + ClarityValue, + cvToValue, + isClarityType, +} from "@stacks/transactions"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `DelegateStackStxCommand` locks STX for stacking within PoX-4 on behalf + * of a delegator. This operation allows the `operator` to stack the `stacker`'s + * STX. + * + * Constraints for running this command include: + * - A minimum threshold of uSTX must be met, determined by the + * `get-stacking-minimum` function at the time of this call. + * - The Stacker cannot currently be engaged in another stacking operation. + * - The Stacker has to currently be delegating to the Operator. + * - The stacked STX amount should be less than or equal to the delegated + * amount. + * - The stacked uSTX amount should be less than or equal to the Stacker's + * balance. + * - The stacked uSTX amount should be greater than or equal to the minimum + * threshold of uSTX. + * - The Operator has to currently be delegated by the Stacker. + * - The Period has to fit the last delegation burn block height. + */ +export class DelegateStackStxCommand implements PoxCommand { + readonly operator: Wallet; + readonly stacker: Wallet; + readonly period: number; + readonly amountUstx: bigint; + readonly unlockBurnHt: number; + + /** + * Constructs a `DelegateStackStxCommand` to lock uSTX as a Pool Operator + * on behalf of a Stacker. + * + * @param operator - Represents the Pool Operator's wallet. + * @param stacker - Represents the STacker's wallet. + * @param period - Number of reward cycles to lock uSTX. + * @param amountUstx - The uSTX amount stacked by the Operator on behalf + * of the Stacker. + * @param unlockBurnHt - The burn height at which the uSTX is unlocked. + */ + constructor( + operator: Wallet, + stacker: Wallet, + period: number, + amountUstx: bigint, + unlockBurnHt: number, + ) { + this.operator = operator; + this.stacker = stacker; + this.period = period; + this.amountUstx = amountUstx; + this.unlockBurnHt = unlockBurnHt; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - A minimum threshold of uSTX must be met, determined by the + // `get-stacking-minimum` function at the time of this call. + // - The Stacker cannot currently be engaged in another stacking + // operation. + // - The Stacker has to currently be delegating to the Operator. + // - The stacked uSTX amount should be less than or equal to the + // delegated amount. + // - The stacked uSTX amount should be less than or equal to the + // Stacker's balance. + // - The stacked uSTX amount should be greater than or equal to the + // minimum threshold of uSTX. + // - The Operator has to currently be delegated by the Stacker. + // - The Period has to fit the last delegation burn block height. + + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const stackerWallet = model.stackers.get(this.stacker.stxAddress)!; + + return ( + model.stackingMinimum > 0 && + !stackerWallet.isStacking && + stackerWallet.hasDelegated && + stackerWallet.delegatedMaxAmount >= Number(this.amountUstx) && + Number(this.amountUstx) <= stackerWallet.ustxBalance && + Number(this.amountUstx) >= model.stackingMinimum && + operatorWallet.poolMembers.includes(this.stacker.stxAddress) && + this.unlockBurnHt <= stackerWallet.delegatedUntilBurnHt + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const burnBlockHeightCV = real.network.runSnippet("burn-block-height"); + const burnBlockHeight = Number( + cvToValue(burnBlockHeightCV as ClarityValue), + ); + + // Act + const delegateStackStx = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "delegate-stack-stx", + [ + // (stacker principal) + Cl.principal(this.stacker.stxAddress), + // (amount-ustx uint) + Cl.uint(this.amountUstx), + // (pox-addr { version: (buff 1), hashbytes: (buff 32) }) + poxAddressToTuple(this.operator.btcAddress), + // (start-burn-ht uint) + Cl.uint(burnBlockHeight), + // (lock-period uint) + Cl.uint(this.period), + ], + this.operator.stxAddress, + ); + const { result: rewardCycle } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "burn-height-to-reward-cycle", + [Cl.uint(burnBlockHeight)], + this.operator.stxAddress, + ); + assert(isClarityType(rewardCycle, ClarityType.UInt)); + + const { result: unlockBurnHeight } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "reward-cycle-to-burn-height", + [Cl.uint(Number(rewardCycle.value) + this.period + 1)], + this.operator.stxAddress, + ); + assert(isClarityType(unlockBurnHeight, ClarityType.UInt)); + + // Assert + expect(delegateStackStx.result).toBeOk( + Cl.tuple({ + stacker: Cl.principal(this.stacker.stxAddress), + "lock-amount": Cl.uint(this.amountUstx), + "unlock-burn-height": Cl.uint(Number(unlockBurnHeight.value)), + }), + ); + + // Get the Stacker's wallet from the model and update it with the new state. + const stackerWallet = model.stackers.get(this.stacker.stxAddress)!; + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + // Update model so that we know this wallet is stacking. This is important + // in order to prevent the test from stacking multiple times with the same + // address. + stackerWallet.isStacking = true; + // Update locked, unlocked, and unlock-height fields in the model. + stackerWallet.amountLocked = Number(this.amountUstx); + stackerWallet.unlockHeight = Number(unlockBurnHeight.value); + stackerWallet.amountUnlocked -= Number(this.amountUstx); + stackerWallet.firstLockedRewardCycle = currentCycle(real.network) + 1; + // Add stacker to the operators lock list. This will help knowing that + // the stacker's funds are locked when calling delegate-stack-extend + // and delegate-stack-increase. + operatorWallet.lockedAddresses.push(this.stacker.stxAddress); + operatorWallet.amountToCommit += Number(this.amountUstx); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label} Ӿ ${this.stacker.label}`, + "delegate-stack-stx", + "lock-amount", + this.amountUstx.toString(), + "until", + stackerWallet.unlockHeight.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} delegate-stack-stx stacker ${this.stacker.label} period ${this.period}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_DelegateStxCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStxCommand.ts new file mode 100644 index 0000000000..4a12b0140d --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_DelegateStxCommand.ts @@ -0,0 +1,124 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { boolCV, Cl } from "@stacks/transactions"; + +/** + * The `DelegateStxCommand` delegates STX for stacking within PoX-4. This + * operation allows the `tx-sender` (the `wallet` in this case) to delegate + * stacking participation to a `delegatee`. + * + * Constraints for running this command include: + * - The Stacker cannot currently be a delegator in another delegation. + * - The PoX address provided should have a valid version (between 0 and 6 + * inclusive). + */ +export class DelegateStxCommand implements PoxCommand { + readonly wallet: Wallet; + readonly delegateTo: Wallet; + readonly untilBurnHt: number; + readonly amount: bigint; + + /** + * Constructs a `DelegateStxCommand` to delegate uSTX for stacking. + * + * @param wallet - Represents the Stacker's wallet. + * @param delegateTo - Represents the Delegatee's STX address. + * @param untilBurnHt - The burn block height until the delegation is valid. + * @param amount - The maximum amount the `Stacker` delegates the `Delegatee` + * to stack on his behalf. + */ + constructor( + wallet: Wallet, + delegateTo: Wallet, + untilBurnHt: number, + amount: bigint, + ) { + this.wallet = wallet; + this.delegateTo = delegateTo; + this.untilBurnHt = untilBurnHt; + this.amount = amount; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Stacker cannot currently be a delegator in another delegation. + + return ( + model.stackingMinimum > 0 && + !model.stackers.get(this.wallet.stxAddress)?.hasDelegated + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + // The amount of uSTX delegated by the Stacker to the Delegatee. + // Even if there are no constraints about the delegated amount, + // it will be checked in the future, when calling delegate-stack-stx. + const amountUstx = Number(this.amount); + + // Act + const delegateStx = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "delegate-stx", + [ + // (amount-ustx uint) + Cl.uint(amountUstx), + // (delegate-to principal) + Cl.principal(this.delegateTo.stxAddress), + // (until-burn-ht (optional uint)) + Cl.some(Cl.uint(this.untilBurnHt)), + // (pox-addr (optional { version: (buff 1), hashbytes: (buff 32) })) + Cl.some(poxAddressToTuple(this.delegateTo.btcAddress)), + ], + this.wallet.stxAddress, + ); + + // Assert + expect(delegateStx.result).toBeOk(boolCV(true)); + + // Get the wallet from the model and update it with the new state. + const wallet = model.stackers.get(this.wallet.stxAddress)!; + const delegatedWallet = model.stackers.get(this.delegateTo.stxAddress)!; + // Update model so that we know this wallet has delegated. This is important + // in order to prevent the test from delegating multiple times with the same + // address. + wallet.hasDelegated = true; + wallet.delegatedTo = this.delegateTo.stxAddress; + wallet.delegatedMaxAmount = amountUstx; + wallet.delegatedUntilBurnHt = this.untilBurnHt; + wallet.delegatedPoxAddress = this.delegateTo.btcAddress; + + delegatedWallet.poolMembers.push(this.wallet.stxAddress); + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.wallet.label}`, + "delegate-stx", + "amount", + amountUstx.toString(), + "delegated to", + this.delegateTo.label, + "until", + this.untilBurnHt.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.wallet.label} delegate-stx to ${this.delegateTo.label} until burn ht ${this.untilBurnHt}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_DisallowContractCallerCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_DisallowContractCallerCommand.ts new file mode 100644 index 0000000000..09618db49c --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_DisallowContractCallerCommand.ts @@ -0,0 +1,108 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { expect } from "vitest"; +import { boolCV, Cl } from "@stacks/transactions"; + +/** + * The `DisallowContractCallerComand` revokes a `contract-caller`'s + * authorization to call stacking methods. + * + * Constraints for running this command include: + * - The Caller to be disallowed must have been previously + * allowed by the Operator. + */ +export class DisallowContractCallerCommand implements PoxCommand { + readonly stacker: Wallet; + readonly callerToDisallow: Wallet; + + /** + * Constructs a `DisallowContractCallerComand` to revoke authorization + * for calling stacking methods. + * + * @param stacker - Represents the `Stacker`'s wallet. + * @param callerToDisallow - The `contract-caller` to be revoked. + */ + constructor(stacker: Wallet, callerToDisallow: Wallet) { + this.stacker = stacker; + this.callerToDisallow = callerToDisallow; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Caller to be disallowed must have been previously allowed + // by the Operator. + + const stacker = model.stackers.get(this.stacker.stxAddress)!; + const callerToDisallow = model.stackers.get( + this.callerToDisallow.stxAddress, + )!; + return ( + stacker.allowedContractCaller === this.callerToDisallow.stxAddress && + callerToDisallow.callerAllowedBy.includes( + this.stacker.stxAddress, + ) === + true + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + // Act + const disallowContractCaller = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "disallow-contract-caller", + [ + // (caller principal) + Cl.principal(this.callerToDisallow.stxAddress), + ], + this.stacker.stxAddress, + ); + + // Assert + expect(disallowContractCaller.result).toBeOk(boolCV(true)); + + // Get the wallet to be revoked stacking rights from the model and + // update it with the new state. + const callerToDisallow = model.stackers.get( + this.callerToDisallow.stxAddress, + )!; + + // Update model so that we know that the stacker has revoked stacking + // allowance. + const stacker = model.stackers.get(this.stacker.stxAddress)!; + stacker.allowedContractCaller = ""; + + // Remove the operator from the caller to disallow's allowance list. + const walletIndexAllowedByList = callerToDisallow.callerAllowedBy.indexOf( + this.stacker.stxAddress, + ); + + expect(walletIndexAllowedByList).toBeGreaterThan(-1); + callerToDisallow.callerAllowedBy.splice(walletIndexAllowedByList, 1); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.stacker.label}`, + "disallow-contract-caller", + this.callerToDisallow.label, + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.stacker.label} disallow-contract-caller ${this.callerToDisallow.label}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_GetStackingMinimumCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_GetStackingMinimumCommand.ts new file mode 100644 index 0000000000..50dd7bf16c --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_GetStackingMinimumCommand.ts @@ -0,0 +1,70 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { assert } from "vitest"; +import { ClarityType, isClarityType } from "@stacks/transactions"; + +/** + * Implements the `PoxCommand` interface to get the minimum stacking amount + * required for a given reward cycle. + */ +export class GetStackingMinimumCommand implements PoxCommand { + readonly wallet: Wallet; + + /** + * Constructs a new `GetStackingMinimumCommand`. + * + * @param wallet The wallet information, including the STX address used to + * query the stacking minimum requirement. + */ + constructor(wallet: Wallet) { + this.wallet = wallet; + } + + check(_model: Readonly): boolean { + // There are no constraints for running this command. + return true; + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + // Act + const { result: stackingMinimum } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "get-stacking-minimum", + [], + this.wallet.stxAddress, + ); + assert(isClarityType(stackingMinimum, ClarityType.UInt)); + + // Update the model with the new stacking minimum. This is important for + // the `check` method of the `StackStxCommand` class to work correctly, as + // we as other tests that may depend on the stacking minimum. + model.stackingMinimum = Number(stackingMinimum.value); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.wallet.label}`, + "get-stacking-minimum", + "pox-4", + stackingMinimum.value.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.wallet.label} get-stacking-minimum`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_GetStxAccountCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_GetStxAccountCommand.ts new file mode 100644 index 0000000000..60d8ff38b2 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_GetStxAccountCommand.ts @@ -0,0 +1,72 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { expect } from "vitest"; +import { Cl } from "@stacks/transactions"; + +/** + * Implements the `PoxCommand` interface to get the info returned from the + * `stx-account`. + */ +export class GetStxAccountCommand implements PoxCommand { + readonly wallet: Wallet; + + /** + * Constructs a new `GetStxAccountCommand`. + * + * @param wallet The wallet information, including the STX address used to + * query the `stx-account`. + */ + constructor(wallet: Wallet) { + this.wallet = wallet; + } + + check(_model: Readonly): boolean { + // There are no constraints for running this command. + return true; + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + const actual = model.stackers.get(this.wallet.stxAddress)!; + expect(real.network.runSnippet(`(stx-account '${this.wallet.stxAddress})`)) + .toBeTuple({ + "locked": Cl.uint(actual.amountLocked), + "unlocked": Cl.uint(actual.amountUnlocked), + "unlock-height": Cl.uint(actual.unlockHeight), + }); + + expect(actual.amountLocked + actual.amountUnlocked).toBe( + actual.ustxBalance, + ); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.wallet.label}`, + "stx-account", + "lock-amount", + actual.amountLocked.toString(), + "unlocked-amount", + actual.amountUnlocked.toString(), + "unlocked-height", + actual.unlockHeight.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.wallet.label} stx-account`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_RevokeDelegateStxCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_RevokeDelegateStxCommand.ts new file mode 100644 index 0000000000..1c30e3d569 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_RevokeDelegateStxCommand.ts @@ -0,0 +1,107 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl, someCV, tupleCV } from "@stacks/transactions"; + +/** + * The `RevokeDelegateStxCommand` revokes the delegation for stacking within + * PoX-4. + * + * Constraints for running this command include: + * - The `Stacker` has to currently be delegating. + */ +export class RevokeDelegateStxCommand implements PoxCommand { + readonly wallet: Wallet; + + /** + * Constructs a RevokeDelegateStxCommand to revoke delegate uSTX for stacking. + * + * @param wallet - Represents the Stacker's wallet. + */ + constructor(wallet: Wallet) { + this.wallet = wallet; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Stacker has to currently be delegating. + + return ( + model.stackingMinimum > 0 && + model.stackers.get(this.wallet.stxAddress)!.hasDelegated === true + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + + const wallet = model.stackers.get(this.wallet.stxAddress)!; + const operatorWallet = model.stackers.get(wallet.delegatedTo)!; + + // Act + const revokeDelegateStx = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "revoke-delegate-stx", + [], + this.wallet.stxAddress, + ); + + // Assert + expect(revokeDelegateStx.result).toBeOk( + someCV( + tupleCV({ + "amount-ustx": Cl.uint(wallet.delegatedMaxAmount), + "delegated-to": Cl.principal( + model.stackers.get(this.wallet.stxAddress)!.delegatedTo || "", + ), + "pox-addr": Cl.some( + poxAddressToTuple(wallet.delegatedPoxAddress || ""), + ), + "until-burn-ht": Cl.some(Cl.uint(wallet.delegatedUntilBurnHt)), + }), + ), + ); + + // Get the Stacker's wallet from the model and update the two wallets + // involved with the new state. + // Update model so that we know this wallet is not delegating anymore. + // This is important in order to prevent the test from revoking the + // delegation multiple times with the same address. + wallet.hasDelegated = false; + wallet.delegatedTo = ""; + wallet.delegatedUntilBurnHt = 0; + wallet.delegatedMaxAmount = 0; + wallet.delegatedPoxAddress = ""; + + // Remove the Stacker from the Pool Operator's pool members list. + const walletIndexInDelegatorsList = operatorWallet.poolMembers.indexOf( + this.wallet.stxAddress, + ); + expect(walletIndexInDelegatorsList).toBeGreaterThan(-1); + operatorWallet.poolMembers.splice(walletIndexInDelegatorsList, 1); + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.wallet.label}`, + "revoke-delegate-stx", + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.wallet.stxAddress} revoke-delegate-stx`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitAuthCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitAuthCommand.ts new file mode 100644 index 0000000000..5312679833 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitAuthCommand.ts @@ -0,0 +1,137 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `StackAggregationCommitAuthCommand` allows an operator to commit + * partially stacked STX & to allocate a new PoX reward address slot. + * This allows a stacker to lock fewer STX than the minimal threshold + * in multiple transactions, so long as: + * 1. The pox-addr is the same. + * 2. The "commit" transaction is called _before_ the PoX anchor block. + * + * This command calls stack-aggregation-commit using an `authorization`. + * + * Constraints for running this command include: + * - The Operator must have locked STX on behalf of at least one stacker. + * - The total amount previously locked by the Operator on behalf of the + * stackers has to be greater than the uSTX threshold. + */ +export class StackAggregationCommitAuthCommand implements PoxCommand { + readonly operator: Wallet; + readonly authId: number; + + /** + * Constructs a `StackAggregationCommitAuthCommand` to lock uSTX for stacking. + * + * @param operator - Represents the `Operator`'s wallet. + * @param authId - Unique `auth-id` for the authorization. + */ + constructor( + operator: Wallet, + authId: number, + ) { + this.operator = operator; + this.authId = authId; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Operator must have locked STX on behalf of at least one stacker. + // - The total amount previously locked by the Operator on behalf of the + // stackers has to be greater than the uSTX threshold. + + const operator = model.stackers.get(this.operator.stxAddress)!; + return operator.lockedAddresses.length > 0 && + operator.amountToCommit >= model.stackingMinimum; + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const currentRewCycle = currentCycle(real.network); + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const committedAmount = operatorWallet.amountToCommit; + + const { result: setSignature } = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "set-signer-key-authorization", + [ + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.operator.btcAddress), + // (period uint) + Cl.uint(1), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (topic (string-ascii 14)) + Cl.stringAscii("agg-commit"), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (allowed bool) + Cl.bool(true), + // (max-amount uint) + Cl.uint(committedAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + expect(setSignature).toBeOk(Cl.bool(true)); + + // Act + const stackAggregationCommit = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "stack-aggregation-commit", + [ + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.operator.btcAddress), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (signer-sig (optional (buff 65))) + Cl.none(), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (max-amount uint) + Cl.uint(committedAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + + // Assert + expect(stackAggregationCommit.result).toBeOk(Cl.bool(true)); + + operatorWallet.amountToCommit -= committedAmount; + operatorWallet.committedRewCycleIndexes.push(model.nextRewardSetIndex); + model.nextRewardSetIndex++; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label}`, + "stack-agg-commit", + "amount committed", + committedAmount.toString(), + "authorization", + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} stack-aggregation-commit auth-id ${this.authId}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitIndexedAuthCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitIndexedAuthCommand.ts new file mode 100644 index 0000000000..dfe7f2beef --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitIndexedAuthCommand.ts @@ -0,0 +1,145 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `StackAggregationCommitIndexedAuthCommand` allows an operator to + * commit partially stacked STX & to allocate a new PoX reward address + * slot. + * This allows a stacker to lock fewer STX than the minimal threshold + * in multiple transactions, so long as: + * 1. The pox-addr is the same. + * 2. The "commit" transaction is called _before_ the PoX anchor block. + * + * This command calls `stack-aggregation-commit-indexed` using an + * `authorization`. + * + * Constraints for running this command include: + * - The Operator must have locked STX on behalf of at least one stacker. + * - The total amount previously locked by the Operator on behalf of the + * stackers has to be greater than the uSTX threshold. + */ +export class StackAggregationCommitIndexedAuthCommand implements PoxCommand { + readonly operator: Wallet; + readonly authId: number; + + /** + * Constructs a `StackAggregationCommitIndexedAuthCommand` to lock uSTX + * for stacking. + * + * @param operator - Represents the `Operator`'s wallet. + * @param authId - Unique `auth-id` for the authorization. + */ + constructor( + operator: Wallet, + authId: number, + ) { + this.operator = operator; + this.authId = authId; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Operator must have locked STX on behalf of at least one stacker. + // - The total amount previously locked by the Operator on behalf of the + // stackers has to be greater than the uSTX threshold. + + const operator = model.stackers.get(this.operator.stxAddress)!; + return ( + operator.lockedAddresses.length > 0 && + operator.amountToCommit >= model.stackingMinimum + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const currentRewCycle = currentCycle(real.network); + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const committedAmount = operatorWallet.amountToCommit; + + const { result: setSignature } = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "set-signer-key-authorization", + [ + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.operator.btcAddress), + // (period uint) + Cl.uint(1), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (topic (string-ascii 14)) + Cl.stringAscii("agg-commit"), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (allowed bool) + Cl.bool(true), + // (max-amount uint) + Cl.uint(committedAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + expect(setSignature).toBeOk(Cl.bool(true)); + + // Act + const stackAggregationCommitIndexed = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "stack-aggregation-commit-indexed", + [ + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.operator.btcAddress), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (signer-sig (optional (buff 65))) + Cl.none(), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (max-amount uint) + Cl.uint(committedAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + + // Assert + expect(stackAggregationCommitIndexed.result).toBeOk( + Cl.uint(model.nextRewardSetIndex), + ); + + // Update the model + operatorWallet.amountToCommit -= committedAmount; + operatorWallet.committedRewCycleIndexes.push(model.nextRewardSetIndex); + model.nextRewardSetIndex++; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label}`, + "stack-agg-commit-indexed", + "amount committed", + committedAmount.toString(), + "authorization", + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} stack-aggregation-commit-indexed auth-id ${this.authId}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitIndexedSigCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitIndexedSigCommand.ts new file mode 100644 index 0000000000..59707e21f4 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitIndexedSigCommand.ts @@ -0,0 +1,145 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { Pox4SignatureTopic, poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { bufferFromHex } from "@stacks/transactions/dist/cl"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `StackAggregationCommitIndexedSigCommand` allows an operator to + * commit partially stacked STX & to allocate a new PoX reward address + * slot. + * This allows a stacker to lock fewer STX than the minimal threshold + * in multiple transactions, so long as: + * 1. The pox-addr is the same. + * 2. The "commit" transaction is called _before_ the PoX anchor block. + * + * This command calls `stack-aggregation-commit-indexed` using a + * `signature`. + * + * Constraints for running this command include: + * - The Operator must have locked STX on behalf of at least one stacker. + * - The total amount previously locked by the Operator on behalf of the + * stackers has to be greater than the uSTX threshold. + */ +export class StackAggregationCommitIndexedSigCommand implements PoxCommand { + readonly operator: Wallet; + readonly authId: number; + + /** + * Constructs a `StackAggregationCommitIndexedSigCommand` to lock uSTX + * for stacking. + * + * @param operator - Represents the `Operator`'s wallet. + * @param authId - Unique `auth-id` for the authorization. + */ + constructor( + operator: Wallet, + authId: number, + ) { + this.operator = operator; + this.authId = authId; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Operator must have locked STX on behalf of at least one stacker. + // - The total amount previously locked by the Operator on behalf of the + // stackers has to be greater than the uSTX threshold. + + const operator = model.stackers.get(this.operator.stxAddress)!; + return ( + operator.lockedAddresses.length > 0 && + operator.amountToCommit >= model.stackingMinimum + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const currentRewCycle = currentCycle(real.network); + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const committedAmount = operatorWallet.amountToCommit; + + const signerSig = this.operator.stackingClient.signPoxSignature({ + // The signer key being authorized. + signerPrivateKey: this.operator.signerPrvKey, + // The reward cycle for which the authorization is valid. + // For stack-stx and stack-extend, this refers to the reward cycle + // where the transaction is confirmed. For stack-aggregation-commit, + // this refers to the reward cycle argument in that function. + rewardCycle: currentRewCycle + 1, + // For stack-stx, this refers to lock-period. For stack-extend, + // this refers to extend-count. For stack-aggregation-commit, this is + // u1. + period: 1, + // A string representing the function where this authorization is valid. + // Either stack-stx, stack-extend, stack-increase or agg-commit. + topic: Pox4SignatureTopic.AggregateCommit, + // The PoX address that can be used with this signer key. + poxAddress: this.operator.btcAddress, + // The unique auth-id for this authorization. + authId: this.authId, + // The maximum amount of uSTX that can be used (per tx) with this signer + // key. + maxAmount: committedAmount, + }); + + // Act + const stackAggregationCommitIndexed = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "stack-aggregation-commit-indexed", + [ + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.operator.btcAddress), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (signer-sig (optional (buff 65))) + Cl.some(bufferFromHex(signerSig)), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (max-amount uint) + Cl.uint(committedAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + + // Assert + expect(stackAggregationCommitIndexed.result).toBeOk( + Cl.uint(model.nextRewardSetIndex), + ); + + // Update the model + operatorWallet.amountToCommit -= committedAmount; + operatorWallet.committedRewCycleIndexes.push(model.nextRewardSetIndex); + model.nextRewardSetIndex++; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label}`, + "stack-agg-commit-indexed", + "amount committed", + committedAmount.toString(), + "signature", + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} stack-aggregation-commit-indexed auth-id ${this.authId}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitSigCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitSigCommand.ts new file mode 100644 index 0000000000..32fe552477 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationCommitSigCommand.ts @@ -0,0 +1,137 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { Pox4SignatureTopic, poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl } from "@stacks/transactions"; +import { bufferFromHex } from "@stacks/transactions/dist/cl"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `StackAggregationCommitSigCommand` allows an operator to commit + * partially stacked STX & to allocate a new PoX reward address slot. + * This allows a stacker to lock fewer STX than the minimal threshold + * in multiple transactions, so long as: + * 1. The pox-addr is the same. + * 2. This "commit" transaction is called _before_ the PoX anchor block. + * + * This command calls `stack-aggregation-commit` using a `signature`. + * + * Constraints for running this command include: + * - The Operator must have locked STX on behalf of at least one stacker. + * - The total amount previously locked by the Operator on behalf of the + * stackers has to be greater than the uSTX threshold. + */ +export class StackAggregationCommitSigCommand implements PoxCommand { + readonly operator: Wallet; + readonly authId: number; + + /** + * Constructs a `StackAggregationCommitSigCommand` to lock uSTX for stacking. + * + * @param operator - Represents the `Operator`'s wallet. + * @param authId - Unique `auth-id` for the authorization. + */ + constructor( + operator: Wallet, + authId: number, + ) { + this.operator = operator; + this.authId = authId; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Operator must have locked STX on behalf of at least one stacker. + // - The total amount previously locked by the Operator on behalf of the + // stackers has to be greater than the uSTX threshold. + + const operator = model.stackers.get(this.operator.stxAddress)!; + return operator.lockedAddresses.length > 0 && + operator.amountToCommit >= model.stackingMinimum; + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const currentRewCycle = currentCycle(real.network); + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const committedAmount = operatorWallet.amountToCommit; + + const signerSig = this.operator.stackingClient.signPoxSignature({ + // The signer key being authorized. + signerPrivateKey: this.operator.signerPrvKey, + // The reward cycle for which the authorization is valid. + // For stack-stx and stack-extend, this refers to the reward cycle + // where the transaction is confirmed. For stack-aggregation-commit, + // this refers to the reward cycle argument in that function. + rewardCycle: currentRewCycle + 1, + // For stack-stx, this refers to lock-period. For stack-extend, + // this refers to extend-count. For stack-aggregation-commit, this is + // u1. + period: 1, + // A string representing the function where this authorization is valid. + // Either stack-stx, stack-extend, stack-increase or agg-commit. + topic: Pox4SignatureTopic.AggregateCommit, + // The PoX address that can be used with this signer key. + poxAddress: this.operator.btcAddress, + // The unique auth-id for this authorization. + authId: this.authId, + // The maximum amount of uSTX that can be used (per tx) with this signer + // key. + maxAmount: committedAmount, + }); + + // Act + const stackAggregationCommit = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "stack-aggregation-commit", + [ + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.operator.btcAddress), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (signer-sig (optional (buff 65))) + Cl.some(bufferFromHex(signerSig)), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (max-amount uint) + Cl.uint(committedAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + + // Assert + expect(stackAggregationCommit.result).toBeOk(Cl.bool(true)); + + operatorWallet.amountToCommit -= committedAmount; + operatorWallet.committedRewCycleIndexes.push(model.nextRewardSetIndex); + model.nextRewardSetIndex++; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label}`, + "stack-agg-commit", + "amount committed", + committedAmount.toString(), + "signature", + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} stack-aggregation-commit auth-id ${this.authId}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationIncreaseCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationIncreaseCommand.ts new file mode 100644 index 0000000000..22ae0a0bea --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackAggregationIncreaseCommand.ts @@ -0,0 +1,159 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { Pox4SignatureTopic, poxAddressToTuple } from "@stacks/stacking"; +import { expect } from "vitest"; +import { Cl, cvToJSON } from "@stacks/transactions"; +import { bufferFromHex } from "@stacks/transactions/dist/cl"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `StackAggregationIncreaseCommand` allows an operator to commit + * partially stacked STX to a PoX address which has already received + * some STX (more than the `stacking minimum`). + * This allows a delegator to lock up marginally more STX from new + * delegates, even if they collectively do not exceed the Stacking + * minimum, so long as the target PoX address already represents at + * least as many STX as the `stacking minimum`. + * This command calls stack-aggregation-increase. + * + * Constraints for running this command include: + * - The Operator must have locked STX on behalf of at least one stacker. + * - The PoX address must have partial committed STX. + * - The Reward Cycle Index must be positive. + */ +export class StackAggregationIncreaseCommand implements PoxCommand { + readonly operator: Wallet; + readonly rewardCycleIndex: number; + readonly authId: number; + + /** + * Constructs a `StackAggregationIncreaseCommand` to commit partially + * stacked STX to a PoX address which has already received some STX. + * + * @param operator - Represents the `Operator`'s wallet. + * @param rewardCycleIndex - The cycle index to increase the commit for. + * @param authId - Unique `auth-id` for the authorization. + */ + constructor( + operator: Wallet, + rewardCycleIndex: number, + authId: number, + ) { + this.operator = operator; + this.rewardCycleIndex = rewardCycleIndex; + this.authId = authId; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - The Operator must have locked STX on behalf of at least one stacker. + // - The PoX address must have partial committed STX. + // - The Reward Cycle Index must be positive. + const operator = model.stackers.get(this.operator.stxAddress)!; + return ( + operator.lockedAddresses.length > 0 && + this.rewardCycleIndex >= 0 && + operator.amountToCommit > 0 + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const currentRewCycle = currentCycle(real.network); + + const operatorWallet = model.stackers.get(this.operator.stxAddress)!; + const committedAmount = operatorWallet.amountToCommit; + + const existingEntryCV = real.network.getMapEntry( + "ST000000000000000000002AMW42H.pox-4", + "reward-cycle-pox-address-list", + Cl.tuple({ + "index": Cl.uint(this.rewardCycleIndex), + "reward-cycle": Cl.uint(currentRewCycle + 1), + }), + ); + + const totalStackedBefore = + cvToJSON(existingEntryCV).value.value["total-ustx"].value; + const maxAmount = committedAmount + Number(totalStackedBefore); + + const signerSig = this.operator.stackingClient.signPoxSignature({ + // The signer key being authorized. + signerPrivateKey: this.operator.signerPrvKey, + // The reward cycle for which the authorization is valid. + // For stack-stx and stack-extend, this refers to the reward cycle + // where the transaction is confirmed. For stack-aggregation-commit, + // this refers to the reward cycle argument in that function. + rewardCycle: currentRewCycle + 1, + // For stack-stx, this refers to lock-period. For stack-extend, + // this refers to extend-count. For stack-aggregation-commit, this is + // u1. + period: 1, + // A string representing the function where this authorization is valid. + // Either stack-stx, stack-extend, stack-increase, agg-commit or agg-increase. + topic: Pox4SignatureTopic.AggregateIncrease, + // The PoX address that can be used with this signer key. + poxAddress: this.operator.btcAddress, + // The unique auth-id for this authorization. + authId: this.authId, + // The maximum amount of uSTX that can be used (per tx) with this signer + // key. + maxAmount: maxAmount, + }); + + // Act + const stackAggregationIncrease = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "stack-aggregation-increase", + [ + // (pox-addr { version: (buff 1), hashbytes: (buff 32) }) + poxAddressToTuple(this.operator.btcAddress), + // (reward-cycle uint) + Cl.uint(currentRewCycle + 1), + // (reward-cycle-index uint)) + Cl.uint(this.rewardCycleIndex), + // (signer-sig (optional (buff 65))) + Cl.some(bufferFromHex(signerSig)), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.operator.signerPubKey), + // (max-amount uint) + Cl.uint(maxAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.operator.stxAddress, + ); + + // Assert + expect(stackAggregationIncrease.result).toBeOk(Cl.bool(true)); + + operatorWallet.amountToCommit -= committedAmount; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.operator.label}`, + "stack-agg-increase", + "amount committed", + committedAmount.toString(), + "cycle index", + this.rewardCycleIndex.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.operator.label} stack-aggregation-increase for index ${this.rewardCycleIndex}`; + } +} diff --git a/contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts b/contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts new file mode 100644 index 0000000000..9c8a467355 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts @@ -0,0 +1,198 @@ +import { + logCommand, + PoxCommand, + Real, + Stub, + Wallet, +} from "./pox_CommandModel.ts"; +import { Pox4SignatureTopic, poxAddressToTuple } from "@stacks/stacking"; +import { assert, expect } from "vitest"; +import { + Cl, + ClarityType, + ClarityValue, + cvToValue, + isClarityType, +} from "@stacks/transactions"; +import { currentCycle } from "./pox_Commands.ts"; + +/** + * The `StackStxCommand` locks STX for stacking within PoX-4. This self-service + * operation allows the `tx-sender` (the `wallet` in this case) to participate + * as a Stacker. + * + * Constraints for running this command include: + * - The Stacker cannot currently be engaged in another stacking operation. + * - A minimum threshold of uSTX must be met, determined by the + * `get-stacking-minimum` function at the time of this call. + * - The amount of uSTX locked may need to be increased in future reward cycles + * if the minimum threshold rises. + */ +export class StackStxCommand implements PoxCommand { + readonly wallet: Wallet; + readonly authId: number; + readonly period: number; + readonly margin: number; + + /** + * Constructs a `StackStxCommand` to lock uSTX for stacking. + * + * @param wallet - Represents the Stacker's wallet. + * @param authId - Unique auth-id for the authorization. + * @param period - Number of reward cycles to lock uSTX. + * @param margin - Multiplier for minimum required uSTX to stack so that each + * Stacker locks a different amount of uSTX across test runs. + */ + constructor( + wallet: Wallet, + authId: number, + period: number, + margin: number, + ) { + this.wallet = wallet; + this.authId = authId; + this.period = period; + this.margin = margin; + } + + check(model: Readonly): boolean { + // Constraints for running this command include: + // - A minimum threshold of uSTX must be met, determined by the + // `get-stacking-minimum` function at the time of this call. + // - The Stacker cannot currently be engaged in another stacking operation. + // - The Stacker cannot currently be delegating STX to a delegatee. + + const stacker = model.stackers.get(this.wallet.stxAddress)!; + return ( + model.stackingMinimum > 0 && !stacker.isStacking && !stacker.hasDelegated + ); + } + + run(model: Stub, real: Real): void { + model.trackCommandRun(this.constructor.name); + const burnBlockHeightCV = real.network.runSnippet("burn-block-height"); + const burnBlockHeight = Number( + cvToValue(burnBlockHeightCV as ClarityValue), + ); + const currentRewCycle = currentCycle(real.network); + + // The maximum amount of uSTX that can be used (per tx) with this signer + // key. For our tests, we will use the minimum amount of uSTX to be stacked + // in the given reward cycle multiplied by the margin, which is a randomly + // generated number passed to the constructor of this class. + const maxAmount = model.stackingMinimum * this.margin; + + const signerSig = this.wallet.stackingClient.signPoxSignature({ + // The signer key being authorized. + signerPrivateKey: this.wallet.signerPrvKey, + // The reward cycle for which the authorization is valid. + // For `stack-stx` and `stack-extend`, this refers to the reward cycle + // where the transaction is confirmed. For `stack-aggregation-commit`, + // this refers to the reward cycle argument in that function. + rewardCycle: currentRewCycle, + // For `stack-stx`, this refers to `lock-period`. For `stack-extend`, + // this refers to `extend-count`. For `stack-aggregation-commit`, this is + // `u1`. + period: this.period, + // A string representing the function where this authorization is valid. + // Either `stack-stx`, `stack-extend`, `stack-increase` or `agg-commit`. + topic: Pox4SignatureTopic.StackStx, + // The PoX address that can be used with this signer key. + poxAddress: this.wallet.btcAddress, + // The unique auth-id for this authorization. + authId: this.authId, + // The maximum amount of uSTX that can be used (per tx) with this signer + // key. + maxAmount: maxAmount, + }); + + // The amount of uSTX to be locked in the reward cycle. For this test, we + // will use the maximum amount of uSTX that can be used (per tx) with this + // signer key. + const amountUstx = maxAmount; + + // Act + const stackStx = real.network.callPublicFn( + "ST000000000000000000002AMW42H.pox-4", + "stack-stx", + [ + // (amount-ustx uint) + Cl.uint(amountUstx), + // (pox-addr (tuple (version (buff 1)) (hashbytes (buff 32)))) + poxAddressToTuple(this.wallet.btcAddress), + // (start-burn-ht uint) + Cl.uint(burnBlockHeight), + // (lock-period uint) + Cl.uint(this.period), + // (signer-sig (optional (buff 65))) + Cl.some(Cl.bufferFromHex(signerSig)), + // (signer-key (buff 33)) + Cl.bufferFromHex(this.wallet.signerPubKey), + // (max-amount uint) + Cl.uint(maxAmount), + // (auth-id uint) + Cl.uint(this.authId), + ], + this.wallet.stxAddress, + ); + + const { result: rewardCycle } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "burn-height-to-reward-cycle", + [Cl.uint(real.network.blockHeight)], + this.wallet.stxAddress, + ); + assert(isClarityType(rewardCycle, ClarityType.UInt)); + + const { result: unlockBurnHeight } = real.network.callReadOnlyFn( + "ST000000000000000000002AMW42H.pox-4", + "reward-cycle-to-burn-height", + [Cl.uint(Number(rewardCycle.value) + this.period + 1)], + this.wallet.stxAddress, + ); + assert(isClarityType(unlockBurnHeight, ClarityType.UInt)); + + // Assert + expect(stackStx.result).toBeOk( + Cl.tuple({ + "lock-amount": Cl.uint(amountUstx), + "signer-key": Cl.bufferFromHex(this.wallet.signerPubKey), + "stacker": Cl.principal(this.wallet.stxAddress), + "unlock-burn-height": Cl.uint(Number(unlockBurnHeight.value)), + }), + ); + + // Get the wallet from the model and update it with the new state. + const wallet = model.stackers.get(this.wallet.stxAddress)!; + // Update model so that we know this wallet is stacking. This is important + // in order to prevent the test from stacking multiple times with the same + // address. + wallet.isStacking = true; + wallet.isStackingSolo = true; + // Update locked, unlocked, and unlock-height fields in the model. + wallet.amountLocked = amountUstx; + wallet.unlockHeight = Number(unlockBurnHeight.value); + wallet.amountUnlocked -= amountUstx; + model.nextRewardSetIndex++; + + // Log to console for debugging purposes. This is not necessary for the + // test to pass but it is useful for debugging and eyeballing the test. + logCommand( + `₿ ${model.burnBlockHeight}`, + `✓ ${this.wallet.label}`, + "stack-stx", + "lock-amount", + amountUstx.toString(), + ); + + // Refresh the model's state if the network gets to the next reward cycle. + model.refreshStateForNextRewardCycle(real); + } + + toString() { + // fast-check will call toString() in case of errors, e.g. property failed. + // It will then make a minimal counterexample, a process called 'shrinking' + // https://github.com/dubzzz/fast-check/issues/2864#issuecomment-1098002642 + return `${this.wallet.label} stack-stx auth-id ${this.authId} and period ${this.period}`; + } +}