Skip to content

Commit

Permalink
[sBTC DR] Add test for asset contract (#97)
Browse files Browse the repository at this point in the history
* feat: add test

* fix: use mint instead of mint!

* feat: improve print event, add more tests

* chore: add clarinet to CI

* fix: remove into_iter

* chore: add missing shell scripts

* chore: move bin folder to asset-contract

* chore: remove sip 10 dependency

* fix: remove extra space
  • Loading branch information
friedger authored Sep 5, 2023
1 parent 672f626 commit c8a7891
Show file tree
Hide file tree
Showing 20 changed files with 667 additions and 49 deletions.
38 changes: 37 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,40 @@ jobs:
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ target
Cargo.lock
coverage
romeo/testing/
romeo/asset-contract/.test
romeo/asset-contract/.coverage
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion romeo/asset-contract/Clarinet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down
32 changes: 32 additions & 0 deletions romeo/asset-contract/README.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 18 additions & 18 deletions romeo/asset-contract/contracts/asset.clar
Original file line number Diff line number Diff line change
@@ -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
;;
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions romeo/asset-contract/deployments/default.devnet-plan.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
id: 0
name: Devnet deployment
network: devnet
stacks-node: "http://localhost:20443"
bitcoin-node: "http://devnet:devnet@localhost:18443"
13 changes: 13 additions & 0 deletions romeo/asset-contract/ext/deps.ts
Original file line number Diff line number Diff line change
@@ -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);
}
121 changes: 121 additions & 0 deletions romeo/asset-contract/ext/generate-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Clarinet, Contract, Account } from 'https://deno.land/x/[email protected]/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<string, Account>, contracts: Map<string, Contract>) {
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<string, Account>) {
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());
}
});
`;
}
Loading

0 comments on commit c8a7891

Please sign in to comment.