diff --git a/lib.tests.ts b/lib.tests.ts new file mode 100644 index 00000000..da007373 --- /dev/null +++ b/lib.tests.ts @@ -0,0 +1,108 @@ +import { join, resolve } from "node:path"; + +import { initSimnet } from "@stacks/clarinet-sdk"; +import type { ClarityValue } from "@stacks/transactions"; +import fc from "fast-check"; + +import { getContractFunction, strategyFor } from "./lib"; + +const manifestPath = join(resolve(__dirname, "example"), "Clarinet.toml"); + +describe("getContractFunction", () => { + it("returns the function interface for a known contract and function", async () => { + const simnet = await initSimnet(manifestPath); + const fn = getContractFunction(simnet, "counter", "increment"); + + expect(fn.name).toBe("increment"); + expect(fn.access).toBe("public"); + }); + + it("returns a function with arguments", async () => { + const simnet = await initSimnet(manifestPath); + const fn = getContractFunction(simnet, "counter", "add"); + + expect(fn.name).toBe("add"); + expect(fn.args.length).toBe(1); + }); + + it("throws when the contract does not exist", async () => { + const simnet = await initSimnet(manifestPath); + + expect(() => getContractFunction(simnet, "nonexistent", "foo")).toThrow( + 'Contract "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.nonexistent" not found.', + ); + }); + + it("throws when the function does not exist", async () => { + const simnet = await initSimnet(manifestPath); + + expect(() => getContractFunction(simnet, "counter", "nonexistent")).toThrow( + 'Function "nonexistent" not found in contract "counter".', + ); + }); + + it("accepts a custom deployer address", async () => { + const simnet = await initSimnet(manifestPath); + + // Using the actual deployer explicitly should work the same. + const fn = getContractFunction( + simnet, + "counter", + "increment", + simnet.deployer, + ); + expect(fn.name).toBe("increment"); + }); +}); + +describe("strategyFor", () => { + it("produces ClarityValue arrays for a function with arguments", async () => { + const simnet = await initSimnet(manifestPath); + const fn = getContractFunction(simnet, "counter", "add"); + const arb = strategyFor(fn, simnet); + + fc.assert( + fc.property(arb, (args: ClarityValue[]) => { + expect(Array.isArray(args)).toBe(true); + expect(args.length).toBe(fn.args.length); + args.forEach((arg) => { + expect(arg).toHaveProperty("type"); + }); + }), + { numRuns: 10 }, + ); + }); + + it("produces an empty array for a function with no arguments", async () => { + const simnet = await initSimnet(manifestPath); + const fn = getContractFunction(simnet, "counter", "increment"); + const arb = strategyFor(fn, simnet); + + fc.assert( + fc.property(arb, (args: ClarityValue[]) => { + expect(args).toEqual([]); + }), + { numRuns: 1 }, + ); + }); + + it("produces arguments usable with simnet.callPublicFn", async () => { + const simnet = await initSimnet(manifestPath); + const fn = getContractFunction(simnet, "counter", "add"); + const arb = strategyFor(fn, simnet); + + fc.assert( + fc.property(arb, (args: ClarityValue[]) => { + // Should not throw — arguments are valid Clarity values. + const { result } = simnet.callPublicFn( + `${simnet.deployer}.counter`, + "add", + args, + simnet.deployer, + ); + expect(result).toBeDefined(); + }), + { numRuns: 5 }, + ); + }); +}); diff --git a/lib.ts b/lib.ts new file mode 100644 index 00000000..166c77a6 --- /dev/null +++ b/lib.ts @@ -0,0 +1,127 @@ +import type { Simnet } from "@stacks/clarinet-sdk"; +import type { ContractInterfaceFunction } from "@stacks/clarinet-sdk-wasm"; +import type { ClarityValue } from "@stacks/transactions"; +import fc from "fast-check"; + +import { argsToCV, functionToArbitrary } from "./shared"; +import type { EnrichedContractInterfaceFunction } from "./shared.types"; +import { + buildTraitReferenceMap, + enrichInterfaceWithTraitData, + extractProjectTraitImplementations, + isTraitReferenceFunction, +} from "./traits"; + +export type { EnrichedContractInterfaceFunction } from "./shared.types"; + +/** + * Retrieves a function interface from a simnet contract by name. The returned + * interface is enriched with trait reference data when applicable, making it + * ready for use with {@link strategyFor}. + * + * @param simnet The simnet instance. + * @param contractName The contract name (e.g., "counter"). + * @param functionName The function name (e.g., "increment"). + * @param deployer The deployer address. Defaults to `simnet.deployer`. + * @returns The enriched function interface. + * + * @example + * ```ts + * const simnet = await initSimnet("./Clarinet.toml"); + * const increment = getContractFunction(simnet, "counter", "increment"); + * ``` + */ +export const getContractFunction = ( + simnet: Simnet, + contractName: string, + functionName: string, + deployer?: string, +): EnrichedContractInterfaceFunction => { + const targetDeployer = deployer ?? simnet.deployer; + const allContracts = simnet.getContractsInterfaces(); + + const contractId = `${targetDeployer}.${contractName}`; + const contractInterface = allContracts.get(contractId); + + if (!contractInterface) { + throw new Error(`Contract "${contractId}" not found.`); + } + + const fn = contractInterface.functions.find( + (f: ContractInterfaceFunction) => f.name === functionName, + ); + + if (!fn) { + throw new Error( + `Function "${functionName}" not found in contract "${contractName}".`, + ); + } + + // Enrich with trait data if the function uses trait references. + if (isTraitReferenceFunction(fn)) { + const traitReferenceMap = buildTraitReferenceMap([fn]); + const enriched = enrichInterfaceWithTraitData( + simnet.getContractAST(contractName), + traitReferenceMap, + [fn], + contractId, + ); + return enriched.get(contractId)![0]; + } + + return fn as EnrichedContractInterfaceFunction; +}; + +/** + * Generates a fast-check arbitrary that produces `ClarityValue[]` arrays + * ready for use with `simnet.callPublicFn` or similar. + * + * Automatically resolves principal addresses and trait implementations from + * the simnet instance. + * + * @param fn The enriched function interface (from {@link getContractFunction}). + * @param simnet The simnet instance. + * @returns A fast-check arbitrary producing Clarity argument arrays. + * + * @example + * ```ts + * import fc from "fast-check"; + * import { initSimnet } from "@stacks/clarinet-sdk"; + * import { getContractFunction, strategyFor } from "@stacks/rendezvous"; + * + * const simnet = await initSimnet("./Clarinet.toml"); + * const increment = getContractFunction(simnet, "counter", "increment"); + * const argsArb = strategyFor(increment, simnet); + * + * fc.assert( + * fc.asyncProperty(argsArb, async (args) => { + * const { result } = simnet.callPublicFn( + * "deployer.counter", "increment", args, "deployer" + * ); + * return result.type !== "err"; + * }) + * ); + * ``` + */ +export const strategyFor = ( + fn: EnrichedContractInterfaceFunction, + simnet: Simnet, +): fc.Arbitrary => { + const allAddresses = [...simnet.getAccounts().values()]; + const projectTraitImplementations = + extractProjectTraitImplementations(simnet); + + const arbitraries = functionToArbitrary( + fn, + allAddresses, + projectTraitImplementations, + ); + + if (arbitraries.length === 0) { + return fc.constant([]); + } + + return fc + .tuple(...arbitraries) + .map((generated: unknown[]) => argsToCV(fn, generated)); +}; diff --git a/package.json b/package.json index 0849c533..ca0a140c 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,17 @@ "name": "@stacks/rendezvous", "version": "0.14.0", "description": "Meet your contract's vulnerabilities head-on.", - "main": "app.js", + "main": "dist/lib.js", + "types": "dist/lib.d.ts", "bin": { "rv": "./dist/app.js" }, + "exports": { + ".": { + "types": "./dist/lib.d.ts", + "default": "./dist/lib.js" + } + }, "scripts": { "build": "npx -p typescript tsc --project tsconfig.json && node -e \"if (process.platform !== 'win32') require('fs').chmodSync('./dist/app.js', 0o755);\"", "fmt": "prettier --write .", diff --git a/tsconfig.json b/tsconfig.json index 260c467c..a25c160b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,8 +49,8 @@ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + "declarationMap": true, /* Create sourcemaps for d.ts files. */ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */