diff --git a/contrib/core-contract-tests/deployments/default.simnet-plan.yaml b/contrib/core-contract-tests/deployments/default.simnet-plan.yaml index 527bc4b0495..507b9751162 100644 --- a/contrib/core-contract-tests/deployments/default.simnet-plan.yaml +++ b/contrib/core-contract-tests/deployments/default.simnet-plan.yaml @@ -12,6 +12,10 @@ genesis: address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 balance: "100000000000000" sbtc-balance: "1000000000" + - name: wallet_10 + address: ST3FFKYTTB975A3JC3F99MM7TXZJ406R3GKE6JV56 + balance: "200000000000000" + sbtc-balance: "1000000000" - name: wallet_2 address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG balance: "100000000000000" diff --git a/contrib/core-contract-tests/package-lock.json b/contrib/core-contract-tests/package-lock.json index dcb097b3041..9ae4a179cd7 100644 --- a/contrib/core-contract-tests/package-lock.json +++ b/contrib/core-contract-tests/package-lock.json @@ -14,6 +14,7 @@ "@clarigen/test": "2.1.3", "@hirosystems/clarinet-sdk": "2.16.0", "@stacks/clarunit": "0.0.1", + "@stacks/rendezvous": "^0.7.4", "@stacks/stacking": "^6.13.2", "@stacks/transactions": "^6.13.0", "chokidar-cli": "^3.0.0", @@ -1756,6 +1757,194 @@ "cross-fetch": "^3.1.5" } }, + "node_modules/@stacks/rendezvous": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@stacks/rendezvous/-/rendezvous-0.7.4.tgz", + "integrity": "sha512-8Ibc5muF56enfcIqtOuU4hbme8Iks7v7NrYr01mQ8KkmXUBFQ+y/j0mPDaqW/GuDJtxrib5mTHJF57tmMK5T+Q==", + "license": "GPL-3.0-only", + "dependencies": { + "@hirosystems/clarinet-sdk": "^3.0.1", + "@stacks/transactions": "^7.0.6", + "ansicolor": "^2.0.3", + "fast-check": "^3.20.0", + "toml": "^3.0.0", + "yaml": "^2.6.1" + }, + "bin": { + "rv": "dist/app.js" + } + }, + "node_modules/@stacks/rendezvous/node_modules/@hirosystems/clarinet-sdk": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk/-/clarinet-sdk-3.3.0.tgz", + "integrity": "sha512-vETqR/UEf14q0JCfYbEuP1Cy4g1DOGMH6aWNu8Vd+FZ5V2DFGostTerzhUxsnxyMqIsTtRWSlwYdSsP4FoCCSQ==", + "license": "GPL-3.0", + "dependencies": { + "@hirosystems/clarinet-sdk-wasm": "3.3.0", + "@stacks/transactions": "^7.0.6", + "kolorist": "^1.8.0", + "prompts": "^2.4.2", + "yargs": "^18.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stacks/rendezvous/node_modules/@hirosystems/clarinet-sdk-wasm": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.3.0.tgz", + "integrity": "sha512-EiP2XPsXM5TWOvK3wrvPkmXCssNDx5ZaWU/w3l+6+HQ5r+PmArp90gSS3EdGqYz7YblghMzKD3CGq1VhWypPfw==", + "license": "GPL-3.0" + }, + "node_modules/@stacks/rendezvous/node_modules/@stacks/common": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-7.0.2.tgz", + "integrity": "sha512-+RSecHdkxOtswmE4tDDoZlYEuULpnTQVeDIG5eZ32opK8cFxf4EugAcK9CsIsHx/Se1yTEaQ21WGATmJGK84lQ==", + "license": "MIT" + }, + "node_modules/@stacks/rendezvous/node_modules/@stacks/network": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.0.2.tgz", + "integrity": "sha512-XzHnoWqku/jRrTgMXhmh3c+I0O9vDH24KlhzGDZtBu+8CGGyHNPAZzGwvoUShonMXrXjEnfO9IYQwV5aJhfv6g==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^7.0.2", + "cross-fetch": "^3.1.5" + } + }, + "node_modules/@stacks/rendezvous/node_modules/@stacks/transactions": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.1.0.tgz", + "integrity": "sha512-/4n5h+ka5N3mq16f1Zo0O0g2gyOYhaXFdGN8ifLz38NJmkjnCDXqi/ogB6NFNpSKGonyqyF5Vz1UPaQHwO8+IA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^7.0.2", + "@stacks/network": "^7.0.2", + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" + } + }, + "node_modules/@stacks/rendezvous/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@stacks/rendezvous/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@stacks/rendezvous/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@stacks/rendezvous/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/@stacks/rendezvous/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stacks/rendezvous/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@stacks/rendezvous/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@stacks/rendezvous/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/@stacks/rendezvous/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/@stacks/stacking": { "version": "6.13.2", "resolved": "https://registry.npmjs.org/@stacks/stacking/-/stacking-6.13.2.tgz", @@ -2074,6 +2263,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansicolor": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ansicolor/-/ansicolor-2.0.3.tgz", + "integrity": "sha512-pzusTqk9VHrjgMCcTPDTTvfJfx6Q3+L5tQ6yKC8Diexmoit4YROTFIkxFvRTNL9y5s0Q8HrSrgerCD5bIC+Kiw==", + "license": "Unlicense" + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2994,9 +3189,9 @@ } }, "node_modules/fast-check": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.16.0.tgz", - "integrity": "sha512-k8GtQHi4pJoRQ1gVDFQno+/FVkowo/ehiz/aCj9O/D7HRWb1sSFzNrw+iPVU8QlWtH+jNwbuN+dDVg3QkS56DQ==", + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", "funding": [ { "type": "individual", @@ -3007,8 +3202,9 @@ "url": "https://opencollective.com/fast-check" } ], + "license": "MIT", "dependencies": { - "pure-rand": "^6.0.0" + "pure-rand": "^6.1.0" }, "engines": { "node": ">=8.0.0" @@ -4180,9 +4376,9 @@ } }, "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==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", "funding": [ { "type": "individual", @@ -4192,7 +4388,8 @@ "type": "opencollective", "url": "https://opencollective.com/fast-check" } - ] + ], + "license": "MIT" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -4745,6 +4942,12 @@ "node": ">=8.0" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/contrib/core-contract-tests/package.json b/contrib/core-contract-tests/package.json index ad103427c3a..cbe47782654 100644 --- a/contrib/core-contract-tests/package.json +++ b/contrib/core-contract-tests/package.json @@ -16,6 +16,7 @@ "@clarigen/test": "2.1.3", "@hirosystems/clarinet-sdk": "2.16.0", "@stacks/clarunit": "0.0.1", + "@stacks/rendezvous": "^0.7.4", "@stacks/stacking": "^6.13.2", "@stacks/transactions": "^6.13.0", "chokidar-cli": "^3.0.0", diff --git a/contrib/core-contract-tests/settings/Devnet.toml b/contrib/core-contract-tests/settings/Devnet.toml index bb941fddc90..6e75cb214f5 100644 --- a/contrib/core-contract-tests/settings/Devnet.toml +++ b/contrib/core-contract-tests/settings/Devnet.toml @@ -71,3 +71,9 @@ balance = 100_000_000_000_000 # stx_address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6 # btc_address: mjSrB3wS4xab3kYqFktwBzfTdPg367ZJ2d +[accounts.wallet_10] +mnemonic = "kiss denial decade slide spawn medal twist lamp evidence economy torch alter witness paper rule snack cushion hill sugar fury public innocent almost divide" +balance = 200_000_000_000_000 +# secret_key: 5b897659452b9f3642be69aee75dc3cc84b2386d55ece1312affdbb80a3b2a7d01 +# stx_address: ST3FFKYTTB975A3JC3F99MM7TXZJ406R3GKE6JV56 +# btc_address: n1qwmgbzf1YeHDW6cTxxEwuqnjbqauvKJ1 diff --git a/contrib/core-contract-tests/tests/pox-4/pox-4.prop.test.ts b/contrib/core-contract-tests/tests/pox-4/pox-4.prop.test.ts index 454480c41fb..60e5d302037 100644 --- a/contrib/core-contract-tests/tests/pox-4/pox-4.prop.test.ts +++ b/contrib/core-contract-tests/tests/pox-4/pox-4.prop.test.ts @@ -13,8 +13,8 @@ import { assert, describe, expect, it } from "vitest"; import { createHash } from "crypto"; // Contract Consts -const INITIAL_TOTAL_LIQ_SUPPLY = 1_000_000_000_000_000; -const MIN_AMOUNT_USTX = 125_000_000_000n; +const INITIAL_TOTAL_LIQ_SUPPLY = 1_200_000_000_000_000; +const MIN_AMOUNT_USTX = 150_000_000_000n; const TESTNET_PREPARE_CYCLE_LENGTH = 50; const TESTNET_REWARD_CYCLE_LENGTH = 1050; const TESTNET_STACKING_THRESHOLD_25 = 8000; @@ -53,6 +53,8 @@ const privateKeyMapping: { "6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01", STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6: "de433bdfa14ec43aa1098d5be594c8ffb20a31485ff9de2923b2689471c401b801", + ST3FFKYTTB975A3JC3F99MM7TXZJ406R3GKE6JV56: + '5b897659452b9f3642be69aee75dc3cc84b2386d55ece1312affdbb80a3b2a7d01', }; const sha256 = (data: Buffer): Buffer => diff --git a/contrib/core-contract-tests/tests/sip-031/commands/Claim.ts b/contrib/core-contract-tests/tests/sip-031/commands/Claim.ts new file mode 100644 index 00000000000..2ce553c7f9a --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/Claim.ts @@ -0,0 +1,41 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { + calculateClaimable, + getWalletNameByAddress, + logCommand, + trackCommandRun, +} from "./utils"; +import { expect } from "vitest"; +import { txOk } from "@clarigen/test"; + +export const Claim = (accounts: Real["accounts"]) => + fc.record({ + sender: fc.constantFrom( + ...Object.values(accounts).map((x) => x.address), + ), + }).map((r) => ({ + check: (model: Readonly) => { + const claimable = calculateClaimable(model); + return model.initialized === true && model.recipient === r.sender && + claimable > 0n; + }, + run: (model: Model, real: Real) => { + trackCommandRun(model, "claim"); + + const expectedClaim = calculateClaimable(model); + const receipt = txOk(real.contracts.sip031.claim(), r.sender); + expect(receipt.value).toBe(expectedClaim); + + model.balance -= expectedClaim; + model.totalClaimed += expectedClaim; + + logCommand({ + sender: getWalletNameByAddress(r.sender), + status: "ok", + action: "claim", + value: `amount ${expectedClaim}`, + }); + }, + toString: () => `claim`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/ClaimErr.ts b/contrib/core-contract-tests/tests/sip-031/commands/ClaimErr.ts new file mode 100644 index 00000000000..81fdd86e39b --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/ClaimErr.ts @@ -0,0 +1,50 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { + calculateClaimable, + getWalletNameByAddress, + logCommand, + trackCommandRun, +} from "./utils"; +import { expect } from "vitest"; +import { txErr } from "@clarigen/test"; + +export const ClaimErr = (accounts: Real["accounts"]) => + fc.record({ + sender: fc.constantFrom( + ...Object.values(accounts).map((x) => x.address), + ), + }).map((r) => ({ + check: (model: Readonly) => { + if (model.initialized !== true) { + return false; + } + + if (model.recipient !== r.sender) { + return true; + } + + const claimable = calculateClaimable(model); + return claimable === 0n; + }, + run: (model: Model, real: Real) => { + trackCommandRun(model, "claim-err"); + + const expectedError = model.recipient !== r.sender + ? model.constants.ERR_NOT_ALLOWED + : model.constants.ERR_NOTHING_TO_CLAIM; + const receipt = txErr(real.contracts.sip031.claim(), r.sender); + expect(receipt.value).toBe(expectedError); + + const errString = expectedError === model.constants.ERR_NOT_ALLOWED + ? "ERR_NOT_ALLOWED" + : "ERR_NOTHING_TO_CLAIM"; + logCommand({ + sender: getWalletNameByAddress(r.sender), + status: "err", + action: "claim-err", + error: errString, + }); + }, + toString: () => `claim-err as ${r.sender}`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/MineBlocks.ts b/contrib/core-contract-tests/tests/sip-031/commands/MineBlocks.ts new file mode 100644 index 00000000000..6b09d1dbc89 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/MineBlocks.ts @@ -0,0 +1,23 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { logCommand, trackCommandRun } from "./utils"; + +export const MineBlocks = () => + fc.record({ + blocks: fc.integer({ min: 1, max: 100 }), + }).map((r) => ({ + check: (model: Readonly) => model.initialized === true, + run: (model: Model, _real: Real) => { + trackCommandRun(model, "mine-blocks"); + + simnet.mineEmptyBlocks(r.blocks); + + logCommand({ + sender: undefined, + status: "ok", + action: "mine-blocks", + value: `${r.blocks}`, + }); + }, + toString: () => `mine-blocks ${r.blocks}`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/Mint.ts b/contrib/core-contract-tests/tests/sip-031/commands/Mint.ts new file mode 100644 index 00000000000..76ac2b57d87 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/Mint.ts @@ -0,0 +1,32 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { txOk } from "@clarigen/test"; +import { logCommand, trackCommandRun } from "./utils"; + +export const Mint = () => + fc.record({ + amount: fc.bigInt(1n, 100000000n), + }).map((r) => ({ + check: (model: Readonly) => model.initialized === true, + run: (model: Model, real: Real) => { + trackCommandRun(model, "mint"); + + txOk( + real.contracts.sip031Indirect.transferStx( + r.amount, + real.contracts.sip031.identifier, + ), + real.accounts.wallet_4.address, + ); + + model.balance += r.amount; + + logCommand({ + sender: undefined, + status: "ok", + action: "mint", + value: `amount ${r.amount}`, + }); + }, + toString: () => `mint ${r.amount}`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/MintInitial.ts b/contrib/core-contract-tests/tests/sip-031/commands/MintInitial.ts new file mode 100644 index 00000000000..797e0b5f4d7 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/MintInitial.ts @@ -0,0 +1,52 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { txOk } from "@clarigen/test"; +import { logCommand, trackCommandRun } from "./utils"; + +export const MintInitial = (accounts: Real["accounts"]) => + fc.record({}).map(() => ({ + check: (model: Readonly) => model.initialized === false, + run: (model: Model, real: Real) => { + trackCommandRun(model, "mint-initial"); + + const contracts = real.contracts; + const indirect = contracts.sip031Indirect; + const sip031 = contracts.sip031; + + // Split initial mint into two transfers to wallet_4 from wallet_5 and wallet_6. + txOk( + indirect.transferStx( + sip031.constants.INITIAL_MINT_AMOUNT / 2n, + accounts.wallet_4.address, + ), + accounts.wallet_5.address, + ); + txOk( + indirect.transferStx( + sip031.constants.INITIAL_MINT_AMOUNT / 2n, + accounts.wallet_4.address, + ), + accounts.wallet_6.address, + ); + + // Forward full amount from wallet_4 into the SIP-031 contract. + txOk( + indirect.transferStx( + sip031.constants.INITIAL_MINT_AMOUNT, + sip031.identifier, + ), + accounts.wallet_4.address, + ); + + model.initialized = true; + model.balance = sip031.constants.INITIAL_MINT_AMOUNT; + + logCommand({ + sender: undefined, + status: "ok", + action: "setup-initial-funding", + value: `amount ${sip031.constants.INITIAL_MINT_AMOUNT}`, + }); + }, + toString: () => `setup-initial-funding`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/UpdateRecipient.ts b/contrib/core-contract-tests/tests/sip-031/commands/UpdateRecipient.ts new file mode 100644 index 00000000000..742ba37f332 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/UpdateRecipient.ts @@ -0,0 +1,40 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { expect } from "vitest"; +import { txOk } from "@clarigen/test"; +import { getWalletNameByAddress, logCommand, trackCommandRun } from "./utils"; + +export const UpdateRecipient = (accounts: Real["accounts"]) => + fc.record({ + sender: fc.constantFrom( + ...Object.values(accounts).map((x) => x.address), + ), + newRecipient: fc.constantFrom( + ...Object.values(accounts as Record).map(( + acc, + ) => acc.address), + ), + }).map((r) => ({ + check: (model: Readonly) => { + return model.initialized === true && model.recipient === r.sender; + }, + run: (model: Model, real: Real) => { + trackCommandRun(model, "update-recipient"); + + const receipt = txOk( + real.contracts.sip031.updateRecipient(r.newRecipient), + r.sender, + ); + expect(receipt.value).toBe(true); + + model.recipient = r.newRecipient; + + logCommand({ + sender: getWalletNameByAddress(r.sender), + status: "ok", + action: "update-recipient", + value: `to ${getWalletNameByAddress(r.newRecipient)}`, + }); + }, + toString: () => `update-recipient to ${r.newRecipient}`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/UpdateRecipientErr.ts b/contrib/core-contract-tests/tests/sip-031/commands/UpdateRecipientErr.ts new file mode 100644 index 00000000000..2731f82b626 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/UpdateRecipientErr.ts @@ -0,0 +1,36 @@ +import fc from "fast-check"; +import type { Model, Real } from "./types"; +import { expect } from "vitest"; +import { txErr } from "@clarigen/test"; +import { getWalletNameByAddress, logCommand, trackCommandRun } from "./utils"; + +export const UpdateRecipientErr = (accounts: Real["accounts"]) => + fc.record({ + sender: fc.constantFrom( + ...Object.values(accounts).map((x) => x.address), + ), + newRecipient: fc.constantFrom( + ...Object.values(accounts).map((x) => x.address), + ), + }).map((r) => ({ + check: (model: Readonly) => { + return model.initialized === true && model.recipient !== r.sender; + }, + run: (model: Model, real: Real) => { + trackCommandRun(model, "update-recipient-err"); + + const receipt = txErr( + real.contracts.sip031.updateRecipient(r.newRecipient), + r.sender, + ); + expect(receipt.value).toBe(model.constants.ERR_NOT_ALLOWED); + + logCommand({ + sender: getWalletNameByAddress(r.sender), + status: "err", + action: "update-recipient-err", + error: "ERR_NOT_ALLOWED", + }); + }, + toString: () => `update-recipient-err as ${r.sender}`, + })); diff --git a/contrib/core-contract-tests/tests/sip-031/commands/types.ts b/contrib/core-contract-tests/tests/sip-031/commands/types.ts new file mode 100644 index 00000000000..0de63a96854 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/types.ts @@ -0,0 +1,28 @@ +import { accounts, project } from "../../clarigen-types"; +import { projectFactory } from "@clarigen/core"; + +const contracts = projectFactory(project, "simnet"); + +export type Real = { + accounts: typeof accounts; + contracts: typeof contracts; +}; + +export interface Model { + // Total STX balance currently held by SIP-031. + balance: bigint; + // Current block height used for vesting calculations. + blockHeight: bigint; + // SIP-031 constants including vesting parameters and error codes. + constants: typeof contracts.sip031.constants; + // Block height that marks when vesting becomes active. + deployBlockHeight: bigint; + // Flag indicating whether the initial funding has been transferred. + initialized: boolean; + // Current recipient address eligible to claim STX and update the recipient. + recipient: string; + // Running total of all STX that have been claimed from the contract. + totalClaimed: bigint; + // Map tracking command execution statistics for reporting purposes. + statistics: Map; +} diff --git a/contrib/core-contract-tests/tests/sip-031/commands/utils.ts b/contrib/core-contract-tests/tests/sip-031/commands/utils.ts new file mode 100644 index 00000000000..9118ed02139 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/commands/utils.ts @@ -0,0 +1,103 @@ +import type { Model } from "./types"; +import { accounts } from "../../clarigen-types"; + +export function calculateClaimable(model: Readonly): bigint { + const c = model.constants; + + const max = c.INITIAL_MINT_VESTING_ITERATIONS; + const amt = c.INITIAL_MINT_VESTING_AMOUNT; + const per = c.STX_PER_ITERATION; + const step = c.INITIAL_MINT_VESTING_ITERATION_BLOCKS; + + // If before deployment, nothing vested yet. + const diff = model.blockHeight < model.deployBlockHeight + ? 0n + : model.blockHeight - model.deployBlockHeight; + + const iter = diff / step; + const vest = iter >= max ? amt : per * iter; + + const total = c.INITIAL_MINT_IMMEDIATE_AMOUNT + vest; + const reserved = c.INITIAL_MINT_AMOUNT - total; + + return model.balance > reserved ? model.balance - reserved : 0n; +} + +export function logCommand({ + sender, + status, + action, + value, + error, +}: { + sender?: string; + status: "ok" | "err"; + action: string; + value?: string | number | bigint; + error?: string; +}) { + const senderStr = (sender ?? "system").padEnd(11, " "); + const statusStr = status === "ok" ? "✓" : "✗"; + const actionStr = action.padEnd(22, " "); + + let msg = `Ӿ ${senderStr} ${statusStr} ${actionStr}`; + if (value !== undefined) msg += ` ${String(value)}`; + if (error !== undefined) msg += ` error ${error}`; + + console.log(msg); +} + +export function trackCommandRun(model: Model, commandName: string) { + const count = model.statistics.get(commandName) || 0; + model.statistics.set(commandName, count + 1); +} + +export function reportCommandRuns(model: Model) { + console.log("\nCommand execution counts:"); + const orderedStatistics = Array.from(model.statistics.entries()).sort( + ([keyA], [keyB]) => { + return keyA.localeCompare(keyB); + }, + ); + + logAsTree(orderedStatistics); +} + +function logAsTree(statistics: [string, number][]) { + const tree: { [key: string]: any } = {}; + + statistics.forEach(([commandName, count]) => { + const split = commandName.split("_"); + let root: string = split[0], + rest: string = "base"; + + if (split.length > 1) { + rest = split.slice(1).join("_"); + } + if (!tree[root]) { + tree[root] = {}; + } + tree[root][rest] = count; + }); + + const printTree = (node: any, indent: string = "") => { + const keys = Object.keys(node); + keys.forEach((key, index) => { + const isLast = index === keys.length - 1; + const boxChar = isLast ? "└─ " : "├─ "; + if (key !== "base") { + if (typeof node[key] === "object") { + console.log(`${indent}${boxChar}${key}: x${node[key]["base"]}`); + printTree(node[key], indent + (isLast ? " " : "│ ")); + } else { + console.log(`${indent}${boxChar}${key}: ${node[key]}`); + } + } + }); + }; + + printTree(tree); +} + +export const getWalletNameByAddress = (address: string): string | undefined => + Object.entries(accounts).find(([, v]) => v.address === address)?.[0]; diff --git a/contrib/core-contract-tests/tests/sip-031/sip-031.stateful.prop.test.ts b/contrib/core-contract-tests/tests/sip-031/sip-031.stateful.prop.test.ts new file mode 100644 index 00000000000..11b25d596f5 --- /dev/null +++ b/contrib/core-contract-tests/tests/sip-031/sip-031.stateful.prop.test.ts @@ -0,0 +1,58 @@ +import fc from "fast-check"; +import { accounts, project } from "../clarigen-types"; +import { projectFactory } from "@clarigen/core"; +import { rov } from "@clarigen/test"; +import { test } from "vitest"; + +import { Claim } from "./commands/Claim"; +import { ClaimErr } from "./commands/ClaimErr"; +import { MineBlocks } from "./commands/MineBlocks"; +import { Mint } from "./commands/Mint"; +import { MintInitial } from "./commands/MintInitial"; +import { Model, Real } from "./commands/types"; +import { UpdateRecipient } from "./commands/UpdateRecipient"; +import { UpdateRecipientErr } from "./commands/UpdateRecipientErr"; +import { reportCommandRuns } from "./commands/utils"; + +const contracts = projectFactory(project, "simnet"); + +test("SIP-031 Stateful", () => { + const real: Real = { + accounts, + contracts, + }; + + const model: Model = { + balance: 0n, + blockHeight: rov(contracts.sip031.getDeployBlockHeight()), + constants: contracts.sip031.constants, + deployBlockHeight: rov(contracts.sip031.getDeployBlockHeight()), + initialized: false, + recipient: accounts.deployer.address, + statistics: new Map(), + totalClaimed: 0n, + }; + + const invariants = [ + Claim(accounts), + ClaimErr(accounts), + MineBlocks(), + Mint(), + MintInitial(accounts), + UpdateRecipient(accounts), + UpdateRecipientErr(accounts), + ]; + + fc.assert( + fc.property( + fc.commands(invariants, { size: "+1" }), + (cmds) => { + const state = () => ({ model: model, real: real }); + fc.modelRun(state, cmds); + }, + ), + { numRuns: 10, verbose: 2 }, + ); + + reportCommandRuns(model); +}); diff --git a/stackslib/src/chainstate/stacks/boot/sip-031.tests.clar b/stackslib/src/chainstate/stacks/boot/sip-031.tests.clar new file mode 100644 index 00000000000..76e84b785e8 --- /dev/null +++ b/stackslib/src/chainstate/stacks/boot/sip-031.tests.clar @@ -0,0 +1,237 @@ +(define-constant ERR_FAILED_ASSERTION u999) +(define-constant ERR_UNWRAP u998) +(define-constant ERR_UNEXPECTED_RESULT u997) +(define-constant ERR_IGNORED u996) + +(define-constant DEPLOYER tx-sender) + +(define-data-var last-iteration-claimed uint u0) +(define-data-var minted-initial bool false) + +;; General helpers + +;; Helper to simulate the initial balance of the SIP-031 contract. This should +;; be called by wallet_10, which has 200M STX. It transfers the initial 200M +;; STX to the contract. +(define-private (initial-mint-helper) + (ok + (and + ;; Ensure that the caller is wallet_10 as per Devnet.toml. It was added + ;; to the devnet with 200M STX for this operation. + (is-eq tx-sender 'ST3FFKYTTB975A3JC3F99MM7TXZJ406R3GKE6JV56) + (not (var-get minted-initial)) + (unwrap-panic (stx-transfer? INITIAL_MINT_AMOUNT tx-sender (as-contract tx-sender))) + (var-set minted-initial true) + ) + ) +) + +;; Helper to transfer extra STX amounts to the contract. In combination with +;; other tests, this ensures that extra transfers do not break the vesting +;; schedule. +(define-private (extra-transfer-to-contract-helper (ustx-amount uint)) + (ok + (and + (not (is-eq tx-sender 'ST3FFKYTTB975A3JC3F99MM7TXZJ406R3GKE6JV56)) + (> ustx-amount u0) + (>= (stx-get-balance tx-sender) ustx-amount) + (unwrap-panic (stx-transfer? ustx-amount tx-sender (as-contract tx-sender))) + ) + ) +) + +;; Property tests + +;; Helper to set up the Rendezvous testing environment for the property testing +;; routine. This will eventually be picked up during property testing runs. +(define-public (test-initial-mint-helper) + (initial-mint-helper) +) + +;; Helper to transfer extra STX amounts to the contract. This will eventually +;; be picked up during property testing runs. +(define-public (test-extra-transfer-helper (ustx-amount uint)) + (extra-transfer-to-contract-helper ustx-amount) +) + +;; Tests that the recipient is updated if the caller is allowed. +(define-public (test-update-recipient-allowed (new-recipient principal)) + (ok + (and + (is-eq (var-get recipient) contract-caller tx-sender) + (try! (update-recipient new-recipient)) + (asserts! + (is-eq new-recipient (var-get recipient)) + (err ERR_FAILED_ASSERTION) + ) + ) + ) +) + +;; Tests that the proper error is returned if the caller is not allowed. +(define-public (test-update-recipient-not-allowed (new-recipient principal)) + (ok + (and + (not (is-eq (var-get recipient) contract-caller tx-sender)) + (asserts! + (is-eq + (unwrap-err! (update-recipient new-recipient) (err ERR_UNWRAP)) + ERR_NOT_ALLOWED + ) + (err ERR_FAILED_ASSERTION) + ) + ) + ) +) + +;; Tests that the recipient is not updated if the caller is not allowed. +(define-public (test-update-recipient-not-allowed-no-effect + (new-recipient principal) + ) + (ok + (let ( + (recipient-before (var-get recipient)) + (update-recipient-result (update-recipient new-recipient)) + ) + (and + (not (is-eq recipient-before contract-caller tx-sender)) + (not (is-eq recipient-before new-recipient)) + (asserts! + (is-eq (var-get recipient) recipient-before) + (err ERR_FAILED_ASSERTION) + ) + ) + ) + ) +) + +;; Tests that the proper error is returned if the caller is not allowed. +(define-public (test-claim-not-allowed) + (ok + (and + (not (is-eq (var-get recipient) contract-caller tx-sender)) + (asserts! + (is-eq + (unwrap-err! (claim) (err ERR_UNWRAP)) + ERR_NOT_ALLOWED + ) + (err ERR_FAILED_ASSERTION) + ) + ) + ) +) + +;; Tests that the proper error is returned if there is nothing to claim. +(define-public (test-claim-nothing-to-claim) + (ok + (and + (is-eq (var-get recipient) contract-caller tx-sender) + (is-eq (calc-claimable-amount burn-block-height) u0) + (asserts! + (is-eq + (unwrap-err! (claim) (err ERR_UNWRAP)) + ERR_NOTHING_TO_CLAIM + ) + (err ERR_FAILED_ASSERTION) + ) + ) + ) +) + +;; Tests that the claim is successful if the caller is allowed, and the +;; recipient balance increases by the claimable amount. +(define-public (test-claim-allowed) + (ok + (let ( + (recipient-balance-before (stx-get-balance (var-get recipient))) + (claimable (calc-claimable-amount burn-block-height)) + (current-iteration (/ (- burn-block-height DEPLOY_BLOCK_HEIGHT) INITIAL_MINT_VESTING_ITERATION_BLOCKS)) + ) + (and + (is-eq (var-get recipient) contract-caller tx-sender) + (> claimable u0) + (asserts! (is-ok (claim)) (err ERR_UNEXPECTED_RESULT)) + (asserts! + (is-eq + (stx-get-balance (var-get recipient)) + (+ recipient-balance-before claimable) + ) + (err ERR_FAILED_ASSERTION) + ) + (var-set last-iteration-claimed current-iteration) + ) + ) + ) +) + +;; Tests that the claimable amount is greater than the STX per iteration if the +;; last iteration claimed is less than the current iteration. +(define-public (test-claimable-amount-gt-iteration-stx) + (ok + (let ( + (recipient-balance-before (stx-get-balance (var-get recipient))) + (claimable (calc-claimable-amount burn-block-height)) + (current-iteration (/ (- burn-block-height DEPLOY_BLOCK_HEIGHT) INITIAL_MINT_VESTING_ITERATION_BLOCKS)) + ) + (and + (is-eq (var-get recipient) contract-caller tx-sender) + (> claimable u0) + (> current-iteration (var-get last-iteration-claimed)) + (asserts! (>= claimable STX_PER_ITERATION) (err claimable)) + ) + ) + ) +) + +;; Invariants + +;; Public wrapper for initial mint setup, required for Rendezvous invariant +;; testing. This will eventually be picked up during invariant testing runs. +(define-public (initial-mint-helper-invariant-runs) + (if + (is-eq (initial-mint-helper) (ok true)) + (ok true) + (err ERR_IGNORED) + ) +) + +;; Public wrapper for extra STX transfers to the contract for Rendezvous +;; invariant testing. This will eventually be picked up during invariant +;; testing runs. +(define-public (extra-transfer-helper-invariant-runs (ustx-amount uint)) + (if + (is-eq (extra-transfer-to-contract-helper ustx-amount) (ok true)) + (ok true) + (err ERR_IGNORED) + ) +) + +;; Tests that the recipient remains unchanged unless `update-recipient` was +;; called successfully at least once. +(define-read-only (invariant-recipient-unchanged) + (if + (is-eq + u0 + (default-to u0 (get called (map-get? context "update-recipient"))) + ) + (is-eq (var-get recipient) DEPLOYER) + true + ) +) + +;; Tests that the amount returned by `calc-total-vested` never exceeds +;; the total initial mint amount, regardless of any extra transfers +;; to the contract. +(define-read-only (invariant-vested-lt-initial-mint (burn-height uint)) + (or + (<= burn-height DEPLOY_BLOCK_HEIGHT) + (<= + (calc-total-vested burn-height) + ;; We explicitly add up the total initial mint amount rather than using + ;; `INITIAL_MINT_AMOUNT` directly. This ensures the invariant remains + ;; valid even if the constants or their relationships change in the main + ;; contract, making this invariant's feedback more robust. + (+ INITIAL_MINT_IMMEDIATE_AMOUNT INITIAL_MINT_VESTING_AMOUNT) + ) + ) +)