diff --git a/README.md b/README.md index fb599f3e..bee67b6b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ npx rv - `--path` – The path to use for the replay functionality. - `--runs` – The number of test iterations to use for exercising the contracts. (default: `100`) +- `--dial` – The path to a JavaScript file containing custom pre- and + post-execution functions (dialers). --- diff --git a/app.tests.ts b/app.tests.ts index dd7e9fc3..8cfe864f 100644 --- a/app.tests.ts +++ b/app.tests.ts @@ -10,7 +10,7 @@ describe("Command-line arguments handling", () => { const helpMessage = ` rv v${version} - Usage: rv [--seed=] [--path=] [--runs=] + Usage: rv [--seed=] [--path=] [--runs=] [--dial=] [--help] Positional arguments: path-to-clarinet-project - The path to the Clarinet project. @@ -21,6 +21,7 @@ describe("Command-line arguments handling", () => { --seed - The seed to use for the replay functionality. --path - The path to use for the replay functionality. --runs - The runs to use for iterating over the tests. Default: 100. + --dial – The path to a JavaScript file containing custom pre- and post-execution functions (dialers). --help - Show the help message. `; const noManifestMessage = red( @@ -196,6 +197,23 @@ describe("Command-line arguments handling", () => { `\nStarting invariant testing type for the counter contract...\n`, ], ], + [ + ["manifest path", "contract name", "type=invariant", "dialers file path"], + [ + "node", + "app.js", + "example", + "counter", + "invariant", + "--dial=example/sip010.js", + ], + [ + `Using manifest path: example/Clarinet.toml`, + `Target contract: counter`, + `Using dial path: example/sip010.js`, + `\nStarting invariant testing type for the counter contract...\n`, + ], + ], [ ["manifest path", "contract name", "type=test"], ["node", "app.js", "example", "counter", "test"], diff --git a/app.ts b/app.ts index 5528b629..fa8fb370 100644 --- a/app.ts +++ b/app.ts @@ -12,6 +12,7 @@ import { issueFirstClassCitizenship } from "./citizen"; import { version } from "./package.json"; import { red } from "ansicolor"; import { existsSync } from "fs"; +import { DialerRegistry } from "./dialer"; const logger = (log: string, logLevel: "log" | "error" | "info" = "log") => { console[logLevel](log); @@ -43,7 +44,7 @@ export const getManifestFileName = ( const helpMessage = ` rv v${version} - Usage: rv [--seed=] [--path=] [--runs=] + Usage: rv [--seed=] [--path=] [--runs=] [--dial=] [--help] Positional arguments: path-to-clarinet-project - The path to the Clarinet project. @@ -54,6 +55,7 @@ const helpMessage = ` --seed - The seed to use for the replay functionality. --path - The path to use for the replay functionality. --runs - The runs to use for iterating over the tests. Default: 100. + --dial – The path to a JavaScript file containing custom pre- and post-execution functions (dialers). --help - Show the help message. `; @@ -140,6 +142,27 @@ export async function main() { radio.emit("logMessage", `Using runs: ${runs}`); } + /** + * The path to the dialer file. The dialer file allows the user to register + * custom pre and post-execution JavaScript functions to be executed before + * and after the public function calls during invariant testing. + */ + const dialPath = parseOptionalArgument("dial") || undefined; + if (dialPath !== undefined) { + radio.emit("logMessage", `Using dial path: ${dialPath}`); + } + + /** + * The dialer registry, which is used to keep track of all the custom dialers + * registered by the user using the `--dial` flag. + */ + const dialerRegistry = + dialPath !== undefined ? new DialerRegistry(dialPath) : undefined; + + if (dialerRegistry !== undefined) { + dialerRegistry.registerDialers(); + } + const simnet = await issueFirstClassCitizenship( manifestDir, manifestPath, @@ -168,7 +191,7 @@ export async function main() { // If "test", call `checkProperties` for property-based testing. switch (type) { case "invariant": { - checkInvariants( + await checkInvariants( simnet, sutContractName, rendezvousList, @@ -176,6 +199,7 @@ export async function main() { seed, path, runs, + dialerRegistry, radio ); break; diff --git a/citizen.ts b/citizen.ts index c008de59..9aadb8d2 100644 --- a/citizen.ts +++ b/citizen.ts @@ -216,9 +216,9 @@ export const buildRendezvousData = ( rendezvousSource: rendezvousSource, rendezvousContractName: contractName, }; - } catch (e: any) { + } catch (error: any) { throw new Error( - `Error processing "${contractName}" contract: ${e.message}` + `Error processing "${contractName}" contract: ${error.message}` ); } }; @@ -295,9 +295,9 @@ export const getTestContractSource = ( return readFileSync(join(manifestDir, testContractPath), { encoding: "utf-8", }).toString(); - } catch (e: any) { + } catch (error: any) { throw new Error( - `Error retrieving the corresponding test contract for the "${sutContractName}" contract. ${e.message}` + `Error retrieving the corresponding test contract for the "${sutContractName}" contract. ${error.message}` ); } }; diff --git a/dialer.tests.ts b/dialer.tests.ts new file mode 100644 index 00000000..c7eb2ff3 --- /dev/null +++ b/dialer.tests.ts @@ -0,0 +1,88 @@ +import { join } from "path"; +import { DialerContext } from "./dialer.types"; +import { DialerRegistry } from "./dialer"; + +const dialPath = join("example", "dialer.ts"); + +describe("DialerRegistry interaction", () => { + it("initializes the dialer registry with the dialer file", async () => { + // Act + const actual = new DialerRegistry(dialPath); + + // Assert + expect(actual).toBeInstanceOf(DialerRegistry); + }); + + it("correctly executes registered pre-dialer", async () => { + // Arrange + const mockPreDialer = jest.fn(); + const registry = new DialerRegistry(dialPath); + registry.registerPreDialer(mockPreDialer); + + // Act + await registry.executePreDialers({} as any as DialerContext); + + // Assert + expect(mockPreDialer).toHaveBeenCalledTimes(1); + }); + + it("correctly executes registered post-dialer", async () => { + // Arrange + const mockPostDialer = jest.fn(); + const registry = new DialerRegistry(dialPath); + registry.registerPostDialer(mockPostDialer); + + // Act + await registry.executePostDialers({} as any as DialerContext); + + // Assert + expect(mockPostDialer).toHaveBeenCalledTimes(1); + }); + + it("all the pre-dialers are executed", async () => { + // Arrange + const mockPreDialer1 = jest.fn(); + const mockPreDialer2 = jest.fn(); + const registry = new DialerRegistry(dialPath); + + registry.registerPreDialer(mockPreDialer1); + registry.registerPreDialer(mockPreDialer2); + + // Act + await registry.executePreDialers({} as any as DialerContext); + + // Assert + expect(mockPreDialer1).toHaveBeenCalledTimes(1); + expect(mockPreDialer2).toHaveBeenCalledTimes(1); + }); + + it("all the post-dialers are executed", async () => { + // Arrange + const mockPostDialer1 = jest.fn(); + const mockPostDialer2 = jest.fn(); + const registry = new DialerRegistry(dialPath); + + registry.registerPostDialer(mockPostDialer1); + registry.registerPostDialer(mockPostDialer2); + + // Act + await registry.executePostDialers({} as any as DialerContext); + + // Assert + expect(mockPostDialer1).toHaveBeenCalledTimes(1); + expect(mockPostDialer2).toHaveBeenCalledTimes(1); + }); + + it("early exits when specified dialer file is not found", async () => { + // Arrange + jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit was called"); + }); + const registry = new DialerRegistry("non-existent.js"); + + // Act & Assert + expect(registry.registerDialers()).rejects.toThrow( + "process.exit was called" + ); + }); +}); diff --git a/dialer.ts b/dialer.ts new file mode 100644 index 00000000..9acd6c07 --- /dev/null +++ b/dialer.ts @@ -0,0 +1,85 @@ +import { existsSync } from "fs"; +import { resolve } from "path"; +import { Dialer, DialerContext } from "./dialer.types"; + +// In telephony, a registry is used for maintaining a known set of handlers, +// devices, or processes. This aligns with this class's purpose. Dialers are +// loaded/stored and run in a structured way, so "registry" fits both telephony +// and DI semantics. +export class DialerRegistry { + private readonly dialPath: string; + private preDialers: Dialer[] = []; + private postDialers: Dialer[] = []; + + constructor(dialPath: string) { + this.dialPath = dialPath; + } + + registerPreDialer(dialer: Dialer) { + this.preDialers.push(dialer); + } + + registerPostDialer(dialer: Dialer) { + this.postDialers.push(dialer); + } + + async registerDialers() { + const resolvedDialPath = resolve(this.dialPath); + + if (!existsSync(resolvedDialPath)) { + console.error(`Error: Dialer file not found: ${resolvedDialPath}`); + process.exit(1); + } + + try { + const userModule = await import(resolvedDialPath); + + Object.entries(userModule).forEach(([key, fn]) => { + if (typeof fn === "function") { + if (key.startsWith("pre")) { + this.registerPreDialer(fn as Dialer); + } else if (key.startsWith("post")) { + this.registerPostDialer(fn as Dialer); + } + } + }); + } catch (error) { + console.error(`Failed to load dialers:`, error); + process.exit(1); + } + } + + async executePreDialers(context: DialerContext) { + if (this.preDialers.length === 0) { + return; + } + + for (const dial of this.preDialers) { + await dial(context); + } + } + + async executePostDialers(context: DialerContext) { + if (this.postDialers.length === 0) { + return; + } + + for (const dial of this.postDialers) { + await dial(context); + } + } +} + +export class PreDialerError extends Error { + constructor(message: string) { + super(message); + this.name = "Pre-dialer error"; + } +} + +export class PostDialerError extends Error { + constructor(message: string) { + super(message); + this.name = "Post-dialer error"; + } +} diff --git a/dialer.types.ts b/dialer.types.ts new file mode 100644 index 00000000..9e8ead20 --- /dev/null +++ b/dialer.types.ts @@ -0,0 +1,11 @@ +import { ParsedTransactionResult } from "@hirosystems/clarinet-sdk"; +import { ClarityValue } from "@stacks/transactions"; +import { EnrichedContractInterfaceFunction } from "./shared.types"; + +export type Dialer = (context: DialerContext) => Promise | void; + +export type DialerContext = { + clarityValueArguments: ClarityValue[]; + functionCall: ParsedTransactionResult | undefined; + selectedFunction: EnrichedContractInterfaceFunction; +}; diff --git a/example/contracts/rendezvous-token.clar b/example/contracts/rendezvous-token.clar index fbf03204..b73281e1 100644 --- a/example/contracts/rendezvous-token.clar +++ b/example/contracts/rendezvous-token.clar @@ -2,6 +2,10 @@ (define-fungible-token rendezvous) +(define-constant deployer tx-sender) + +(define-constant ERR_UNAUTHORIZED (err u400)) + ;; SIP-010 methods. (define-read-only (get-total-supply) @@ -34,8 +38,22 @@ (recipient principal) (memo (optional (buff 34))) ) - (match (ft-transfer? rendezvous amount sender recipient) - response (ok response) - error (err error) + (begin + ;; This print event is required for rendezvous to be a SIP-010 compliant + ;; fungible token. Comment out this line and run the following command to + ;; see the `dialers` in action: + ;; ```rv example rendezvous-token invariant --dial=example/sip010.js``` + (match memo to-print (print to-print) 0x) + (match (ft-transfer? rendezvous amount sender recipient) + response (ok response) + error (err error) + ) + ) +) + +(define-public (mint (recipient principal) (amount uint)) + (begin + (asserts! (is-eq contract-caller deployer) ERR_UNAUTHORIZED) + (ft-mint? rendezvous amount recipient) ) ) \ No newline at end of file diff --git a/example/contracts/rendezvous-token.tests.clar b/example/contracts/rendezvous-token.tests.clar new file mode 100644 index 00000000..a4d366c6 --- /dev/null +++ b/example/contracts/rendezvous-token.tests.clar @@ -0,0 +1,7 @@ +;; Invariants + +;; This invariant returns true regardless of the state of the contract. Its +;; purpose is to allow the demonstration of the `dialers` feature. +(define-read-only (invariant-always-true) + true +) \ No newline at end of file diff --git a/example/sip010.js b/example/sip010.js new file mode 100644 index 00000000..90ae9e80 --- /dev/null +++ b/example/sip010.js @@ -0,0 +1,75 @@ +// This file contains a custom predefined post-dialer that checks the SIP-010 +// transfer function for the print event. For a token to be SIP-010 compliant, +// its transfer function must emit the print event with the `memo` content. +// +// This is just an example of the power of custom dialers. In sBTC, the +// transfer function did not emit the print event, resulting in sBTC not being +// SIP-010 compliant: https://github.com/stacks-network/sbtc/issues/1090. +// +// This custom dialer allows for the detection of such issues for any fungible +// token contract in the future. +// +// References: +// https://github.com/stacksgov/sips/blob/2c3b36c172c0b71369bc26cffa080e8bdd2b3b84/sips/sip-010/sip-010-fungible-token-standard.md?plain=1#L69 +// https://github.com/stacks-network/sbtc/commit/74daed379e9aad73dd09bebb83231f2c48ef4d81 + +const { cvToHex } = require("@stacks/transactions"); + +async function postTransferSip010PrintEvent(context) { + const selectedFunction = context.selectedFunction; + + // A fungible token complies with the SIP-010 standard if the transfer event + // always emits the print event with the `memo` content. + if (selectedFunction.name !== "transfer") { + return; + } + + const functionCallEvents = context.functionCall.events; + + // The `memo` parameter is the fourth parameter of the `rendezvous-token`'s + // `transfer` function. + const memoParameterIndex = 3; + + const memoGeneratedArgumentCV = + context.clarityValueArguments[memoParameterIndex]; + + // The `memo` argument is optional. If `none`, nothing has to be printed. + if (memoGeneratedArgumentCV.type === 9) { + return; + } + + // If not `none`, the `memo` argument must be `some`. Otherwise, the + // generated clarity argument is not an option type, so it does not comply + // with the SIP-010 fungible token trait. + if (memoGeneratedArgumentCV.type !== 10) { + throw new Error("The memo argument has to be an option type!"); + } + + // Turn the inner value of the `some` type into a hex to compare it with the + // print event data. + const hexMemoArgumentValue = cvToHex(memoGeneratedArgumentCV.value); + + const sip010PrintEvent = functionCallEvents.find( + (ev) => ev.event === "print_event" + ); + + if (!sip010PrintEvent) { + throw new Error( + "No print event found. The transfer function must emit the SIP-010 print event containing the memo!" + ); + } + + const sip010PrintEventValue = sip010PrintEvent.data.raw_value; + + if (sip010PrintEventValue !== hexMemoArgumentValue) { + throw new Error( + `The print event memo value is not equal to the memo parameter value: ${hexMemoArgumentValue} !== ${sip010PrintEventValue}` + ); + } + + return; +} + +module.exports = { + postTransferSip010PrintEvent, +}; diff --git a/invariant.ts b/invariant.ts index 75d75d8c..ac4b0332 100644 --- a/invariant.ts +++ b/invariant.ts @@ -18,6 +18,7 @@ import { isTraitReferenceFunction, } from "./traits"; import { EnrichedContractInterfaceFunction } from "./shared.types"; +import { DialerRegistry, PostDialerError, PreDialerError } from "./dialer"; /** * Runs invariant testing on the target contract and logs the progress. Reports @@ -30,10 +31,11 @@ import { EnrichedContractInterfaceFunction } from "./shared.types"; * @param seed The seed for reproducible invariant testing. * @param path The path for reproducible invariant testing. * @param runs The number of test runs. + * @param dialerRegistry The custom dialer registry. * @param radio The custom logging event emitter. * @returns void */ -export const checkInvariants = ( +export const checkInvariants = async ( simnet: Simnet, targetContractName: string, rendezvousList: string[], @@ -41,6 +43,7 @@ export const checkInvariants = ( seed: number | undefined, path: string | undefined, runs: number | undefined, + dialerRegistry: DialerRegistry | undefined, radio: EventEmitter ) => { // A map where the keys are the Rendezvous identifiers and the values are @@ -171,8 +174,8 @@ export const checkInvariants = ( reporter(runDetails, radio, "invariant"); }; - fc.assert( - fc.property( + await fc.assert( + fc.asyncProperty( fc .record({ // The target contract identifier. It is a constant value equal @@ -241,7 +244,7 @@ export const checkInvariants = ( }) .map((burnBlocks) => ({ ...r, ...burnBlocks })) ), - (r) => { + async (r) => { const selectedFunctionsArgsCV = r.selectedFunctions.map( (selectedFunction, index) => argsToCV(selectedFunction, r.selectedFunctionsArgsList[index]) @@ -251,7 +254,7 @@ export const checkInvariants = ( r.invariantArgs ); - r.selectedFunctions.forEach((selectedFunction, index) => { + for (const [index, selectedFunction] of r.selectedFunctions.entries()) { const [sutCallerWallet, sutCallerAddress] = r.sutCallers[index]; const printedFunctionArgs = r.selectedFunctionsArgsList[index] @@ -267,14 +270,26 @@ export const checkInvariants = ( .join(" "); try { - const { result: functionCallResult } = simnet.callPublicFn( + if (dialerRegistry !== undefined) { + await dialerRegistry.executePreDialers({ + selectedFunction: selectedFunction, + functionCall: undefined, + clarityValueArguments: selectedFunctionsArgsCV[index], + }); + } + } catch (error: any) { + throw new PreDialerError(error.message); + } + + try { + const functionCall = simnet.callPublicFn( r.rendezvousContractId, selectedFunction.name, selectedFunctionsArgsCV[index], sutCallerAddress ); - const functionCallResultJson = cvToJSON(functionCallResult); + const functionCallResultJson = cvToJSON(functionCall.result); if (functionCallResultJson.success) { localContext[r.rendezvousContractId][selectedFunction.name]++; @@ -300,6 +315,18 @@ export const checkInvariants = ( `${underline(selectedFunction.name)} ` + printedFunctionArgs ); + + try { + if (dialerRegistry !== undefined) { + await dialerRegistry.executePostDialers({ + selectedFunction: selectedFunction, + functionCall: functionCall, + clarityValueArguments: selectedFunctionsArgsCV[index], + }); + } + } catch (error: any) { + throw new PostDialerError(error.message); + } } else { radio.emit( "logMessage", @@ -314,22 +341,29 @@ export const checkInvariants = ( ); } } catch (error: any) { - // If the function call fails with a runtime error, log a dimmed - // message. Since the public function result is ignored, there's - // no need to throw an error. - radio.emit( - "logMessage", - dim( - `₿ ${simnet.burnBlockHeight.toString().padStart(8)} ` + - `Ӿ ${simnet.blockHeight.toString().padStart(8)} ` + - `${sutCallerWallet} ` + - `${targetContractName} ` + - `${underline(selectedFunction.name)} ` + - printedFunctionArgs - ) - ); + if ( + error instanceof PreDialerError || + error instanceof PostDialerError + ) { + throw error; + } else { + // If the function call fails with a runtime error, log a dimmed + // message. Since the public function result is ignored, there's + // no need to throw an error. + radio.emit( + "logMessage", + dim( + `₿ ${simnet.burnBlockHeight.toString().padStart(8)} ` + + `Ӿ ${simnet.blockHeight.toString().padStart(8)} ` + + `${sutCallerWallet} ` + + `${targetContractName} ` + + `${underline(selectedFunction.name)} ` + + printedFunctionArgs + ) + ); + } } - }); + } const printedInvariantArgs = r.invariantArgs .map((arg) => {