From e7f646755622dcddc0025bc9a204c2c2c0057ec3 Mon Sep 17 00:00:00 2001 From: Nikos Baxevanis Date: Sat, 16 Mar 2024 10:01:26 +0100 Subject: [PATCH] wip: PoX-4 StackStxCommand & stateful property tests planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit lays the groundwork for the StackStxCommand and GetStackingMinimumCommand classes for PoX-4. It also proposes the introduction of fast-check based stateful tests, similar to the efforts for sBTC (https://github.com/stacks-network/sbtc/pull/152). As highlighted in https://github.com/stacks-network/stacks-core/issues/4548, this initiative is part of an ongoing effort to embrace a more rigorous, property-based testing strategy for PoX-4 interactions. The planned stateful tests aim to simulate various stacking scenarios, ensuring compliance with PoX-4 protocols and robust error handling. This strategy is expected to greatly enhance test coverage and the reliability of PoX-4 stacking operations, bolstering confidence in the protocol’s robustness and correctness. Note: This is an early-stage WIP commit. Implementation details and testing strategies are subject to substantial development and refinement. --- .../tests/pox-4/pox-4.stateful-prop.test.ts | 87 +++++++++ .../tests/pox-4/pox_CommandModel.ts | 32 ++++ .../tests/pox-4/pox_Commands.ts | 48 +++++ .../pox-4/pox_GetStackingMinimumCommand.ts | 60 +++++++ .../tests/pox-4/pox_StackStxCommand.ts | 169 ++++++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 contrib/core-contract-tests/tests/pox-4/pox-4.stateful-prop.test.ts create mode 100644 contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts create mode 100644 contrib/core-contract-tests/tests/pox-4/pox_Commands.ts create mode 100644 contrib/core-contract-tests/tests/pox-4/pox_GetStackingMinimumCommand.ts create mode 100644 contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts 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..2c6d3c1f26 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox-4.stateful-prop.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "vitest"; + +import { initSimnet } from "@hirosystems/clarinet-sdk"; +import { Real, Stub, StxAddress, Wallet } 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"; + +describe("PoX-4 invariant tests", () => { + it("statefully does solo stacking with a signature", async () => { + // SUT stands for "System Under Test". + const sut: Real = { + network: await initSimnet(), + }; + + // This is the initial state of the model. + const model: Stub = { + stackingMinimum: 0, + wallets: new Map(), + }; + + const wallets = [ + "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801", + "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901", + "3eccc5dac8056590432db6a35d52b9896876a3d5cbdea53b72400bc9c2099fe801", + "7036b29cb5e235e5fd9b09ae3e8eec4404e44906814d5d01cbca968a60ed4bfb01", + "b463f0df6c05d2f156393eee73f8016c5372caa0e9e29a901bb7171d90dc4f1401", + ].map((prvKey) => { + const pubKey = getPublicKeyFromPrivate(prvKey); + const devnet = new StacksDevnet(); + const signerPrvKey = createStacksPrivateKey(prvKey); + const signerPubKey = getPublicKeyFromPrivate(signerPrvKey.data); + const btcAddress = publicKeyToBtcAddress(pubKey); + const stxAddress = getAddressFromPrivateKey( + prvKey, + TransactionVersion.Testnet, + ); + + return { + prvKey, + pubKey, + stxAddress, + btcAddress, + signerPrvKey, + signerPubKey, + client: new StackingClient(stxAddress, devnet), + ustxBalance: 100_000_000_000_000, + isStacking: false, + amountLocked: 0, + unlockHeight: 0, + }; + }); + + // Add the wallets to the model. + wallets.forEach((wallet) => { + model.wallets.set(wallet.stxAddress, wallet); + }); + + simnet.setEpoch("3.0"); + + fc.assert( + fc.property( + PoxCommands(model.wallets), + (cmds) => { + const initialState = () => ({ model: model, real: sut }); + fc.modelRun(initialState, cmds); + }, + ), + { + numRuns: 1, + verbose: 2, + }, + ); + }); +}); 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..b0ce6e5156 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts @@ -0,0 +1,32 @@ +import fc from "fast-check"; + +import { Simnet } from "@hirosystems/clarinet-sdk"; +import { StacksPrivateKey } from "@stacks/transactions"; +import { StackingClient } from "@stacks/stacking"; + +export type StxAddress = string; + +export type Stub = { + stackingMinimum: number; + wallets: Map; +}; + +export type Real = { + network: Simnet; +}; + +export type Wallet = { + prvKey: string; + pubKey: string; + stxAddress: string; + btcAddress: string; + signerPrvKey: StacksPrivateKey; + signerPubKey: string; + client: StackingClient; + ustxBalance: number; + isStacking: boolean; + amountLocked: number; + unlockHeight: number; +}; + +export type PoxCommand = fc.Command; 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..397b8e88b3 --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_Commands.ts @@ -0,0 +1,48 @@ +import fc from "fast-check"; +import { Real, Stub, StxAddress, Wallet } from "./pox_CommandModel"; +import { GetStackingMinimumCommand } from "./pox_GetStackingMinimumCommand"; +import { StackStxCommand } from "./pox_StackStxCommand"; + +export function PoxCommands( + wallets: Map, +): 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, + ) + ), + ]; + + // 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: "large" }); +} 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..14ba177c4d --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_GetStackingMinimumCommand.ts @@ -0,0 +1,60 @@ +import { 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 { + // Can always check the minimum number of uSTX to be stacked in the given + // reward cycle. + return true; + } + + run(model: Stub, real: Real): void { + // 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. + console.info( + `✓ ${this.wallet.stxAddress.padStart(8, " ")} ${ + "get-stacking-minimum".padStart(34, " ") + } ${"pox-4".padStart(12, " ")} ${ + stackingMinimum.value.toString().padStart(13, " ") + }`, + ); + } + + 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} get-stacking-minimum`; + } +} 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..087331387c --- /dev/null +++ b/contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts @@ -0,0 +1,169 @@ +import { PoxCommand, Real, Stub, Wallet } from "./pox_CommandModel.ts"; +import { Pox4SignatureTopic, poxAddressToTuple } from "@stacks/stacking"; +import { assert, expect } from "vitest"; +import { Cl, ClarityType, isClarityType } from "@stacks/transactions"; + +/** + * 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. + return model.stackingMinimum > 0 && + !model.wallets.get(this.wallet.stxAddress)?.isStacking; + } + + run(model: Stub, real: Real): void { + // 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.client.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: 0, + // 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(real.network.blockHeight), + // (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.wallets.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; + // Update locked, unlocked, and unlock-height fields in the model. + wallet.amountLocked = amountUstx; + wallet.unlockHeight = Number(unlockBurnHeight.value); + wallet.ustxBalance -= 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. + console.info( + `✓ ${this.wallet.stxAddress.padStart(8, " ")} ${ + "stack-stx".padStart(34, " ") + } ${"lock-amount".padStart(12, " ")} ${ + amountUstx.toString().padStart(13, " ") + }`, + ); + } + + 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} stack-stx auth-id ${this.authId} and period ${this.period}`; + } +}