diff --git a/src/index.ts b/src/index.ts index 354f07b..fb5b594 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from "fs/promises"; import { dirname, join, relative } from "path"; import { createInterface, Interface } from "readline/promises"; -import { stdin, stdout } from 'process'; +import { stdin, stdout } from "process"; import { exec } from "child_process"; import { promisify } from "util"; @@ -24,20 +24,20 @@ async function getFiles(dir: string): Promise { } function kebabToPascal(input: string): string { - return ('-' + input).replace(/-(\w)/g, (_, c) => c.toUpperCase()); + return ("-" + input).replace(/-(\w)/g, (_, c) => c.toUpperCase()); } async function reprompt(cb: () => Promise): Promise { for (; ;) { const result = await cb(); - if (typeof result !== 'undefined') { + if (typeof result !== "undefined") { return result; } } } export const runCommand = (command: string, cwd: string = process.cwd()) => { - const env = { ...process.env, FORCE_COLOR: process.stdout.isTTY ? '1' : '0' }; + const env = { ...process.env, FORCE_COLOR: process.stdout.isTTY ? "1" : "0" }; const thread = exec(command, { cwd, env }); return new Promise((resolve, reject) => { thread.stdout?.pipe(process.stdout); @@ -67,7 +67,7 @@ async function folderExists(path: string): Promise { await stat(path); return true; } catch (err: any) { - if (err.code === 'ENOENT') { + if (err.code === "ENOENT") { return false; } throw err; @@ -78,7 +78,7 @@ const execAsync = promisify(exec); export async function isGitInstalled(): Promise { try { - await execAsync('git --version'); + await execAsync("git --version"); return true; } catch { return false; @@ -86,13 +86,13 @@ export async function isGitInstalled(): Promise { } function detectPackageManager() { - const pkgManager = process.env.npm_config_user_agent?.split(' ')[0]?.split('/')[0] ?? 'npm'; + const pkgManager = process.env.npm_config_user_agent?.split(" ")[0]?.split("/")[0] ?? "npm"; switch (pkgManager) { - case 'yarn': return { name: 'yarn', install: 'yarn', run: 'yarn' } as const; - case 'pnpm': return { name: 'pnpm', install: 'pnpm install', run: 'pnpm run' } as const; - case 'bun': return { name: 'bun', install: 'bun install', run: 'bun run' } as const; - default: return { name: 'npm', install: 'npm install', run: 'npm run' } as const; + case "yarn": return { name: "yarn", install: "yarn", run: "yarn" } as const; + case "pnpm": return { name: "pnpm", install: "pnpm install", run: "pnpm run" } as const; + case "bun": return { name: "bun", install: "bun install", run: "bun run" } as const; + default: return { name: "npm", install: "npm install", run: "npm run" } as const; } } @@ -100,19 +100,30 @@ const reservedTactWords = [ "Int", "Bool", "Builder", "Slice", "Cell", "Address", "String", "StringBuilder", ]; +const templateNameRoots = { + "Empty contract": join(__dirname, "../template/empty"), + "Quick start": join(__dirname, "../template/quick-start"), +}; + async function main(reader: Interface) { - const templateRoot = join(__dirname, "../template/empty"); - - const packageName = await reprompt(async () => { - const placeholder = 'example'; - const fullPrompt = `Enter package name (${placeholder}): `; - const result = (await reader.question(fullPrompt)) || placeholder; - const validPackageName = /^(?=.{1,214}$)(?:@[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*$/; - if (!result.match(validPackageName)) { - return; - } - return result; - }); + let templateRoot = templateNameRoots["Quick start"]; + let packageName = "tact-quick-start"; + let contractName = "QuickStart"; + let isQuickStart = process.argv.slice(2).includes("--quick-start"); + + if (!isQuickStart) { + templateRoot = templateNameRoots["Empty contract"]; + packageName = await reprompt(async () => { + const placeholder = "example"; + const fullPrompt = `Enter package name (${placeholder}): `; + const result = (await reader.question(fullPrompt)) || placeholder; + const validPackageName = /^(?=.{1,214}$)(?:@[a-z0-9]+(?:[._-][a-z0-9]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*$/; + if (!result.match(validPackageName)) { + return; + } + return result; + }); + } const targetRoot = join(process.cwd(), packageName); @@ -137,16 +148,18 @@ async function main(reader: Interface) { // }); } - const contractName = await reprompt(async () => { - const placeholder = kebabToPascal(packageName); - const fullPrompt = `Enter contract name (${placeholder}): `; - const result = (await reader.question(fullPrompt)) || placeholder; - const validContractName = /[A-Z][a-zA-Z0-9_]*/; - if (!result.match(validContractName) || reservedTactWords.includes(result)) { - return; - } - return result; - }); + if (!isQuickStart) { + contractName = await reprompt(async () => { + const placeholder = kebabToPascal(packageName); + const fullPrompt = `Enter contract name (${placeholder}): `; + const result = (await reader.question(fullPrompt)) || placeholder; + const validContractName = /[A-Z][a-zA-Z0-9_]*/; + if (!result.match(validContractName) || reservedTactWords.includes(result)) { + return; + } + return result; + }); + } const manager = detectPackageManager(); @@ -194,12 +207,17 @@ async function main(reader: Interface) { process.exit(31); } } else { - console.error('Git is not installed'); - console.error('Git repository will not be initialized'); + console.error("Git is not installed"); + console.error("Git repository will not be initialized"); } - console.log('To switch to generated project, use'); - console.log(`cd ${relative(process.cwd(), targetRoot)}`) + const relDir = relative(process.cwd(), targetRoot); + console.log("\nTo switch to the generated project, use\n"); + console.error(` cd ${relDir}`); + + if (isQuickStart) { + console.log(`\nThen see the ${relDir}/README.md to get started!`); + } } async function withReader(cb: (reader: Interface) => Promise): Promise { @@ -207,8 +225,8 @@ async function withReader(cb: (reader: Interface) => Promise): Promise try { return await cb(reader); } finally { - reader.close() + reader.close(); } } -void withReader(main); \ No newline at end of file +void withReader(main); diff --git a/template/quick-start/.github/workflows/pull.yml b/template/quick-start/.github/workflows/pull.yml new file mode 100644 index 0000000..679a7b9 --- /dev/null +++ b/template/quick-start/.github/workflows/pull.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + strategy: + fail-fast: false + matrix: + node-version: [22] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Enable corepack + run: corepack enable + + - name: Activate chosen package manager + run: corepack prepare --activate + + - name: Install dependencies + run: pmInstall + + - name: Build + run: pmRun build + + - name: Run Tact formatter + run: pmRun fmt:check + + - name: Run Misti + run: pmRun lint + + - name: Run tests + run: pmRun test diff --git a/template/quick-start/.prettierrc.json b/template/quick-start/.prettierrc.json new file mode 100644 index 0000000..611ddf9 --- /dev/null +++ b/template/quick-start/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 4, + "useTabs": false +} diff --git a/template/quick-start/.vscode/extensions.json b/template/quick-start/.vscode/extensions.json new file mode 100644 index 0000000..8759306 --- /dev/null +++ b/template/quick-start/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tonstudio.vscode-tact", "tonwhales.func-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/template/quick-start/README.md b/template/quick-start/README.md new file mode 100644 index 0000000..3909eb8 --- /dev/null +++ b/template/quick-start/README.md @@ -0,0 +1,95 @@ +# Tact Quick Start + +Tact is a fresh programming language for TON Blockchain, focused on efficiency and ease of development. It is a good fit for complex smart contracts, quick onboarding and rapid prototyping. + +This quick start guide will help you [set up your editor or IDE](#editor-setup), understand some of the basics of Tact and TON, and learn from the given Tact smart contracts: a counter and an open-ended poll. + +We'll also cover how to create, test, and deploy contracts using the provided template, which includes a complete development environment with all the necessary tools pre-configured. + +## Editor setup + +- [VS Code extension](https://marketplace.visualstudio.com/items?itemName=tonstudio.vscode-tact) - Powerful and feature-rich extension for Visual Studio Code (VSCode) and VSCode-based editors like VSCodium, Cursor, Windsurf, and others. + - Get it on the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=tonstudio.vscode-tact). + - Get it on the [Open VSX Registry](https://open-vsx.org/extension/tonstudio/vscode-tact). + - Or install from the [`.vsix` files in nightly releases](https://github.com/tact-lang/tact-language-server/releases). +- [Language Server (LSP Server)](https://github.com/tact-lang/tact-language-server) - Supports Sublime Text, (Neo)Vim, Helix, and other editors with LSP support. +- [Intelli Tact](https://plugins.jetbrains.com/plugin/27290-tact) - Powerful and feature-rich plugin for JetBrains IDEs like WebStorm, IntelliJ IDEA, and others. +- [tact.vim](https://github.com/tact-lang/tact.vim) - Vim 8+ plugin. +- [tact-sublime](https://github.com/tact-lang/tact-sublime) - Sublime Text 4 package. + - Get it on the [Package Control](https://packagecontrol.io/packages/Tact). +- [tree-sitter-tact](https://github.com/tact-lang/tree-sitter-tact) - Tree-sitter grammar for Tact. + +The language server, VSCode extension, and JetBrains plugin linked above come with hover documentation for built-in keywords, which is very helpful for discovering the language on your own! + +If you're done with the setup, let's discuss the contents of this project. + +## Project structure + +Start exploring this project with a Counter contract. Once ready, move to the Poll or to any of the auxiliary files. + +- `src/counter.tact` — source code of the Counter contract +- `src/counter.spec.ts` — test suite for it +- `src/poll.tact` — source code of the Open-ended Poll contract +- `src/poll.spec.ts` — test suite for it +- `deploy.ts` – script for deploying the contracts +- `tact.config.json` – compiler settings + +To add new contract files to the project, do: + +1. Create a new `src/my-new-contract.tact` +2. Copy an existing test suite into the one for the new contract in `src/my-new-contract.spec.ts`, then adjust the contents to match the new contract +3. If the contract is used directly and not deployed by other ones, then: + * Modify `deploy.ts` + * Modify `tact.config.json` + +To build, test or deploy contracts see the following commands. + +## Commands + +- `pmRun build` – build contracts and the `.ts` wrappers +- `pmRun test` – build contracts and run jest tests +- `pmRun fmt` – fix source code formatting +- `pmRun lint` – run semantic checks with `misti` linter +- `pmRun verifier:testnet` – deploy contract to testnet +- `pmRun verifier:mainnet` – deploy contract to mainnet +- `pmRun fmt:check` – check source code formatting (for CI) + +## Available CLIs + +- `tact` – Tact compiler +- `tact-fmt` – Tact formatter +- `unboc` – Disassembler +- `@nowarp/misti` – Misti static analyzer +- `jest` – Jest testing framework + +Use `npx` to run any of the CLIs available. For example, to invoke the Tact formatter, execute this: + +```shell +npx tact-fmt +``` + +## Learn more about Tact + +- [Website](https://tact-lang.org/) +- [Documentation](https://docs.tact-lang.org/) + - [Learn Tact in Y minutes](https://docs.tact-lang.org/book/learn-tact-in-y-minutes/) + - [Debugging and testing Tact contracts](https://docs.tact-lang.org/book/debug/) + - [Gas best practices](https://docs.tact-lang.org/book/gas-best-practices/) + - [Security best practices](https://docs.tact-lang.org/book/security-best-practices/) +- [Awesome Tact](https://github.com/tact-lang/awesome-tact) + +For more interesting contract examples, see the [Tact's DeFi Cookbook](https://github.com/tact-lang/defi-cookbook). + +## Community + +If you can’t find the answer in the [docs](https://docs.tact-lang.org), or you’ve tried to do some local testing and it still didn’t help — don’t hesitate to reach out to Tact’s flourishing community: + +- [`@tactlang` on Telegram](https://t.me/tactlang) - Main community chat and discussion group. +- [`@tactlang_ru` on Telegram](https://t.me/tactlang_ru) _(Russian)_ +- [`@tact_kitchen` on Telegram](https://t.me/tact_kitchen) - Channel with updates from the team. +- [`@tact_language` on X/Twitter](https://x.com/tact_language) +- [`tact-lang` organization on GitHub](https://github.com/tact-lang) +- [`@ton_studio` on Telegram](https://t.me/ton_studio) +- [`@thetonstudio` on X/Twitter](https://x.com/thetonstudio) + +Good luck on your coding adventure with ⚡ Tact! diff --git a/template/quick-start/deploy.ts b/template/quick-start/deploy.ts new file mode 100644 index 0000000..7ceb511 --- /dev/null +++ b/template/quick-start/deploy.ts @@ -0,0 +1,52 @@ +import { resolve } from "path"; +import { readFile } from "fs/promises"; +import { contractAddress } from "@ton/core"; +import { prepareTactDeployment } from "@tact-lang/deployer"; +import { Counter } from "./output/counter_Counter"; +import { Poll } from "./output/poll_Poll"; + +async function main() { + console.log("Deploying..."); + const toProduction = process.argv.length === 3 && process.argv[2] === "mainnet"; + + // 1. Counter contract + { + // Default (initial) values of persistent state variables are supplied here + const init = await Counter.init(0n, 0n); + + // Obtaining a convenient link to deploy a new contract in the mainnet or testnet, + // which could be used by your existing Toncoin wallet. + const prepare = await prepareTactDeployment({ + // The .pkg file is a special JSON file containing the compiled contract, + // its dependencies, and all related metadata. + // + // See: https://docs.tact-lang.org/ref/evolution/otp-006/ + pkg: await readFile(resolve(__dirname, "output", "counter_Counter.pkg")), + data: init.data.toBoc(), + testnet: !toProduction, + }); + + // Contract addresses on TON are obtained deterministically, + // from the initial code and data. The most used chain is basechain, + // which is the workchain with ID 0. + const address = contractAddress(0, init).toString({ testOnly: !toProduction }); + console.log(`Contract address: ${address}`); + console.log(`Please, follow deployment link: ${prepare}`); + } + + // 2. Poll contract — the procedure is the same, + // but persistent state variables and contract output would differ. + { + const init = await Poll.init(0n, 0n, 0n); + const prepare = await prepareTactDeployment({ + pkg: await readFile(resolve(__dirname, "output", "poll_Poll.pkg")), + data: init.data.toBoc(), + testnet: !toProduction, + }); + const address = contractAddress(0, init).toString({ testOnly: !toProduction }); + console.log(`Contract address: ${address}`); + console.log(`Please, follow deployment link: ${prepare}`); + } +} + +void main(); diff --git a/template/quick-start/gitignore b/template/quick-start/gitignore new file mode 100644 index 0000000..b456ead --- /dev/null +++ b/template/quick-start/gitignore @@ -0,0 +1,9 @@ +node_modules +dist +output +.idea/ +.DS_Store/ +*.swp +.helix/ +.vim/ +.nvim/ diff --git a/template/quick-start/jest.config.ts b/template/quick-start/jest.config.ts new file mode 100644 index 0000000..809a7f9 --- /dev/null +++ b/template/quick-start/jest.config.ts @@ -0,0 +1,6 @@ +export default { + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["/node_modules/", "/output/", "/dist/"], + snapshotSerializers: ["@tact-lang/ton-jest/serializers"], +}; diff --git a/template/quick-start/package.json b/template/quick-start/package.json new file mode 100644 index 0000000..d7ff9bc --- /dev/null +++ b/template/quick-start/package.json @@ -0,0 +1,31 @@ +{ + "name": "packageName", + "version": "0.0.0", + "scripts": { + "build": "tact --config ./tact.config.json", + "fmt": "tact-fmt --write ./src && prettier -l -w .", + "fmt:check": "tact-fmt --check ./src && prettier --check .", + "lint": "misti ./tact.config.json", + "test": "pmRun build && jest", + "verifier:testnet": "pmRun test && ts-node deploy.ts", + "verifier:mainnet": "pmRun test && ts-node deploy.ts mainnet" + }, + "devDependencies": { + "@nowarp/misti": "~0.8.1", + "@tact-lang/compiler": "^1.6.6", + "@tact-lang/deployer": "^0.2.0", + "@tact-lang/ton-jest": "^0.0.4", + "@ton/core": "^0.60.1", + "@ton/crypto": "^3.3.0", + "@ton/sandbox": "^0.28.0", + "@ton/test-utils": "^0.5.0", + "@ton/ton": "^15.2.1", + "@types/jest": "^29.5.14", + "@types/node": "^22.14.0", + "jest": "^29.7.0", + "prettier": "^3.5.3", + "ts-jest": "^29.3.1", + "ts-node": "^10.9.2", + "typescript": "^5.8.3" + } +} diff --git a/template/quick-start/src/counter.spec.ts b/template/quick-start/src/counter.spec.ts new file mode 100644 index 0000000..33ce395 --- /dev/null +++ b/template/quick-start/src/counter.spec.ts @@ -0,0 +1,65 @@ +import "@ton/test-utils"; +import { toNano } from "@ton/core"; +import { Blockchain, SandboxContract, TreasuryContract } from "@ton/sandbox"; +import { Counter } from "../output/counter_Counter"; + +describe("Counter contract", () => { + let blockchain: Blockchain; + let treasury: SandboxContract; + + beforeEach(async () => { + blockchain = await Blockchain.create(); + blockchain.verbosity.print = false; + treasury = await blockchain.treasury("deployer"); + }); + + it("should be deployed correctly", async () => { + // Prepare a contract wrapper with initial data given as 0n, 0n: + // counter and ID both set to 0 + const contract = blockchain.openContract(await Counter.fromInit(0n, 0n)); + + // Send a message that `receive()` would handle + const sendResult = await contract.send(treasury.getSender(), { value: toNano(1) }, null); + + // Expect a successful deployment + expect(sendResult.transactions).toHaveTransaction({ + from: treasury.address, + to: contract.address, + deploy: true, + success: true, + }); + }); + + it("should increase the counter", async () => { + const contract = blockchain.openContract(await Counter.fromInit(0n, 0n)); + let sendResult = await contract.send(treasury.getSender(), { value: toNano(1) }, null); + expect(sendResult.transactions).toHaveTransaction({ + from: treasury.address, + to: contract.address, + deploy: true, + success: true, + }); + + // Let's increase the counter 3 times + let incTimes = 3; + while (incTimes--) { + // And each time check the counter state before and after the Add message received + const counterBefore = await contract.getCounter(); + + // The message should be sent and received successfully + sendResult = await contract.send(treasury.getSender(), { value: toNano(1) }, { $$type: 'Add' }); + expect(sendResult.transactions).toHaveTransaction({ + from: treasury.address, + to: contract.address, + success: true, + }); + + // The counter's state after receiving the Add message should increase by 1 + const counterAfter = await contract.getCounter(); + expect(counterAfter - counterBefore).toBe(1n); + } + }); + + // NOTE: To add your own tests, simply copy-paste any of the `it()` clauses + // and adjust the logic to match your expected outcomes! +}); diff --git a/template/quick-start/src/counter.tact b/template/quick-start/src/counter.tact new file mode 100644 index 0000000..5262f28 --- /dev/null +++ b/template/quick-start/src/counter.tact @@ -0,0 +1,90 @@ +//////////////////////////////////////////////////////////////// +// // +// Welcome to your first Tact smart contract! // +// ------------------------------------------ // +// // +// ;@ // +// 88; ,d ,d // +// 888: 88 88 // +// %88%8888X; MM88MMM ,adPPYYba, ,adPPYba, MM88MMM // +// ;@8888@8XX@ 88 "" `Y8 a8" "" 88 // +// 8X88 88 ,adPPPPP88 8b 88 // +// 888 88, 88, ,88 "8a, ,aa 88, // +// S88 "Y888 `"8bbdP"Y8 `"Ybbd8"' "Y888 // +// t8; // +// // +//////////////////////////////////////////////////////////////// + +/// Let's define a simple counter contract. +/// Comments below describe each of its parts. +contract Counter( + // The following are persistent state variables of the contract. + // + // Their default (initial) values are supplied during deployment + // and the current values are kept on the Blockchain, + // persisting between transactions. + // + counter: Int as uint32, // actual value of the counter + id: Int as uint32, // a unique ID to deploy multiple instances + // of this contract in a same workchain +) { + /// Registers a receiver of empty messages from other contracts. + /// + /// It handles internal messages with `null` body + /// and is very handy and cheap for the deployments. + /// + /// To deploy this contract in the local TON emulator, Sandbox, + /// we'll send a `null` body message from the special "treasury" contract + /// to the address of this one. See the `deploy.ts` script for more. + receive() { + // Forward the remaining value in the + // incoming message back to the sender. + cashback(sender()); + } + + /// Registers a binary receiver of the Add message bodies. + /// + /// Notice that we do not use the received body + /// and it is discarded with a wildcard _ + receive(_: Add) { + // Duh + self.counter += 1; + + // Forward the remaining value in the + // incoming message back to the sender + cashback(sender()); + } + + /// A getter function, which can only be called from off-chain + /// and never by other contracts. This one is useful to see the counter state. + get fun counter(): Int { + return self.counter; // ← current counter value in the contract's storage + } + + /// Another getter function, now for the ID. + get fun id(): Int { + return self.id; // ← current ID value in the contract's storage (persistent state) + } + + /// Notice that you can make a single getter that returns the contract's state + /// with all its storage variables at once by using a special structure + /// with the name of the given contract. + get fun state(): Counter { + return self; + } +} + +/// Defining a new message struct type, +/// which has no fields and a manually assigned 32-bit opcode prefix. +/// +/// The opcode goes before all message body fields and serves as a tag, +/// helping contracts differentiate the message bodies and parse their subsequent +/// contents in accordance to their message struct layouts. +/// +/// In our case there are no fields after the opcode as the message struct is empty, +/// but in the real-world contracts there are about 4-5 fields on average +/// in each defined message struct. +message(0x12345678) Add {} + +// If you've got your grip on the Counter contract, +// let's move to the second one: Open-ended Poll (poll.tact) diff --git a/template/quick-start/src/poll.spec.ts b/template/quick-start/src/poll.spec.ts new file mode 100644 index 0000000..3fd1c6c --- /dev/null +++ b/template/quick-start/src/poll.spec.ts @@ -0,0 +1,86 @@ +import "@ton/test-utils"; +import { toNano } from "@ton/core"; +import { Blockchain, SandboxContract, TreasuryContract } from "@ton/sandbox"; +import { Poll } from "../output/poll_Poll"; + +describe("Poll contract", () => { + let blockchain: Blockchain; + let treasury: SandboxContract; + + beforeEach(async () => { + blockchain = await Blockchain.create(); + blockchain.verbosity.print = false; + treasury = await blockchain.treasury("deployer"); + }); + + it("should be deployed correctly", async () => { + // Prepare a contract wrapper with initial data given as 0n, 0n, 0n: + // ID, option0, and option1 all set to 0 + const contract = blockchain.openContract(await Poll.fromInit(0n, 0n, 0n)); + + // Send a message that `receive()` would handle + const sendResult = await contract.send(treasury.getSender(), { value: toNano(1) }, null); + + // Expect a successful deployment + expect(sendResult.transactions).toHaveTransaction({ + from: treasury.address, + to: contract.address, + deploy: true, + success: true, + }); + }); + + it("should deploy Voter contracts correctly", async () => { + const contract = blockchain.openContract(await Poll.fromInit(0n, 0n, 0n)); + let sendResult = await contract.send(treasury.getSender(), { value: toNano(1) }, null); + expect(sendResult.transactions).toHaveTransaction({ + from: treasury.address, + to: contract.address, + deploy: true, + success: true, + }); + + // Let's create Toncoin wallets for two voters + const wallet0 = await blockchain.treasury("voter-wallet-0"); + const wallet1 = await blockchain.treasury("voter-wallet-1"); + + // And a small helper function for our needs + const voteFrom = async ( + wallet: SandboxContract, + option: 0n | 1n, + success = true, + ) => { + const res = await contract.send(wallet.getSender(), { value: toNano(1) }, {$$type: 'Vote', option }); + expect(res.transactions).toHaveTransaction({ + from: wallet.address, + to: contract.address, + success, + }); + }; + + // Vote for option 0 from the first voter's wallet + await voteFrom(wallet0, 0n); + + // Vote for option 1 from the second voter's wallet + await voteFrom(wallet1, 1n); + + // There should be a vote for option one and option two + const state = await contract.getState(); + expect(state.option0).toBe(1n); + expect(state.option1).toBe(1n); + + // Finally, voters should not be able to vote more than once. + // That is, the resulting state after new vote attempts should not change. + await voteFrom(wallet0, 0n); // repeat previous vote + await voteFrom(wallet0, 1n); // attempt a different vote + await voteFrom(wallet1, 1n); // repeat previous vote + await voteFrom(wallet1, 0n); // attempt a different vote + + const finalState = await contract.getState(); + expect(finalState.option0).toBe(state.option0); + expect(finalState.option1).toBe(state.option1); + }); + + // NOTE: To add your own tests, simply copy-paste any of the `it()` clauses + // and adjust the logic to match your expected outcomes! +}); diff --git a/template/quick-start/src/poll.tact b/template/quick-start/src/poll.tact new file mode 100644 index 0000000..ce50928 --- /dev/null +++ b/template/quick-start/src/poll.tact @@ -0,0 +1,129 @@ +/// The following contract is similar to a poll feature you've probably seen on many +/// social media networks out there, except that now it works on the blockchain +/// and is decentralized — anyone can deploy their own poll and count unique votes +/// for some options. In this example, the poll only has 2 options to pick from. +/// +/// The parent Poll contract deploys individual Voter contracts for each voter's Toncoin wallet. +/// This approach for building contract systems is called "sharding", and it is very common on TON. +/// Instead of attempting to make a single large contract process everything, +/// it is much better to deploy small contracts for each user +/// and have a single main (or master) contract provisioning those small contracts. +/// +/// Sharding is the best approach on TON for many reasons: +/// * The maximum state size of each contract is very limited +/// * Contracts can only send and receive messages to interact with each other, +/// which makes all communications asynchronous +/// * The network itself can shard and process multiple contracts at a time +/// +contract Poll( + _randomId: Int as uint256, + option0: Int as uint32, + option1: Int as uint32, +) { + // Empty receiver for the deployment, + // which expects the `null` message body + receive() { + // Forward the remaining value in the + // incoming message back to the sender + cashback(sender()); + } + + /// Registers a binary receiver of the `Vote` message bodies. + /// + /// Notice that we do not use the received body + /// and it is discarded with a wildcard _ + receive(msg: Vote) { + // Limit available options. + require( + msg.option >= 0 && msg.option <= 1, + "Only options allowed are: 0 and 1", + ); + + // Unconditionally process the vote. + // Notice that the checks for duplicated votes is done in the Voter contract + // to reduce the overall gas fees and simplify the checking logic + // as we cannot query other contracts syncronously on TON. + if (msg.option == 0) { + self.option0 += 1; + } else { + self.option1 += 1; + } + + // Send a message to existing Voter contract, + // which will deploy it if it didn't exist yet. + deploy(DeployParameters { + value: 0, + mode: SendRemainingValue, // use the excesses in the received message + init: initOf Voter(sender(), myAddress(), false), // pass initial data + body: inMsg().asCell(), // make sure the Vote message is sent + // This is important for the remaining logic to work. + }); + } + + /// This is a special receiver — it only handles the `Vote` message bodies + /// that have bounced back to this contract, which can only happen if this contract + /// has sent a message and there was an error during its processing on the recepients' side. + /// + /// In our case, the receiver of the regular `Vote` message bodies sends a deployment message + /// with the `Vote` body, processing of which can intentionally cause an error if the voter + /// has voted already. And that would mean we need to revert the excessive vote change + /// applied earlier in the previous receiver. + bounced(msg: bounced) { + if (msg.option == 0) { + self.option0 -= 1; + } else { + self.option1 -= 1; + } + } + + /// As we've seen in the Counter contract, you can make a single getter that returns + /// the contract's state with all its storage variables at once by using + /// a special structure with the name of the given contract. + get fun state(): Poll { + return self; + } +} + +/// Child contract for each of the voter's Toncoin wallet. +/// By creating an individual contract we circumvent the problem of the limited contract's state +/// and effectively use the whole blockchain as a map of voters that did participate in a given poll. +/// +/// These contracts are not deployed on their own and isn't listed as an entrypoint in tact.config.json. +/// Instead, they are deployed by the parent Poll contract. +/// +/// Since contract addresses on TON are determined on deployment from the initial code and data, +/// it is crucial for the Voter contract to have some data that will be unique to each voter. +/// That is why there is a seemingly unused `_origin` address stored in the contract's persistent state. +contract Voter( + /// An address of the voter's Toncoin wallet. + _origin: Address, + + /// Address of the `Poll` contract this Voter will participate in. + poll: Address, + + /// Whether this voter voted already. + /// This field is needed to only process unique votes + /// and it should be set to true upon the deployment of this contract. + hasVoted: Bool, +) { + /// Registers a binary receiver of the `Vote` message bodies. + /// Since its the only defined receiver, it will be used for the deployment. + /// + /// Notice that we do not use the received body + /// and it is discarded with a wildcard _ + receive(_: Vote) { + require( + self.poll == sender(), + "New votes are accepted only from the parent Poll contract", + ); + require(!self.hasVoted, "This voter has participated already"); + self.hasVoted = true; + } +} + +/// Defining a new message struct type, +/// which has a single `option` field and an automatically assigned 32-bit opcode prefix. +message Vote { + /// Allows only 0 or 1 + option: Int as uint1; +} diff --git a/template/quick-start/tact.config.json b/template/quick-start/tact.config.json new file mode 100644 index 0000000..94eaba3 --- /dev/null +++ b/template/quick-start/tact.config.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://raw.githubusercontent.com/tact-lang/tact/main/src/config/configSchema.json", + "projects": [ + { + "name": "counter", + "path": "./src/counter.tact", + "output": "./output", + "mode": "full", + "options": { + "external": false, + "debug": true, + "ipfsAbiGetter": false, + "interfacesGetter": false, + "experimental": { + "inline": false + }, + "safety": { + "nullChecks": true + }, + "optimizations": { + "internalExternalReceiversOutsideMethodsMap": false + } + } + }, + { + "name": "poll", + "path": "./src/poll.tact", + "output": "./output", + "mode": "full", + "options": { + "external": false, + "debug": true, + "ipfsAbiGetter": false, + "interfacesGetter": false, + "experimental": { + "inline": false + } + } + } + ] +} diff --git a/template/quick-start/tsconfig.json b/template/quick-start/tsconfig.json new file mode 100644 index 0000000..465597f --- /dev/null +++ b/template/quick-start/tsconfig.json @@ -0,0 +1,65 @@ +{ + "include": ["src/**/*"], + "compilerOptions": { + "outDir": "./dist", + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "lib": ["ESNext"], + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ + "declaration": true /* Generates corresponding '.d.ts' file. */, + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": false, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + "noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */, + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + "resolveJsonModule": true + } +}