Skip to content

Commit

Permalink
wip: PoX-4 StackStxCommand & stateful property tests planning
Browse files Browse the repository at this point in the history
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 (stacks-network/sbtc#152).

As highlighted in #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.
  • Loading branch information
moodmosaic committed Mar 17, 2024
1 parent d3f1a31 commit 50aa5fc
Show file tree
Hide file tree
Showing 5 changed files with 387 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, 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";

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(),
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,
transactionVersion: devnet.version,
magicBytes: "X2",
peerNetworkId: devnet.chainId,
}),
initialUstxBalance: 100_000_000_000_000,
};
}),
};

// This is the initial state of the model.
const model: Stub = {
stackingMinimum: 0,
wallets: new Map<string, number>(),
stackers: new Set<string>(),
};

simnet.setEpoch("3.0");

fc.assert(
fc.property(
PoxCommands(sut.wallets),
(cmds) => {
const initialState = () => ({ model: model, real: sut });
fc.modelRun(initialState, cmds);
},
),
{
numRuns: 1,
verbose: 2,
},
);
});
});
29 changes: 29 additions & 0 deletions contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import fc from "fast-check";

import { Simnet } from "@hirosystems/clarinet-sdk";
import { StacksPrivateKey } from "@stacks/transactions";
import { StackingClient } from "@stacks/stacking";

export type Stub = {
stackingMinimum: number;
wallets: Map<string, number>; // string: Address, number: Balance
stackers: Set<string>; // string: Address
};

export type Real = {
network: Simnet;
wallets: Wallet[];
};

export type Wallet = {
prvKey: string;
pubKey: string;
stxAddress: string;
btcAddress: string;
signerPrvKey: StacksPrivateKey;
signerPubKey: string;
client: StackingClient;
initialUstxBalance: number;
};

export type PoxCommand = fc.Command<Stub, Real>;
48 changes: 48 additions & 0 deletions contrib/core-contract-tests/tests/pox-4/pox_Commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import fc from "fast-check";
import { Real, Stub, Wallet } from "./pox_CommandModel";
import { GetStackingMinimumCommand } from "./pox_GetStackingMinimumCommand";
import { StackStxCommand } from "./pox_StackStxCommand";

export function PoxCommands(
wallets: Wallet[],
): fc.Arbitrary<Iterable<fc.Command<Stub, Real>>> {
const cmds = [
// GetStackingMinimumCommand
fc.record({
wallet: fc.constantFrom(...wallets),
}).map((
r: {
wallet: Wallet;
},
) =>
new GetStackingMinimumCommand(
r.wallet,
)
),
// StackStxCommand
fc.record({
wallet: fc.constantFrom(...wallets),
authId: fc.nat(),
period: fc.integer({ min: 1, max: 3 }),
margin: fc.integer({ min: 2, 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" });
}
Original file line number Diff line number Diff line change
@@ -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<Stub>): 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(12, " ")
}`,
);
}

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`;
}
}
166 changes: 166 additions & 0 deletions contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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<Stub>): 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.stackers.has(this.wallet.stxAddress) === false;
}

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)),
}),
);

// 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.
model.stackers.add(this.wallet.stxAddress);

// TODO (moodmosaic): Update the wallet's balance in the model.
// model.wallets.set(this.wallet.stxAddress, model.wallets.get(this.wallet.stxAddress)! - 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(12, " ")
}`,
);
}

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}`;
}
}

0 comments on commit 50aa5fc

Please sign in to comment.