-
Notifications
You must be signed in to change notification settings - Fork 679
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wip: PoX-4 StackStxCommand & stateful property tests planning
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
1 parent
d3f1a31
commit 50aa5fc
Showing
5 changed files
with
387 additions
and
0 deletions.
There are no files selected for viewing
84 changes: 84 additions & 0 deletions
84
contrib/core-contract-tests/tests/pox-4/pox-4.stateful-prop.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
29
contrib/core-contract-tests/tests/pox-4/pox_CommandModel.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" }); | ||
} |
60 changes: 60 additions & 0 deletions
60
contrib/core-contract-tests/tests/pox-4/pox_GetStackingMinimumCommand.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
166
contrib/core-contract-tests/tests/pox-4/pox_StackStxCommand.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | ||
} | ||
} |