diff --git a/packages/demo-staking/.eslintignore b/packages/demo-staking/.eslintignore new file mode 120000 index 00000000..86039baf --- /dev/null +++ b/packages/demo-staking/.eslintignore @@ -0,0 +1 @@ +../../.eslintignore \ No newline at end of file diff --git a/packages/demo-staking/.gitignore b/packages/demo-staking/.gitignore new file mode 100644 index 00000000..68bf3735 --- /dev/null +++ b/packages/demo-staking/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +docs/ diff --git a/packages/demo-staking/README.md b/packages/demo-staking/README.md new file mode 100644 index 00000000..f97fadc5 --- /dev/null +++ b/packages/demo-staking/README.md @@ -0,0 +1,8 @@ +# @cosmwasm/demo-staking + +## License + +This package is part of the cosmwasm-js repository, licensed under the Apache +License 2.0 (see +[NOTICE](https://github.com/confio/cosmwasm-js/blob/master/NOTICE) and +[LICENSE](https://github.com/confio/cosmwasm-js/blob/master/LICENSE)). diff --git a/packages/demo-staking/jasmine-testrunner.js b/packages/demo-staking/jasmine-testrunner.js new file mode 100755 index 00000000..9fada59b --- /dev/null +++ b/packages/demo-staking/jasmine-testrunner.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +require("source-map-support").install(); +const defaultSpecReporterConfig = require("../../jasmine-spec-reporter.config.json"); + +// setup Jasmine +const Jasmine = require("jasmine"); +const jasmine = new Jasmine(); +jasmine.loadConfig({ + spec_dir: "build", + spec_files: ["**/*.spec.js"], + helpers: [], + random: false, + seed: null, + stopSpecOnExpectationFailure: false, +}); +jasmine.jasmine.DEFAULT_TIMEOUT_INTERVAL = 15 * 1000; + +// setup reporter +const { SpecReporter } = require("jasmine-spec-reporter"); +const reporter = new SpecReporter({ ...defaultSpecReporterConfig }); + +// initialize and execute +jasmine.env.clearReporters(); +jasmine.addReporter(reporter); +jasmine.execute(); diff --git a/packages/demo-staking/karma.conf.js b/packages/demo-staking/karma.conf.js new file mode 100644 index 00000000..e68db403 --- /dev/null +++ b/packages/demo-staking/karma.conf.js @@ -0,0 +1,54 @@ +module.exports = function (config) { + config.set({ + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: ".", + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ["jasmine"], + + // list of files / patterns to load in the browser + files: ["dist/web/tests.js"], + + client: { + jasmine: { + random: false, + timeoutInterval: 15000, + }, + }, + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ["progress", "kjhtml"], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ["Firefox"], + + browserNoActivityTimeout: 90000, + + // Keep brower open for debugging. This is overridden by yarn scripts + singleRun: false, + + customLaunchers: { + ChromeHeadlessInsecure: { + base: "ChromeHeadless", + flags: ["--disable-web-security"], + }, + }, + }); +}; diff --git a/packages/demo-staking/nonces/README.txt b/packages/demo-staking/nonces/README.txt new file mode 100644 index 00000000..092fe732 --- /dev/null +++ b/packages/demo-staking/nonces/README.txt @@ -0,0 +1 @@ +Directory used to trigger lerna package updates for all packages diff --git a/packages/demo-staking/package.json b/packages/demo-staking/package.json new file mode 100644 index 00000000..45c6d0cf --- /dev/null +++ b/packages/demo-staking/package.json @@ -0,0 +1,48 @@ +{ + "name": "@cosmwasm/demo-staking", + "version": "0.8.0-alpha.0", + "description": "Demo interaction with the staking contract", + "author": "Simon Warta ", + "license": "Apache-2.0", + "main": "build/index.js", + "types": "types/index.d.ts", + "files": [ + "build/", + "types/", + "*.md", + "!*.spec.*", + "!**/testdata/" + ], + "repository": { + "type": "git", + "url": "https://github.com/confio/cosmwasm-js/tree/master/packages/demo-staking" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "docs": "shx rm -rf docs && typedoc --options typedoc.js", + "format": "prettier --write --loglevel warn \"./src/**/*.ts\"", + "lint": "eslint --max-warnings 0 \"**/*.{js,ts}\"", + "lint-fix": "eslint --max-warnings 0 \"**/*.{js,ts}\" --fix", + "move-types": "shx rm -rf ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -f ./types/*.spec.d.ts", + "format-types": "prettier --write --loglevel warn \"./types/**/*.d.ts\"", + "build": "shx rm -rf ./build && tsc && yarn move-types && yarn format-types", + "build-or-skip": "[ -n \"$SKIP_BUILD\" ] || yarn build", + "test-node": "node jasmine-testrunner.js", + "test-firefox": "yarn pack-web && karma start --single-run --browsers Firefox", + "test-chrome": "yarn pack-web && karma start --single-run --browsers ChromeHeadlessInsecure", + "test": "yarn build-or-skip && yarn test-node", + "pack-web": "yarn build-or-skip && webpack --mode development --config webpack.web.config.js" + }, + "dependencies": { + "@cosmwasm/sdk": "^0.8.0-alpha.0", + "@iov/crypto": "^2.1.0", + "@iov/encoding": "^2.1.0", + "@iov/stream": "^2.0.2", + "@iov/utils": "^2.0.2" + }, + "devDependencies": { + "@iov/keycontrol": "^2.1.0" + } +} diff --git a/packages/demo-staking/src/index.spec.ts b/packages/demo-staking/src/index.spec.ts new file mode 100644 index 00000000..23a86ad3 --- /dev/null +++ b/packages/demo-staking/src/index.spec.ts @@ -0,0 +1,153 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { Coin, coins, makeCosmoshubPath, Secp256k1Pen, SigningCosmWasmClient } from "@cosmwasm/sdk"; + +import { + BalanceResponse, + HandleMsg, + InitMsg, + InvestmentResponse, + QueryMsg, + TokenInfoResponse, +} from "./schema"; + +function pendingWithoutWasmd(): void { + if (!process.env.WASMD_ENABLED) { + return pending("Set WASMD_ENABLED to enable Cosmos node-based tests"); + } +} + +const httpUrl = "http://localhost:1317"; +const faucet = { + mnemonic: + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone", + address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", +}; + +/** Code info staking.wasm */ +const codeId = 3; + +/** Instance parameters */ +const params = { + name: "Bounty", + symbol: "BOUNTY", + decimals: 3, + validator: "cosmosvaloper1ea5cpmcj2vf5d0xwurncx7zdnmkuc6eq696h9a", + exitTax: "0.005", // 0.5 % + minWithdrawal: "700", +}; + +describe("Staking demo", () => { + it("works", async () => { + pendingWithoutWasmd(); + // The owner of the contract that will collect the tax + const ownerPen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const ownerAddress = ownerPen.address("cosmos"); + const ownerClient = new SigningCosmWasmClient(httpUrl, ownerAddress, (signBytes) => + ownerPen.sign(signBytes), + ); + + // a user of the contract + const userPen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic, makeCosmoshubPath(1)); + const userAddress = userPen.address("cosmos"); + const userClient = new SigningCosmWasmClient( + httpUrl, + userAddress, + (signBytes) => userPen.sign(signBytes), + { + exec: { + amount: coins(5000, "ucosm"), + gas: "300000", // 300k, needed for unbonding + }, + }, + ); + + const initMsg: InitMsg = { + name: params.name, + symbol: params.symbol, + decimals: params.decimals, + validator: params.validator, + exit_tax: params.exitTax, + min_withdrawal: params.minWithdrawal, + }; + const { contractAddress } = await ownerClient.instantiate( + codeId, + initMsg, + `Staking derivative BOUNTY ${new Date()}`, + ); + + // Query token info (immutable) + { + const query: QueryMsg = { token_info: {} }; + const response: TokenInfoResponse = await ownerClient.queryContractSmart(contractAddress, query); + expect(response).toEqual({ + decimals: params.decimals, + name: params.name, + symbol: params.symbol, + }); + } + + // Query investment info (changes with bonding/unbonding) + { + const query: QueryMsg = { investment: {} }; + const response: InvestmentResponse = await ownerClient.queryContractSmart(contractAddress, query); + expect(response).toEqual({ + token_supply: "0", + staked_tokens: { denom: "ustake", amount: "0" }, + nominal_value: "1", + owner: ownerAddress, + exit_tax: params.exitTax, + validator: params.validator, + min_withdrawal: params.minWithdrawal, + }); + } + + const bondMsg: HandleMsg = { bond: {} }; + const amount: Coin = { + amount: "112233", + denom: "ustake", + }; + await userClient.execute(contractAddress, bondMsg, undefined, [amount]); + + // Investment info changed + { + const quer: QueryMsg = { investment: {} }; + const response: InvestmentResponse = await ownerClient.queryContractSmart(contractAddress, quer); + expect(response).toEqual({ + token_supply: "112233", + staked_tokens: { denom: "ustake", amount: "112233" }, + nominal_value: "1", + owner: ownerAddress, + exit_tax: params.exitTax, + validator: params.validator, + min_withdrawal: params.minWithdrawal, + }); + } + + // Get balance + { + const query: QueryMsg = { balance: { address: userAddress } }; + const response: BalanceResponse = await ownerClient.queryContractSmart(contractAddress, query); + expect(response).toEqual({ + balance: "112233", + }); + } + + const unbondMsg: HandleMsg = { unbond: { amount: "110000" } }; + // await ownerClient.execute(contractAddress, unbondMsg); + await userClient.execute(contractAddress, unbondMsg); + + // Get balance + { + const query: QueryMsg = { balance: { address: userAddress } }; + const response: BalanceResponse = await ownerClient.queryContractSmart(contractAddress, query); + expect(response).toEqual({ balance: "2233" }); + } + + // Check collected tax (0.5 % of 110000) + { + const query: QueryMsg = { balance: { address: ownerAddress } }; + const response: BalanceResponse = await ownerClient.queryContractSmart(contractAddress, query); + expect(response).toEqual({ balance: "550" }); + } + }); +}); diff --git a/packages/demo-staking/src/index.ts b/packages/demo-staking/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/demo-staking/src/schema.d.ts b/packages/demo-staking/src/schema.d.ts new file mode 100644 index 00000000..371ee2ff --- /dev/null +++ b/packages/demo-staking/src/schema.d.ts @@ -0,0 +1,210 @@ +/* + * This file was generated with ❤️ by wasm.glass and is licensed + * for you under WTFPL OR 0BSD OR Unlicense OR MIT OR BSD-3-Clause. + * Note that different terms apply for the contract's source code and schema. + * Type generation powered by json-schema-to-typescript. + */ + +export interface BalanceResponse { + balance: string; + [k: string]: unknown; +} + +export interface ClaimsResponse { + claims: string; + [k: string]: unknown; +} + +export type HandleMsg = + | { + transfer: { + amount: string; + recipient: string; + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + bond: { + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + unbond: { + amount: string; + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + claim: { + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + reinvest: { + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + __bond_all_tokens: { + [k: string]: unknown; + }; + [k: string]: unknown; + }; + +export interface InitMsg { + /** + * decimal places of the derivative token (for UI) TODO: does this make sense? Do we need to normalize on this? We don't even know the decimals of the native token + */ + decimals: number; + /** + * this is how much the owner takes as a cut when someone unbonds TODO + */ + exit_tax: string; + /** + * This is the minimum amount we will pull out to reinvest, as well as a minumum that can be unbonded (to avoid needless staking tx) + */ + min_withdrawal: string; + /** + * name of the derivative token (FIXME: auto-generate?) + */ + name: string; + /** + * symbol / ticker of the derivative token + */ + symbol: string; + /** + * This is the validator that all tokens will be bonded to + */ + validator: string; + [k: string]: unknown; +} + +/** + * Investment info is fixed at initialization, and is used to control the function of the contract + */ +export interface InvestmentInfo { + /** + * this is the denomination we can stake (and only one we accept for payments) + */ + bond_denom: string; + /** + * this is how much the owner takes as a cut when someone unbonds + */ + exit_tax: string; + /** + * This is the minimum amount we will pull out to reinvest, as well as a minumum that can be unbonded (to avoid needless staking tx) + */ + min_withdrawal: string; + /** + * owner created the contract and takes a cut + */ + owner: string; + /** + * All tokens are bonded to this validator FIXME: humanize/canonicalize address doesn't work for validator addrresses + */ + validator: string; + [k: string]: unknown; +} + +export interface InvestmentResponse { + /** + * this is how much the owner takes as a cut when someone unbonds + */ + exit_tax: string; + /** + * This is the minimum amount we will pull out to reinvest, as well as a minumum that can be unbonded (to avoid needless staking tx) + */ + min_withdrawal: string; + /** + * A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0 + * + * The greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18) + */ + nominal_value: string; + /** + * owner created the contract and takes a cut + */ + owner: string; + staked_tokens: { + amount: string; + denom: string; + [k: string]: unknown; + }; + token_supply: string; + /** + * All tokens are bonded to this validator + */ + validator: string; + [k: string]: unknown; +} + +export type QueryMsg = + | { + balance: { + address: string; + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + claims: { + address: string; + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + token_info: { + [k: string]: unknown; + }; + [k: string]: unknown; + } + | { + investment: { + [k: string]: unknown; + }; + [k: string]: unknown; + }; + +/** + * Supply is dynamic and tracks the current supply of staked and ERC20 tokens. + */ +export interface Supply { + /** + * bonded is how many native tokens exist bonded to the validator + */ + bonded: string; + /** + * claims is how many tokens need to be reserved paying back those who unbonded + */ + claims: string; + /** + * issued is how many derivative tokens this contract has issued + */ + issued: string; + [k: string]: unknown; +} + +/** + * TokenInfoResponse is info to display the derivative token in a UI + */ +export interface TokenInfoResponse { + /** + * decimal places of the derivative token (for UI) + */ + decimals: number; + /** + * name of the derivative token + */ + name: string; + /** + * symbol / ticker of the derivative token + */ + symbol: string; + [k: string]: unknown; +} diff --git a/packages/demo-staking/tsconfig.json b/packages/demo-staking/tsconfig.json new file mode 100644 index 00000000..167e8c02 --- /dev/null +++ b/packages/demo-staking/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "declarationDir": "build/types", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/demo-staking/typedoc.js b/packages/demo-staking/typedoc.js new file mode 100644 index 00000000..e2387c7d --- /dev/null +++ b/packages/demo-staking/typedoc.js @@ -0,0 +1,14 @@ +const packageJson = require("./package.json"); + +module.exports = { + src: ["./src"], + out: "docs", + exclude: "**/*.spec.ts", + target: "es6", + name: `${packageJson.name} Documentation`, + readme: "README.md", + mode: "file", + excludeExternals: true, + excludeNotExported: true, + excludePrivate: true, +}; diff --git a/packages/demo-staking/types/index.d.ts b/packages/demo-staking/types/index.d.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/demo-staking/webpack.web.config.js b/packages/demo-staking/webpack.web.config.js new file mode 100644 index 00000000..7373cace --- /dev/null +++ b/packages/demo-staking/webpack.web.config.js @@ -0,0 +1,19 @@ +const glob = require("glob"); +const path = require("path"); +const webpack = require("webpack"); + +const target = "web"; +const distdir = path.join(__dirname, "dist", "web"); + +module.exports = [ + { + // bundle used for Karma tests + target: target, + entry: glob.sync("./build/**/*.spec.js"), + output: { + path: distdir, + filename: "tests.js", + }, + plugins: [new webpack.EnvironmentPlugin(["WASMD_ENABLED"])], + }, +]; diff --git a/packages/sdk/src/pen.spec.ts b/packages/sdk/src/pen.spec.ts index 7769893e..549177a8 100644 --- a/packages/sdk/src/pen.spec.ts +++ b/packages/sdk/src/pen.spec.ts @@ -28,7 +28,7 @@ describe("Sec256k1Pen", () => { }); }); - describe("createSignature", () => { + describe("sign", () => { it("creates correct signatures", async () => { const pen = await Secp256k1Pen.fromMnemonic( "special sign fit simple patrol salute grocery chicken wheat radar tonight ceiling", @@ -44,4 +44,13 @@ describe("Sec256k1Pen", () => { expect(valid).toEqual(true); }); }); + + describe("address", () => { + it("creates same address as Go imlementation", async () => { + const pen = await Secp256k1Pen.fromMnemonic( + "oyster design unusual machine spread century engine gravity focus cave carry slot", + ); + expect(pen.address("cosmos")).toEqual("cosmos1cjsxept9rkggzxztslae9ndgpdyt2408lk850u"); + }); + }); }); diff --git a/packages/sdk/src/pen.ts b/packages/sdk/src/pen.ts index 86037fe2..936abc0e 100644 --- a/packages/sdk/src/pen.ts +++ b/packages/sdk/src/pen.ts @@ -9,6 +9,7 @@ import { Slip10RawIndex, } from "@iov/crypto"; +import { rawSecp256k1PubkeyToAddress } from "./address"; import { encodeSecp256k1Signature } from "./signature"; import { StdSignature } from "./types"; @@ -84,4 +85,8 @@ export class Secp256k1Pen implements Pen { const fixedLengthSignature = new Uint8Array([...signature.r(32), ...signature.s(32)]); return encodeSecp256k1Signature(this.pubkey, fixedLengthSignature); } + + public address(prefix: string): string { + return rawSecp256k1PubkeyToAddress(this.pubkey, prefix); + } } diff --git a/packages/sdk/types/pen.d.ts b/packages/sdk/types/pen.d.ts index ee129395..068db3cb 100644 --- a/packages/sdk/types/pen.d.ts +++ b/packages/sdk/types/pen.d.ts @@ -29,4 +29,5 @@ export declare class Secp256k1Pen implements Pen { * Creates and returns a signature */ sign(signBytes: Uint8Array, prehashType?: PrehashType): Promise; + address(prefix: string): string; } diff --git a/scripts/wasmd/contracts/checksums.sha256 b/scripts/wasmd/contracts/checksums.sha256 index 119ff0b5..623f51f3 100644 --- a/scripts/wasmd/contracts/checksums.sha256 +++ b/scripts/wasmd/contracts/checksums.sha256 @@ -1,2 +1,3 @@ 1f6285492e7ea00596ef472ba166cb96ac3f91d694cb8c8e15f7c023ac451947 cw-erc20.wasm 7c0e964c9a46f53af8a4097fbf45edf9670c1813b99f4ecb1084ccadb30de2fe cw-nameservice.wasm +0f08a890443dbf644f61a7dc3aa7b2a03e9d142dd1b718aa8b7f8a80b886bff1 staking.wasm diff --git a/scripts/wasmd/contracts/staking.wasm b/scripts/wasmd/contracts/staking.wasm new file mode 100644 index 00000000..332510ea Binary files /dev/null and b/scripts/wasmd/contracts/staking.wasm differ diff --git a/scripts/wasmd/deploy_staking.js b/scripts/wasmd/deploy_staking.js new file mode 100755 index 00000000..1369fddf --- /dev/null +++ b/scripts/wasmd/deploy_staking.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +/* eslint-disable @typescript-eslint/camelcase */ +const { SigningCosmWasmClient, Secp256k1Pen } = require("@cosmwasm/sdk"); +const fs = require("fs"); + +const httpUrl = "http://localhost:1317"; +const faucet = { + mnemonic: + "economy stock theory fatal elder harbor betray wasp final emotion task crumble siren bottom lizard educate guess current outdoor pair theory focus wife stone", + address: "cosmos1pkptre7fdkl6gfrzlesjjvhxhlc3r4gmmk8rs6", +}; + +const codeMeta = { + source: "", // not intended to be published + builder: "cosmwasm/rust-optimizer:0.8.0", +}; + +// To get the proper validator address, run the demo chain (./scripts/wasmd/start.sh), then run: +// curl http://localhost:1317/staking/validators | jq .result[0].operator_address +const bounty = { + label: "Bounty", + initMsg: { + name: "Bounty", + symbol: "BOUNTY", + decimals: 3, + validator: "cosmosvaloper1ea5cpmcj2vf5d0xwurncx7zdnmkuc6eq696h9a", + exit_tax: "0.005", // 0.5 % + min_withdrawal: "7", + }, +}; + +async function main() { + const pen = await Secp256k1Pen.fromMnemonic(faucet.mnemonic); + const client = new SigningCosmWasmClient(httpUrl, faucet.address, (signBytes) => pen.sign(signBytes)); + + const wasm = fs.readFileSync(__dirname + "/contracts/staking.wasm"); + const uploadReceipt = await client.upload(wasm, codeMeta, "Upload Staking code"); + console.info(`Upload succeeded. Receipt: ${JSON.stringify(uploadReceipt)}`); + + for (const { label, initMsg } of [bounty]) { + const memo = `Create an staking instance "${label}"`; + const { contractAddress } = await client.instantiate(uploadReceipt.codeId, initMsg, label, memo); + console.info(`Contract "${label}" instantiated at ${contractAddress}`); + } +} + +main().then( + () => { + console.info("Done deploying staking instances."); + process.exit(0); + }, + (error) => { + console.error(error); + process.exit(1); + }, +); diff --git a/scripts/wasmd/env b/scripts/wasmd/env index ce8fb0cb..2ce4628d 100644 --- a/scripts/wasmd/env +++ b/scripts/wasmd/env @@ -1,5 +1,5 @@ # Choose from https://hub.docker.com/r/cosmwasm/wasmd-demo/tags REPOSITORY="cosmwasm/wasmd-demo" -VERSION="v0.8.0-alpha3" +VERSION="v0.8.0" CONTAINER_NAME="wasmd" diff --git a/scripts/wasmd/init.sh b/scripts/wasmd/init.sh index 389f3725..eeba36fb 100755 --- a/scripts/wasmd/init.sh +++ b/scripts/wasmd/init.sh @@ -11,3 +11,4 @@ echo "Okay, thank you for your patience." SCRIPT_DIR="$(realpath "$(dirname "$0")")" "$SCRIPT_DIR/deploy_erc20.js" "$SCRIPT_DIR/deploy_nameservice.js" +"$SCRIPT_DIR/deploy_staking.js"