diff --git a/contrib/core-contract-tests/.gitignore b/contrib/core-contract-tests/.gitignore index 39b70c2f7f..4ddd794859 100644 --- a/contrib/core-contract-tests/.gitignore +++ b/contrib/core-contract-tests/.gitignore @@ -4,4 +4,6 @@ npm-debug.log* coverage *.info costs-reports.json -node_modules \ No newline at end of file +node_modules +*.log.txt +history.txt \ No newline at end of file diff --git a/contrib/core-contract-tests/Clarinet.toml b/contrib/core-contract-tests/Clarinet.toml index 5cdea76a4c..7fcacd376a 100644 --- a/contrib/core-contract-tests/Clarinet.toml +++ b/contrib/core-contract-tests/Clarinet.toml @@ -8,3 +8,11 @@ costs_version = 1 [contracts.bns] path = "../../stackslib/src/chainstate/stacks/boot/bns.clar" depends_on = [] + +[contracts.bns_test] +path = "contracts/bns-tests/bns_test.clar" +depends_on = [] + +[contracts.bns_flow_test] +path = "contracts/bns-tests/bns_flow_test.clar" +depends_on = [] diff --git a/contrib/core-contract-tests/contracts/bns-tests/bns_flow_test.clar b/contrib/core-contract-tests/contracts/bns-tests/bns_flow_test.clar new file mode 100644 index 0000000000..0464fa77fb --- /dev/null +++ b/contrib/core-contract-tests/contracts/bns-tests/bns_flow_test.clar @@ -0,0 +1,19 @@ +(define-constant ERR_NAMESPACE_NOT_FOUND 1005) + +;; @name: test delegation to wallet_2, stacking and revoking +(define-public (test-name-registration) + (begin + ;; @caller wallet_1 + (unwrap! (contract-call? .bns name-preorder 0x0123456789abcdef01230123456789abcdef0123 u1000000) (err "name-preorder by wallet 1 should succeed")) + ;; @caller wallet_2 + (unwrap! (contract-call? .bns name-preorder 0x30123456789abcdef01230123456789abcdef012 u1000000) (err "name-preorder by wallet 2 should succeed")) + + ;; @mine-blocks-before 100 + ;; @caller wallet_1 + (try! (register)) + (ok true))) + +(define-public (register) + (let ((result (contract-call? .bns name-register 0x123456 0x123456 0x123456 0x))) + (asserts! (is-eq result (err ERR_NAMESPACE_NOT_FOUND)) (err "name-register should fail")) + (ok true))) \ No newline at end of file diff --git a/contrib/core-contract-tests/contracts/bns-tests/bns_test.clar b/contrib/core-contract-tests/contracts/bns-tests/bns_test.clar new file mode 100644 index 0000000000..a84aff638e --- /dev/null +++ b/contrib/core-contract-tests/contracts/bns-tests/bns_test.clar @@ -0,0 +1,10 @@ +(define-constant ERR_NAMESPACE_NOT_FOUND 1005) + +;; @name: test preorder and publish with invalid names +;; @caller: wallet_1 +(define-public (test-name-registration) + (begin + (unwrap! (contract-call? .bns name-preorder 0x0123456789abcdef01230123456789abcdef0123 u1000000) (err "preorder should succeeed")) + (let ((result (contract-call? .bns name-register 0x123456 0x123456 0x123456 0x))) + (asserts! (is-eq result (err ERR_NAMESPACE_NOT_FOUND)) (err "registration should fail")) + (ok true)))) diff --git a/contrib/core-contract-tests/contracts/parser-tests/all-annotations.clar b/contrib/core-contract-tests/contracts/parser-tests/all-annotations.clar new file mode 100644 index 0000000000..3fec2c0334 --- /dev/null +++ b/contrib/core-contract-tests/contracts/parser-tests/all-annotations.clar @@ -0,0 +1,13 @@ +;; @name all annotation test +;; @description all annotation test +;; @mine-before 10 +;; @caller wallet_1 +(define-public (test-all-annotations-1) + (ok true)) + +;; @name all annotation test 2 +;; @description all annotation test 2 +;; @mine-before 20 +;; @caller wallet_2 +(define-public (test-all-annotations-2) + (ok true)) \ No newline at end of file diff --git a/contrib/core-contract-tests/contracts/parser-tests/bad-annotations.clar b/contrib/core-contract-tests/contracts/parser-tests/bad-annotations.clar new file mode 100644 index 0000000000..5adfd3ce8f --- /dev/null +++ b/contrib/core-contract-tests/contracts/parser-tests/bad-annotations.clar @@ -0,0 +1,5 @@ +;; @namexx bad annotation test +;; @mine-after 10 +;; @callerxx wallet_1 +(define-public (test-bad-annotations) + (ok false)) diff --git a/contrib/core-contract-tests/contracts/parser-tests/bad-flow.clar b/contrib/core-contract-tests/contracts/parser-tests/bad-flow.clar new file mode 100644 index 0000000000..569a24de82 --- /dev/null +++ b/contrib/core-contract-tests/contracts/parser-tests/bad-flow.clar @@ -0,0 +1,12 @@ +;;@name bad annotation flow test +(define-public (test-bad-flow) + (begin + ;; @caller wallet_1 + (try! (my-test-function)) + (unwrap! (contract-call? invalid-syntax)) + (unwrap! (contract-call? .bns)) + (ok true))) + + +(define-private (my-test-function) + (ok true)) \ No newline at end of file diff --git a/contrib/core-contract-tests/contracts/parser-tests/no-annotations.clar b/contrib/core-contract-tests/contracts/parser-tests/no-annotations.clar new file mode 100644 index 0000000000..d5088a3cef --- /dev/null +++ b/contrib/core-contract-tests/contracts/parser-tests/no-annotations.clar @@ -0,0 +1,2 @@ +(define-public (test-no-annotations) + (ok true)) \ No newline at end of file diff --git a/contrib/core-contract-tests/contracts/parser-tests/simple-annotations.clar b/contrib/core-contract-tests/contracts/parser-tests/simple-annotations.clar new file mode 100644 index 0000000000..d62357e1f9 --- /dev/null +++ b/contrib/core-contract-tests/contracts/parser-tests/simple-annotations.clar @@ -0,0 +1,4 @@ +;; @name simple annotation test +(define-public (test-simple-annotations) + ;; @mine-before is ignored here + (ok true)) \ No newline at end of file diff --git a/contrib/core-contract-tests/contracts/parser-tests/simple-flow.clar b/contrib/core-contract-tests/contracts/parser-tests/simple-flow.clar new file mode 100644 index 0000000000..e7a5953b1c --- /dev/null +++ b/contrib/core-contract-tests/contracts/parser-tests/simple-flow.clar @@ -0,0 +1,11 @@ +;;@name simple flow test +(define-public (test-simple-flow) + (begin + ;; @caller wallet_1 + (try! (my-test-function)) + ;; @caller wallet_2 + (unwrap! (contract-call? .bns name-resolve 0x 0x)) + (ok true))) + +(define-public (my-test-function) + (ok true)) \ No newline at end of file diff --git a/contrib/core-contract-tests/package-lock.json b/contrib/core-contract-tests/package-lock.json index e5c3e22e18..bcae560d0c 100644 --- a/contrib/core-contract-tests/package-lock.json +++ b/contrib/core-contract-tests/package-lock.json @@ -12,6 +12,8 @@ "@hirosystems/clarinet-sdk": "^1.1.0", "@stacks/transactions": "^6.9.0", "chokidar-cli": "^3.0.0", + "fast-check": "^3.13.1", + "path": "^0.12.7", "typescript": "^5.2.2", "vite": "^4.4.9", "vitest": "^0.34.4", @@ -955,6 +957,27 @@ "node": ">=6" } }, + "node_modules/fast-check": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.14.0.tgz", + "integrity": "sha512-9Z0zqASzDNjXBox/ileV/fd+4P+V/f3o4shM6QawvcdLFh8yjPG4h5BrHUZ8yzY6amKGDTAmRMyb/JZqe+dCgw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1017,6 +1040,11 @@ "node": ">= 6" } }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1245,6 +1273,15 @@ "node": ">=6" } }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -1332,6 +1369,14 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -1344,6 +1389,21 @@ "node": ">= 6" } }, + "node_modules/pure-rand": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", + "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -1523,6 +1583,14 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dependencies": { + "inherits": "2.0.3" + } + }, "node_modules/vite": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", diff --git a/contrib/core-contract-tests/package.json b/contrib/core-contract-tests/package.json index 2f11d87369..6de4118421 100644 --- a/contrib/core-contract-tests/package.json +++ b/contrib/core-contract-tests/package.json @@ -12,6 +12,8 @@ "@hirosystems/clarinet-sdk": "^1.1.0", "@stacks/transactions": "^6.9.0", "chokidar-cli": "^3.0.0", + "fast-check": "^3.13.1", + "path": "^0.12.7", "typescript": "^5.2.2", "vite": "^4.4.9", "vitest": "^0.34.4", diff --git a/contrib/core-contract-tests/tests/clar/clar-flow.test.ts b/contrib/core-contract-tests/tests/clar/clar-flow.test.ts new file mode 100644 index 0000000000..ecd99d561a --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clar-flow.test.ts @@ -0,0 +1,175 @@ +import { ParsedTransactionResult, tx } from "@hirosystems/clarinet-sdk"; +import * as fs from "fs"; +import path from "path"; +import { describe, it } from "vitest"; +import { + CallInfo, + FunctionAnnotations, + FunctionBody, + extractTestAnnotationsAndCalls, +} from "./utils/clarity-parser-flow-tests"; +import { expectOk, isValidTestFunction } from "./utils/test-helpers"; + +/** + * Returns true if the contract is a test contract using the flow convention + * @param contractName name of the contract + * @returns + */ +function isTestContract(contractName: string) { + return contractName.substring(contractName.length - 10) === "_flow_test"; +} + +const accounts = simnet.getAccounts(); +clearLogFile(); + +// for each test contract create a test suite +simnet.getContractsInterfaces().forEach((contract, contractFQN) => { + if (!isTestContract(contractFQN)) { + return; + } + + describe(contractFQN, () => { + // determine whether the contract has a prepare function + const hasDefaultPrepareFunction = + contract.functions.findIndex((f) => f.name === "prepare") >= 0; + + contract.functions.forEach((functionCall) => { + if (!isValidTestFunction(functionCall)) { + return; + } + + const functionName = functionCall.name; + const source = simnet.getContractSource(contractFQN)!; + const [annotations, functionBodies] = + extractTestAnnotationsAndCalls(source); + const functionAnnotations: FunctionAnnotations = + annotations[functionName] || {}; + const testname = `${functionCall.name}${ + functionAnnotations.name ? `: ${functionAnnotations.name}` : "" + }`; + it(testname, () => { + writeToLogFile(`\n\n${testname}\n\n`); + if (hasDefaultPrepareFunction && !functionAnnotations.prepare) + functionAnnotations.prepare = "prepare"; + if (functionAnnotations["no-prepare"]) + delete functionAnnotations.prepare; + + const functionBody = functionBodies[functionName] || []; + + mineBlocksFromFunctionBody(contractFQN, functionName, functionBody); + }); + }); + }); +}); + +/** + * Mines one or more blocks based on the functions calls in the test function. + * The function body must be one of the following: + * 1. (unwrap! (contract-call? .contract-name function-name args)) + * 2. (try! (function-name)) + * + * @param contractFQN the contract id + * @param testFunctionName the name of the test function containing calls + * @param calls a list of function calls with annotations part of the test function body + */ +function mineBlocksFromFunctionBody( + contractFQN: string, + testFunctionName: string, + calls: FunctionBody +) { + let blockStarted = false; + let txs: any[] = []; + let block: ParsedTransactionResult[] = []; + + // go through all function calls and + // bundle function them into blocks + for (const { callAnnotations, callInfo } of calls) { + // mine empty blocks + const mineBlocksBefore = + parseInt(callAnnotations["mine-blocks-before"] as string) || 0; + // get caller address + const caller = accounts.get( + (callAnnotations["caller"] as string) || "deployer" + )!; + + if (mineBlocksBefore >= 1) { + if (blockStarted) { + writeToLogFile(txs); + // mine block with txs and assert ok on all of them + block = simnet.mineBlock(txs); + for (let index = 0; index < txs.length; index++) { + expectOk(block, contractFQN, testFunctionName, index); + } + txs = []; + blockStarted = false; + } + // mine empty blocks if necessary + if (mineBlocksBefore > 1) { + simnet.mineEmptyBlocks(mineBlocksBefore - 1); + writeToLogFile(mineBlocksBefore - 1); + } + } + // start a new block if necessary + if (!blockStarted) { + blockStarted = true; + } + // add tx to current block + txs.push(generateCallWithArguments(callInfo, contractFQN, caller)); + } + // close final block + if (blockStarted) { + writeToLogFile(txs); + block = simnet.mineBlock(txs); + for (let index = 0; index < txs.length; index++) { + expectOk(block, contractFQN, testFunctionName, index); + } + txs = []; + blockStarted = false; + } +} + +/** + * creates a Tx + * @param callInfo + * @param contractPrincipal + * @param callerAddress + * @returns + */ +function generateCallWithArguments( + callInfo: CallInfo, + contractPrincipal: string, + callerAddress: string +) { + const contractName = callInfo.contractName || contractPrincipal; + const functionName = callInfo.functionName; + + return tx.callPublicFn( + contractName, + functionName, + callInfo.args.map((arg) => arg.value), + callerAddress + ); +} + +/** + * writes data to a log file that represents the sequence of blocks mined + * @param data + */ +function writeToLogFile(data: ParsedTransactionResult[] | number | string) { + const filePath = path.join(__dirname, "clar-flow-test.log.txt"); + if (typeof data === "number") { + fs.appendFileSync(filePath, `${data} empty blocks\n`); + } else if (typeof data === "string") { + fs.appendFileSync(filePath, `${data}\n`); + } else { + fs.appendFileSync(filePath, `block:\n${JSON.stringify(data, null, 2)}\n`); + } +} + +/** + * clears the log file + */ +function clearLogFile() { + const filePath = path.join(__dirname, "clar-flow-test.log.txt"); + fs.writeFileSync(filePath, ""); +} diff --git a/contrib/core-contract-tests/tests/clar/clar.test.ts b/contrib/core-contract-tests/tests/clar/clar.test.ts new file mode 100644 index 0000000000..3306d26451 --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clar.test.ts @@ -0,0 +1,156 @@ +import { tx } from "@hirosystems/clarinet-sdk"; +import { describe, it } from "vitest"; +import { + FunctionAnnotations, + extractTestAnnotations, +} from "./utils/clarity-parser"; +import { expectOkTrue, isValidTestFunction } from "./utils/test-helpers"; + +/** + * Returns true if the contract is a test contract + * @param contractName name of the contract + * @returns + */ +function isTestContract(contractName: string) { + return ( + contractName.substring(contractName.length - 5) === "_test" && + contractName.substring(contractName.length - 10) !== "_flow_test" + ); +} + +const accounts = simnet.getAccounts(); + +// for each test contract create a test suite +simnet.getContractsInterfaces().forEach((contract, contractFQN) => { + if (!isTestContract(contractFQN)) { + return; + } + + describe(contractFQN, () => { + // determine whether the contract has a prepare function + const hasDefaultPrepareFunction = + contract.functions.findIndex((f) => f.name === "prepare") >= 0; + + contract.functions.forEach((functionCall) => { + if (!isValidTestFunction(functionCall)) { + return; + } + + const functionName = functionCall.name; + const source = simnet.getContractSource(contractFQN)!; + const annotations: any = extractTestAnnotations(source); + const functionAnnotations: FunctionAnnotations = + annotations[functionName] || {}; + + const mineBlocksBefore = + parseInt(annotations["mine-blocks-before"] as string) || 0; + + const testDescription = `${functionCall.name}${ + functionAnnotations.name ? `: ${functionAnnotations.name}` : "" + }`; + it(testDescription, () => { + // handle prepare function for this test + if (hasDefaultPrepareFunction && !functionAnnotations.prepare) + functionAnnotations.prepare = "prepare"; + if (functionAnnotations["no-prepare"]) + delete functionAnnotations.prepare; + + // handle caller address for this test + const callerAddress = functionAnnotations.caller + ? annotations.caller[0] === "'" + ? `${(annotations.caller as string).substring(1)}` + : accounts.get(annotations.caller)! + : accounts.get("deployer")!; + + if (functionAnnotations.prepare) { + // mine block with prepare function call + mineBlockWithPrepareAndTestFunctionCall( + contractFQN, + functionAnnotations.prepare as string, + mineBlocksBefore, + functionName, + callerAddress + ); + } else { + // mine block without prepare function call + mineBlockWithTestFunctionCall( + contractFQN, + mineBlocksBefore, + functionName, + callerAddress + ); + } + }); + }); + }); +}); + +/** + * Mine a block with a prepare function call and a test function call + * If requested, mine the specified number of blocks beforehand. + * @param contractFQN the contract id of the test + * @param prepareFunctionName the function name of the prepare function + * @param mineBlocksBefore the number of blocks to mine before the prepare function call, can be 0 + * @param functionName the test function name + * @param callerAddress the caller of the test function + */ +function mineBlockWithPrepareAndTestFunctionCall( + contractFQN: string, + prepareFunctionName: string, + mineBlocksBefore: number, + functionName: string, + callerAddress: string +) { + if (mineBlocksBefore > 0) { + let block = simnet.mineBlock([ + tx.callPublicFn( + contractFQN, + prepareFunctionName, + [], + accounts.get("deployer")! + ), + ]); + expectOkTrue(block, contractFQN, prepareFunctionName, 0); + simnet.mineEmptyBlocks(mineBlocksBefore - 1); + + block = simnet.mineBlock([ + tx.callPublicFn(contractFQN, functionName, [], callerAddress), + ]); + + expectOkTrue(block, contractFQN, functionName, 0); + } else { + let block = simnet.mineBlock([ + tx.callPublicFn( + contractFQN, + prepareFunctionName, + [], + accounts.get("deployer")! + ), + tx.callPublicFn(contractFQN, functionName, [], callerAddress), + ]); + expectOkTrue(block, contractFQN, prepareFunctionName, 0); + expectOkTrue(block, contractFQN, functionName, 1); + } +} + +/** + * Mines a block with a single test function call + * If requested, mine the specified number of blocks beforehand. + * + * @param contractFQN the contract id of the test + * @param mineBlocksBefore the number of blocks to mine before the test function call, can be 0 + * @param functionName the test function name + * @param callerAddress the caller of the test function + */ +function mineBlockWithTestFunctionCall( + contractFQN: string, + mineBlocksBefore: number, + functionName: string, + callerAddress: string +) { + simnet.mineEmptyBlocks(mineBlocksBefore); + const block = simnet.mineBlock([ + tx.callPublicFn(contractFQN, functionName, [], callerAddress), + ]); + expectOkTrue(block, contractFQN, functionName, 0); +} diff --git a/contrib/core-contract-tests/tests/clar/clarity-parser-flow.test.ts b/contrib/core-contract-tests/tests/clar/clarity-parser-flow.test.ts new file mode 100644 index 0000000000..e92180bc1f --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clarity-parser-flow.test.ts @@ -0,0 +1,56 @@ +import * as fs from "fs"; +import path from "path"; +import { describe, expect, it } from "vitest"; +import { extractTestAnnotationsAndCalls } from "./utils/clarity-parser-flow-tests"; +import { bufferCV } from "@stacks/transactions"; + +describe("verify clarity parser for flow tests", () => { + it("should parse flow test with simple annotations", () => { + const [annotations, callInfos] = extractTestAnnotationsAndCalls( + fs.readFileSync( + path.join(__dirname, "../../contracts/parser-tests/simple-flow.clar"), + "utf8" + ) + ); + expect(annotations["test-simple-flow"]).toEqual({}); + // check the two function calls + expect(callInfos["test-simple-flow"][0]).toEqual({ + callAnnotations: { caller: "wallet_1" }, + callInfo: { + args: [], + contractName: "", + functionName: "my-test-function", + }, + }); + expect(callInfos["test-simple-flow"][1]).toEqual({ + callAnnotations: { caller: "wallet_2" }, + callInfo: { + args: [ + { type: "buffer", value: bufferCV(new Uint8Array([])) }, + { type: "buffer", value: bufferCV(new Uint8Array([])) }, + ], + contractName: "bns", + functionName: "name-resolve", + }, + }); + }); + + it("should parse flow test with bad annotations", () => { + const [annotations, callInfos] = extractTestAnnotationsAndCalls( + fs.readFileSync( + path.join(__dirname, "../../contracts/parser-tests/bad-flow.clar"), + "utf8" + ) + ); + expect(annotations["test-bad-flow"]).toEqual({}); + expect(callInfos["test-bad-flow"][0]).toEqual({ + callAnnotations: { caller: "wallet_1" }, + callInfo: { + args: [], + contractName: "", + functionName: "my-test-function", + }, + }); + expect(callInfos["test-bad-flow"].length).toEqual(1); + }); +}); diff --git a/contrib/core-contract-tests/tests/clar/clarity-parser-string-to-cv.prop.test.ts b/contrib/core-contract-tests/tests/clar/clarity-parser-string-to-cv.prop.test.ts new file mode 100644 index 0000000000..8d56285d25 --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clarity-parser-string-to-cv.prop.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { stringToCV } from "./utils/string-to-cv"; +import { ClarityType, intCV, stringUtf8CV, uintCV } from "@stacks/transactions"; +import fc from "fast-check"; + +const uint128 = fc + .tuple(fc.bigUintN(64), fc.bigUintN(64)) + .map(([hi, lo]) => (hi << BigInt(64)) | lo); + +const int128 = fc + .tuple(fc.bigIntN(64), fc.bigIntN(64)) + .map(([hi, lo]) => (hi << BigInt(64)) | lo); + +describe("verify string to cv conversion", () => { + it("should convert string to cv", () => { + fc.assert(fc.property(fc.string(), (someStr) => { + const result = stringToCV(someStr, { "string-utf8": { length: 100 } }); + expect(result).toEqual({ + type: "string", + value: stringUtf8CV(someStr), + }); + })); + }); + + it("should convert uint to cv", () => { + fc.assert(fc.property(uint128, (someUInt) => { + const result = stringToCV(`u${someUInt}`, "uint128"); + expect(result).toEqual({ + type: "uint", + value: uintCV(someUInt), + }); + })); + }); + + it("should convert tuple to cv", () => { + fc.assert(fc.property(fc.record({ + key: fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}$/), + val: int128 }), (r) => { + const result = stringToCV(`{${r.key}: ${r.val}}`, { + tuple: [{ name: r.key, type: "int128" }], + }); + expect(result).toEqual({ + type: "tuple", + value: { data: { [r.key]: intCV(r.val) }, type: ClarityType.Tuple }, + }); + })); + }); +}); + +describe("custom arbitraries for 128-bit unsigned/signed integers", () => { + it("generates 128-bit unsigned integers in range [0, 2^128 - 1]", () => { + fc.assert(fc.property(uint128, (actual) => + actual >= BigInt(0) && + actual <= BigInt("340282366920938463463374607431768211455") + )); + }); + + it("generates 128-bit signed integers in range [-2^127, 2^127 - 1]", () => { + fc.assert(fc.property(int128, (actual) => + actual >= BigInt("-170141183460469231731687303715884105728") && + actual <= BigInt( "170141183460469231731687303715884105727") + )); + }); +}); diff --git a/contrib/core-contract-tests/tests/clar/clarity-parser-string-to-cv.test.ts b/contrib/core-contract-tests/tests/clar/clarity-parser-string-to-cv.test.ts new file mode 100644 index 0000000000..bbaece5f67 --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clarity-parser-string-to-cv.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { stringToCV } from "./utils/string-to-cv"; +import { ClarityType, intCV, stringUtf8CV, uintCV } from "@stacks/transactions"; + +describe("verify string to cv conversion", () => { + it("should convert string to cv", () => { + const result = stringToCV("hello", { "string-utf8": { length: 100 } }); + expect(result).toEqual({ + type: "string", + value: stringUtf8CV("hello"), + }); + }); + + it("should convert uint to cv", () => { + const result = stringToCV("u12345", "uint128"); + expect(result).toEqual({ + type: "uint", + value: uintCV(12345), + }); + }); + + it("should convert tuple to cv", () => { + const result = stringToCV("{a: 12345}", { + tuple: [{ name: "a", type: "int128" }], + }); + expect(result).toEqual({ + type: "tuple", + value: { data: { a: intCV(12345) }, type: ClarityType.Tuple }, + }); + }); +}); diff --git a/contrib/core-contract-tests/tests/clar/clarity-parser.prop.test.ts b/contrib/core-contract-tests/tests/clar/clarity-parser.prop.test.ts new file mode 100644 index 0000000000..8b4b47cbc1 --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clarity-parser.prop.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { extractTestAnnotations } from "./utils/clarity-parser"; +import fc from "fast-check"; + +describe("verify clarity parser", () => { + it("should handle arbitrary inputs gracefully without crashing", () => { + fc.assert(fc.property(fc.string(), (arbitrary) => { + const result = extractTestAnnotations(arbitrary); + expect(result).toEqual({}); + })); + }); + + const generators = fc.record({ + // Generates a 'name' string that starts with a letter followed by up to 9 + // alphanumeric characters, and can optionally include up to two additional + // words, each separated by a space and up to 10 characters long. + "name": fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}( [a-zA-Z0-9]{1,10}){0,2}$/), + + // Generates a 'description' string with the same pattern as 'name'. + "description": fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}( [a-zA-Z0-9]{1,10}){0,2}$/), + + // Generates a 'mineBefore' string representing the number of blocks before + // a transaction is mined (as a positive integer). + "mineBefore": fc.integer({ min: 1 }).map(String), + + // Generates a 'caller' string that is either "wallet_" followed by a number, + // "faucet", or "deployer". + "caller": fc.stringMatching(/^(wallet_\d+|faucet|deployer)$/), + + // Generates a 'functionName' string similar to 'name', but words are + // separated by dashes instead of spaces. + "functionName": fc.stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,9}(-[a-zA-Z0-9]{1,10}){0,2}$/), + }); + + it("should parse with simple annotations", () => { + fc.assert(fc.property(generators, (expected) => { + const result = extractTestAnnotations( +` +;; @name ${expected.name} +(define-public (${expected.functionName}) + ;; @mine-before is ignored here + (ok true)) +` + ); + expect(result[expected.functionName]).toEqual({ + name: expected.name, + }); + })); + }); + + it("should parse with all annotations", () => { + fc.assert(fc.property(fc.array(generators), (array) => { + const contractSource = array + .map((expected) => +` +;; @name ${expected.name} +;; @description ${expected.description} +;; @mine-before ${expected.mineBefore} +;; @caller ${expected.caller} +(define-public (${expected.functionName}) + (ok true)) +` + ) + .join(); + + const result = extractTestAnnotations(contractSource); + + array.forEach(expected => { + expect(result[expected.functionName]).toEqual({ + name : expected.name, + description : expected.description, + "mine-before": expected.mineBefore, + caller : expected.caller, + }); + }); + })); + }); +}); diff --git a/contrib/core-contract-tests/tests/clar/clarity-parser.test.ts b/contrib/core-contract-tests/tests/clar/clarity-parser.test.ts new file mode 100644 index 0000000000..36c7655adb --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/clarity-parser.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import * as fs from "fs"; +import path from "path"; +import { extractTestAnnotations } from "./utils/clarity-parser"; + +describe("verify clarity parser", () => { + it("should parse without annotations", () => { + const result = extractTestAnnotations( + fs.readFileSync( + path.join( + __dirname, + "../../contracts/parser-tests/no-annotations.clar" + ), + "utf8" + ) + ); + expect(result).toEqual({}); + }); + + it("should parse with simple annotations", () => { + const result = extractTestAnnotations( + fs.readFileSync( + path.join( + __dirname, + "../../contracts/parser-tests/simple-annotations.clar" + ), + "utf8" + ) + ); + expect(result["test-simple-annotations"]).toEqual({ + name: "simple annotation test", + }); + }); + + it("should parse with all annotations", () => { + const result = extractTestAnnotations( + fs.readFileSync( + path.join( + __dirname, + "../../contracts/parser-tests/all-annotations.clar" + ), + "utf8" + ) + ); + expect(result["test-all-annotations-1"]).toEqual({ + caller: "wallet_1", + description: "all annotation test", + "mine-before": "10", + name: "all annotation test", + }); + + expect(result["test-all-annotations-2"]).toEqual({ + caller: "wallet_2", + description: "all annotation test 2", + "mine-before": "20", + name: "all annotation test 2", + }); + }); + + it("should parse with bad annotations", () => { + const result = extractTestAnnotations( + fs.readFileSync( + path.join( + __dirname, + "../../contracts/parser-tests/bad-annotations.clar" + ), + "utf8" + ) + ); + expect(result["test-bad-annotations"]).toEqual({ + namexx: "bad annotation test", + callerxx: "wallet_1", + "mine-after": "10", + }); + }); +}); diff --git a/contrib/core-contract-tests/tests/clar/utils/clarity-parser-flow-tests.ts b/contrib/core-contract-tests/tests/clar/utils/clarity-parser-flow-tests.ts new file mode 100644 index 0000000000..5504d246af --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/utils/clarity-parser-flow-tests.ts @@ -0,0 +1,201 @@ +import { ClarityValue } from "@stacks/transactions"; +import { stringToCV } from "./string-to-cv"; +export type FunctionAnnotations = { [key: string]: string | boolean }; +export type FunctionBody = { + callAnnotations: FunctionAnnotations[]; + callInfo: CallInfo; +}[]; + +export type ContractCall = { + callAnnotations: FunctionAnnotations; + callInfo: CallInfo; +}; + +export type CallInfo = { + contractName: string; + functionName: string; + args: { type: string; value: ClarityValue }[]; +}; + +const functionRegex = + /^([ \t]{0,};;[ \t]{0,}@[^()]+?)\n[ \t]{0,}\(define-public[\s]+\((.+?)[ \t|)]/gm; +const annotationsRegex = /^;;[ \t]{1,}@([a-z-]+)(?:$|[ \t]+?(.+?))$/; + +const callRegex = + /\n*^([ \t]{0,};;[ \t]{0,}@[\s\S]+?)\n[ \t]{0,}(\((?:[^()]*|\((?:[^()]*|\([^()]*\))*\))*\))/gm; + +/** + * Parser function for flow unit tests. + * + * Flow unit tests can be used for tx calls where + * the tx-sender should be equal to the contract-caller. + * + * Takes the whole contract source and returns an object containing + * the function annotations and function bodies for each function. + * @param contractSource + * @returns + */ +export function extractTestAnnotationsAndCalls(contractSource: string) { + const functionAnnotations: any = {}; + const functionBodies: any = {}; + contractSource = contractSource.replace(/\r/g, ""); + const matches1 = contractSource.matchAll(functionRegex); + + let indexStart: number = -1; + let headerLength: number = 0; + let indexEnd: number = -1; + let lastFunctionName: string = ""; + let contractCalls: { + callAnnotations: FunctionAnnotations; + callInfo: CallInfo; + }[]; + for (const [functionHeader, comments, functionName] of matches1) { + if (functionName.substring(0, 5) !== "test-") continue; + functionAnnotations[functionName] = {}; + const lines = comments.split("\n"); + for (const line of lines) { + const [, prop, value] = line.match(annotationsRegex) || []; + if (prop) functionAnnotations[functionName][prop] = value ?? true; + } + if (indexStart < 0) { + indexStart = contractSource.indexOf(functionHeader); + headerLength = functionHeader.length; + lastFunctionName = functionName; + } else { + indexEnd = contractSource.indexOf(functionHeader); + const lastFunctionBody = contractSource.substring( + indexStart + headerLength, + indexEnd + ); + + // add contracts calls in functions body for last function + contractCalls = extractContractCalls(lastFunctionBody); + + functionBodies[lastFunctionName] = contractCalls; + indexStart = indexEnd; + headerLength = functionHeader.length; + lastFunctionName = functionName; + } + } + const lastFunctionBody = contractSource.substring(indexStart + headerLength); + contractCalls = extractContractCalls(lastFunctionBody); + functionBodies[lastFunctionName] = contractCalls; + + return [functionAnnotations, functionBodies]; +} + +/** + * Takes a string and returns an array of objects containing + * the call annotations and call info within the function body. + * + * The function body should look like this + * (begin + * ... lines of code.. + * (ok true)) + * + * Only two lines of code are accepted: + * 1. (unwrap! (contract-call? .contract-name function-name args)) + * 2. (try! (function-name)) + * @param lastFunctionBody + * @returns + */ +export function extractContractCalls(lastFunctionBody: string) { + const calls = lastFunctionBody.matchAll(callRegex); + const contractCalls: ContractCall[] = []; + for (const [, comments, call] of calls) { + const callAnnotations: FunctionAnnotations = {}; + const lines = comments.split("\n"); + for (const line of lines) { + const [, prop, value] = line.trim().match(annotationsRegex) || []; + if (prop) callAnnotations[prop] = value ?? true; + } + // try to extract call info from (unwrap! (contract-call? ...)) + let callInfo = extractUnwrapInfo(call); + if (!callInfo) { + // try to extract call info from (try! (my-function)) + callInfo = extractCallInfo(call); + } + if (callInfo) { + contractCalls.push({ callAnnotations, callInfo }); + } else { + throw new Error(`Could not extract call info from ${call}`); + } + } + return contractCalls; +} + +/** + * handle (unwrap! (contract-call? ...)) statements + * @param statement + * @returns + */ +function extractUnwrapInfo(statement: string): CallInfo | null { + const match = statement.match( + /\(unwrap! \(contract-call\? \.(.+?) (.+?)(( .+?)*)\)/ + ); + if (!match) return null; + + const contractName = match[1]; + const functionName = match[2]; + const argStrings = splitArgs(match[3]); + let fn: any; + simnet.getContractsInterfaces().forEach((contract, contractFQN) => { + const [_, ctrName] = contractFQN.split("."); + if (ctrName === contractName) { + fn = contract.functions.find((f) => f.name === functionName); + if (!fn) { + throw `function ${functionName} not found in contract ${contractName}`; + } + } + }); + if (!fn) { + throw `function ${functionName} not found in contract ${contractName}`; + } + const args = fn.args.map((arg: any, index: number) => + stringToCV(argStrings[index], arg.type) + ); + + return { + contractName, + functionName, + args, + }; +} + + +function extractCallInfo(statement: string) { + const match = statement.match(/\(try! \((.+?)\)\)/); + if (!match) return null; + return { contractName: "", functionName: match[1], args: [] }; +} + + + +// take a string containing function arguments and +// split them correctly into an array of argument strings +function splitArgs(argString: string): string[] { + const splitArgs: string[] = []; + let argStart = 0; + let brackets = 0; // curly brackets + let rbrackets = 0; // round brackets + + for (let i = 0; i < argString.length; i++) { + const char = argString[i]; + + if (char === "{") brackets++; + if (char === "}") brackets--; + if (char === "(") rbrackets++; + if (char === ")") rbrackets--; + + const atLastChar = i === argString.length - 1; + if ((char === " " && brackets === 0 && rbrackets === 0) || atLastChar) { + const newArg = argString.slice(argStart, i + (atLastChar ? 1 : 0)); + if (newArg.trim()) { + splitArgs.push(newArg.trim()); + } + argStart = i + 1; + } + } + + return splitArgs; +} diff --git a/contrib/core-contract-tests/tests/clar/utils/clarity-parser.ts b/contrib/core-contract-tests/tests/clar/utils/clarity-parser.ts new file mode 100644 index 0000000000..5200d7c90b --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/utils/clarity-parser.ts @@ -0,0 +1,25 @@ +const functionRegex = + /^([ \t]{0,};;[ \t]{0,}@[^()]+?)\n[ \t]{0,}\(define-public[\s]+\((.+?)[ \t|)]/gm; +const annotationsRegex = /^;;[ \t]{1,}@([a-z-]+)(?:$|[ \t]+?(.+?))$/; + +/** + * Parser function for normal unit tests. + * + * Takes the whole contract source and returns an object containing + * the function annotations for each function + * @param contractSource + * @returns + */ +export function extractTestAnnotations(contractSource: string) { + const functionAnnotations: any = {}; + const matches = contractSource.replace(/\r/g, "").matchAll(functionRegex); + for (const [, comments, functionName] of matches) { + functionAnnotations[functionName] = {}; + const lines = comments.split("\n"); + for (const line of lines) { + const [, prop, value] = line.match(annotationsRegex) || []; + if (prop) functionAnnotations[functionName][prop] = value ?? true; + } + } + return functionAnnotations; +} diff --git a/contrib/core-contract-tests/tests/clar/utils/string-to-cv.ts b/contrib/core-contract-tests/tests/clar/utils/string-to-cv.ts new file mode 100644 index 0000000000..c6153cd121 --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/utils/string-to-cv.ts @@ -0,0 +1,129 @@ +import { hexToBytes } from "@stacks/common"; +import { Cl, ClarityValue } from "@stacks/transactions"; +import { buffer } from "@stacks/transactions/dist/cl"; + +export type ContractInterfaceTupleEntryType = { + name: string; + type: ContractInterfaceAtomType; +}; + +export type ContractInterfaceAtomType = + | "none" + | "int128" + | "uint128" + | "bool" + | "principal" + | { + buffer: { + length: number; + }; + } + | { + "string-utf8": { + length: number; + }; + } + | { + "string-ascii": { + length: number; + }; + } + | { + tuple: ContractInterfaceTupleEntryType[]; + } + | { + optional: ContractInterfaceAtomType; + } + | { + response: { + ok: ContractInterfaceAtomType; + error: ContractInterfaceAtomType; + }; + } + | { + list: { + type: ContractInterfaceAtomType; + length: number; + }; + } + | "trait_reference"; + +/** + * converts a string argument into a ClarityValue using the the type hint + * @param arg argument value as string + * @param type should be of type ContractInterfaceAtomType + * @returns + */ +export function stringToCV( + arg: string, + type: ContractInterfaceAtomType +): { type: string; value: ClarityValue } { + switch (type) { + case "uint128": + return { type: "uint", value: Cl.uint(arg.slice(1)) }; + case "int128": + return { type: "int", value: Cl.int(arg) }; + case "principal": + const [address, name] = arg.split("."); + return name + ? { type: "principal", value: Cl.contractPrincipal(address, name) } + : { type: "principal", value: Cl.standardPrincipal(address) }; + case "bool": + return { type: "bool", value: Cl.bool(arg === "true") }; + } + const typeDescriptor = Object.keys(type)[0]; + switch (typeDescriptor) { + case "buffer": + const hexValue = arg.toLowerCase().startsWith("0x") ? arg.slice(2) : arg; + return { type: "buffer", value: buffer(hexToBytes(hexValue)) }; + case "string-utf8": + return { type: "string", value: Cl.stringUtf8(arg) }; + case "string-ascii": + return { type: "string", value: Cl.stringAscii(arg) }; + case "tuple": + return { + type: "tuple", + value: parseTuple( + arg, + (type as { tuple: ContractInterfaceTupleEntryType[] }).tuple + ), + }; + case "optional": + if (arg === "none") { + return { + type: "none", + value: Cl.none(), + }; + } else { + return { + type: "some", + value: Cl.some( + stringToCV( + arg, + (type as { optional: ContractInterfaceAtomType }).optional + ).value + ), + }; + } + default: + throw new Error(`Unsupported type ${type}`); + } +} + +function parseTuple(tupleString: string, tupleEntries: any): ClarityValue { + const tupleItems: { [key: string]: ClarityValue } = {}; + tupleString + .slice(1, -1) + .split(",") + .map((item, index) => { + const [key, value] = item.split(":").map((s) => s.trim()); + const uintMatch = value.match(/u(\d+)/); + if (uintMatch) { + tupleItems[key] = Cl.uint(uintMatch[1]); + } else { + tupleItems[key] = stringToCV(value, tupleEntries[index].type).value; + } + }); + + return Cl.tuple(tupleItems); +} diff --git a/contrib/core-contract-tests/tests/clar/utils/test-helpers.ts b/contrib/core-contract-tests/tests/clar/utils/test-helpers.ts new file mode 100644 index 0000000000..7ac48d681a --- /dev/null +++ b/contrib/core-contract-tests/tests/clar/utils/test-helpers.ts @@ -0,0 +1,62 @@ +import { ParsedTransactionResult } from "@hirosystems/clarinet-sdk"; +import { Cl, ClarityType, cvToString } from "@stacks/transactions"; +import { expect } from "vitest"; + +/** + * checks whether the function is a valid test function starting with "test-" + * and not having any arguments + * @param functionCall + * @returns + */ +export function isValidTestFunction(functionCall: any) { + if (functionCall.name.startsWith("test-") && functionCall.args.length > 0) { + throw new Error("test functions must not have arguments"); + } + return ( + functionCall.name.startsWith("test-") && functionCall.args.length === 0 + ); +} + +/** + * expect the result of tx index in the given block to be (ok true) + * @param block + * @param contractFQN + * @param functionName + * @param index + */ +export function expectOkTrue( + block: ParsedTransactionResult[], + contractFQN: string, + functionName: string, + index: number = 0 +) { + if (block[index].result.type === ClarityType.ResponseErr) { + console.log(cvToString(block[index].result)); + } + expect( + block[index].result, + `${contractFQN}, ${functionName}, ${cvToString(block[index].result)}` + ).toBeOk(Cl.bool(true)); +} + +/** + * expect the result of tx index in the given block to be succesfull (ok ...) + * @param block + * @param contractFQN + * @param functionName + * @param index + */ +export function expectOk( + block: ParsedTransactionResult[], + contractFQN: string, + functionName: string, + index: number = 0 +) { + if (block[index].result.type === ClarityType.ResponseErr) { + console.log(cvToString(block[index].result)); + } + expect( + block[index].result.type, + `${contractFQN}, ${functionName}, ${cvToString(block[index].result)}` + ).toBe(ClarityType.ResponseOk); +}