diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 5dc8830e..77ab7b2d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -51,4 +51,40 @@ jobs: uses: codecov/codecov-action@v3 with: files: ./coverage/lcov.info - fail_ci_if_error: true \ No newline at end of file + fail_ci_if_error: true + + clarinet: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: rustfmt + override: true + + - name: Install clarinet develop version + run: chmod +x ./romeo/asset-contract/scripts/install_clarinet_action.sh && ./romeo/asset-contract/scripts/install_clarinet_action.sh + + - name: Run unit tests for romeo + working-directory: ./romeo/asset-contract + run: ./scripts/test.sh + + - name: Print coverage report + working-directory: ./romeo/asset-contract + run: sudo apt-get install -qq -y lcov html2text > /dev/null && genhtml --branch-coverage .coverage/lcov.info -o .coverage/ && html2text .coverage/contracts/index.html + + - name: "Export romeo code coverage" + uses: codecov/codecov-action@v3 + with: + directory: ./romeo/asset-contract/.coverage/ + files: lcov.info + verbose: false + flags: unittests + ignore: tests diff --git a/.gitignore b/.gitignore index a244797a..f9b80211 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ target Cargo.lock coverage romeo/testing/ +romeo/asset-contract/.test +romeo/asset-contract/.coverage diff --git a/README.md b/README.md index 15d17501..897636db 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ This repository uses the task runner cargo-make to manage its build scripts and cargo install --version 0.36.13 cargo-make ``` +Also verify that openssl is install on your machine. + [coverage-badge]: https://codecov.io/github/stacks-network/sbtc/branch/master/graph/badge.svg?token=2sbE9YLwT6 [coverage-link]: https://codecov.io/github/stacks-network/sbtc [discord-badge]: https://img.shields.io/static/v1?logo=discord&label=discord&message=Join&color=blue diff --git a/romeo/asset-contract/Clarinet.toml b/romeo/asset-contract/Clarinet.toml index 76e840ee..8c1b367f 100644 --- a/romeo/asset-contract/Clarinet.toml +++ b/romeo/asset-contract/Clarinet.toml @@ -6,13 +6,18 @@ telemetry = true cache_dir = './.cache' [[project.requirements]] -contract_id = "SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard" +contract_id = "ST1NXBK3K5YYMD6FD41MVNP3JS1GABZ8TRVX023PT.sip-010-trait-ft-standard" [contracts.asset] path = 'contracts/asset.clar' clarity_version = 2 epoch = 2.4 +[contracts.asset_test] +path = 'tests/asset_test.clar' +clarity_version = 2 +epoch = 2.4 + [repl.analysis] passes = ['check_checker'] diff --git a/romeo/asset-contract/README.md b/romeo/asset-contract/README.md new file mode 100644 index 00000000..8d1ec0cc --- /dev/null +++ b/romeo/asset-contract/README.md @@ -0,0 +1,32 @@ +# Clarity contracts for sBTC DR 0.1 + +This folder contains clarity contracts and tools for clarity supporting the sBTC DR 0.1 (Romeo). + +## Contract `asset.clar` +sBTC is a wrapped BTC asset on Stacks. + +It is a fungible token (SIP-10) that is backed 1:1 by BTC +For this version the wallet is controlled by a centralized entity. +sBTC is minted when BTC is deposited into the wallet and +burned when BTC is withdrawn from the wallet. + +Requests for minting and burning are made by the contract owner. + +## Getting started for developers +See https://stacks-network.github.io/sbtc-docs/ + +## Contributing + +### Unit tests +Tests are written in clarity. It requires `clarinet`. You can install the lastest version via `./scripts/install_clarinet_*`. + +Run the unit tests through the script +``` +./scripts/tests.sh +``` + +### Dependencies +When updating the version of clarinet deno library for tests make sure to update +* deps.ts +* generate-test-ts +* install_clarinet_action.sh \ No newline at end of file diff --git a/romeo/asset-contract/contracts/asset.clar b/romeo/asset-contract/contracts/asset.clar index d6e6b65a..9754c5a5 100644 --- a/romeo/asset-contract/contracts/asset.clar +++ b/romeo/asset-contract/contracts/asset.clar @@ -1,20 +1,20 @@ -;; title: asset -;; version: +;; title: wrapped BTC on Stacks +;; version: 0.1.0 ;; summary: sBTC dev release asset contract -;; description: - -;; traits -;; -(impl-trait 'ST1NXBK3K5YYMD6FD41MVNP3JS1GABZ8TRVX023PT.sip-010-trait-ft-standard.sip-010-trait) +;; description: sBTC is a wrapped BTC asset on Stacks. +;; It is a fungible token (SIP-10) that is backed 1:1 by BTC +;; For this version the wallet is controlled by a centralized entity. +;; sBTC is minted when BTC is deposited into the wallet and +;; burned when BTC is withdrawn from the wallet. +;; Requests for minting and burning are made by the contract owner. ;; token definitions ;; -(define-fungible-token sbtc u21000000) +(define-fungible-token sbtc u21000000000000) ;; constants ;; -(define-constant err-invalid-caller u1) -(define-constant err-not-token-owner u2) +(define-constant err-forbidden (err u403)) ;; data vars ;; @@ -25,32 +25,32 @@ ;; (define-public (set-bitcoin-wallet-public-key (public-key (buff 33))) (begin - (asserts! (is-contract-owner) (err err-invalid-caller)) + (asserts! (is-contract-owner) err-forbidden) (ok (var-set bitcoin-wallet-public-key (some public-key))) ) ) -(define-public (mint! (amount uint) (dst principal) (deposit-txid (string-ascii 72))) +(define-public (mint (amount uint) (dst principal) (deposit-txid (string-ascii 72))) (begin - (asserts! (is-contract-owner) (err err-invalid-caller)) + (asserts! (is-contract-owner) err-forbidden) ;; TODO #79: Assert deposit-txid exists on chain - (print deposit-txid) + (print {notification: "mint", payload: deposit-txid}) (ft-mint? sbtc amount dst) ) ) -(define-public (burn! (amount uint) (src principal) (withdraw-txid (string-ascii 72))) +(define-public (burn (amount uint) (src principal) (withdraw-txid (string-ascii 72))) (begin - (asserts! (is-contract-owner) (err err-invalid-caller)) + (asserts! (is-contract-owner) err-forbidden) ;; TODO #79: Assert withdraw-txid exists on chain - (print withdraw-txid) + (print {notification: "burn", payload: withdraw-txid}) (ft-burn? sbtc amount src) ) ) (define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) (begin - (asserts! (is-eq tx-sender sender) (err err-not-token-owner)) + (asserts! (is-eq tx-sender sender) err-forbidden) (try! (ft-transfer? sbtc amount sender recipient)) (match memo to-print (print to-print) 0x) (ok true) diff --git a/romeo/asset-contract/deployments/default.devnet-plan.yaml b/romeo/asset-contract/deployments/default.devnet-plan.yaml new file mode 100644 index 00000000..33c4aa81 --- /dev/null +++ b/romeo/asset-contract/deployments/default.devnet-plan.yaml @@ -0,0 +1,6 @@ +--- +id: 0 +name: Devnet deployment +network: devnet +stacks-node: "http://localhost:20443" +bitcoin-node: "http://devnet:devnet@localhost:18443" diff --git a/romeo/asset-contract/ext/deps.ts b/romeo/asset-contract/ext/deps.ts new file mode 100644 index 00000000..012d79af --- /dev/null +++ b/romeo/asset-contract/ext/deps.ts @@ -0,0 +1,13 @@ +export function getContractName(contractId: string) { + return contractId.split('.')[1]; +} + +export function isTestContract(contractName: string) { + return contractName.substring(contractName.length - 5) === "_test"; +} + +/*eslint @typescript-eslint/no-explicit-any: ["error", { "ignoreRestArgs": true }]*/ +export function exitWithError(...args: any[]) { + console.error(...args); + Deno.exit(1); +} diff --git a/romeo/asset-contract/ext/generate-tests.ts b/romeo/asset-contract/ext/generate-tests.ts new file mode 100644 index 00000000..487463bf --- /dev/null +++ b/romeo/asset-contract/ext/generate-tests.ts @@ -0,0 +1,121 @@ +import { Clarinet, Contract, Account } from 'https://deno.land/x/clarinet@v1.7.1/index.ts'; +import { extractTestAnnotations, getContractName } from './utils/clarity-parser.ts'; +import { defaultDeps, generateBootstrapFile, warningText } from './utils/generate.ts'; + +const sourcebootstrapFile = './tests/bootstrap.ts'; +const targetFolder = '.test'; + +function isTestContract(contractName: string) { + return contractName.substring(contractName.length - 5) === "_test" && + contractName.substring(contractName.length - 10) !== "_flow_test"; +} + +Clarinet.run({ + async fn(accounts: Map, contracts: Map) { + Deno.writeTextFile(`${targetFolder}/deps.ts`, defaultDeps); + Deno.writeTextFile(`${targetFolder}/bootstrap.ts`, await generateBootstrapFile(sourcebootstrapFile)); + + for (const [contractId, contract] of contracts) { + console.log(contractId); + const contractName = getContractName(contractId); + if (!isTestContract(contractName)) + continue; + + const hasDefaultPrepareFunction = contract.contract_interface.functions.reduce( + (a, v) => a || (v.name === 'prepare' && v.access === 'public' && v.args.length === 0), + false); + const annotations = extractTestAnnotations(contract.source); + + const code: string[][] = []; + code.push([ + warningText, + ``, + `import { Clarinet, Tx, Chain, Account, types, assertEquals, printEvents } from './deps.ts';`, + `import { bootstrap } from './bootstrap.ts';`, + `` + ]); + + for (const { name, access, args } of contract.contract_interface.functions.reverse()) { + if (access !== 'public' || name.substring(0, 5) !== 'test-') + continue; + if (args.length > 0) + throw new Error(`Test functions cannot take arguments. (Offending function: ${name})`); + const functionAnnotations = annotations[name] || {}; + if (hasDefaultPrepareFunction && !functionAnnotations.prepare) + functionAnnotations.prepare = 'prepare'; + if (functionAnnotations['no-prepare']) + delete functionAnnotations.prepare; + code.push([generateTest(contractId, name, functionAnnotations)]); + } + + Deno.writeTextFile(`${targetFolder}/${contractName}.ts`, code.flat().join("\n")); + } + } +}); + +type FunctionAnnotations = { [key: string]: string | boolean }; + +// generates contract call ts code for prepare function in mineBlock +function generatePrepareTx(contractPrincipal: string, annotations: FunctionAnnotations) { + return `Tx.contractCall('${contractPrincipal}', '${annotations['prepare']}', [], deployer.address)`; +} + +/** + * generates a mineBlock ts code containing optional prepare function + * and the test function call + */ +function generateNormalMineBlock(contractPrincipal: string, testFunction: string, annotations: FunctionAnnotations) { + return `let block = chain.mineBlock([ + ${annotations['prepare'] ? `${generatePrepareTx(contractPrincipal, annotations)},` : ''} + Tx.contractCall('${contractPrincipal}', '${testFunction}', [], callerAddress) + ]);`; +} + +/** + * Generates a mineBlock ts code containing + * - optional block with prepare function, + * - several empty blocks and + * - the test function call + * + * supports the `@print events` annotations + */ +function generateSpecialMineBlock(mineBlocksBefore: number, contractPrincipal: string, testFunction: string, annotations: FunctionAnnotations) { + let code = ``; + if (annotations['prepare']) { + code = `let prepareBlock = chain.mineBlock([${generatePrepareTx(contractPrincipal, annotations)}]); + prepareBlock.receipts.map(({result}) => result.expectOk()); + `; + if (annotations['print'] === 'events') + code += `\n\t\tprintEvents(prepareBlock);\n`; + } + if (mineBlocksBefore > 1) + code += ` + chain.mineEmptyBlock(${mineBlocksBefore - 1});`; + return `${code} + let block = chain.mineBlock([Tx.contractCall('${contractPrincipal}', '${testFunction}', [], callerAddress)]); + ${annotations['print'] === 'events' ? 'printEvents(block);' : ''}`; +} + +/** + * Generates the ts code for a unit test + * @param contractPrincipal + * @param testFunction + * @param annotations + * @returns + */ +function generateTest(contractPrincipal: string, testFunction: string, annotations: FunctionAnnotations) { + const mineBlocksBefore = parseInt(annotations['mine-blocks-before'] as string) || 0; + return `Clarinet.test({ + name: "${annotations.name ? testFunction + ': ' + (annotations.name as string).replace(/"/g, '\\"') : testFunction}", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get("deployer")!; + bootstrap && bootstrap(chain, deployer); + const callerAddress = ${annotations.caller ? (annotations.caller[0] === "'" ? `"${(annotations.caller as string).substring(1)}"` : `accounts.get('${annotations.caller}')!.address`) : `accounts.get('deployer')!.address`}; + ${mineBlocksBefore >= 1 + ? generateSpecialMineBlock(mineBlocksBefore, contractPrincipal, testFunction, annotations) + : generateNormalMineBlock(contractPrincipal, testFunction, annotations)} + block.receipts.map(({result}) => result.expectOk()); + } +}); +`; +} diff --git a/romeo/asset-contract/ext/utils/clarity-parser.ts b/romeo/asset-contract/ext/utils/clarity-parser.ts new file mode 100644 index 00000000..a6cc968a --- /dev/null +++ b/romeo/asset-contract/ext/utils/clarity-parser.ts @@ -0,0 +1,241 @@ + + +export type FunctionAnnotations = { [key: string]: string | boolean }; +export type FunctionBody = { + callAnnotations: FunctionAnnotations[]; + callInfo: CallInfo; +}[]; + +export type CallInfo = { + contractName: string; + functionName: string; + args: { type: string; value: string }[]; +}; + +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 = {}; + 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; +} + + + + +/** + * Parser function for flow unit tests. + * + * Flow unit tests can be used for tx calls are required 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 = {}; + const functionBodies = {}; + 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]; +} + +const callRegex = + /\n*^([ \t]{0,};;[ \t]{0,}@[\s\S]+?)\n[ \t]{0,}(\((?:[^()]*|\((?:[^()]*|\([^()]*\))*\))*\))/gm; + +/** + * 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: { + callAnnotations: FunctionAnnotations; + callInfo: CallInfo; + }[] = []; + for (const [, comments, call] of calls) { + const callAnnotations = {}; + 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; + } + + // 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; + } + + function parseTuple(tupleString: string): string { + const tupleItems = tupleString + .slice(1, -1) + .split(",") + .map((item) => { + const [key, value] = item.split(":").map((s) => s.trim()); + const uintMatch = value.match(/u(\d+)/); + if (uintMatch) { + return `"${key}": types.uint(${uintMatch[1]})`; + } else { + return `${key}: "${value}"`; + } + }) + .join(", "); + + return `types.tuple({${tupleItems}})`; + } + + 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]); + + const args = argStrings.map((arg) => parseArg(arg)); + + return { + contractName, + functionName, + args, + }; + } + + function parseArg(arg:string): {type:string, value: string} { + if (arg.startsWith("'")) { + return { type: "principal", value: `types.principal("${arg.slice(1)}")` }; + } else if (arg.startsWith("u")) { + return { type: "uint", value: `types.uint(${arg.slice(1)})` }; + } else if (arg.startsWith("{")) { + return { type: "tuple", value: parseTuple(arg) }; + } else if (arg.startsWith("(some ")) { + return { type: "some", value: `types.some(${parseArg(arg.substring(6, arg.length)).value})`} + } else if (arg === "none") { + return { type: "none", value: "types.none()" }; + } else { + return { type: "raw", value: `"${arg}"` }; + } + } + + function extractCallInfo(statement: string) { + const match = statement.match(/\(try! \((.+?)\)\)/); + if (!match) return null; + return { contractName: "", functionName: match[1], args: [] }; + } + +export function getContractName(contractId: string) { + return contractId.split(".")[1]; +} diff --git a/romeo/asset-contract/ext/utils/generate.ts b/romeo/asset-contract/ext/utils/generate.ts new file mode 100644 index 00000000..63a2a7fd --- /dev/null +++ b/romeo/asset-contract/ext/utils/generate.ts @@ -0,0 +1,32 @@ + +export const warningText = `// Code generated using \`clarinet run ./scripts/tests.ts\` +// Manual edits will be lost.`; + + +export const defaultDeps = `import { Clarinet, Tx, Chain, Account, Block, types } from 'https://deno.land/x/clarinet@v1.7.1/index.ts'; +import { assertEquals } from 'https://deno.land/std@0.170.0/testing/asserts.ts'; + +export { Clarinet, Tx, Chain, types, assertEquals }; +export type { Account }; + +const dirOptions = { strAbbreviateSize: Infinity, depth: Infinity, colors: true }; + +export function printEvents(block: Block) { + block.receipts.map(({ events }) => events && events.map(event => console.log(Deno.inspect(event, dirOptions)))); +} +`; + +// generates ts code for a module with a bootstrap function +// that can be optionally defined at the provided path. +export async function generateBootstrapFile(bootstrapFile?: string) { + let bootstrapSource = 'export function bootstrap(){}'; + if (bootstrapFile) { + try { + bootstrapSource = await Deno.readTextFile(bootstrapFile); + } + catch (error) { + console.error(`Could not read bootstrap file ${bootstrapFile}`, error); + } + } + return `${warningText}\n\n${bootstrapSource}`; +} \ No newline at end of file diff --git a/romeo/asset-contract/scripts/coverage-report.sh b/romeo/asset-contract/scripts/coverage-report.sh new file mode 100755 index 00000000..8a9950f8 --- /dev/null +++ b/romeo/asset-contract/scripts/coverage-report.sh @@ -0,0 +1,3 @@ +#!/bin/sh +genhtml .coverage/lcov.info --branch-coverage -o .coverage/ +open .coverage/index.html diff --git a/romeo/asset-contract/scripts/install_clarinet_action.sh b/romeo/asset-contract/scripts/install_clarinet_action.sh new file mode 100755 index 00000000..0a5a48a0 --- /dev/null +++ b/romeo/asset-contract/scripts/install_clarinet_action.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +apt-get update +apt-get install -y unzip wget + +wget -nv https://github.com/hirosystems/clarinet/releases/download/v1.7.1/clarinet-linux-x64-glibc.tar.gz -O clarinet-linux-x64.tar.gz +tar -xf clarinet-linux-x64.tar.gz +chmod +x ./clarinet +mv ./clarinet /usr/local/bin diff --git a/romeo/asset-contract/scripts/install_clarinet_mac.sh b/romeo/asset-contract/scripts/install_clarinet_mac.sh new file mode 100755 index 00000000..69663962 --- /dev/null +++ b/romeo/asset-contract/scripts/install_clarinet_mac.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +if ! command -v brew &> /dev/null +then + echo "brew could not be found, please install it first. See https://brew.sh/" + exit +fi + +brew install clarinet \ No newline at end of file diff --git a/romeo/asset-contract/scripts/install_clarinet_ubuntu.sh b/romeo/asset-contract/scripts/install_clarinet_ubuntu.sh new file mode 100755 index 00000000..3dda23a9 --- /dev/null +++ b/romeo/asset-contract/scripts/install_clarinet_ubuntu.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +apt-get update +apt-get install -y unzip curl lcov + +curl -s https://api.github.com/repos/hirosystems/clarinet/releases/latest | grep "/clarinet-linux-x64-glibc.tar.gz" | cut -d : -f 2,3 | tr -d \" | wget -qi - +tar -xzf clarinet-linux-x64-glibc.tar.gz +chmod +x ./clarinet +mv ./clarinet /usr/local/bin diff --git a/romeo/asset-contract/scripts/test.sh b/romeo/asset-contract/scripts/test.sh new file mode 100755 index 00000000..1c365a6d --- /dev/null +++ b/romeo/asset-contract/scripts/test.sh @@ -0,0 +1,6 @@ +#!/bin/sh +mkdir -p .test +mkdir -p .coverage +rm -r .test/* +clarinet run --allow-write --allow-read ext/generate-tests.ts +clarinet test --coverage .coverage/lcov.info .test diff --git a/romeo/asset-contract/tests/asset_test.clar b/romeo/asset-contract/tests/asset_test.clar new file mode 100644 index 00000000..d9150aaf --- /dev/null +++ b/romeo/asset-contract/tests/asset_test.clar @@ -0,0 +1,119 @@ +(define-constant wallet-1 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5) +(define-constant wallet-2 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG) +(define-constant test-mint-amount u10000000) +(define-constant expected-total-supply (* u2 test-mint-amount)) +(define-constant expected-token-uri (some u"https://assets.stacks.co/sbtc.pdf")) +(define-constant expected-name "sBTC") +(define-constant expected-symbol "sBTC") +(define-constant expected-decimals u8) + +(define-constant err-forbidden (err u403)) + +(define-private (assert-eq (result (response bool uint)) (compare (response bool uint)) (message (string-ascii 100))) + (ok (asserts! (is-eq result compare) (err message))) +) + +(define-private (assert-eq-string (result (response (string-ascii 32) uint)) (compare (response (string-ascii 32) uint)) (message (string-ascii 100))) + (ok (asserts! (is-eq result compare) (err message))) +) + +(define-private (assert-eq-uint (result (response uint uint)) (compare (response uint uint)) (message (string-ascii 100))) + (ok (asserts! (is-eq result compare) (err message))) +) + +;; Prepare function called for all tests (unless overridden) +(define-public (prepare) + (begin + ;; Mint some tokens to test principals. + (try! (contract-call? .asset mint test-mint-amount wallet-1 "a txid 1")) + (try! (contract-call? .asset mint test-mint-amount wallet-2 "a txid_2")) + (ok true) + ) +) + + +;; --- Protocol tests + +;; @name Protocol can mint tokens +;; @no-prepare +;; @caller deployer +(define-public (test-protocol-mint) + (contract-call? .asset mint u10000000 wallet-1 "a txid") +) + +;; @name Non-protocol contracts cannot mint tokens +;; @no-prepare +;; @caller wallet_1 +(define-public (test-protocol-mint-external) + (assert-eq (contract-call? .asset mint u10000000 wallet-1 "a txid") err-forbidden "Should have failed") +) + +;; @name Protocol can burn tokens +;; @caller deployer +(define-public (test-protocol-burn) + (contract-call? .asset burn u10000000 wallet-1 "a txid") +) + +;; @name Non-protocol contracts cannot burn tokens +;; @caller wallet_1 +(define-public (test-protocol-burn-external) + (assert-eq (contract-call? .asset burn u10000000 wallet-1 "a txid") err-forbidden "Should have failed") +) + +;; @name Protocol can set wallet address +;; @no-prepare +;; @caller deployer +(define-public (test-protocol-set-wallet-public-key) + (contract-call? .asset set-bitcoin-wallet-public-key 0x1234) +) + +;; @name Non-protocol contracts cannot mint tokens +;; @no-prepare +;; @caller wallet_1 +(define-public (test-protocol-set-wallet-public-key-external) + (assert-eq (contract-call? .asset set-bitcoin-wallet-public-key 0x1234) err-forbidden "Should have returned err forbidden") +) + +;; --- SIP010 tests + +;; @name Token owner can transfer their tokens +;; @caller wallet_1 +(define-public (test-transfer) + (contract-call? .asset transfer u100 tx-sender wallet-2 none) +) + +;; @name Cannot transfer someone else's tokens +;; @caller wallet_1 +(define-public (test-transfer-external) + (assert-eq (contract-call? .asset transfer u100 wallet-2 tx-sender none) err-forbidden "Should have failed") +) + +;; @name Can get name +(define-public (test-get-name) + (assert-eq-string (contract-call? .asset get-name) (ok expected-name) "Name does not match") +) + +;; @name Can get symbol +(define-public (test-get-symbol) + (assert-eq-string (contract-call? .asset get-symbol) (ok expected-symbol) "Symbol does not match") +) + +;; @name Can get decimals +(define-public (test-get-decimals) + (assert-eq-uint (contract-call? .asset get-decimals) (ok expected-decimals) "Decimals do not match") +) + +;; @name Can user balance +(define-public (test-get-balance) + (assert-eq-uint (contract-call? .asset get-balance wallet-1) (ok test-mint-amount) "Balance does not match") +) + +;; @name Can get total supply +(define-public (test-get-total-supply) + (assert-eq-uint (contract-call? .asset get-total-supply) (ok expected-total-supply) "Total supply does not match") +) + +;; @name Can get token URI +(define-public (test-get-token-uri) + (ok (asserts! (is-eq (contract-call? .asset get-token-uri) (ok expected-token-uri)) (err "Token uri does not match"))) +) diff --git a/romeo/asset-contract/tests/asset_test.ts b/romeo/asset-contract/tests/asset_test.ts deleted file mode 100644 index f061691c..00000000 --- a/romeo/asset-contract/tests/asset_test.ts +++ /dev/null @@ -1,26 +0,0 @@ - -import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v1.7.1/index.ts'; -import { assertEquals } from 'https://deno.land/std@0.170.0/testing/asserts.ts'; - -Clarinet.test({ - name: "Ensure that <...>", - async fn(chain: Chain, accounts: Map) { - // arrange: set up the chain, state, and other required elements - let wallet_1 = accounts.get("wallet_1")!; - - // act: perform actions related to the current test - let block = chain.mineBlock([ - /* - * Add transactions with: - * Tx.contractCall(...) - */ - ]); - - // assert: review returned data, contract state, and other requirements - assertEquals(block.receipts.length, 0); - assertEquals(block.height, 2); - - // TODO - assertEquals("TODO", "a complete test"); - }, -}); diff --git a/romeo/src/system.rs b/romeo/src/system.rs index 4a9d0b71..0db91e7a 100644 --- a/romeo/src/system.rs +++ b/romeo/src/system.rs @@ -198,7 +198,7 @@ async fn mint_asset(config: &Config, client: LockedClient, deposit_info: Deposit let tx_payload = TransactionPayload::ContractCall(TransactionContractCall { address: config.stacks_address(), contract_name: config.contract_name.clone(), - function_name: ClarityName::from("mint!"), + function_name: ClarityName::from("mint"), function_args, }); diff --git a/sbtc-core/src/operations/op_return/deposit.rs b/sbtc-core/src/operations/op_return/deposit.rs index 609f5c72..1a7fd9c9 100644 --- a/sbtc-core/src/operations/op_return/deposit.rs +++ b/sbtc-core/src/operations/op_return/deposit.rs @@ -131,8 +131,8 @@ impl Deposit { return Err(DepositParseError::NotSbtcOp); }; - let Some(Ok(Instruction::PushBytes(mut data))) = instructions_iter.next() else { - return Err(DepositParseError::NotSbtcOp) + let Some(Ok(Instruction::PushBytes(mut data))) = instructions_iter.next() else { + return Err(DepositParseError::NotSbtcOp); }; let deposit_data = DepositOutputData::codec_deserialize(&mut data)