├── .prettierignore ├── web └── sample │ ├── tact_build │ ├── README.md │ ├── manifest.json │ ├── polyfills.ts │ ├── tsconfig.json │ ├── package.json │ ├── vite.config.ts │ ├── index.html │ └── index.ts ├── contracts ├── sample │ ├── errcodes.tact │ ├── messages.tact │ └── sample.tact ├── staking │ ├── errcodes.tact │ ├── messages.tact │ ├── README.md │ └── staking.tact ├── nft │ ├── errcodes.tact │ ├── messages.tact │ ├── README.md │ └── nft.tact ├── jetton │ ├── errcodes.tact │ ├── messages.tact │ ├── README.md │ └── jetton.tact ├── common │ ├── errcodes.tact │ ├── messages.tact │ └── traits.tact └── helloworld │ └── helloworld.tact ├── .prettierrc ├── jest.config.ts ├── tests ├── README.md ├── HelloWorld.spec.ts ├── Sample.spec.ts ├── Nft.spec.ts ├── Staking.spec.ts └── Jetton.spec.ts ├── wrappers ├── Sample.compile.ts ├── Staking.compile.ts └── HelloWorld.compile.ts ├── tact.config.json ├── tsconfig.json ├── scripts ├── utils.ts ├── helloworld.ts ├── sample.ts ├── signature.ts ├── helloworld_upgrade.ts ├── addr_vanity.ts ├── jetton.ts ├── nft.ts └── staking.ts ├── Makefile ├── .github └── workflows │ └── test.yml ├── LICENSE ├── package.json ├── .gitignore └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /web/sample/tact_build: -------------------------------------------------------------------------------- 1 | ../../build -------------------------------------------------------------------------------- /contracts/sample/errcodes.tact: -------------------------------------------------------------------------------- 1 | const codeInvalidSignature: Int = 1000; 2 | -------------------------------------------------------------------------------- /web/sample/README.md: -------------------------------------------------------------------------------- 1 | # TACT Web Sample 2 | 3 | ```sh 4 | yarn 5 | 6 | npm run dev 7 | ``` 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "bracketSpacing": true, 6 | "semi": true 7 | } 8 | -------------------------------------------------------------------------------- /web/sample/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://blog.laisky.com", 3 | "name": "Laisky's Blog", 4 | "iconUrl": "https://ario.laisky.com/alias/head.png" 5 | } 6 | -------------------------------------------------------------------------------- /contracts/staking/errcodes.tact: -------------------------------------------------------------------------------- 1 | const codeInsufficientStakedTon: Int = 50301; 2 | const codeInsufficientStakedJetton: Int = 50302; 3 | const codeStakeAmountMustBePositive: Int = 50303; 4 | const codeStakeJettonNotFound: Int = 50304; 5 | -------------------------------------------------------------------------------- /web/sample/polyfills.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'buffer'; 2 | import process from 'process'; 3 | 4 | if (typeof window !== 'undefined') { 5 | window.global = window; 6 | window.Buffer = Buffer; 7 | window.process = process; 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | extensionsToTreatAsEsm: ['.ts'], 7 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Test on Sandbox 2 | 3 | ## References 4 | 5 | - [Writing Tests for Tact](https://github.com/ton-org/sandbox/blob/main/docs/tact-testing-examples.md) 6 | - [Debugging Tact contracts](https://docs.tact-lang.org/book/debug) 7 | 8 | ## Run 9 | 10 | ```sh 11 | make test 12 | ``` 13 | -------------------------------------------------------------------------------- /wrappers/Sample.compile.ts: -------------------------------------------------------------------------------- 1 | import { CompilerConfig } from '@ton/blueprint'; 2 | 3 | export const compile: CompilerConfig = { 4 | lang: 'tact', 5 | target: 'contracts/sample/sample.tact', 6 | options: { 7 | debug: true, 8 | external: true, 9 | masterchain: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /tact.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projects": [{ 3 | "name": "tact-utils", 4 | "path": "./contracts/sample/sample.tact", 5 | "output": "./build", 6 | "options":{ 7 | "debug": true, 8 | "external": true, 9 | "masterchain": true 10 | } 11 | }] 12 | } 13 | -------------------------------------------------------------------------------- /wrappers/Staking.compile.ts: -------------------------------------------------------------------------------- 1 | import { CompilerConfig } from '@ton/blueprint'; 2 | 3 | export const compile: CompilerConfig = { 4 | lang: 'tact', 5 | target: 'contracts/staking/staking.tact', 6 | options: { 7 | debug: true, 8 | external: true, 9 | masterchain: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /contracts/nft/errcodes.tact: -------------------------------------------------------------------------------- 1 | import "../common/errcodes.tact"; 2 | 3 | // ===================================== 4 | // Project specific error codes 5 | // ===================================== 6 | const codeNftIndexNotExists: Int = 50200; 7 | const codeNftCustomPayloadInvalid: Int = 50201; 8 | const codeRoyaltyNumInvalid: Int = 50202; 9 | -------------------------------------------------------------------------------- /wrappers/HelloWorld.compile.ts: -------------------------------------------------------------------------------- 1 | import { CompilerConfig } from '@ton/blueprint'; 2 | 3 | export const compile: CompilerConfig = { 4 | lang: 'tact', 5 | target: 'contracts/helloworld/helloworld.tact', 6 | options: { 7 | debug: true, 8 | external: true, 9 | masterchain: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "outDir": "dist", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { Address, beginCell } from '@ton/core'; 2 | 3 | 4 | export const randomInt = (): number => { 5 | return Math.floor(Math.random() * 10000); 6 | } 7 | 8 | export const emptyAddress = () => Address.parseRaw('0:0000000000000000000000000000000000000000000000000000000000000000'); 9 | 10 | export const emptySlice = () => beginCell().endCell().asSlice(); 11 | -------------------------------------------------------------------------------- /web/sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "lib": ["es6", "dom"] 11 | }, 12 | "include": ["/**/*"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /contracts/jetton/errcodes.tact: -------------------------------------------------------------------------------- 1 | import "../common/errcodes.tact"; 2 | 3 | // ===================================== 4 | // Project specific error codes 5 | // ===================================== 6 | const codeNotMintable: Int = 50100; 7 | const codeExceedsMaxSupply: Int = 50101; 8 | const codeMapIndexNotExists: Int = 50102; 9 | const codeJettonBalanceInsufficient: Int = 50103; 10 | const codeAmountShouldBePositive: Int = 50104; 11 | const codeReceiverInsufficientReceiverTonAmount: Int = 50105; 12 | -------------------------------------------------------------------------------- /scripts/helloworld.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { toNano } from '@ton/core'; 3 | import { HelloWorld } from "../build/HelloWorld/tact_HelloWorld"; 4 | 5 | export async function run(provider: NetworkProvider): Promise { 6 | const contract = await provider.open(await HelloWorld.fromInit( 7 | provider.sender().address!!, 8 | )); 9 | 10 | await contract.send( 11 | provider.sender(), 12 | { 13 | value: toNano("1"), 14 | bounce: false, 15 | }, 16 | "hello" 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | npx blueprint build 3 | 4 | test: 5 | npx blueprint test 6 | 7 | publish: 8 | npm publish --access public 9 | 10 | gen_vanity: 11 | npx blueprint run --testnet --tonconnect addr_vanity & 12 | npx blueprint run --testnet --tonconnect addr_vanity & 13 | npx blueprint run --testnet --tonconnect addr_vanity & 14 | npx blueprint run --testnet --tonconnect addr_vanity & 15 | npx blueprint run --testnet --tonconnect addr_vanity & 16 | npx blueprint run --testnet --tonconnect addr_vanity & 17 | npx blueprint run --testnet --tonconnect addr_vanity & 18 | 19 | wait 20 | -------------------------------------------------------------------------------- /contracts/sample/messages.tact: -------------------------------------------------------------------------------- 1 | import "../common/messages.tact"; 2 | 3 | 4 | message(0xfed5e4e8) MintJettonSample { 5 | queryId: Int as uint64; 6 | amount: Int as coins; 7 | receiver: Address; 8 | } 9 | 10 | message(0xe2b82ed5) MintNftSample { 11 | queryId: Int as uint64; 12 | receiver: Address; 13 | } 14 | 15 | message(0x1e8dbe39) VerifyDataSignature { 16 | queryId: Int as uint64; 17 | data: Cell; 18 | signature: Slice; 19 | publicKey: Int as uint256; 20 | } 21 | 22 | message(0x6d83a3c1) VerifyMerkleProof { 23 | queryId: Int as uint64; 24 | proof: MerkleProof; 25 | } 26 | -------------------------------------------------------------------------------- /contracts/common/errcodes.tact: -------------------------------------------------------------------------------- 1 | // ===================================== 2 | // Common error codes 3 | // ===================================== 4 | const codeSenderAddressInvalid: Int = 50000; 5 | const codeInflowValueNotSufficient: Int = 50001; 6 | const codeBalanceNotSufficient: Int = 50002; 7 | const codeUnauthorized: Int = 50003; 8 | const codeNonceInvalid: Int = 50004; 9 | const codeNotImplemented: Int = 50005; 10 | const codeMsgValueInvalid: Int = 50006; 11 | const codeForwardPayloadInvalid: Int = 50007; 12 | const codeMerkleInvalid: Int = 50008; 13 | const codeMerkleInvalidNullCursor: Int = 50009; 14 | const codeMerkleInvalidNullRight: Int = 50010; 15 | const codeMerkleInvalidNullRoot: Int = 50011; 16 | const codeMerkleInvalidRoot: Int = 50012; 17 | const codeMerkleNotEnoughProof: Int = 50013; 18 | -------------------------------------------------------------------------------- /contracts/nft/messages.tact: -------------------------------------------------------------------------------- 1 | import "../common/messages.tact"; 2 | 3 | // ===================================== 4 | // Received messages 5 | // ===================================== 6 | 7 | // ------------------------------------- 8 | // Non-standard messages 9 | // ------------------------------------- 10 | 11 | message(0xe535b616) MintNFT { 12 | queryId: Int as uint64; 13 | receiver: Address; 14 | responseDestination: Address; 15 | forwardAmount: Int as coins = 0; 16 | forwardPayload: Cell?; 17 | } 18 | 19 | message(0x48a60907) UpdateCollection { 20 | queryId: Int as uint64; 21 | responseDestination: Address; 22 | collectionContent: Tep64TokenData?; 23 | itemContentUrlPrefix: String?; 24 | royalty: RoyaltyParams?; 25 | } 26 | 27 | 28 | // ===================================== 29 | // Responsed structures 30 | // ===================================== 31 | 32 | struct NftItemInitForwardPayload { 33 | index: Int as uint256; 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "main", "test" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Cache Node.js modules 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.os }}-node- 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: '22' 29 | 30 | - name: Install dependencies 31 | run: npm install -g yarn && yarn 32 | 33 | - name: npx blueprint build helloworld 34 | run: npx blueprint build helloworld 35 | 36 | - name: npx blueprint build sample 37 | run: npx blueprint build sample 38 | 39 | - name: Run tests 40 | run: make test 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Laisky.Cai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "downloads", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host 0.0.0.0 --port 11301", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "description": "", 16 | "dependencies": { 17 | "@ton/ton": "^15.1.0", 18 | "@tonconnect/ui": "^2.0.11", 19 | "@vitejs/plugin-react": "^4.3.4", 20 | "browserify-zlib": "^0.2.0", 21 | "buffer": "^6.0.3", 22 | "events": "^3.3.0", 23 | "process": "^0.11.10", 24 | "rollup-plugin-polyfill-node": "^0.13.0", 25 | "stream": "^0.0.3", 26 | "tweetnacl": "^1.0.3", 27 | "tweetnacl-util": "^0.15.1", 28 | "vite": "^6.0.7" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.26.0", 32 | "@babel/preset-env": "^7.26.0", 33 | "babel-loader": "^9.2.1", 34 | "ts-loader": "^9.5.2", 35 | "typescript": "^5.7.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /scripts/sample.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { comment, toNano } from '@ton/core'; 3 | 4 | import { JettonMasterTemplate } from '../build/Sample/tact_JettonMasterTemplate'; 5 | import { JettonWalletTemplate } from '../build/Sample/tact_JettonWalletTemplate'; 6 | import { loadTep64TokenData } from '../build/Sample/tact_Sample'; 7 | import { randomInt } from './utils'; 8 | import { SampleMaster } from '../build/Sample/tact_SampleMaster'; 9 | 10 | 11 | export async function run(provider: NetworkProvider): Promise { 12 | const admin = provider.sender().address!!; 13 | 14 | const sampleMasterContract = await provider.open(await SampleMaster.fromInit( 15 | admin, 16 | )) 17 | 18 | // deploy sample master contract 19 | await sampleMasterContract.send( 20 | provider.sender(), 21 | { 22 | value: toNano("1"), 23 | bounce: false, 24 | }, 25 | { 26 | $$type: "Deploy", 27 | queryId: BigInt(Math.floor(Date.now() / 1000)), 28 | } 29 | ) 30 | 31 | await provider.waitForDeploy(sampleMasterContract.address, 50); 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@laisky/tact-utils", 3 | "version": "0.0.1", 4 | "description": "useful utilities for TON Tact", 5 | "author": "Laisky Cai", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/Laisky/tact-utils" 10 | }, 11 | "keywords": [ 12 | "TON", 13 | "tact" 14 | ], 15 | "scripts": { 16 | "start": "blueprint run", 17 | "build": "blueprint build", 18 | "test": "jest --verbose" 19 | }, 20 | "devDependencies": { 21 | "@ton/blueprint": "^0.27.0", 22 | "@ton/core": "~0", 23 | "@ton/crypto": "^3.3.0", 24 | "@ton/sandbox": "^0.23.0", 25 | "@ton/test-utils": "^0.4.2", 26 | "@ton/ton": "^15.1.0", 27 | "@types/jest": "^29.5.14", 28 | "@types/node": "^22.10.7", 29 | "jest": "^29.7.0", 30 | "prettier": "^3.4.2", 31 | "ts-jest": "^29.2.5", 32 | "ts-node": "^10.9.2", 33 | "typescript": "^5.7.3" 34 | }, 35 | "dependencies": { 36 | "buffer": "^6.0.3", 37 | "husky": "^9.1.7", 38 | "ton": "^13.9.0", 39 | "ton-crypto": "^3.2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/signature.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { beginCell, toNano } from '@ton/core'; 3 | import { keyPairFromSeed, sign, KeyPair, getSecureRandomBytes } from 'ton-crypto'; 4 | 5 | import { randomInt } from './utils'; 6 | import { SampleMaster } from '../build/Sample/tact_SampleMaster'; 7 | 8 | 9 | export async function run(provider: NetworkProvider): Promise { 10 | const contract = provider.open( 11 | await SampleMaster.fromInit( 12 | provider.sender().address!!, 13 | ) 14 | ); 15 | 16 | const data = Buffer.from('Hello wordl!'); 17 | const dataCell = beginCell().storeBuffer(data).endCell(); 18 | 19 | // Create Keypair 20 | const seed: Buffer = await getSecureRandomBytes(32); 21 | const keypair: KeyPair = keyPairFromSeed(seed); 22 | 23 | // Sign 24 | const signature = sign(dataCell.hash(), keypair.secretKey); 25 | 26 | const pubkey: bigint = BigInt('0x' + keypair.publicKey.toString('hex')); 27 | 28 | await contract.send( 29 | provider.sender(), 30 | { 31 | value: toNano("1"), 32 | bounce: false 33 | }, 34 | { 35 | $$type: "VerifyDataSignature", 36 | queryId: BigInt(randomInt()), 37 | data: dataCell, 38 | signature: beginCell().storeBuffer(signature).asSlice(), 39 | publicKey: pubkey, 40 | } 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /web/sample/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'path'; 4 | import { fileURLToPath } from 'url'; 5 | 6 | 7 | // Get the directory name of the current module 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | 12 | export default defineConfig({ 13 | plugins: [ 14 | react(), 15 | ], 16 | resolve: { 17 | alias: { 18 | '@': path.resolve(__dirname, 'src'), 19 | buffer: 'buffer', 20 | process: 'process/browser', 21 | stream: 'stream-browserify', 22 | zlib: 'browserify-zlib', 23 | 'tweetnacl-util': 'tweetnacl-util/nacl-util.js' 24 | }, 25 | extensions: ['.ts', '.tsx', '.js'] 26 | }, 27 | define: { 28 | 'process.env': {}, 29 | global: 'globalThis' 30 | }, 31 | build: { 32 | outDir: 'dist', 33 | commonjsOptions: { 34 | transformMixedEsModules: true 35 | }, 36 | rollupOptions: { 37 | input: './index.ts', 38 | output: { 39 | entryFileNames: 'bundle.js' 40 | } 41 | } 42 | }, 43 | server: { 44 | port: 3000, 45 | // watch: { 46 | // // usePolling: true, 47 | // interval: 1000, 48 | // additionalPaths: (watcher) => { 49 | // watcher.add('./src/scss/**'); 50 | // } 51 | // }, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /contracts/jetton/messages.tact: -------------------------------------------------------------------------------- 1 | import "../common/messages.tact"; 2 | 3 | // ===================================== 4 | // Project specific messages 5 | // ===================================== 6 | // MintJetton is a message that allows the owner to mint new tokens 7 | // and send them to a specified walletOwner. 8 | message(0xa593886f) MintJetton { 9 | queryId: Int as uint64; 10 | amount: Int; 11 | receiver: Address; 12 | responseDestination: Address; 13 | forwardAmount: Int as coins; 14 | forwardPayload: Cell?; 15 | } 16 | 17 | // MultiMint is a message that allows the owner to mint new tokens for 18 | // multiple receivers and send them to their wallets at once. 19 | message(0xe78d9033) MultiMint { 20 | queryId: Int as uint64; 21 | receivers: map; 22 | receiverCount: Int as uint32; 23 | } 24 | 25 | message UpdateJettonContent { 26 | queryId: Int as uint64; 27 | content: Tep64TokenData; 28 | } 29 | 30 | // ===================================== 31 | // Structs 32 | // ===================================== 33 | struct JettonWalletData { 34 | balance: Int; 35 | owner: Address; 36 | master: Address; 37 | walletCode: Cell; 38 | } 39 | 40 | struct JettonMasterData { 41 | totalSupply: Int; 42 | mintable: Bool; 43 | owner: Address; 44 | content: Cell; 45 | walletCode: Cell; 46 | } 47 | 48 | struct MultiMintReceiver { 49 | receiver: Address; 50 | // Amount of Jettokens to mint 51 | amount: Int; 52 | // Amount of TON to forward 53 | tonAmount: Int as coins; 54 | responseDestination: Address; 55 | forwardAmount: Int as coins; 56 | forwardPayload: Cell?; 57 | } 58 | -------------------------------------------------------------------------------- /contracts/helloworld/helloworld.tact: -------------------------------------------------------------------------------- 1 | import "@stdlib/deploy"; 2 | 3 | import "../common/traits.tact"; 4 | import "../common/messages.tact"; 5 | 6 | contract HelloWorld with Upgradable { 7 | owner: Address; 8 | 9 | init(owner: Address) { 10 | self.owner = owner; 11 | } 12 | 13 | receive("hello") { 14 | let resp: String = ""; 15 | if (self.owner != sender()) { 16 | resp = "hello, world" 17 | }else { 18 | resp = "hello, owner" 19 | } 20 | 21 | send(SendParameters{ 22 | to: sender(), 23 | value: 0, 24 | mode: SendRemainingValue, 25 | bounce: false, 26 | body: resp.asComment(), 27 | }); 28 | } 29 | 30 | get fun version(): String { 31 | return "v1"; 32 | } 33 | } 34 | 35 | 36 | contract HelloWorldV2 with Upgradable { 37 | owner: Address; 38 | 39 | init(owner: Address) { 40 | self.owner = owner; 41 | } 42 | 43 | receive("hello") { 44 | let resp: String = ""; 45 | if (self.owner != sender()) { 46 | resp = "hello-v2 world" 47 | }else { 48 | resp = "hello-v2, owner" 49 | } 50 | 51 | send(SendParameters{ 52 | to: sender(), 53 | value: 0, 54 | mode: SendRemainingValue, 55 | bounce: false, 56 | body: resp.asComment(), 57 | }); 58 | } 59 | 60 | get fun version(): String { 61 | return "v2"; 62 | } 63 | } 64 | 65 | contract HelloWorldVanity with Deployable { 66 | owner: Address; 67 | 68 | init(owner: Address, salt: Slice) { 69 | self.owner = owner; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scripts/helloworld_upgrade.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider, sleep } from '@ton/blueprint'; 2 | import { beginCell, Slice, toNano } from '@ton/core'; 3 | 4 | import { HelloWorld } from "../build/HelloWorld/tact_HelloWorld"; 5 | import { HelloWorldV2 } from "../build/HelloWorld/tact_HelloWorldV2"; 6 | 7 | export async function run(provider: NetworkProvider): Promise { 8 | // ------------------------------------- 9 | // degrade 10 | // ------------------------------------- 11 | 12 | const v1 = await provider.open(await HelloWorld.fromInit( 13 | provider.sender().address!!, 14 | )); 15 | 16 | // degrade 17 | await v1.send( 18 | provider.sender(), 19 | { 20 | value: toNano("1"), 21 | bounce: false, 22 | }, 23 | { 24 | $$type: "UpgradeContract", 25 | queryId: BigInt("123"), 26 | code: v1.init!!.code, 27 | data: null, 28 | responseDestination: provider.sender().address!!, 29 | } 30 | ) 31 | 32 | await provider.waitForDeploy(v1.address) 33 | await sleep(2000); // wait for setcode finished 34 | 35 | // show version 36 | console.log(`before upgrade, version: ${await v1.getVersion()}`) 37 | 38 | // ------------------------------------- 39 | // upgrade 40 | // ------------------------------------- 41 | const v2 = await HelloWorldV2.fromInit( 42 | provider.sender().address!!, 43 | ) 44 | await v1.send( 45 | provider.sender(), 46 | { 47 | value: toNano("1"), 48 | bounce: false, 49 | }, 50 | { 51 | $$type: "UpgradeContract", 52 | queryId: BigInt("123"), 53 | code: v2.init!!.code, 54 | data: null, 55 | responseDestination: provider.sender().address!!, 56 | } 57 | ) 58 | 59 | await provider.waitForDeploy(v1.address); 60 | await sleep(2000); // wait for setcode finished 61 | 62 | // show version 63 | console.log(`after upgrade, version: ${await v1.getVersion()}`) 64 | } 65 | -------------------------------------------------------------------------------- /contracts/nft/README.md: -------------------------------------------------------------------------------- 1 | # NFT 2 | 3 | - [NFT](#nft) 4 | - [Scripts](#scripts) 5 | - [Flows](#flows) 6 | - [Mint](#mint) 7 | - [Transfer](#transfer) 8 | - [Update Collection Data](#update-collection-data) 9 | 10 | [📖 TEP-062 NFT Standard](https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md) 11 | 12 | ## Scripts 13 | 14 | - [../scripts/nft.ts](https://github.com/Laisky/tact-utils/blob/main/scripts/nft.ts) 15 | - [../tests/NFT.spec.ts](https://github.com/Laisky/tact-utils/blob/main/tests/Nft.spec.ts) 16 | 17 | ## Flows 18 | 19 | ### Mint 20 | 21 | > ![](https://s3.laisky.com/uploads/2024/10/nft-mint.png) 22 | > 23 | > 24 | 25 | ```mermaid 26 | sequenceDiagram 27 | participant D as ResponseDestination
(mostly User) 28 | participant B as User 29 | participant A as NFT COllection 30 | participant C as NFT Item 31 | 32 | B ->>+ A: MintNFT
(0xe535b616) 33 | Note over A: update nextItemIndex 34 | A -->>- C: NFTTransfer
(0x5fcc3d14) 35 | activate C 36 | Note over C: initialized 37 | opt 38 | C -->> D: OwnershipAssigned
(0x05138d91) 39 | end 40 | C -->>- D: Excesses
(0xd53276db) 41 | ``` 42 | 43 | ### Transfer 44 | 45 | > ![](https://s3.laisky.com/uploads/2024/10/nft-transfer.png) 46 | > 47 | > 48 | 49 | ```mermaid 50 | sequenceDiagram 51 | participant D as ResponseDestination
(mostly User) 52 | participant B as User 53 | participant C as NFT Item 54 | 55 | B ->>+ C: NFTTransfer
(0x5fcc3d14) 56 | activate C 57 | Note over C: update owner 58 | opt 59 | C -->> D: OwnershipAssigned
(0x05138d91) 60 | end 61 | C -->>- D: Excesses
(0xd53276db) 62 | ``` 63 | 64 | ### Update Collection Data 65 | 66 | > ![](https://s3.laisky.com/uploads/2024/10/nft-update.png) 67 | > 68 | > 69 | 70 | ```mermaid 71 | sequenceDiagram 72 | participant D as ResponseDestination
(mostly User) 73 | participant B as User 74 | participant A as NFT COllection 75 | 76 | B ->>+ A: UpdateCollection
(0x48a60907) 77 | Note over A: update collection data 78 | A -->>- D: Excesses
(0xd53276db) 79 | ``` 80 | -------------------------------------------------------------------------------- /web/sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Airdrop 8 | 57 | 60 | 61 | 62 | 63 |
64 |
65 |
66 | 67 | 68 |
69 |
70 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /scripts/addr_vanity.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { Address, beginCell, OpenedContract, toNano } from '@ton/core'; 3 | import { HelloWorldVanity } from '../build/HelloWorld/tact_HelloWorldVanity'; 4 | 5 | import { randomInt } from './utils'; 6 | import { randomBytes } from 'crypto'; 7 | 8 | export async function run(provider: NetworkProvider): Promise { 9 | let contract: OpenedContract; 10 | 11 | // Generate a vanity address 12 | const target = '_ton'; 13 | const salt = await generateVanityAddr(provider.sender().address!!, target); 14 | 15 | if (!salt) { 16 | return; 17 | } 18 | 19 | contract = provider.open(await HelloWorldVanity.fromInit( 20 | provider.sender().address!!, 21 | beginCell().storeBuffer(salt).asSlice(), 22 | )); 23 | 24 | await contract.send( 25 | provider.sender(), 26 | { 27 | value: toNano("1"), 28 | bounce: false, 29 | }, 30 | { 31 | $$type: "Deploy", 32 | queryId: BigInt(randomInt()), 33 | } 34 | ); 35 | } 36 | 37 | async function generateVanityAddr(owner: Address, target: string): Promise { 38 | // Generate random salt 39 | let salt = randomBytes(32); 40 | 41 | // Determine increment direction 42 | let incr = BigInt(1); 43 | if (Math.random() < 0.5) { 44 | incr = BigInt(-1); 45 | } 46 | 47 | console.log(`Start searching from salt: ${salt.toString('hex')}, increment: ${incr}`); 48 | 49 | while (true) { 50 | try { 51 | // Convert salt to BigInt for manipulation 52 | let currentSalt = BigInt('0x' + salt.toString('hex')); 53 | currentSalt += incr; 54 | 55 | // Check for overflow 56 | if (currentSalt < BigInt(0) || currentSalt > BigInt('0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff')) { 57 | console.log('Overflow, exit'); 58 | return null; 59 | } 60 | 61 | // Update salt buffer 62 | salt = Buffer.from(currentSalt.toString(16).padStart(64, '0'), 'hex'); 63 | 64 | let contract = await HelloWorldVanity.fromInit( 65 | owner, 66 | beginCell().storeBuffer(salt).asSlice(), 67 | ); 68 | 69 | // The tail of the address should be '_ton' 70 | const contractAddress = contract.address.toString(); 71 | if (contractAddress.endsWith(target)) { 72 | console.log(`Vanity address found: ${contractAddress}`); 73 | console.log(`Salt used: ${salt.toString('hex')}`); 74 | return salt; 75 | } 76 | } catch (error) { 77 | console.error('Error generating vanity address:', error); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # project 133 | temp 134 | build 135 | dist 136 | .DS_Store 137 | 138 | # VS Code 139 | .vscode/* 140 | .history/ 141 | *.vsix 142 | 143 | # IDEA files 144 | .idea 145 | 146 | # VIM 147 | Session.vim 148 | -------------------------------------------------------------------------------- /contracts/jetton/README.md: -------------------------------------------------------------------------------- 1 | # Jetton 2 | 3 | - [Jetton](#jetton) 4 | - [Scripts](#scripts) 5 | - [Flows](#flows) 6 | - [Mint](#mint) 7 | - [Transfer](#transfer) 8 | - [Burn](#burn) 9 | - [ProvideWalletAddress](#providewalletaddress) 10 | 11 | [📖 TEP-074 Fungible tokens (Jettons) standard](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md) 12 | 13 | ## Scripts 14 | 15 | - [../scripts/jetton.ts](https://github.com/Laisky/tact-utils/blob/main/scripts/jetton.ts) 16 | - [../tests/Jetton.spec.ts](https://github.com/Laisky/tact-utils/blob/main/tests/Jetton.spec.ts) 17 | 18 | ## Flows 19 | 20 | ### Mint 21 | 22 | > ![](https://s3.laisky.com/uploads/2024/10/jetton-mint.png) 23 | > 24 | > 25 | 26 | ```mermaid 27 | sequenceDiagram 28 | participant D as ResponseDestination
(mostly User) 29 | participant B as User 30 | participant A as JettonMaster 31 | participant C as JettonWallet 32 | 33 | B ->>+ A: MintJetton
(0xa593886f) 34 | Note over A: update total supply 35 | A -->>- C: TokenTransferInternal
(0x178d4519) 36 | activate C 37 | Note over C: update balance 38 | opt 39 | C -->> D: TransferNotification
(0x7362d09c) 40 | end 41 | C -->>- D: Excesses
(0xd53276db) 42 | ``` 43 | 44 | ### Transfer 45 | 46 | > ![](https://s3.laisky.com/uploads/2024/10/jetton-transfer.png) 47 | > 48 | > 49 | 50 | ```mermaid 51 | sequenceDiagram 52 | participant D as ResponseDestination
(mostly User) 53 | participant B as User 54 | participant C as UserJettonWallet 55 | participant E as NewUserJettonWallet 56 | participant F as NewUser 57 | 58 | B ->>+ C: TokenTransfer
(0xf8a7ea5) 59 | activate C 60 | Note over C: update balance 61 | C -->>- E: TokenTransferInternal
(0x178d4519) 62 | activate E 63 | Note over E: update balance 64 | opt 65 | E -->> F: TransferNotification
(0x7362d09c) 66 | end 67 | E -->>- D: Excesses
(0xd53276db) 68 | ``` 69 | 70 | ### Burn 71 | 72 | > ![](https://s3.laisky.com/uploads/2024/10/jetton-burn.png) 73 | > 74 | > 75 | 76 | ```mermaid 77 | sequenceDiagram 78 | participant D as ResponseDestination
(mostly User) 79 | participant B as User 80 | participant C as JettonWallet 81 | participant A as JettonMaster 82 | 83 | B ->>+ C: Burn
(0x59f07bc) 84 | Note over C: update balance 85 | C -->>- A: TokenBurnNotification
(0x7bdd97de) 86 | activate A 87 | Note over A: update total supply 88 | A -->>- D: Excesses
(0xd53276db) 89 | ``` 90 | 91 | ### ProvideWalletAddress 92 | 93 | ```mermaid 94 | sequenceDiagram 95 | participant B as User 96 | participant A as JettonMaster 97 | 98 | B ->>+ A: ProvideWalletAddress
(0x2c76b973) 99 | A -->>- B: TakeWalletAddress
(0xd1735400) 100 | ``` 101 | -------------------------------------------------------------------------------- /web/sample/index.ts: -------------------------------------------------------------------------------- 1 | import "./polyfills"; 2 | 3 | import { TonConnectUI } from '@tonconnect/ui'; 4 | import { toNano, comment, beginCell, Address } from '@ton/core'; 5 | import { storeMintNftSample, storeMintJettonSample } from "./tact_build/Sample/tact_SampleMaster"; 6 | 7 | const SampleMasterContractAddress = "0QAtWTWntd3uwIeGCBYGn4Hk69KZYI9BxRNhUBOdCFZIm8tk"; 8 | 9 | const tonConnectUI = new TonConnectUI({ 10 | manifestUrl: 'https://s3.laisky.com/uploads/2024/09/connect-manifest-v2.json', 11 | buttonRootId: 'ton-connect' 12 | }); 13 | 14 | tonConnectUI.onStatusChange(async (walletInfo) => { 15 | console.log('walletInfo', walletInfo); 16 | 17 | const getJettonButton = document.getElementById("getJetton") as HTMLButtonElement; 18 | const getNftButton = document.getElementById("getNft") as HTMLButtonElement; 19 | 20 | if (walletInfo) { 21 | // enable buttons 22 | getJettonButton.disabled = false; 23 | getNftButton.disabled = false; 24 | } else { 25 | // disable buttons 26 | getJettonButton.disabled = true; 27 | getNftButton.disabled = true; 28 | } 29 | }); 30 | 31 | document.getElementById("getJetton") 32 | ?.addEventListener("click", async (evt) => { 33 | evt.preventDefault(); 34 | 35 | // random int from 1 to 100 36 | const number = Math.floor(Math.random() * 100) + 1; 37 | 38 | const transaction = { 39 | validUntil: Math.floor(Date.now() / 1000) + 60, // 60 sec 40 | messages: [ 41 | { 42 | address: SampleMasterContractAddress, 43 | amount: toNano("1").toString(), 44 | payload: beginCell() 45 | .store(storeMintJettonSample({ 46 | $$type: "MintJettonSample", 47 | queryId: BigInt(Math.floor(Date.now() / 1000)), 48 | amount: toNano(number), 49 | receiver: Address.parse(tonConnectUI.account!!.address), 50 | })) 51 | .endCell() 52 | .toBoc().toString("base64") 53 | }, 54 | ] 55 | } 56 | 57 | const result = await tonConnectUI.sendTransaction(transaction); 58 | console.log('result', result); 59 | }); 60 | 61 | document.getElementById("getNft") 62 | ?.addEventListener("click", async (evt) => { 63 | evt.preventDefault(); 64 | 65 | const transaction = { 66 | validUntil: Math.floor(Date.now() / 1000) + 60, // 60 sec 67 | messages: [ 68 | { 69 | address: SampleMasterContractAddress, 70 | amount: toNano("1").toString(), 71 | payload: beginCell() 72 | .store(storeMintNftSample({ 73 | $$type: "MintNftSample", 74 | queryId: BigInt(Math.floor(Date.now() / 1000)), 75 | receiver: Address.parse(tonConnectUI.account!!.address), 76 | })) 77 | .endCell() 78 | .toBoc().toString("base64") 79 | }, 80 | ] 81 | } 82 | 83 | 84 | const result = await tonConnectUI.sendTransaction(transaction); 85 | console.log('result', result); 86 | }); 87 | -------------------------------------------------------------------------------- /contracts/staking/messages.tact: -------------------------------------------------------------------------------- 1 | message(0xa576751e) StakeInternal { 2 | queryId: Int as uint64; 3 | // the address of the jetton wallet 4 | jettonWallet: Address?; 5 | jettonAmount: Int as coins; 6 | // amount of TON coins to stake 7 | amount: Int; 8 | // address to send the response to 9 | responseDestination: Address; 10 | // amount of TON to forward 11 | forwardAmount: Int as coins; 12 | // payload to forward 13 | forwardPayload: Cell?; 14 | } 15 | 16 | message(0x7ac4404c) StakeToncoin { 17 | queryId: Int as uint64; 18 | // amount of TON coins to stake 19 | amount: Int; 20 | // address to send the response to 21 | responseDestination: Address; 22 | // amount of TON to forward 23 | forwardAmount: Int as coins; 24 | // payload to forward 25 | forwardPayload: Cell?; 26 | } 27 | 28 | message(0x2c7981f1) StakeNotification { 29 | queryId: Int as uint64; 30 | // the amount of TON coins staked 31 | amount: Int as coins; 32 | jettonAmount: Int as coins; 33 | jettonWallet: Address?; 34 | forwardPayload: Cell?; 35 | } 36 | 37 | message(0xe656dfa2) StakeReleaseNotification { 38 | queryId: Int as uint64; 39 | // the amount of TON coins released 40 | amount: Int as coins; 41 | jettons: map; 42 | jettonsIdx: Int as uint64; 43 | // the address to receive released assets 44 | destination: Address; 45 | // payload to forward 46 | forwardPayload: Cell?; 47 | } 48 | 49 | // only stakingMaster's owner can call this message 50 | message(0x51fa3a81) StakeRelease { 51 | queryId: Int as uint64; 52 | // the amount of TON coins to release 53 | amount: Int as coins; 54 | // the amount of jettons to release 55 | jettons: map; 56 | jettonsIdx: Int as uint64; 57 | // the address of the staked asset owner that will be released 58 | owner: Address; 59 | // the address to receive the released assets 60 | destination: Address; 61 | // the address to send the excesses to 62 | responseDestination: Address; 63 | // the custom payload to be sent with the released ton coins 64 | customPayload: Cell?; 65 | // amount of TON to forward 66 | forwardAmount: Int as coins; 67 | // payload to forward 68 | forwardPayload: Cell?; 69 | } 70 | 71 | struct TokenTransferForwardPayload { 72 | // the data structure of the forwardPayload should be 73 | // - 0: undefined 74 | // - 1: StakeJetton 75 | // - 2: StakeReleaseNotification 76 | type: Int as uint8; 77 | stakeJetton: StakeJetton?; 78 | stakeRelease: StakeReleaseNotification?; 79 | } 80 | 81 | // put StakeJetton as the forwardPayload in TokenTransfer. 82 | // and the forwardAmount of TokenTransfer should be greater than 83 | // the sum of the tonAmount and forwardAmount of StakeJetton. 84 | struct StakeJetton { 85 | tonAmount: Int as coins; 86 | // address to send the response to 87 | responseDestination: Address; 88 | // amount of TON to forward 89 | forwardAmount: Int as coins; 90 | // payload to forward 91 | forwardPayload: Cell?; 92 | } 93 | 94 | struct StakeReleaseJettonInfo { 95 | // pay the token transfer fee, 96 | // should be greater than forwardAmount. 97 | tonAmount: Int as coins; 98 | jettonAmount: Int as coins; 99 | jettonWallet: Address; 100 | // the address to receive the released jettons. 101 | // should be wallet address, not the jetton wallet address. 102 | destination: Address; 103 | customPayload: Cell?; 104 | // amount of TON to forward 105 | forwardAmount: Int as coins; 106 | // payload to forward 107 | forwardPayload: Cell?; 108 | } 109 | 110 | struct StakedJettonInfo { 111 | jettonAmount: Int as coins; 112 | } 113 | 114 | struct StakedInfo { 115 | stakedTonAmount: Int as coins; 116 | stakedJettons: map; 117 | } 118 | -------------------------------------------------------------------------------- /contracts/staking/README.md: -------------------------------------------------------------------------------- 1 | # Staking Contract Template for TON Tact 2 | 3 | - [Staking Contract Template for TON Tact](#staking-contract-template-for-ton-tact) 4 | - [Scripts](#scripts) 5 | - [Flows](#flows) 6 | - [Stake TON coins](#stake-ton-coins) 7 | - [Stake Jettons and TON coins](#stake-jettons-and-ton-coins) 8 | - [Stake Jettons](#stake-jettons) 9 | - [Release](#release) 10 | - [Release TON coins](#release-ton-coins) 11 | - [Release Jettons](#release-jettons) 12 | 13 | Users can stake TON coins along with any Jettons. The primary entity is the Staking Master Contract, and each user will have their individual Staking Wallet Contract. Users have the flexibility to stake and redeem their assets at any time. 14 | 15 | **This is an experimental contract template! 🚀 PRs are welcome! 💻✨** 16 | 17 | ## Scripts 18 | 19 | - [../scripts/staking.ts](https://github.com/Laisky/tact-utils/blob/main/scripts/staking.ts) 20 | - [../tests/Staking.spec.ts](https://github.com/Laisky/tact-utils/blob/main/tests/Staking.spec.ts) 21 | 22 | ## Flows 23 | 24 | ### Stake TON coins 25 | 26 | > ![](https://s3.laisky.com/uploads/2024/10/stake-ton.png) 27 | > 28 | > 29 | 30 | ```mermaid 31 | sequenceDiagram 32 | participant A as StakingMaster 33 | participant B as UserStakingWallet 34 | participant C as User 35 | 36 | C ->>+ A: StakeToncoin
(0x7ac4404c) 37 | Note over A: add to locked value 38 | A -->>- B: StakeInternal
(0xa576751e) 39 | activate B 40 | Note over B: update staked balance 41 | opt 42 | B -->> C: StakeNotification
(0x2c7981f1) 43 | end 44 | B -->>- C: Excesses
(0xd53276db) 45 | 46 | ``` 47 | 48 | ### Stake Jettons and TON coins 49 | 50 | #### Stake Jettons 51 | 52 | > ![](https://s3.laisky.com/uploads/2024/10/stake-jetton.png?v=3) 53 | > 54 | > 55 | 56 | ```mermaid 57 | sequenceDiagram 58 | participant C as User 59 | participant D as UserJettonWallet 60 | participant E as StakingMasterJettonWallet 61 | participant B as UserStakingWallet 62 | participant A as StakingMaster 63 | 64 | C ->>+ D: TokenTransfer
(0xf8a7ea5)
to StakingMaster 65 | D -->>- E: TokenTransferInternal
(0x178d4519) 66 | activate E 67 | E -->>+ C: Excesses
(0xd53276db) 68 | E -->>- A: TransferNotification
(0x7362d09c) 69 | activate A 70 | A -->>- B: StakeInternal
(0xa576751e) 71 | activate B 72 | Note over B: update staked balance 73 | opt 74 | B -->> C: StakeNotification
(0x2c7981f1) 75 | end 76 | B -->>- C: Excesses
(0xd53276db) 77 | ``` 78 | 79 | ### Release 80 | 81 | > ![](https://s3.laisky.com/uploads/2024/10/stake-release.png) 82 | > 83 | > 84 | 85 | #### Release TON coins 86 | 87 | ```mermaid 88 | sequenceDiagram 89 | participant A as StakingMaster 90 | participant B as UserStakingWallet 91 | participant C as User 92 | 93 | C ->>+ B: StakeRelease
(0x51fa3a81) 94 | Note over B: update staked balance 95 | B -->>- A: StakeRelease
(0x51fa3a81) 96 | activate A 97 | Note over A: update locked value 98 | opt 99 | A -->> C: StakeReleaseNotification
(0xe656dfa2) 100 | end 101 | A -->>- C: Excesses
(0xd53276db) 102 | ``` 103 | 104 | #### Release Jettons 105 | 106 | ```mermaid 107 | sequenceDiagram 108 | participant E as StakingMasterJettonWallet 109 | participant A as StakingMaster 110 | participant B as UserStakingWallet 111 | participant C as User 112 | participant D as UserJettonWallet 113 | 114 | C ->>+ B: StakeRelease
(0x51fa3a81) 115 | Note over B: update staked balance 116 | B -->>- A: StakeRelease
(0x51fa3a81) 117 | activate A 118 | Note over A: update locked value 119 | opt 120 | A -->> C: StakeReleaseNotification
(0xe656dfa2) 121 | end 122 | A -->> C: Excesses
(0xd53276db) 123 | par 124 | A -->>- E: TokenTransfer
(0xf8a7ea5)
to User 125 | activate E 126 | E -->>- D: TokenTransferInternal
(0x178d4519) 127 | activate D 128 | D -->> C: Excesses
(0xd53276db) 129 | D -->>- C: TransferNotification
(0x7362d09c) 130 | end 131 | ``` 132 | -------------------------------------------------------------------------------- /scripts/jetton.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { comment, toNano } from '@ton/core'; 3 | 4 | import { JettonMasterTemplate } from '../build/Sample/tact_JettonMasterTemplate'; 5 | import { JettonWalletTemplate } from '../build/Sample/tact_JettonWalletTemplate'; 6 | import { loadTep64TokenData } from '../build/Sample/tact_Sample'; 7 | import { randomInt } from './utils'; 8 | 9 | 10 | export async function run(provider: NetworkProvider): Promise { 11 | const receiverAddr = provider.sender().address!!; 12 | 13 | const jettonMasterContract = await provider.open( 14 | await JettonMasterTemplate.fromInit( 15 | receiverAddr, 16 | { 17 | $$type: "Tep64TokenData", 18 | flag: BigInt("1"), 19 | content: "https://s3.laisky.com/uploads/2024/09/jetton-sample.json", 20 | } 21 | ) 22 | ); 23 | const jettonWalletContract = await provider.open( 24 | await JettonWalletTemplate.fromInit( 25 | jettonMasterContract.address, 26 | receiverAddr, 27 | ) 28 | ); 29 | 30 | console.log(`jetton master address: ${jettonMasterContract.address}`); 31 | 32 | console.log("-------------------------------------") 33 | console.log(`mint jetton to ${receiverAddr.toString()}`); 34 | console.log("-------------------------------------") 35 | 36 | await jettonMasterContract.send( 37 | provider.sender(), 38 | { 39 | value: toNano("1"), 40 | bounce: false, 41 | }, 42 | { 43 | $$type: "MintJetton", 44 | queryId: BigInt(Math.floor(Date.now() / 1000)), 45 | amount: toNano(randomInt()), 46 | receiver: receiverAddr, 47 | responseDestination: receiverAddr, 48 | forwardAmount: toNano("0.1"), 49 | forwardPayload: comment("forward_payload"), 50 | } 51 | ); 52 | 53 | console.log("-------------------------------------") 54 | console.log(`wait jetton wallet deployed and show info`); 55 | console.log("-------------------------------------") 56 | 57 | console.log(`jetton wallet address: ${jettonWalletContract.address}`); 58 | await provider.waitForDeploy(jettonWalletContract.address, 50); 59 | 60 | 61 | console.log("-------------------------------------") 62 | console.log(`transfer jetton`); 63 | console.log("-------------------------------------") 64 | await jettonWalletContract.send( 65 | provider.sender(), 66 | { 67 | value: toNano("1"), 68 | bounce: false, 69 | }, 70 | { 71 | $$type: "TokenTransfer", 72 | queryId: BigInt(Math.floor(Date.now() / 1000)), 73 | amount: toNano("1"), 74 | destination: receiverAddr, 75 | responseDestination: receiverAddr, 76 | customPayload: comment("transfer jetton"), 77 | forwardAmount: toNano("0.1"), 78 | forwardPayload: comment("forward_payload"), 79 | } 80 | ); 81 | 82 | console.log("-------------------------------------") 83 | console.log(`burn jetton`); 84 | console.log("-------------------------------------") 85 | await jettonWalletContract.send( 86 | provider.sender(), 87 | { 88 | value: toNano("1"), 89 | bounce: false, 90 | }, 91 | { 92 | $$type: "Burn", 93 | queryId: BigInt(Math.floor(Date.now() / 1000)), 94 | amount: toNano("1"), 95 | responseDestination: receiverAddr, 96 | customPayload: comment("burn jetton"), 97 | } 98 | ); 99 | 100 | console.log("-------------------------------------") 101 | console.log(`show jetton info`); 102 | console.log("-------------------------------------") 103 | 104 | const jettonData = await jettonMasterContract.getGetJettonData(); 105 | const jettonContent = loadTep64TokenData(jettonData.content.asSlice()); 106 | console.log(`mintable: ${jettonData.mintable}`); 107 | console.log(`owner: ${jettonData.owner}`); 108 | console.log(`jetton content: ${jettonContent.content}`); 109 | console.log(`jetton total supply: ${jettonData.totalSupply}`); 110 | 111 | const walletData = await jettonWalletContract.getGetWalletData(); 112 | console.log(`jetton wallet owner: ${walletData.owner}`); 113 | console.log(`jetton wallet master: ${walletData.master}`); 114 | console.log(`jetton wallet balance: ${walletData.balance}`); 115 | } 116 | -------------------------------------------------------------------------------- /tests/HelloWorld.spec.ts: -------------------------------------------------------------------------------- 1 | import { comment, toNano } from '@ton/core'; 2 | import { 3 | Blockchain, 4 | printTransactionFees, 5 | SandboxContract, 6 | TreasuryContract 7 | } from '@ton/sandbox'; 8 | import '@ton/test-utils'; 9 | 10 | import { HelloWorld } from '../build/HelloWorld/tact_HelloWorld'; 11 | import { HelloWorldV2 } from '../build/HelloWorld/tact_HelloWorldV2'; 12 | 13 | describe('HelloWorld', () => { 14 | 15 | let blockchain: Blockchain; 16 | let deployer: SandboxContract; 17 | let contract: SandboxContract; 18 | 19 | beforeAll(async () => { 20 | blockchain = await Blockchain.create(); 21 | deployer = await blockchain.treasury('deployer'); 22 | 23 | contract = blockchain.openContract( 24 | await HelloWorld.fromInit( 25 | deployer.address, 26 | ) 27 | ); 28 | }); 29 | 30 | it('deploy with hello', async () => { 31 | const tx = await contract.send( 32 | deployer.getSender(), 33 | { 34 | value: toNano('1'), 35 | bounce: false, 36 | }, 37 | 'hello' 38 | ); 39 | console.log('deploy with hello'); 40 | printTransactionFees(tx.transactions); 41 | 42 | expect(tx.transactions).toHaveTransaction({ 43 | from: deployer.address, 44 | to: contract.address, 45 | success: true, 46 | op: 0x0, 47 | }) 48 | 49 | expect(tx.transactions).toHaveTransaction({ 50 | from: contract.address, 51 | to: deployer.address, 52 | success: true, 53 | body: comment("hello, owner"), 54 | }) 55 | 56 | const ver = await contract.getVersion(); 57 | expect(ver).toEqual('v1'); 58 | }); 59 | 60 | it('hello from anomynous', async () => { 61 | const anomynous = await blockchain.treasury("anonynous"); 62 | 63 | const tx = await contract.send( 64 | anomynous.getSender(), 65 | { 66 | value: toNano('1'), 67 | bounce: false, 68 | }, 69 | 'hello' 70 | ); 71 | console.log('hello from anomynous'); 72 | printTransactionFees(tx.transactions); 73 | 74 | expect(tx.transactions).toHaveTransaction({ 75 | from: anomynous.address, 76 | to: contract.address, 77 | success: true, 78 | op: 0x0, 79 | }) 80 | 81 | expect(tx.transactions).toHaveTransaction({ 82 | from: contract.address, 83 | to: anomynous.address, 84 | success: true, 85 | body: comment("hello, world"), 86 | }) 87 | }); 88 | 89 | it('upgrade to v2', async () => { 90 | const v2Init = await HelloWorldV2.fromInit( 91 | deployer.address, 92 | ); 93 | 94 | const tx = await contract.send( 95 | deployer.getSender(), 96 | { 97 | value: toNano("1"), 98 | bounce: false, 99 | }, 100 | { 101 | $$type: "UpgradeContract", 102 | queryId: BigInt("123"), 103 | code: v2Init.init!!.code, 104 | data: null, 105 | responseDestination: deployer.getSender().address, 106 | } 107 | ) 108 | console.log('upgrade to v2'); 109 | printTransactionFees(tx.transactions); 110 | 111 | expect(tx.transactions).toHaveTransaction({ 112 | from: deployer.address, 113 | to: contract.address, 114 | success: true, 115 | op: 0x112a9509, 116 | }) 117 | expect(tx.transactions).toHaveTransaction({ 118 | from: contract.address, 119 | to: deployer.address, 120 | success: true, 121 | op: 0xd53276db, 122 | }) 123 | 124 | const ver = await contract.getVersion(); 125 | expect(ver).toEqual('v2'); 126 | }); 127 | 128 | it('downgrade to v1', async () => { 129 | const v1Init = await HelloWorld.fromInit( 130 | deployer.address, 131 | ); 132 | 133 | const tx = await contract.send( 134 | deployer.getSender(), 135 | { 136 | value: toNano("1"), 137 | bounce: false, 138 | }, 139 | { 140 | $$type: "UpgradeContract", 141 | queryId: BigInt("123"), 142 | code: v1Init.init!!.code, 143 | data: null, 144 | responseDestination: deployer.getSender().address, 145 | } 146 | ) 147 | console.log('downgrade to v1'); 148 | printTransactionFees(tx.transactions); 149 | 150 | expect(tx.transactions).toHaveTransaction({ 151 | from: deployer.address, 152 | to: contract.address, 153 | success: true, 154 | op: 0x112a9509, 155 | }) 156 | expect(tx.transactions).toHaveTransaction({ 157 | from: contract.address, 158 | to: deployer.address, 159 | success: true, 160 | op: 0xd53276db, 161 | }) 162 | 163 | const ver = await contract.getVersion(); 164 | expect(ver).toEqual('v1'); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /scripts/nft.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { comment, toNano } from '@ton/core'; 3 | 4 | import { 5 | loadTep64TokenData 6 | } from '../build/Sample/tact_NftCollectionSample'; 7 | import { NftCollectionTemplate } from '../build/Sample/tact_NftCollectionTemplate'; 8 | import { NftItemTemplate } from '../build/Sample/tact_NftItemTemplate'; 9 | import { randomInt } from './utils'; 10 | 11 | 12 | export async function run(provider: NetworkProvider): Promise { 13 | // let receiver = await provider.ui().input( 14 | // "input the address of the jetton receiver(default to yourself):", 15 | // ); 16 | 17 | // // mint nft 18 | // // strip prefix and suffix space 19 | // receiver = receiver.trim(); 20 | // let receiverAddr: Address; 21 | // if (receiver) { 22 | // receiverAddr = Address.parse(receiver); 23 | // } else { 24 | // receiverAddr = provider.sender().address!!; 25 | // } 26 | 27 | const receiverAddr = provider.sender().address!!; 28 | console.log(`mint NFT to ${receiverAddr.toString()}`); 29 | 30 | const nftCollectionContract = await provider.open( 31 | await NftCollectionTemplate.fromInit( 32 | provider.sender().address!!, 33 | { 34 | $$type: "Tep64TokenData", 35 | flag: BigInt("1"), 36 | content: "https://s3.laisky.com/uploads/2024/09/nft-sample-collection.json", 37 | }, 38 | "https://s3.laisky.com/uploads/2024/09/nft-sample-item-", 39 | null, 40 | )); 41 | 42 | console.log("-------------------------------------") 43 | console.log(`mint nft`); 44 | console.log("-------------------------------------") 45 | 46 | await nftCollectionContract.send( 47 | provider.sender(), 48 | { 49 | value: toNano("1"), 50 | bounce: false, 51 | }, 52 | { 53 | $$type: "MintNFT", 54 | queryId: BigInt(randomInt()), 55 | receiver: receiverAddr, 56 | responseDestination: receiverAddr, 57 | forwardAmount: toNano("0.1"), 58 | forwardPayload: comment("forward payload"), 59 | } 60 | ); 61 | 62 | console.log("-------------------------------------") 63 | console.log(`wait nft collection deployed and show info`); 64 | console.log("-------------------------------------") 65 | 66 | console.log(`nft collection address: ${nftCollectionContract.address}`); 67 | await provider.waitForDeploy(nftCollectionContract.address, 50); 68 | 69 | const collectionData = await nftCollectionContract.getGetCollectionData(); 70 | const nftItemContract = await provider.open( 71 | await NftItemTemplate.fromInit( 72 | nftCollectionContract.address, 73 | collectionData.nextItemIndex - BigInt(1), 74 | ) 75 | ); 76 | const collectionContent = loadTep64TokenData(collectionData.collectionContent.asSlice()); 77 | console.log(`nft collection owner: ${collectionData.ownerAddress}`); 78 | console.log(`nft collection next index: ${collectionData.nextItemIndex}`); 79 | console.log(`nft collection content: ${collectionContent.content}`); 80 | 81 | console.log("-------------------------------------"); 82 | console.log("wait nft item deployed and show info"); 83 | console.log("-------------------------------------"); 84 | 85 | console.log(`nft item address: ${nftItemContract.address}`); 86 | await provider.waitForDeploy(nftItemContract.address, 50); 87 | 88 | const itemData = await nftItemContract.getGetNftData(); 89 | const itemContentCell = await nftCollectionContract.getGetNftContent( 90 | itemData.index, 91 | itemData.individualContent, 92 | ); 93 | const itemContent = loadTep64TokenData(itemContentCell.asSlice()); 94 | console.log(`nft item owner: ${itemData.ownerAddress}`); 95 | console.log(`nft item collection: ${itemData.collectionAddress}`); 96 | console.log(`nft item index: ${itemData.index}`); 97 | console.log(`nft item content: ${itemContent.content}`); 98 | 99 | console.log("-------------------------------------"); 100 | console.log("update collection content"); 101 | console.log("-------------------------------------"); 102 | await nftCollectionContract.send( 103 | provider.sender(), 104 | { 105 | value: toNano("1"), 106 | bounce: false, 107 | }, 108 | { 109 | $$type: "UpdateCollection", 110 | queryId: BigInt(randomInt()), 111 | responseDestination: provider.sender().address!!, 112 | collectionContent: { 113 | $$type: "Tep64TokenData", 114 | flag: BigInt(1), 115 | content: "https://s3.laisky.com/uploads/2024/09/nft-sample-collection-updated.json", 116 | }, 117 | itemContentUrlPrefix: "https://s3.laisky.com/uploads/2024/09/nft-sample-item-updated-", 118 | royalty: null 119 | } 120 | ); 121 | 122 | console.log("-------------------------------------"); 123 | console.log("transfer nft item"); 124 | console.log("-------------------------------------"); 125 | await nftItemContract.send( 126 | provider.sender(), 127 | { 128 | value: toNano("1"), 129 | bounce: false, 130 | }, 131 | { 132 | $$type: "NFTTransfer", 133 | queryId: BigInt(randomInt()), 134 | newOwner: receiverAddr, 135 | responseDestination: receiverAddr, 136 | customPayload: comment("custom payload"), 137 | forwardAmount: toNano("0.1"), 138 | forwardPayload: comment("forward payload"), 139 | } 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /contracts/common/messages.tact: -------------------------------------------------------------------------------- 1 | // ===================================== 2 | // TEP-74: Standard Jettom messages 3 | // 4 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md 5 | // ===================================== 6 | 7 | message(0xf8a7ea5) TokenTransfer { 8 | queryId: Int as uint64; 9 | // the amount of jettons to transfer 10 | amount: Int as coins; 11 | // the address of the jetton receiver 12 | destination: Address; 13 | // the address to send the excesses to 14 | responseDestination: Address; 15 | // the custom payload to be sent with the excesses 16 | customPayload: Cell?; 17 | // the amount of TON coins to forward, 18 | // if not zero, will send additional msg with the specified amount 19 | forwardAmount: Int as coins; 20 | // the payload to forward 21 | forwardPayload: Cell?; 22 | } 23 | 24 | // transfer tokens between jetton wallets or master contract 25 | message(0x178d4519) TokenTransferInternal { 26 | queryId: Int as uint64; 27 | amount: Int as coins; 28 | from: Address; 29 | responseDestination: Address; 30 | forwardAmount: Int as coins; 31 | forwardPayload: Cell?; 32 | } 33 | 34 | message(0x7362d09c) TransferNotification { 35 | queryId: Int as uint64; 36 | // the amount of jettons transferred 37 | amount: Int as coins; 38 | // the address of the jetton sender 39 | sender: Address; 40 | forwardPayload: Cell?; 41 | } 42 | 43 | message(0x595f07bc) Burn { 44 | queryId: Int as uint64; 45 | amount: Int as coins; 46 | responseDestination: Address; 47 | customPayload: Cell?; 48 | } 49 | 50 | message(0x7bdd97de) TokenBurnNotification { 51 | queryId: Int as uint64; 52 | amount: Int as coins; 53 | sender: Address; 54 | responseDestination: Address; 55 | } 56 | 57 | message(0xd53276db) Excesses { 58 | queryId: Int as uint64; 59 | } 60 | 61 | // ------------------------------------- 62 | // TEP-89: Discoverable Jettons Wallets 63 | // 64 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0089-jetton-wallet-discovery.md 65 | // ------------------------------------- 66 | 67 | message(0x2c76b973) ProvideWalletAddress { 68 | queryId: Int as uint64; 69 | ownerAddress: Address; 70 | includeAddress: Bool; 71 | } 72 | 73 | message(0xd1735400) TakeWalletAddress { 74 | queryId: Int as uint64; 75 | // if it is not possible to generate wallet address 76 | // for address in question (for instance wrong workchain) 77 | // wallet_address in take_wallet_address 78 | // should be equal to addr_none 79 | walletAddress: Address; 80 | // If include_address is set to True, 81 | // take_wallet_address should include 82 | // owner_address equal to owner_address in request 83 | ownerAddress: Address?; 84 | } 85 | 86 | // ------------------------------------- 87 | // TEP-62: NFT standard messages 88 | // 89 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md 90 | // ------------------------------------- 91 | 92 | 93 | message(0x693d3950) GetRoyaltyParams { 94 | queryId: Int as uint64; 95 | } 96 | 97 | message(0xa8cb00ad) ReportRoyaltyParams { 98 | queryId: Int as uint64; 99 | numerator: Int as uint16; 100 | denominator: Int as coins; 101 | destination: Address; 102 | } 103 | 104 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md#1-transfer 105 | message(0x5fcc3d14) NFTTransfer { 106 | queryId: Int as uint64; 107 | // newOwner is address of the new owner of the NFT item. 108 | newOwner: Address; 109 | // responseDestination is the address where to send a response 110 | // with confirmation of a successful transfer and the rest of 111 | // the incoming message coins. 112 | responseDestination: Address; 113 | // customPayload is the optional custom data. 114 | customPayload: Cell?; 115 | // forwardAmount is the amount of nanotons to be sent to the new owner. 116 | forwardAmount: Int as coins; 117 | // forwardPayload is the optional custom data that should be 118 | // sent to the new owner. 119 | forwardPayload: Cell?; 120 | } 121 | 122 | message(0x05138d91) OwnershipAssigned { 123 | queryId: Int as uint64; 124 | prevOwner: Address; 125 | forwardPayload: Cell?; 126 | } 127 | 128 | message(0x2fcb26a2) GetStaticData { 129 | queryId: Int as uint64; 130 | } 131 | 132 | message(0x8b771735) ReportStaticData { 133 | queryId: Int as uint64; 134 | index: Int as uint256; 135 | collection: Address; 136 | } 137 | 138 | struct GetNftData { 139 | init: Bool; 140 | index: Int as uint256; 141 | collectionAddress: Address; 142 | ownerAddress: Address; 143 | individualContent: Cell; 144 | } 145 | 146 | struct CollectionData { 147 | nextItemIndex: Int; 148 | collectionContent: Cell; 149 | ownerAddress: Address; 150 | } 151 | 152 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0066-nft-royalty-standard.md#get-methods 153 | struct RoyaltyParams { 154 | numerator: Int; 155 | denominator: Int; 156 | destination: Address; 157 | } 158 | 159 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md#content-representation 160 | struct Tep64TokenData { 161 | // flag is the flag of the content type. 162 | // 0 means on-chain, 1 means off-chain. 163 | flag: Int as uint8; 164 | content: String; 165 | } 166 | 167 | // ------------------------------------- 168 | // Custom messages 169 | // ------------------------------------- 170 | 171 | // SetStaticTax is the message that used to set the static tax fee. 172 | message(0x1509a420) SetStaticTax { 173 | staticTax: Int as coins; 174 | } 175 | 176 | message(0x112a9509) UpgradeContract { 177 | queryId: Int; 178 | code: Cell?; 179 | data: Cell?; 180 | responseDestination: Address; 181 | } 182 | 183 | struct MerkleProof { 184 | data: Cell; 185 | root: Int as uint256; 186 | proof: map; 187 | proofLen: Int as uint32; 188 | } 189 | -------------------------------------------------------------------------------- /tests/Sample.spec.ts: -------------------------------------------------------------------------------- 1 | import { beginCell, Cell, comment, Dictionary, toNano } from '@ton/core'; 2 | import { 3 | Blockchain, 4 | printTransactionFees, 5 | SandboxContract, 6 | TreasuryContract 7 | } from '@ton/sandbox'; 8 | import '@ton/test-utils'; 9 | import { keyPairFromSeed, sign, KeyPair, getSecureRandomBytes, sha256 } from 'ton-crypto'; 10 | 11 | import { SampleMaster } from '../build/Sample/tact_SampleMaster'; 12 | 13 | describe('Jetton', () => { 14 | 15 | let blockchain: Blockchain; 16 | let admin: SandboxContract; 17 | let sampleMaster: SandboxContract; 18 | 19 | beforeAll(async () => { 20 | blockchain = await Blockchain.create(); 21 | admin = await blockchain.treasury('admin'); 22 | sampleMaster = blockchain.openContract( 23 | await SampleMaster.fromInit( 24 | admin.address, 25 | ) 26 | ); 27 | 28 | console.log(`admin: ${admin.address}`); 29 | console.log(`sampleMaster: ${sampleMaster.address}`); 30 | }); 31 | 32 | it("valid signature", async () => { 33 | const data = Buffer.from('Hello wordl!'); 34 | const dataCell = beginCell().storeBuffer(data).endCell(); 35 | 36 | // Create Keypair 37 | const seed: Buffer = await getSecureRandomBytes(32); 38 | const keypair: KeyPair = keyPairFromSeed(seed); 39 | 40 | // Sign 41 | const signature = sign(dataCell.hash(), keypair.secretKey); 42 | const pubkey: bigint = BigInt('0x' + keypair.publicKey.toString('hex')); 43 | 44 | const tx = await sampleMaster.send( 45 | admin.getSender(), 46 | { 47 | value: toNano("1"), 48 | bounce: false 49 | }, 50 | { 51 | $$type: "VerifyDataSignature", 52 | queryId: BigInt(Math.floor(Date.now() / 1000)), 53 | data: dataCell, 54 | signature: beginCell().storeBuffer(signature).asSlice(), 55 | publicKey: pubkey, 56 | } 57 | ); 58 | 59 | console.log("staking jetton"); 60 | printTransactionFees(tx.transactions); 61 | 62 | expect(tx.transactions).toHaveTransaction({ 63 | from: admin.address, 64 | to: sampleMaster.address, 65 | success: true, 66 | op: 0x1e8dbe39, // VerifyDataSignature 67 | }); 68 | }); 69 | 70 | it("invalid signature", async () => { 71 | const data = Buffer.from('Hello wordl!'); 72 | const dataCell = beginCell().storeBuffer(data).endCell(); 73 | 74 | // Create Keypair 75 | const seed: Buffer = await getSecureRandomBytes(32); 76 | const keypair: KeyPair = keyPairFromSeed(seed); 77 | 78 | // Sign 79 | const signature = sign(dataCell.hash(), keypair.secretKey); 80 | 81 | // Use different keypair 82 | const seed2: Buffer = await getSecureRandomBytes(32); 83 | const keypair2: KeyPair = keyPairFromSeed(seed2); 84 | const pubkey: bigint = BigInt('0x' + keypair2.publicKey.toString('hex')); 85 | 86 | const tx = await sampleMaster.send( 87 | admin.getSender(), 88 | { 89 | value: toNano("1"), 90 | bounce: false 91 | }, 92 | { 93 | $$type: "VerifyDataSignature", 94 | queryId: BigInt(Math.floor(Date.now() / 1000)), 95 | data: dataCell, 96 | signature: beginCell().storeBuffer(signature).asSlice(), 97 | publicKey: pubkey, 98 | } 99 | ); 100 | 101 | console.log("staking jetton"); 102 | printTransactionFees(tx.transactions); 103 | 104 | expect(tx.transactions).toHaveTransaction({ 105 | from: admin.address, 106 | to: sampleMaster.address, 107 | success: false, 108 | op: 0x1e8dbe39, // VerifyDataSignature 109 | }); 110 | }); 111 | 112 | it("int to hex", async () => { 113 | const num = BigInt("26864658293786238469963656558471928520481084212123434372023814007136979246767"); 114 | const expectHex = `${num.toString(16)}`; 115 | 116 | const gotHex = await sampleMaster.getTestInt2hex(num); 117 | expect(gotHex).toEqual(expectHex); 118 | }); 119 | 120 | it("hash int onchain", async () => { 121 | const data = 'Hello wordl!'; 122 | const dataCell = beginCell().storeStringTail(data).endCell(); 123 | 124 | const gotHash = await sampleMaster.getTestIntHash(dataCell); 125 | const expectHash = dataCell.hash().toString('hex'); 126 | 127 | expect(gotHash).toEqual(expectHash); 128 | }); 129 | 130 | it("hash string onchain", async () => { 131 | const v1 = "4d2377d0bc3befe8a721e96b13e22d3b4e557024353e69e2b5d0f315ad49aa05"; 132 | const v2 = "551f6c3e8d7ae7d9b3ac53bca9b6f82cff322fb16113820776d14a3f93b93951"; 133 | 134 | const gotHash = await sampleMaster.getTestStrHash(v1, v2); 135 | const expectHash = (await sha256(v1 + v2)).toString('hex'); 136 | 137 | console.log(BigInt("0x"+(await sha256(v1 + v2)).toString('hex'))); 138 | 139 | expect(gotHash).toEqual(expectHash); 140 | }); 141 | 142 | const generateMerkleProof = async (data: Cell) => { 143 | const d0 = comment("hello"); 144 | const d1 = comment("world"); 145 | 146 | let proofs = []; 147 | proofs.push( 148 | d0.hash().toString("hex"), 149 | d1.hash().toString("hex"), 150 | ); 151 | 152 | let root; 153 | root = (await sha256(data.hash().toString('hex') + d0.hash().toString('hex'))).toString('hex'); 154 | root = (await sha256(root + d1.hash().toString('hex'))).toString('hex'); 155 | 156 | console.log(`proofs: ${proofs}`); 157 | return { 158 | proofs, 159 | root, 160 | }; 161 | }; 162 | 163 | it("merkle offchain", async () => { 164 | const data = comment('abc'); 165 | const { proofs, root } = await generateMerkleProof(data); 166 | 167 | // verify proofs and root in js 168 | let hash = data.hash().toString('hex'); 169 | for (let i = 0; i < proofs.length; i++) { 170 | const left = hash; 171 | const right = proofs[i]; 172 | console.log(`hash: ${hash}`); 173 | hash = (await sha256(left + right)).toString('hex'); 174 | } 175 | 176 | console.log(`calculated root: ${hash}`); 177 | expect(hash).toEqual(root); 178 | }); 179 | 180 | it("merkle onchain", async () => { 181 | const data = comment('abc'); 182 | const { proofs, root } = await generateMerkleProof(data); 183 | 184 | let proof = Dictionary.empty(); 185 | for (let i = 0; i < proofs.length; i++) { 186 | proof = proof.set(i, BigInt(`0x${proofs[i]}`)); 187 | } 188 | 189 | await sampleMaster.getTestVerifyMerkleProof( 190 | { 191 | $$type: "VerifyMerkleProof", 192 | queryId: BigInt(Math.floor(Date.now() / 1000)), 193 | proof: { 194 | $$type: "MerkleProof", 195 | data: data, 196 | root: BigInt(`0x${root}`), 197 | proof: proof, 198 | proofLen: BigInt(proofs.length), 199 | }, 200 | } 201 | ); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /contracts/common/traits.tact: -------------------------------------------------------------------------------- 1 | import "@stdlib/ownable"; 2 | import "@stdlib/deploy"; 3 | 4 | import "./errcodes.tact"; 5 | import "./messages.tact"; 6 | 7 | // The native sha256 in Tact truncates the input, computing only the first 128 bytes. 8 | // Using fullSha256 allows computing the complete sha256 value. 9 | // 10 | // - https://github.com/tact-lang/tact/issues/1056 11 | // - https://docs.tact-lang.org/book/assembly-functions/#onchainsha256 12 | fun fullSha256(data: String): Int { 13 | _onchainShaPush(data); 14 | while (_onchainShaShouldProceed()) { 15 | _onchainShaOperate(); 16 | } 17 | return _onchainShaHashExt(); 18 | } 19 | 20 | // Helper assembly functions, 21 | // each manipulating the stack in their own ways 22 | // in different parts of the `fullSha256()` function 23 | asm fun _onchainShaPush(data: String) { ONE } 24 | asm fun _onchainShaShouldProceed(): Bool { OVER SREFS 0 NEQINT } 25 | asm fun _onchainShaOperate() { OVER LDREF s0 POP CTOS s0 s1 XCHG INC } 26 | asm fun _onchainShaHashExt(): Int { HASHEXT_SHA256 } 27 | 28 | trait Txable { 29 | owner: Address; 30 | // staticTax is the tax fee that is charged for each transaction. 31 | // the tax fee will be saved in the contract's balance. 32 | staticTax: Int; 33 | 34 | receive(msg: SetStaticTax) { 35 | self.receiveSetStaticTax(msg); 36 | } 37 | 38 | get fun staticTax(): Int { 39 | return self.staticTax; 40 | } 41 | 42 | virtual fun receiveSetStaticTax(msg: SetStaticTax) { 43 | nativeThrowUnless(codeUnauthorized, sender() == self.owner); 44 | 45 | self.staticTax = msg.staticTax; 46 | let answer = beginString() 47 | .concat("set static tax fee to ") 48 | .concat(msg.staticTax.toString()) 49 | .toString(); 50 | self.reply(answer.asComment()); 51 | } 52 | } 53 | 54 | trait Nonce { 55 | nonce: Int; 56 | 57 | get fun nonce(): Int { 58 | return self.nonce; 59 | } 60 | 61 | virtual fun checkNonce(receivedNonce: Int) { 62 | nativeThrowUnless(codeNonceInvalid, receivedNonce > self.nonce); 63 | self.nonce = receivedNonce; 64 | } 65 | 66 | virtual fun getNextNonce(): Int { 67 | self.nonce = self.nonce + 1; 68 | return self.nonce; 69 | } 70 | } 71 | 72 | // Common is the common trait that contains some common and useful traits. 73 | trait Common with Txable, Deployable { 74 | owner: Address; 75 | staticTax: Int; 76 | // lockedValue is the value that is locked in the contract, 77 | // can not be withdrawn by the owner. 78 | lockedValue: Int; 79 | 80 | // default to forward excesses to the owner 81 | receive(msg: Excesses) { 82 | self.receiveExcesses(msg); 83 | } 84 | 85 | // this is a non-standard method, 86 | // allows the owner to withdraw unlocked balances 87 | receive("withdraw") { 88 | self.receiveWithdraw(); 89 | } 90 | 91 | get fun tonBalance(): Int { 92 | return myBalance(); 93 | } 94 | 95 | virtual fun receiveWithdraw() { 96 | let ctx: Context = context(); 97 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 98 | nativeThrowUnless(codeBalanceNotSufficient, 99 | myBalance() > (self.lockedValue + self.staticTax)); 100 | 101 | // if there is some locked value in the contract, 102 | // should reserve the balance a little more than the locked value. 103 | if (self.lockedValue != 0) { 104 | nativeReserve(self.lockedValue + self.staticTax, ReserveExact); 105 | } 106 | 107 | send(SendParameters{ 108 | to: self.owner, 109 | value: 0, 110 | mode: SendRemainingBalance, 111 | bounce: false, 112 | body: Excesses{queryId: 0}.toCell() 113 | } 114 | ); 115 | } 116 | 117 | fun fullSha256(data: String): Int { 118 | return fullSha256(data); 119 | } 120 | 121 | fun verifyMerkleSha256(proof: MerkleProof) { 122 | nativeThrowUnless(codeMerkleNotEnoughProof, proof.proofLen > 1); 123 | 124 | let cur = proof.data.hash(); 125 | let i = 0; 126 | while (i < proof.proofLen) { 127 | let right = proof.proof.get(i); 128 | 129 | nativeThrowUnless(codeMerkleInvalidNullRight, right != null); 130 | 131 | cur = self.fullSha256(beginString() 132 | .concat(self.int2hex(cur)) 133 | .concat(self.int2hex(right!!)) 134 | .toString()); 135 | 136 | i += 1; 137 | } 138 | 139 | nativeThrowUnless(codeMerkleInvalidRoot, cur == proof.root); 140 | } 141 | 142 | // can be used to reserve the balance for SendRemainingBalance. 143 | // default to reserve the balance before current transaction plus staticTax. 144 | // 145 | // if lockedValue has been increased, the delta should be positive. 146 | virtual fun reserveValue(delta: Int) { 147 | let ctx = context(); 148 | let val = max((myBalance() - ctx.value) + self.staticTax, self.lockedValue + self.staticTax); 149 | nativeReserve(val + delta, ReserveExact); 150 | } 151 | 152 | virtual fun receiveExcesses(msg: Excesses) { 153 | self.reserveValue(0); 154 | send(SendParameters{ 155 | to: self.owner, 156 | bounce: false, 157 | value: 0, 158 | mode: SendRemainingBalance, 159 | body: msg.toCell(), 160 | } 161 | ); 162 | } 163 | 164 | fun int2hex(n: Int): String { 165 | let store: map = emptyMap(); 166 | let nextPos = 63; // Fixed 64 chars (32 bytes) output 167 | 168 | // Convert to positive BigInt 169 | let num = n; 170 | if (num < 0) { 171 | num = -num; 172 | } 173 | 174 | // Calculate hex digits 175 | while (num > 0) { 176 | let remainder = num % 16; 177 | store.set(nextPos, remainder); 178 | nextPos -= 1; 179 | num = num / 16; 180 | } 181 | 182 | // Pad with zeros 183 | while (nextPos >= 0) { 184 | store.set(nextPos, 0); 185 | nextPos -= 1; 186 | } 187 | 188 | // Build hex string 189 | let result = beginString(); 190 | let i = 0; 191 | while (i < 64) { 192 | let v = store.get(i) != null ? store.get(i)!! : 0; 193 | if (v < 10) { 194 | result.append(v.toString()); 195 | } else { 196 | // Use ASCII values for A-F 197 | result.append( 198 | (v == 10 ? "a" : 199 | v == 11 ? "b" : 200 | v == 12 ? "c" : 201 | v == 13 ? "d" : 202 | v == 14 ? "e" : "f") 203 | ); 204 | } 205 | 206 | i += 1; 207 | } 208 | 209 | return result.toString(); 210 | }} 211 | 212 | @name(set_code) 213 | native setCode(code: Cell); 214 | 215 | @name(set_data) 216 | native setData(d: Cell); 217 | 218 | // Upgradable is the trait that allows the contract to be upgraded. 219 | // 220 | // be careful when using this trait, the contract should be designed to be upgradable. 221 | trait Upgradable with Ownable { 222 | owner: Address; 223 | 224 | receive(msg: UpgradeContract) { 225 | self.receiveUpgradable(msg); 226 | } 227 | 228 | virtual fun receiveUpgradable(msg: UpgradeContract) { 229 | nativeThrowUnless(codeUnauthorized, sender() == self.owner); 230 | if (msg.code != null) { 231 | setCode(msg.code!!); 232 | } 233 | 234 | // not fully tested for data upgrade 235 | if (msg.data != null) { 236 | setData(msg.data!!); 237 | } 238 | 239 | // refund 240 | send(SendParameters{ 241 | to: msg.responseDestination, 242 | value: 0, 243 | mode: SendRemainingValue, 244 | bounce: false, 245 | body: Excesses{queryId: msg.queryId}.toCell() 246 | } 247 | ); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /scripts/staking.ts: -------------------------------------------------------------------------------- 1 | import { NetworkProvider } from '@ton/blueprint'; 2 | import { beginCell, comment, Dictionary, fromNano, toNano } from '@ton/core'; 3 | 4 | import { JettonMasterTemplate } from '../build/Sample/tact_JettonMasterTemplate'; 5 | import { JettonWalletTemplate } from '../build/Sample/tact_JettonWalletTemplate'; 6 | import { Sample } from '../build/Sample/tact_Sample'; 7 | import { 8 | StakeReleaseJettonInfo, 9 | StakingMasterTemplate, 10 | storeStakeJetton 11 | } from '../build/Staking/tact_StakingMasterTemplate'; 12 | import { StakingWalletTemplate } from '../build/Staking/tact_StakingWalletTemplate'; 13 | import { randomInt } from './utils'; 14 | import { SampleMaster } from '../build/Sample/tact_SampleMaster'; 15 | 16 | 17 | export async function run(provider: NetworkProvider): Promise { 18 | const stakingMasterContract = provider.open( 19 | await StakingMasterTemplate.fromInit( 20 | provider.sender().address!!, 21 | ) 22 | ); 23 | const stakingWalletContract = provider.open( 24 | await StakingWalletTemplate.fromInit( 25 | stakingMasterContract.address, 26 | provider.sender().address!!, 27 | ) 28 | ); 29 | const sampleMasterContract = await provider.open( 30 | await SampleMaster.fromInit( 31 | provider.sender().address!!, 32 | ), 33 | ); 34 | const sampleContract = provider.open( 35 | await Sample.fromInit( 36 | sampleMasterContract.address, 37 | provider.sender().address!!, 38 | ) 39 | ); 40 | const jettonMasterContract = provider.open( 41 | await JettonMasterTemplate.fromInit( 42 | sampleContract.address, 43 | { 44 | $$type: "Tep64TokenData", 45 | flag: BigInt("1"), 46 | content: "https://s3.laisky.com/uploads/2024/09/jetton-sample.json", 47 | } 48 | ) 49 | ); 50 | const jettonWalletContract = provider.open( 51 | await JettonWalletTemplate.fromInit( 52 | jettonMasterContract.address, 53 | provider.sender().address!!, 54 | ) 55 | ); 56 | const stakingJettonWalletContract = provider.open( 57 | await JettonWalletTemplate.fromInit( 58 | jettonMasterContract.address, 59 | stakingMasterContract.address, 60 | ) 61 | ); 62 | 63 | console.log("-------------------------------------") 64 | console.log('>> mint jetton to yourself'); 65 | console.log("-------------------------------------") 66 | console.log(`mint jetton to ${provider.sender().address!!.toString()}`); 67 | const amount = randomInt(); 68 | 69 | await sampleMasterContract.send( 70 | provider.sender(), 71 | { 72 | value: toNano("1"), 73 | bounce: false, 74 | }, 75 | { 76 | $$type: "MintJettonSample", 77 | queryId: BigInt(randomInt()), 78 | amount: toNano(amount), 79 | receiver: provider.sender().address!!, 80 | } 81 | ); 82 | 83 | console.log("-------------------------------------") 84 | console.log('>> wait jetton wallet deployed'); 85 | console.log("-------------------------------------") 86 | await provider.waitForDeploy(jettonWalletContract.address, 50); 87 | 88 | console.log("-------------------------------------") 89 | console.log("staking ton coin...") 90 | console.log("-------------------------------------") 91 | await stakingMasterContract.send( 92 | provider.sender(), 93 | { 94 | value: toNano("1"), 95 | bounce: false, 96 | }, 97 | { 98 | $$type: "StakeToncoin", 99 | queryId: BigInt(randomInt()), 100 | amount: toNano("0.01"), 101 | responseDestination: provider.sender().address!!, 102 | forwardAmount: toNano("0.1"), 103 | forwardPayload: comment("forward_payload"), 104 | } 105 | ); 106 | 107 | await provider.waitForDeploy(stakingWalletContract.address, 50); 108 | 109 | console.log("-------------------------------------") 110 | console.log("staking jetton...") 111 | console.log("-------------------------------------") 112 | await jettonWalletContract.send( 113 | provider.sender(), 114 | { 115 | value: toNano("1"), 116 | bounce: false, 117 | }, 118 | { 119 | $$type: "TokenTransfer", 120 | queryId: BigInt(Math.ceil(Math.random() * 1000000)), 121 | amount: toNano("1"), 122 | destination: stakingMasterContract.address, 123 | responseDestination: stakingWalletContract.address, 124 | forwardAmount: toNano("0.2"), 125 | forwardPayload: beginCell() 126 | .store(storeStakeJetton({ 127 | $$type: "StakeJetton", 128 | tonAmount: toNano("0.01"), 129 | responseDestination: provider.sender().address!!, 130 | forwardAmount: toNano("0.1"), 131 | forwardPayload: comment("forward_payload"), 132 | })) 133 | .endCell(), 134 | customPayload: null, 135 | } 136 | ); 137 | 138 | console.log("-------------------------------------") 139 | console.log("show staking info") 140 | console.log("-------------------------------------") 141 | { 142 | const stakedInfo = await stakingWalletContract.getStakedInfo(); 143 | console.log(`staked TON coin: ${fromNano(stakedInfo.stakedTonAmount)}`); 144 | 145 | for (const jettonWalletAddr of stakedInfo.stakedJettons.keys()) { 146 | const jettonWallet = provider.open( 147 | JettonWalletTemplate.fromAddress(jettonWalletAddr) 148 | ); 149 | const walletData = await jettonWallet.getGetWalletData(); 150 | 151 | console.log(`user staked jetton: ${fromNano(stakedInfo.stakedJettons.get(jettonWalletAddr)!!.jettonAmount)}`); 152 | console.log(`total jetton: ${fromNano(walletData.balance)}`); 153 | } 154 | } 155 | 156 | console.log("-------------------------------------") 157 | console.log("release staking") 158 | console.log("-------------------------------------") 159 | let releasejettons = Dictionary.empty(); 160 | releasejettons.set( 161 | BigInt("0"), 162 | { 163 | $$type: "StakeReleaseJettonInfo", 164 | tonAmount: toNano("0.2"), 165 | jettonAmount: toNano("0.01"), 166 | jettonWallet: stakingJettonWalletContract.address, 167 | destination: provider.sender().address!!, 168 | customPayload: null, 169 | forwardAmount: toNano("0.1"), 170 | forwardPayload: comment("forward_payload"), 171 | } 172 | ) 173 | 174 | await stakingWalletContract.send( 175 | provider.sender(), 176 | { 177 | value: toNano("1"), 178 | bounce: false, 179 | }, 180 | { 181 | $$type: "StakeRelease", 182 | queryId: BigInt(randomInt()), 183 | amount: toNano("0.001"), 184 | jettons: releasejettons, 185 | jettonsIdx: BigInt("1"), 186 | owner: provider.sender().address!!, 187 | destination: provider.sender().address!!, 188 | responseDestination: provider.sender().address!!, 189 | customPayload: comment("custom_payload"), 190 | forwardAmount: toNano("0.1"), 191 | forwardPayload: comment("forward_payload"), 192 | } 193 | ); 194 | 195 | console.log("-------------------------------------") 196 | console.log("show staking info") 197 | console.log("-------------------------------------") 198 | { 199 | const stakedInfo = await stakingWalletContract.getStakedInfo(); 200 | console.log(`staked TON coin: ${fromNano(stakedInfo.stakedTonAmount)}`); 201 | 202 | for (const jettonWalletAddr of stakedInfo.stakedJettons.keys()) { 203 | const jettonWallet = provider.open( 204 | JettonWalletTemplate.fromAddress(jettonWalletAddr) 205 | ); 206 | const walletData = await jettonWallet.getGetWalletData(); 207 | 208 | console.log(`user staked jetton: ${fromNano(stakedInfo.stakedJettons.get(jettonWalletAddr)!!.jettonAmount)}`); 209 | console.log(`total jetton: ${fromNano(walletData.balance)}`); 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/Nft.spec.ts: -------------------------------------------------------------------------------- 1 | import { comment, toNano } from '@ton/core'; 2 | import { 3 | Blockchain, 4 | printTransactionFees, 5 | SandboxContract, 6 | TreasuryContract 7 | } from '@ton/sandbox'; 8 | import '@ton/test-utils'; 9 | 10 | import { NftCollectionTemplate } from '../build/Sample/tact_NftCollectionTemplate'; 11 | import { NftItemTemplate } from '../build/Sample/tact_NftItemTemplate'; 12 | import { loadTep64TokenData } from '../build/Sample/tact_NftCollectionSample'; 13 | import { randomInt } from '../scripts/utils'; 14 | 15 | describe('NFT', () => { 16 | let blockchain: Blockchain; 17 | let nftCollectionContract: SandboxContract; 18 | let nftItemContract: SandboxContract; 19 | let admin: SandboxContract; 20 | let user: SandboxContract; 21 | let forwardReceiver: SandboxContract; 22 | 23 | beforeAll(async () => { 24 | blockchain = await Blockchain.create(); 25 | admin = await blockchain.treasury('admin'); 26 | user = await blockchain.treasury('user'); 27 | forwardReceiver = await blockchain.treasury('forwardReceiver'); 28 | 29 | nftCollectionContract = blockchain.openContract( 30 | await NftCollectionTemplate.fromInit( 31 | admin.address, 32 | { 33 | $$type: "Tep64TokenData", 34 | flag: BigInt("1"), 35 | content: "https://s3.laisky.com/uploads/2024/09/nft-sample-collection.json", 36 | }, 37 | "https://s3.laisky.com/uploads/2024/09/nft-sample-item-", 38 | { 39 | $$type: "RoyaltyParams", 40 | numerator: BigInt(10), 41 | denominator: BigInt(100), 42 | destination: forwardReceiver.address, 43 | }, 44 | ) 45 | ); 46 | 47 | nftItemContract = blockchain.openContract( 48 | await NftItemTemplate.fromInit( 49 | nftCollectionContract.address, 50 | BigInt(0), 51 | ) 52 | ); 53 | 54 | console.log(`admin: ${admin.address}`); 55 | console.log(`user: ${user.address}`); 56 | console.log(`forwardReceiver: ${forwardReceiver.address}`); 57 | console.log(`nftCollectionContract: ${nftCollectionContract.address}`); 58 | console.log(`nftItemContract: ${nftItemContract.address}`); 59 | }); 60 | 61 | it("mint nft", async () => { 62 | const tx = await nftCollectionContract.send( 63 | admin.getSender(), 64 | { 65 | value: toNano("1"), 66 | bounce: false, 67 | }, 68 | { 69 | $$type: "MintNFT", 70 | queryId: BigInt(randomInt()), 71 | receiver: user.address, 72 | responseDestination: forwardReceiver.address, 73 | forwardAmount: toNano("0.1"), 74 | forwardPayload: comment("forward payload"), 75 | } 76 | ); 77 | console.log("mint nft"); 78 | printTransactionFees(tx.transactions); 79 | 80 | expect(tx.transactions).toHaveTransaction({ 81 | from: admin.address, 82 | to: nftCollectionContract.address, 83 | success: true, 84 | op: 0xe535b616, // MintNFT 85 | }); 86 | expect(tx.transactions).toHaveTransaction({ 87 | from: nftCollectionContract.address, 88 | to: nftItemContract.address, 89 | success: true, 90 | op: 0x5fcc3d14, // NFTTransfer 91 | }); 92 | expect(tx.transactions).toHaveTransaction({ 93 | from: nftItemContract.address, 94 | to: forwardReceiver.address, 95 | success: true, 96 | op: 0x05138d91, // OwnershipAssigned 97 | }); 98 | expect(tx.transactions).toHaveTransaction({ 99 | from: nftItemContract.address, 100 | to: forwardReceiver.address, 101 | success: true, 102 | op: 0xd53276db, // Excesses 103 | }); 104 | 105 | const collectionData = await nftCollectionContract.getGetCollectionData(); 106 | expect(collectionData.nextItemIndex).toEqual(BigInt(1)); 107 | expect(collectionData.ownerAddress.equals(admin.address)).toBeTruthy(); 108 | 109 | const itemData = await nftItemContract.getGetNftData(); 110 | expect(itemData.ownerAddress.equals(user.address)).toBeTruthy(); 111 | expect(itemData.collectionAddress.equals(nftCollectionContract.address)).toBeTruthy(); 112 | expect(itemData.index).toEqual(BigInt(0)); 113 | expect(itemData.init).toBeTruthy(); 114 | }); 115 | 116 | it("transfer nft", async () => { 117 | const tx = await nftItemContract.send( 118 | user.getSender(), 119 | { 120 | value: toNano("1"), 121 | bounce: false, 122 | }, 123 | { 124 | $$type: "NFTTransfer", 125 | queryId: BigInt(randomInt()), 126 | newOwner: admin.address, 127 | responseDestination: forwardReceiver.address, 128 | customPayload: comment("custom payload"), 129 | forwardAmount: toNano("0.1"), 130 | forwardPayload: comment("forward payload"), 131 | } 132 | ); 133 | console.log("transfer nft"); 134 | printTransactionFees(tx.transactions); 135 | 136 | expect(tx.transactions).toHaveTransaction({ 137 | from: user.address, 138 | to: nftItemContract.address, 139 | success: true, 140 | op: 0x5fcc3d14, // TransferNFT 141 | }); 142 | expect(tx.transactions).toHaveTransaction({ 143 | from: nftItemContract.address, 144 | to: forwardReceiver.address, 145 | success: true, 146 | op: 0x05138d91, // OwnershipAssigned 147 | }); 148 | expect(tx.transactions).toHaveTransaction({ 149 | from: nftItemContract.address, 150 | to: forwardReceiver.address, 151 | success: true, 152 | op: 0xd53276db, // Excesses 153 | }); 154 | 155 | const itemData = await nftItemContract.getGetNftData(); 156 | expect(itemData.ownerAddress.equals(admin.address)).toBeTruthy(); 157 | }); 158 | 159 | it("update collection content", async () => { 160 | const tx = await nftCollectionContract.send( 161 | admin.getSender(), 162 | { 163 | value: toNano("1"), 164 | bounce: false, 165 | }, 166 | { 167 | $$type: "UpdateCollection", 168 | queryId: BigInt(randomInt()), 169 | collectionContent: { 170 | $$type: "Tep64TokenData", 171 | flag: BigInt("1"), 172 | content: "new-content", 173 | }, 174 | itemContentUrlPrefix: "new-prefix", 175 | responseDestination: forwardReceiver.address, 176 | royalty: null, 177 | } 178 | ); 179 | 180 | console.log("update collection content"); 181 | printTransactionFees(tx.transactions); 182 | 183 | expect(tx.transactions).toHaveTransaction({ 184 | from: admin.address, 185 | to: nftCollectionContract.address, 186 | success: true, 187 | op: 0x48a60907, // UpdateCollection 188 | }); 189 | expect(tx.transactions).toHaveTransaction({ 190 | from: nftCollectionContract.address, 191 | to: forwardReceiver.address, 192 | success: true, 193 | op: 0xd53276db, // Excesses 194 | }); 195 | 196 | const collectionData = await nftCollectionContract.getGetCollectionData(); 197 | const collectionContent = loadTep64TokenData(collectionData.collectionContent.asSlice()); 198 | expect(collectionContent.content).toEqual("new-content"); 199 | 200 | const itemData = await nftItemContract.getGetNftData(); 201 | 202 | const itemContent = await nftCollectionContract.getGetNftContent( 203 | itemData.index, 204 | itemData.individualContent, 205 | ); 206 | const itemContentData = loadTep64TokenData(itemContent.asSlice()); 207 | expect(itemContentData.content).toEqual("new-prefix0.json"); 208 | }); 209 | 210 | it("royalty", async () => { 211 | const data = await nftCollectionContract.getRoyaltyParams(); 212 | expect(data.numerator).toEqual(BigInt(10)); 213 | expect(data.denominator).toEqual(BigInt(100)); 214 | expect(data.destination.equals(forwardReceiver.address)).toBeTruthy(); 215 | 216 | const tx = await nftCollectionContract.send( 217 | admin.getSender(), 218 | { 219 | value: toNano("1"), 220 | bounce: false, 221 | }, 222 | { 223 | $$type: "GetRoyaltyParams", 224 | queryId: BigInt(randomInt()), 225 | }, 226 | ); 227 | 228 | console.log("royalty"); 229 | printTransactionFees(tx.transactions); 230 | 231 | expect(tx.transactions).toHaveTransaction({ 232 | from: admin.address, 233 | to: nftCollectionContract.address, 234 | success: true, 235 | op: 0x693d3950, // GetRoyaltyParams 236 | }); 237 | expect(tx.transactions).toHaveTransaction({ 238 | from: nftCollectionContract.address, 239 | to: admin.address, 240 | success: true, 241 | op: 0xa8cb00ad, // ReportRoyaltyParams 242 | }); 243 | }); 244 | }); 245 | -------------------------------------------------------------------------------- /contracts/sample/sample.tact: -------------------------------------------------------------------------------- 1 | // ===================================== 2 | // https://testnet.tonviewer.com/transaction/275a294d5a80852ca205449d7cfe4bc015329f0eb4b988a08c4d09bd31556862 3 | // ===================================== 4 | 5 | import "../common/traits.tact"; 6 | import "../jetton/jetton.tact"; 7 | import "../staking/staking.tact"; 8 | import "../jetton/messages.tact"; 9 | import "../nft/nft.tact"; 10 | import "../nft/messages.tact"; 11 | 12 | import "./messages.tact"; 13 | import "./errcodes.tact"; 14 | 15 | 16 | contract SampleMaster with Common { 17 | owner: Address; 18 | staticTax: Int as coins = ton("0.001"); 19 | lockedValue: Int as coins = 0; 20 | 21 | init(owner: Address) { 22 | self.owner = sender(); 23 | } 24 | 25 | fun getSample(owner: Address): StateInit { 26 | return initOf Sample( 27 | myAddress(), 28 | owner, 29 | ); 30 | } 31 | 32 | get fun testIntHash(data: Cell): String { 33 | return self.int2hex(data.hash()); 34 | } 35 | 36 | get fun testStrHash(v1: String, v2: String): String { 37 | let hashed = beginString() 38 | .concat(v1) 39 | .concat(v2) 40 | .toString(); 41 | 42 | return self.int2hex(self.fullSha256(hashed)); 43 | } 44 | 45 | get fun testVerifyMerkleProof(msg: VerifyMerkleProof) { 46 | self.verifyMerkleSha256(msg.proof); 47 | 48 | // refund 49 | // self.reserveValue(0); 50 | // send(SendParameters{ 51 | // to: sender(), 52 | // value: 0, 53 | // mode: SendRemainingBalance, 54 | // bounce: false, 55 | // body: Excesses{ 56 | // queryId: msg.queryId, 57 | // }.toCell(), 58 | // }); 59 | } 60 | 61 | receive(msg: VerifyDataSignature) { 62 | let ok = checkSignature(msg.data.hash(), msg.signature, msg.publicKey); 63 | nativeThrowUnless(codeInvalidSignature, ok); 64 | 65 | // refund 66 | send(SendParameters{ 67 | to: sender(), 68 | value: 0, 69 | mode: SendRemainingValue, 70 | bounce: false, 71 | body: Excesses{ 72 | queryId: msg.queryId, 73 | }.toCell(), 74 | }); 75 | } 76 | 77 | receive(msg: MintJettonSample) { 78 | let ctx = context(); 79 | nativeThrowUnless(codeInflowValueNotSufficient, ctx.value >= ton("0.1")); 80 | 81 | let sampleContract = self.getSample(sender()); 82 | 83 | self.reserveValue(0); 84 | send(SendParameters{ 85 | to: contractAddress(sampleContract), 86 | value: 0, 87 | mode: SendRemainingBalance, 88 | bounce: false, 89 | body: MintJetton{ 90 | queryId: msg.queryId, 91 | amount: msg.amount, 92 | receiver: msg.receiver, 93 | responseDestination: msg.receiver, 94 | forwardAmount: ton("0.1"), 95 | forwardPayload: "Newly minted jetton".asComment(), 96 | }.toCell(), 97 | data: sampleContract.data, 98 | code: sampleContract.code, 99 | }); 100 | } 101 | 102 | receive(msg: MintNftSample) { 103 | let ctx = context(); 104 | nativeThrowUnless(codeInflowValueNotSufficient, ctx.value >= ton("0.1")); 105 | 106 | let sampleContract = self.getSample(sender()); 107 | 108 | self.reserveValue(0); 109 | send(SendParameters{ 110 | to: contractAddress(sampleContract), 111 | value: 0, 112 | mode: SendRemainingBalance, 113 | bounce: false, 114 | body: MintNFT{ 115 | queryId: msg.queryId, 116 | receiver: msg.receiver, 117 | responseDestination: msg.receiver, 118 | forwardAmount: ton("0.1"), 119 | forwardPayload: "Newly minted nft".asComment(), 120 | }.toCell(), 121 | data: sampleContract.data, 122 | code: sampleContract.code, 123 | }); 124 | } 125 | 126 | get fun testInt2hex(n: Int): String { 127 | return self.int2hex(n); 128 | } 129 | } 130 | 131 | 132 | contract Sample with Common { 133 | owner: Address; 134 | master: Address; 135 | staticTax: Int as coins = ton("0.001"); 136 | lockedValue: Int as coins = 0; 137 | 138 | init(master: Address, owner: Address) { 139 | self.owner = owner; 140 | self.master = master; 141 | } 142 | 143 | fun getJettonMaster(): StateInit { 144 | return initOf JettonMasterTemplate( 145 | myAddress(), 146 | Tep64TokenData{ 147 | flag: 1, 148 | content: "https://s3.laisky.com/uploads/2024/09/jetton-sample.json", 149 | }, 150 | ); 151 | } 152 | 153 | fun getNftCollectionContract(): StateInit { 154 | return initOf NftCollectionSample( 155 | myAddress(), 156 | Tep64TokenData{ 157 | flag: 1, 158 | content: "https://s3.laisky.com/uploads/2024/09/nft-sample-collection.json", 159 | }, 160 | "https://s3.laisky.com/uploads/2024/09/nft-sample-item-", 161 | null, 162 | ); 163 | } 164 | 165 | fun getStakingMasterContract(): StateInit { 166 | return initOf StakingMasterTemplate( 167 | myAddress(), 168 | ); 169 | } 170 | 171 | fun getStakingWalletContract(): StateInit { 172 | let master = self.getStakingMasterContract(); 173 | return initOf StakingWalletTemplate( 174 | contractAddress(master), 175 | myAddress(), 176 | ); 177 | } 178 | 179 | bounced(msg: bounced) { 180 | send(SendParameters{ 181 | to: self.owner, 182 | value: 0, 183 | mode: SendRemainingValue, 184 | bounce: false, 185 | body: Excesses{ 186 | queryId: msg.queryId, 187 | }.toCell(), 188 | }); 189 | } 190 | bounced(msg: bounced) { 191 | send(SendParameters{ 192 | to: self.owner, 193 | value: 0, 194 | mode: SendRemainingValue, 195 | bounce: false, 196 | body: Excesses{ 197 | queryId: msg.queryId, 198 | }.toCell(), 199 | }); 200 | } 201 | 202 | override fun receiveExcesses(msg: Excesses) { 203 | send(SendParameters{ 204 | to: self.owner, 205 | value: 0, 206 | mode: SendRemainingValue, 207 | bounce: false, 208 | body: Excesses{ 209 | queryId: msg.queryId, 210 | }.toCell(), 211 | }); 212 | } 213 | 214 | // mint new jetton for self, then transfer it to the sender 215 | receive(msg: MintJetton) { 216 | let ctx = context(); 217 | nativeThrowUnless(codeUnauthorized, sender() == self.master); 218 | 219 | let jettonMasterContract = self.getJettonMaster(); 220 | 221 | self.reserveValue(0); 222 | send(SendParameters{ 223 | to: contractAddress(jettonMasterContract), 224 | value: 0, 225 | mode: SendRemainingBalance, 226 | bounce: true, 227 | body: msg.toCell(), 228 | code: jettonMasterContract.code, 229 | data: jettonMasterContract.data, 230 | }); 231 | } 232 | 233 | // mint new nft for self, then transfer it to the sender 234 | receive(msg: MintNFT) { 235 | let ctx = context(); 236 | nativeThrowUnless(codeUnauthorized, sender() == self.master); 237 | 238 | let nftMasterContract = self.getNftCollectionContract(); 239 | 240 | self.reserveValue(0); 241 | send(SendParameters{ 242 | to: contractAddress(nftMasterContract), 243 | value: 0, 244 | mode: SendRemainingBalance, 245 | bounce: true, 246 | body: msg.toCell(), 247 | code: nftMasterContract.code, 248 | data: nftMasterContract.data, 249 | }); 250 | } 251 | } 252 | 253 | 254 | contract NftCollectionSample with NftCollection { 255 | owner: Address; 256 | staticTax: Int as coins = ton("0.001"); 257 | lockedValue: Int as coins = ton("0"); 258 | 259 | nextItemIndex: Int as uint256 = 0; 260 | // collectionContent should follow the format of the TEP-64 261 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md 262 | collectionContent: Cell; 263 | // itemContentUrlPrefix is the prefix of the individual NFT's content url. 264 | // e.g. "https://s3.laisky.com/public/nft/ton-demo/" 265 | itemContentUrlPrefix: String; 266 | royalty: RoyaltyParams; 267 | 268 | init(owner: Address, 269 | collectionContent: Tep64TokenData, 270 | itemContentUrlPrefix: String, 271 | royalty: RoyaltyParams?) { 272 | self.owner = owner; 273 | self.collectionContent = collectionContent.toCell(); 274 | self.itemContentUrlPrefix = itemContentUrlPrefix; 275 | 276 | if (royalty != null) { 277 | self.royalty = royalty!!; 278 | } else { 279 | self.royalty = RoyaltyParams{ 280 | numerator: 0, 281 | denominator: 10, 282 | destination: owner, 283 | }; 284 | } 285 | 286 | nativeThrowUnless(codeRoyaltyNumInvalid, self.royalty.numerator < self.royalty.denominator); 287 | nativeThrowUnless(codeRoyaltyNumInvalid, self.royalty.denominator > 0); 288 | } 289 | 290 | // I only prepare 6 items for the demo, 291 | // so I just return the content by the index % 6. 292 | override fun getNftContent(index: Int, individualContent: Cell): Cell { 293 | return Tep64TokenData{ 294 | flag: 1, 295 | content: beginString() 296 | .concat(self.itemContentUrlPrefix) 297 | .concat((index % 6).toString()) 298 | .concat(".json") 299 | .toString(), 300 | }.toCell(); 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /contracts/nft/nft.tact: -------------------------------------------------------------------------------- 1 | // ===================================== 2 | // https://blog.laisky.com/p/ton-tact/ 3 | // ===================================== 4 | 5 | import "@stdlib/deploy"; 6 | import "@stdlib/ownable"; 7 | import "./messages.tact"; 8 | import "./errcodes.tact"; 9 | import "../common/traits.tact"; 10 | 11 | // ===================================== 12 | // Contracts 13 | // 14 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md 15 | // ===================================== 16 | 17 | contract NftCollectionTemplate with NftCollection { 18 | owner: Address; 19 | staticTax: Int as coins = ton("0.001"); 20 | lockedValue: Int as coins = ton("0"); 21 | 22 | nextItemIndex: Int as uint256 = 0; 23 | collectionContent: Cell; 24 | // itemContentUrlPrefix is the prefix of the individual NFT's content url. 25 | // the full url should be: `${itemContentUrlPrefix}${individualContent}.json` 26 | itemContentUrlPrefix: String; 27 | royalty: RoyaltyParams; 28 | 29 | init(owner: Address, 30 | collectionContent: Tep64TokenData, 31 | itemContentUrlPrefix: String, 32 | royalty: RoyaltyParams?) { 33 | self.owner = owner; 34 | self.collectionContent = collectionContent.toCell(); 35 | self.itemContentUrlPrefix = itemContentUrlPrefix; 36 | 37 | if (royalty != null) { 38 | self.royalty = royalty!!; 39 | } else { 40 | self.royalty = RoyaltyParams{ 41 | numerator: 0, 42 | denominator: 10, 43 | destination: owner, 44 | }; 45 | } 46 | 47 | nativeThrowUnless(codeRoyaltyNumInvalid, 48 | self.royalty.numerator < self.royalty.denominator); 49 | nativeThrowUnless(codeRoyaltyNumInvalid, 50 | self.royalty.denominator > 0); 51 | } 52 | } 53 | 54 | 55 | contract NftItemTemplate with NftItem { 56 | owner: Address; 57 | staticTax: Int as coins = ton("0.001"); 58 | lockedValue: Int as coins = ton("0"); 59 | 60 | initialized: Bool = false; 61 | collection: Address; 62 | itemIndex: Int as uint256; 63 | individualContent: Cell; 64 | 65 | init(collection: Address, index: Int) { 66 | nativeThrowUnless(codeUnauthorized, sender() == collection); 67 | 68 | self.owner = sender(); 69 | self.collection = collection; 70 | self.individualContent = emptyCell(); 71 | self.itemIndex = index; 72 | } 73 | } 74 | 75 | // ===================================== 76 | // Traits 77 | // 78 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md 79 | // ===================================== 80 | 81 | 82 | trait NftCollection with Common { 83 | owner: Address; 84 | staticTax: Int; 85 | lockedValue: Int; 86 | 87 | nextItemIndex: Int; 88 | collectionContent: Cell; 89 | // itemContentUrlPrefix is the prefix of the individual NFT's content url. 90 | // the full url should be: `${itemContentUrlPrefix}${individualContent}.json` 91 | itemContentUrlPrefix: String; 92 | royalty: RoyaltyParams; 93 | 94 | // ------------------------------------- 95 | // TEP-062 standard interfaces 96 | // ------------------------------------- 97 | 98 | // collection's owner can mint NFTs 99 | receive(msg: MintNFT) { 100 | self.receiveMintNFT(msg); 101 | } 102 | 103 | receive(msg: GetRoyaltyParams) { 104 | self.receiveGetRoyaltyParams(msg); 105 | } 106 | 107 | get fun get_collection_data(): CollectionData { 108 | return self.getCollectionData(); 109 | } 110 | 111 | get fun get_nft_address_by_index(index: Int): Address { 112 | return self.getNftAddressByIndex(index); 113 | } 114 | 115 | get fun get_nft_content(index: Int, individualContent: Cell): Cell { 116 | return self.getNftContent(index, individualContent); 117 | } 118 | 119 | get fun royalty_params(): RoyaltyParams { 120 | return self.royaltyParams(); 121 | } 122 | 123 | // ------------------------------------- 124 | // Non standard methods 125 | // ------------------------------------- 126 | 127 | // update collection's content and itemContentUrlPrefix 128 | receive(msg: UpdateCollection) { 129 | self.receiveUpdateCollection(msg); 130 | } 131 | 132 | bounced(msg: bounced) { 133 | self.receiveBouncedNFTTransfer(); 134 | } 135 | 136 | virtual fun receiveUpdateCollection(msg: UpdateCollection) { 137 | nativeThrowUnless(codeUnauthorized, sender() == self.owner); 138 | 139 | if (msg.collectionContent != null) { 140 | self.collectionContent = msg.collectionContent!!.toCell(); 141 | } 142 | 143 | if (msg.itemContentUrlPrefix != null) { 144 | self.itemContentUrlPrefix = msg.itemContentUrlPrefix!!; 145 | } 146 | 147 | if (msg.royalty != null) { 148 | self.royalty = msg.royalty!!; 149 | } 150 | 151 | // refund 152 | self.reserveValue(0); 153 | send(SendParameters{ 154 | to: msg.responseDestination, 155 | value: 0, 156 | mode: SendRemainingBalance, 157 | bounce: false, 158 | body: Excesses{ 159 | queryId: msg.queryId 160 | }.toCell(), 161 | }); 162 | } 163 | 164 | virtual fun receiveBouncedNFTTransfer() { 165 | self.nextItemIndex = self.nextItemIndex - 1; 166 | } 167 | 168 | virtual fun receiveMintNFT(msg: MintNFT) { 169 | let ctx: Context = context(); 170 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 171 | nativeThrowUnless(codeInflowValueNotSufficient, ctx.value >= self.staticTax + msg.forwardAmount); 172 | 173 | let nftItemContract = self.getNftItemContract(self.nextItemIndex); 174 | 175 | // create NFT item contract 176 | self.reserveValue(0); 177 | send(SendParameters{ 178 | to: contractAddress(nftItemContract), 179 | value: 0, 180 | bounce: true, 181 | mode: SendRemainingBalance, 182 | body: NFTTransfer { 183 | queryId: msg.queryId, 184 | newOwner: msg.receiver, 185 | responseDestination: msg.responseDestination, 186 | forwardAmount: msg.forwardAmount, 187 | forwardPayload: msg.forwardPayload, 188 | customPayload: self.nextItemIndex.toString().asSlice().asCell(), 189 | }.toCell(), 190 | code: nftItemContract.code, 191 | data: nftItemContract.data, 192 | }); 193 | 194 | self.nextItemIndex += 1; 195 | } 196 | 197 | virtual fun receiveGetRoyaltyParams(msg: GetRoyaltyParams) { 198 | send(SendParameters { 199 | to: sender(), 200 | value: 0, 201 | mode: SendRemainingValue, 202 | bounce: false, 203 | body: ReportRoyaltyParams{ 204 | queryId: msg.queryId, 205 | numerator: self.royalty.numerator, 206 | denominator: self.royalty.denominator, 207 | destination: self.royalty.destination, 208 | }.toCell(), 209 | }); 210 | } 211 | 212 | virtual fun getNftItemContract(nextItemIndex: Int): StateInit { 213 | return initOf NftItemTemplate(myAddress(), nextItemIndex); 214 | } 215 | 216 | virtual fun getCollectionData(): CollectionData { 217 | return CollectionData{ 218 | nextItemIndex: self.nextItemIndex, 219 | collectionContent: self.collectionContent, 220 | ownerAddress: self.owner, 221 | }; 222 | } 223 | 224 | virtual fun getNftAddressByIndex(index: Int): Address { 225 | nativeThrowUnless(codeNftIndexNotExists, index < self.nextItemIndex); 226 | return contractAddress(self.getNftItemContract(index)); 227 | } 228 | 229 | virtual fun getNftContent(index: Int, individualContent: Cell): Cell { 230 | return Tep64TokenData{ 231 | flag: 1, 232 | content: beginString() 233 | .concat(self.itemContentUrlPrefix) 234 | .concat(individualContent.asSlice().asString()) 235 | .concat(".json") 236 | .toString(), 237 | }.toCell(); 238 | } 239 | 240 | virtual fun royaltyParams(): RoyaltyParams { 241 | return self.royalty; 242 | } 243 | } 244 | 245 | 246 | trait NftItem with Common { 247 | owner: Address; 248 | staticTax: Int; 249 | lockedValue: Int; 250 | 251 | initialized: Bool; 252 | collection: Address; 253 | itemIndex: Int; 254 | individualContent: Cell; 255 | 256 | receive(msg: NFTTransfer) { 257 | self.receiveNFTTransfer(msg); 258 | } 259 | 260 | receive(msg: GetStaticData) { 261 | self.receiveGetStaticData(msg); 262 | } 263 | 264 | get fun get_nft_data(): GetNftData { 265 | return self.getNftData(); 266 | } 267 | 268 | virtual fun receiveNFTTransfer(msg: NFTTransfer) { 269 | let ctx: Context = context(); 270 | 271 | nativeThrowUnless(codeInflowValueNotSufficient, ctx.value >= msg.forwardAmount); 272 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 273 | 274 | let prevOwner = self.owner; 275 | self.owner = msg.newOwner; 276 | if (self.initialized == false) { 277 | // only the collection contract can initialize the NFT 278 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.collection); 279 | nativeThrowUnless(codeNftCustomPayloadInvalid, msg.customPayload != null); 280 | 281 | self.individualContent = msg.customPayload!!; 282 | self.initialized = true; 283 | } 284 | 285 | // forward 286 | if (msg.forwardAmount > 0) { 287 | send(SendParameters{ 288 | to: msg.responseDestination, 289 | value: msg.forwardAmount, 290 | bounce: false, 291 | body: OwnershipAssigned{ 292 | queryId: msg.queryId, 293 | prevOwner: prevOwner, 294 | forwardPayload: msg.forwardPayload, 295 | }.toCell(), 296 | }); 297 | } 298 | 299 | // refund the remaining balance to the responseDestination 300 | self.reserveValue(0); 301 | send(SendParameters{ 302 | to: msg.responseDestination, 303 | value: 0, 304 | mode: SendRemainingBalance, 305 | bounce: false, 306 | body: Excesses{ 307 | queryId: msg.queryId 308 | }.toCell(), 309 | }); 310 | } 311 | 312 | virtual fun receiveGetStaticData(msg: GetStaticData) { 313 | let ctx: Context = context(); 314 | 315 | self.reserveValue(0); 316 | send(SendParameters { 317 | to: ctx.sender, 318 | value: 0, 319 | mode: SendRemainingBalance, 320 | bounce: true, 321 | body: ReportStaticData{ 322 | queryId: msg.queryId, 323 | index: self.itemIndex, 324 | collection: self.collection 325 | }.toCell(), 326 | }); 327 | } 328 | 329 | virtual fun getNftData(): GetNftData { 330 | return GetNftData { 331 | init: self.initialized, 332 | index: self.itemIndex, 333 | collectionAddress: self.collection, 334 | ownerAddress: self.owner, 335 | individualContent: self.individualContent 336 | }; 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tact-utils 2 | 3 | [![Awesome TACT](https://awesome.re/badge.svg)](https://github.com/tact-lang/awesome-tact) 4 | [![Twitter](https://img.shields.io/twitter/follow/LaiskyCai?style=social)](https://twitter.com/LaiskyCai) 5 | [![Telegram](https://img.shields.io/badge/telegram-laiskycai-blue?logo=telegram)](https://t.me/laiskycai) 6 | 7 | A collection of TON Tact templates and tools. 8 | 9 | Provides ready-to-use templates for Jetton, NFT, Traits, as well as some commonly used tools. 10 | 11 | [Click to view my article on using Tact to implement Jetton & NFTs! 🌟](https://blog.laisky.com/p/ton-tact/) 12 | 13 | **Tested on Node.js v22.9** 14 | 15 | **Still undergoing frequent updates!** 16 | 17 | - [tact-utils](#tact-utils) 18 | - [Demo](#demo) 19 | - [Install](#install) 20 | - [Build](#build) 21 | - [Examples](#examples) 22 | - [Hello World](#hello-world) 23 | - [Jetton](#jetton) 24 | - [NFT](#nft) 25 | - [Helpful Traits](#helpful-traits) 26 | - [Common](#common) 27 | - [Convert hashed value from `Int` to hex `String`](#convert-hashed-value-from-int-to-hex-string) 28 | - [Onchain full-SHA256](#onchain-full-sha256) 29 | - [Verify Merkle root on-chain](#verify-merkle-root-on-chain) 30 | - [Txable](#txable) 31 | - [Upgradale](#upgradale) 32 | - [Jetton](#jetton-1) 33 | - [Jetton Template](#jetton-template) 34 | - [Jetton Trait](#jetton-trait) 35 | - [Helpful Tools](#helpful-tools) 36 | - [Helpful Communites](#helpful-communites) 37 | 38 | ## Demo 39 | 40 | 41 | 42 | ## Install 43 | 44 | Install nodejs: 45 | 46 | ```sh 47 | yarn 48 | ``` 49 | 50 | ### Build 51 | 52 | ```sh 53 | npx blueprint build 54 | ``` 55 | 56 | ## Examples 57 | 58 | `helloworld` is a simple example of a contract, while `sample` is an example that includes complex contract calls. 59 | Please do not use `helloworld` and `sample` directly in your development. 60 | Instead, use the code in `common`, `jetton`, and `nft` according to your needs. 61 | 62 | ### Hello World 63 | 64 | ```sh 65 | npx blueprint build helloworld 66 | npx blueprint run --testnet --tonconnect helloworld 67 | ``` 68 | 69 | ![](https://s3.laisky.com/uploads/2024/09/IMG_4203.jpeg) 70 | 71 | ### Jetton 72 | 73 | > To provide a more comprehensive code template, the sample deliberately includes a more complex Jetton implementation. 74 | > You don't need to use the Sample directly in your project; 75 | > rather, you should utilize the contracts and code in `jetton` and `common` as per your requirements. 76 | 77 | - [Sample Transaction](https://testnet.tonviewer.com/transaction/5fd248e34b3cb728aff786e990ac45324a2f070d89d9356fdac47fa61444813a) 78 | - [Sequence Diagram](https://github.com/Laisky/tact-utils/blob/main/contracts/jetton/README.md) 79 | 80 | ```sh 81 | npx blueprint build sample 82 | npx blueprint run --testnet --tonconnect jetton 83 | 84 | ? input the address of the jetton receiver(default to yourself): 85 | 0QARnduCSjymI91urfHE_jXlnTHrmr0e4yaPubtPQkgy553b 86 | 87 | Sent transaction 88 | ------------------------------------- 89 | jetton master address: EQD9PR60ImXHSE1KIemZGS30F0aHc0QUnfC6sMYyw9HtSGqA 90 | Contract deployed at address EQD9PR60ImXHSE1KIemZGS30F0aHc0QUnfC6sMYyw9HtSGqA 91 | You can view it at https://testnet.tonscan.org/address/EQD9PR60ImXHSE1KIemZGS30F0aHc0QUnfC6sMYyw9HtSGqA 92 | mintable: true 93 | owner: EQDRAI32YdVGZGDq18ygyPyflOpY5qIAA9ukd-OJ0CfYJ8SN 94 | jetton content: https://s3.laisky.com/uploads/2024/09/jetton-sample.json 95 | jetton total supply: 19000000000 96 | ------------------------------------- 97 | jetton wallet address: EQDJiYKObYkxFFTR5v53TihdY723W8YCh34jvdu7qcwhBhVx 98 | Contract deployed at address EQDJiYKObYkxFFTR5v53TihdY723W8YCh34jvdu7qcwhBhVx 99 | You can view it at https://testnet.tonscan.org/address/EQDJiYKObYkxFFTR5v53TihdY723W8YCh34jvdu7qcwhBhVx 100 | jetton wallet owner: EQARnduCSjymI91urfHE_jXlnTHrmr0e4yaPubtPQkgy53uU 101 | jetton wallet master: EQD9PR60ImXHSE1KIemZGS30F0aHc0QUnfC6sMYyw9HtSGqA 102 | jetton wallet balance: 19000000000 103 | ``` 104 | 105 | ![Jettom Sample](https://s3.laisky.com/uploads/2024/09/jetton-sample-shot.png) 106 | 107 | ### NFT 108 | 109 | > To provide a more comprehensive code template, the sample deliberately includes a more complex NFT implementation. 110 | > You don't need to use the Sample directly in your project; 111 | > rather, you should utilize the contracts and code in `nft` and `common` as per your requirements. 112 | 113 | - [Sample Transaction](https://testnet.tonviewer.com/transaction/aef4b07e37d012e9b8051c1c4f2bcb263194b72d7f874218271595824b62a0bd) 114 | - [Sequence Diagram](https://github.com/Laisky/tact-utils/blob/main/contracts/nft/README.md) 115 | 116 | ```sh 117 | npx blueprint build sample 118 | npx blueprint run --testnet --tonconnect nft 119 | 120 | Sent transaction 121 | ------------------------------------- 122 | nft collection address: EQBHuZqwFHShebGvdOwRCeC1XbWPvYpOZsF7k7gkirDofyXG 123 | Contract deployed at address EQBHuZqwFHShebGvdOwRCeC1XbWPvYpOZsF7k7gkirDofyXG 124 | You can view it at https://testnet.tonscan.org/address/EQBHuZqwFHShebGvdOwRCeC1XbWPvYpOZsF7k7gkirDofyXG 125 | nft collection owner: EQCVjlulLBzq9FSR2wQqZJU3uzE-TDXlvWKJAtHqu5SyHqoh 126 | nft collection next index: 1 127 | nft collection content: https://s3.laisky.com/uploads/2024/09/nft-sample-collection.json 128 | ------------------------------------- 129 | nft item address: EQCub9bLM0sjI2qJGafmMFiPsDFJhq5RkDVQRlnNV9Rr_W77 130 | Contract deployed at address EQCub9bLM0sjI2qJGafmMFiPsDFJhq5RkDVQRlnNV9Rr_W77 131 | You can view it at https://testnet.tonscan.org/address/EQCub9bLM0sjI2qJGafmMFiPsDFJhq5RkDVQRlnNV9Rr_W77 132 | nft item owner: EQCVjlulLBzq9FSR2wQqZJU3uzE-TDXlvWKJAtHqu5SyHqoh 133 | nft item collection: EQBHuZqwFHShebGvdOwRCeC1XbWPvYpOZsF7k7gkirDofyXG 134 | nft item index: 0 135 | nft item content: https://s3.laisky.com/uploads/2024/09/nft-sample-item-0.json 136 | ``` 137 | 138 | ![NFT Sample](https://s3.laisky.com/uploads/2024/09/nft-sample-shot.png) 139 | 140 | ## Helpful Traits 141 | 142 | Clone this repo to your project: 143 | 144 | ```sh 145 | git clone https://github.com/Laisky/tact-utils.git 146 | ``` 147 | 148 | Then import the traits you need: 149 | 150 | ```js 151 | import './tact-utils/contracts/common/traits.tact'; 152 | import './tact-utils/contracts/common/messages.tact'; 153 | ``` 154 | 155 | ### Common 156 | 157 | ```js 158 | import './tact-utils/contracts/common/traits.tact'; 159 | 160 | contract YOUR_CONTRACT with Common { 161 | owner: Address; 162 | } 163 | ``` 164 | 165 | #### Convert hashed value from `Int` to hex `String` 166 | 167 | In the Common Trait, there is a function named `int2hex(Int): String` that can convert a hash value of type `Int` to a hexadecimal string of type `String`. 168 | 169 | #### Onchain full-SHA256 170 | 171 | In Tact, the `sha256` function truncates the input string, keeping only the first 128 bytes. Therefore, a `fullSha256` function is re-implemented in `common` to compute the complete sha256. 172 | 173 | In `common/traits.tact`, there is both a function named `fullSha256` and a method named `fullSha256` that belongs to the Common trait. 174 | 175 | ```js 176 | // Contract 177 | contract YOURCONTRACT with Common { 178 | get fun testStrHash(v1: String, v2: String): String { 179 | let hashed = beginString() 180 | .concat(v1) 181 | .concat(v2) 182 | .toString(); 183 | 184 | return self.int2hex(self.fullSha256(hashed)); 185 | } 186 | } 187 | 188 | // Test script 189 | it("hash string onchain", async () => { 190 | const v1 = "4d2377d0bc3befe8a721e96b13e22d3b4e557024353e69e2b5d0f315ad49aa05"; 191 | const v2 = "551f6c3e8d7ae7d9b3ac53bca9b6f82cff322fb16113820776d14a3f93b93951"; 192 | 193 | const gotHash = await sampleMaster.getTestStrHash(v1, v2); 194 | const expectHash = (await sha256(v1 + v2)).toString('hex'); 195 | 196 | console.log(BigInt("0x"+(await sha256(v1 + v2)).toString('hex'))); 197 | 198 | expect(gotHash).toEqual(expectHash); 199 | }); 200 | ``` 201 | 202 | #### Verify Merkle root on-chain 203 | 204 | In `common/traits.tact`, there is a function named `verifyMerkleSha256(MerkleProof)` that can verify the Merkle root on-chain. 205 | 206 | ```js 207 | // Contract 208 | contract YOURCONTRACT with Common { 209 | get fun testVerifyMerkleProof(msg: VerifyMerkleProof) { 210 | self.verifyMerkleSha256(msg.proof); 211 | } 212 | } 213 | 214 | // Test script 215 | const generateMerkleProof = async (data: Cell) => { 216 | const d0 = comment("hello"); 217 | const d1 = comment("world"); 218 | 219 | let proofs = []; 220 | proofs.push( 221 | d0.hash().toString("hex"), 222 | d1.hash().toString("hex"), 223 | ); 224 | 225 | let root; 226 | root = (await sha256(data.hash().toString('hex') + d0.hash().toString('hex'))).toString('hex'); 227 | root = (await sha256(root + d1.hash().toString('hex'))).toString('hex'); 228 | 229 | console.log(`proofs: ${proofs}`); 230 | return { 231 | proofs, 232 | root, 233 | }; 234 | }; 235 | 236 | it("merkle onchain", async () => { 237 | const data = comment('abc'); 238 | const { proofs, root } = await generateMerkleProof(data); 239 | 240 | let proof = Dictionary.empty(); 241 | for (let i = 0; i < proofs.length; i++) { 242 | proof = proof.set(i, BigInt(`0x${proofs[i]}`)); 243 | } 244 | 245 | await sampleMaster.getTestVerifyMerkleProof( 246 | { 247 | $$type: "VerifyMerkleProof", 248 | queryId: BigInt(Math.floor(Date.now() / 1000)), 249 | proof: { 250 | $$type: "MerkleProof", 251 | data: data, 252 | root: BigInt(`0x${root}`), 253 | proof: proof, 254 | proofLen: BigInt(proofs.length), 255 | }, 256 | } 257 | ); 258 | }); 259 | 260 | ``` 261 | 262 | ### Txable 263 | 264 | Set a `staticTax` to charge a fixed fee for every transaction, keeping it in the contract. Owners can adjust it anytime via `SetStaticTax` msg. 265 | 266 | ```js 267 | contract YOUR_CONTRACT with Txable { 268 | owner: Address; 269 | staticTax: Int as coins = ton("0.001"); 270 | } 271 | ``` 272 | 273 | ### Upgradale 274 | 275 | Allow the contract to be upgraded by the owner. 276 | 277 | ```js 278 | contract YOUR_CONTRACT with Upgradable { 279 | owner: Address; 280 | } 281 | ``` 282 | 283 | ### Jetton 284 | 285 | #### Jetton Template 286 | 287 | Easily implement your own Jetton contract using Jetton Template. 288 | 289 | ```js 290 | import './tact-utils/contracts/jetton/jetton.tact'; 291 | import './tact-utils/contracts/common/messages.tact'; 292 | 293 | contract YOUR_CONTRACT { 294 | owner: Address; 295 | 296 | receive("SOME_MSG") { 297 | let jettonMaster = initOf JettonMasterTemplate( 298 | self.owner, 299 | Tep64TokenData{ 300 | flag: 1, 301 | content: "https://s3.laisky.com/uploads/2024/09/jetton-sample.json", 302 | }, 303 | ) 304 | } 305 | } 306 | ``` 307 | 308 | #### Jetton Trait 309 | 310 | You can also deeply customize Jetton contracts using Jetton Trait. 311 | 312 | ```js 313 | import './tact-utils/contracts/jetton/jetton.tact'; 314 | 315 | contract YOUR_CONTRACT with JettonMaster { 316 | owner: Address; 317 | staticTax: Int as coins = ton("0.001"); 318 | lockedValue: Int as coins = 0; 319 | content: Cell; 320 | totalSupply: Int as coins; 321 | mintable: Bool; 322 | 323 | init(owner: Address, content: Tep64TokenData) { 324 | self.owner = owner; 325 | 326 | self.content = content.toCell(); 327 | self.totalSupply = 0; 328 | self.mintable = true; 329 | } 330 | } 331 | ``` 332 | 333 | ## Helpful Tools 334 | 335 | - [TON Converter](https://ario.laisky.com/alias/ton-converter) 336 | - [Free permanent file storage.](https://ario.laisky.com/alias/doc) 337 | 338 | ## Helpful Communites 339 | 340 | - [TON Tact Language Chat](https://t.me/tactlang) 341 | - [TON Dev Chat (EN)](https://t.me/tondev_eng) 342 | -------------------------------------------------------------------------------- /contracts/staking/staking.tact: -------------------------------------------------------------------------------- 1 | // ===================================== 2 | // https://blog.laisky.com/p/ton-tact/ 3 | // ===================================== 4 | 5 | import "@stdlib/ownable"; 6 | import "@stdlib/deploy"; 7 | 8 | import "../common/traits.tact"; 9 | import "../common/messages.tact"; 10 | 11 | import "./errcodes.tact"; 12 | import "./messages.tact"; 13 | 14 | // ===================================== 15 | // Contract Templates 16 | // ===================================== 17 | 18 | const MinimalGas: Int = ton("0.01"); 19 | 20 | contract StakingMasterTemplate with StakingMaster { 21 | owner: Address; 22 | staticTax: Int as coins = ton("0.001"); 23 | lockedValue: Int as coins = 0; 24 | 25 | init(owner: Address) { 26 | self.owner = owner; 27 | } 28 | } 29 | 30 | contract StakingWalletTemplate with StakingWallet { 31 | owner: Address; 32 | master: Address; 33 | staticTax: Int as coins = ton("0.001"); 34 | lockedValue: Int as coins = 0; 35 | 36 | stakedJettons: map = emptyMap(); 37 | stakedTon: Int as coins = 0; 38 | 39 | init(master: Address, owner: Address) { 40 | self.owner = owner; 41 | self.master = master; 42 | } 43 | } 44 | 45 | // ===================================== 46 | // Contract Traits 47 | // ===================================== 48 | 49 | trait StakingMaster with Common { 50 | owner: Address; 51 | staticTax: Int; 52 | lockedValue: Int; 53 | 54 | get fun userWallet(owner: Address): Address { 55 | return contractAddress(self.getUserWallet(owner)); 56 | } 57 | 58 | receive(msg: StakeRelease) { 59 | self.receiveRelease(msg); 60 | } 61 | 62 | receive(msg: StakeToncoin) { 63 | self.receiveStakeToncoin(msg); 64 | } 65 | 66 | receive(msg: TransferNotification) { 67 | self.receiveTransferNotification(msg); 68 | } 69 | 70 | // user transfer jetton to staking master address, 71 | // and also set responseDestination to the staking master address. 72 | virtual fun receiveTransferNotification(msg: TransferNotification) { 73 | let ctx = context(); 74 | nativeThrowUnless(codeForwardPayloadInvalid, msg.forwardPayload != null); 75 | nativeThrowUnless(codeStakeAmountMustBePositive, msg.amount >= 0); 76 | 77 | let stakeMsg = StakeJetton.fromCell(msg.forwardPayload!!); 78 | nativeThrowUnless(codeInflowValueNotSufficient, 79 | ctx.value > stakeMsg.tonAmount + stakeMsg.forwardAmount + self.staticTax + MinimalGas); 80 | nativeThrowUnless(codeStakeAmountMustBePositive, stakeMsg.tonAmount >= 0); 81 | 82 | // update stake info 83 | self.lockedValue += stakeMsg.tonAmount; 84 | 85 | let userWallet = self.getUserWallet(msg.sender); 86 | 87 | // notify stake wallet to update staked info 88 | self.reserveValue(0); 89 | send(SendParameters{ 90 | to: contractAddress(userWallet), 91 | bounce: false, 92 | value: 0, 93 | mode: SendRemainingBalance, 94 | body: StakeInternal{ 95 | queryId: msg.queryId, 96 | jettonWallet: sender(), 97 | jettonAmount: msg.amount, 98 | amount: stakeMsg.tonAmount, 99 | responseDestination: stakeMsg.responseDestination, 100 | forwardAmount: stakeMsg.forwardAmount, 101 | forwardPayload: stakeMsg.forwardPayload, 102 | }.toCell(), 103 | data: userWallet.data, 104 | code: userWallet.code, 105 | }); 106 | } 107 | 108 | virtual fun receiveStakeToncoin(msg: StakeToncoin) { 109 | let ctx = context(); 110 | nativeThrowUnless(codeInflowValueNotSufficient, 111 | ctx.value >= msg.amount + msg.forwardAmount + self.staticTax); 112 | nativeThrowUnless(codeStakeAmountMustBePositive, msg.amount > 0); 113 | 114 | self.lockedValue += msg.amount; 115 | 116 | let userStakeWallet = self.getUserWallet(sender()); 117 | 118 | self.reserveValue(msg.amount); 119 | send(SendParameters{ 120 | to: contractAddress(userStakeWallet), 121 | bounce: true, 122 | value: 0, 123 | mode: SendRemainingBalance, 124 | body: StakeInternal{ 125 | queryId: msg.queryId, 126 | jettonWallet: null, 127 | jettonAmount: 0, 128 | amount: msg.amount, 129 | responseDestination: msg.responseDestination, 130 | forwardAmount: msg.forwardAmount, 131 | forwardPayload: msg.forwardPayload, 132 | }.toCell(), 133 | data: userStakeWallet.data, 134 | code: userStakeWallet.code, 135 | }); 136 | } 137 | 138 | virtual fun receiveRelease(msg: StakeRelease) { 139 | let ctx = context(); 140 | 141 | // check sender 142 | nativeThrowUnless(codeUnauthorized, 143 | ctx.sender == contractAddress(self.getUserWallet(msg.owner))); 144 | 145 | let totalCost: Int = msg.amount; 146 | 147 | // release ton coins 148 | if (msg.amount > 0) { 149 | nativeThrowUnless(codeBalanceNotSufficient, self.lockedValue >= msg.amount); 150 | self.lockedValue -= msg.amount; 151 | send(SendParameters{ 152 | to: msg.destination, 153 | bounce: false, 154 | value: msg.amount, 155 | body: msg.customPayload, 156 | }); 157 | } 158 | 159 | // release jettons 160 | let i: Int = 0; 161 | while (i < msg.jettonsIdx) { 162 | let jetton = msg.jettons.get(i)!!; 163 | totalCost += jetton.tonAmount; 164 | 165 | send(SendParameters{ 166 | to: jetton.jettonWallet, 167 | bounce: false, 168 | value: jetton.tonAmount, 169 | body: TokenTransfer{ 170 | queryId: msg.queryId, 171 | amount: jetton.jettonAmount, 172 | destination: jetton.destination, 173 | responseDestination: msg.responseDestination, 174 | customPayload: jetton.customPayload, 175 | forwardAmount: jetton.forwardAmount, 176 | forwardPayload: jetton.forwardPayload, 177 | }.toCell(), 178 | }); 179 | 180 | i += 1; 181 | } 182 | 183 | // check cost 184 | totalCost += msg.forwardAmount; 185 | nativeThrowUnless(codeInflowValueNotSufficient, ctx.value >= totalCost + self.staticTax); 186 | 187 | // forward 188 | if (msg.forwardAmount > 0) { 189 | send(SendParameters{ 190 | to: msg.destination, 191 | bounce: false, 192 | value: msg.forwardAmount, 193 | body: StakeReleaseNotification{ 194 | queryId: msg.queryId, 195 | amount: msg.amount, 196 | jettons: msg.jettons, 197 | jettonsIdx: msg.jettonsIdx, 198 | destination: msg.destination, 199 | forwardPayload: msg.forwardPayload, 200 | }.toCell(), 201 | }); 202 | } 203 | 204 | // refund 205 | self.reserveValue(-msg.amount); 206 | send(SendParameters{ 207 | to: msg.responseDestination, 208 | bounce: false, 209 | value: 0, 210 | mode: SendRemainingBalance, 211 | body: Excesses{ 212 | queryId: msg.queryId, 213 | }.toCell(), 214 | }); 215 | } 216 | 217 | // getWalletContract creates a new wallet contract for the specified owner 218 | virtual fun getUserWallet(owner: Address): StateInit { 219 | let init = initOf StakingWalletTemplate( 220 | myAddress(), 221 | owner, 222 | ); 223 | 224 | return init; 225 | } 226 | } 227 | 228 | trait StakingWallet with Common { 229 | owner: Address; 230 | master: Address; 231 | staticTax: Int; 232 | lockedValue: Int; 233 | 234 | // stakedJettons is the map of staked jettons. 235 | // key is the address of the jetton wallet contract. 236 | stakedJettons: map; 237 | stakedTon: Int; 238 | 239 | get fun stakedInfo(): StakedInfo { 240 | return self.getStakedInfo(); 241 | } 242 | 243 | receive(msg: StakeToncoin) { 244 | self.receiveStakeToncoin(msg); 245 | } 246 | 247 | receive(msg: StakeRelease) { 248 | self.receiveRelease(msg); 249 | } 250 | 251 | receive(msg: StakeInternal) { 252 | self.receiveStakeInternal(msg); 253 | } 254 | 255 | virtual fun receiveStakeInternal(msg: StakeInternal) { 256 | let ctx = context(); 257 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.master); 258 | 259 | // update staked info 260 | self.stakedTon += msg.amount; 261 | 262 | if (msg.jettonWallet != null) { 263 | let stakeInfo = self.stakedJettons.get(msg.jettonWallet!!); 264 | if (stakeInfo == null) { 265 | self.stakedJettons.set( 266 | msg.jettonWallet!!, 267 | StakedJettonInfo{ 268 | jettonAmount: msg.jettonAmount, 269 | } 270 | ); 271 | } else { 272 | self.stakedJettons.set( 273 | msg.jettonWallet!!, 274 | StakedJettonInfo{ 275 | jettonAmount: stakeInfo!!.jettonAmount + msg.jettonAmount, 276 | } 277 | ); 278 | } 279 | } 280 | 281 | if (msg.forwardAmount > 0) { 282 | send(SendParameters{ 283 | to: self.owner, 284 | bounce: false, 285 | value: msg.forwardAmount, 286 | body: StakeNotification{ 287 | queryId: msg.queryId, 288 | amount: msg.amount, 289 | jettonAmount: msg.amount, 290 | jettonWallet: msg.jettonWallet, 291 | forwardPayload: msg.forwardPayload, 292 | }.toCell(), 293 | }); 294 | } 295 | 296 | // refund 297 | self.reserveValue(0); 298 | send(SendParameters{ 299 | to: msg.responseDestination, 300 | bounce: false, 301 | value: 0, 302 | mode: SendRemainingBalance, 303 | body: Excesses{ 304 | queryId: msg.queryId, 305 | }.toCell(), 306 | }); 307 | } 308 | 309 | virtual fun receiveRelease(msg: StakeRelease) { 310 | let ctx = context(); 311 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 312 | 313 | // release ton coins 314 | if (msg.amount > 0) { 315 | nativeThrowUnless(codeInsufficientStakedTon, self.stakedTon >= msg.amount); 316 | self.stakedTon -= msg.amount; 317 | } 318 | 319 | // release jettons 320 | let totalCost = 0; 321 | let i: Int = 0; 322 | while (i < msg.jettonsIdx) { 323 | let jetton = msg.jettons.get(i)!!; 324 | totalCost += jetton.tonAmount; 325 | 326 | // update staked info 327 | let stakedInfo = self.stakedJettons.get(jetton.jettonWallet); 328 | 329 | nativeThrowUnless(codeStakeJettonNotFound, stakedInfo != null); 330 | nativeThrowUnless(codeInsufficientStakedJetton, 331 | stakedInfo!!.jettonAmount >= jetton.jettonAmount); 332 | 333 | self.stakedJettons.set( 334 | jetton.jettonWallet, 335 | StakedJettonInfo{ 336 | jettonAmount: stakedInfo!!.jettonAmount - jetton.jettonAmount, 337 | } 338 | ); 339 | 340 | i += 1; 341 | } 342 | 343 | // check cost 344 | totalCost += msg.forwardAmount; 345 | nativeThrowUnless(codeInflowValueNotSufficient, ctx.value >= totalCost + self.staticTax); 346 | 347 | // told staking master to transfer released jetton 348 | self.reserveValue(0); 349 | send(SendParameters{ 350 | to: self.master, 351 | bounce: false, 352 | value: 0, 353 | mode: SendRemainingBalance, 354 | body: msg.toCell(), 355 | }); 356 | } 357 | 358 | virtual fun getStakedInfo(): StakedInfo { 359 | return StakedInfo{ 360 | stakedTonAmount: self.stakedTon, 361 | stakedJettons: self.stakedJettons, 362 | } 363 | } 364 | 365 | virtual fun receiveStakeToncoin(msg: StakeToncoin) { 366 | let ctx = context(); 367 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.master); 368 | nativeThrowUnless(codeInflowValueNotSufficient, 369 | ctx.value >= msg.forwardAmount + self.staticTax); 370 | 371 | self.stakedTon += msg.amount; 372 | 373 | if (msg.forwardAmount > 0) { 374 | send(SendParameters{ 375 | to: self.owner, 376 | bounce: false, 377 | value: msg.forwardAmount, 378 | body: StakeNotification{ 379 | queryId: msg.queryId, 380 | amount: msg.amount, 381 | jettonAmount: 0, 382 | jettonWallet: null, 383 | forwardPayload: msg.forwardPayload, 384 | }.toCell(), 385 | }) 386 | } 387 | 388 | // refund 389 | self.reserveValue(0); 390 | send(SendParameters{ 391 | to: msg.responseDestination, 392 | bounce: false, 393 | value: 0, 394 | mode: SendRemainingBalance, 395 | body: Excesses{ 396 | queryId: msg.queryId, 397 | }.toCell(), 398 | }); 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /tests/Staking.spec.ts: -------------------------------------------------------------------------------- 1 | import { beginCell, comment, Dictionary, toNano } from '@ton/core'; 2 | import { Blockchain, printTransactionFees, SandboxContract, TreasuryContract } from '@ton/sandbox'; 3 | import '@ton/test-utils'; 4 | 5 | import { JettonMasterTemplate } from '../build/Sample/tact_JettonMasterTemplate'; 6 | import { JettonWalletTemplate } from '../build/Sample/tact_JettonWalletTemplate'; 7 | import { StakeReleaseJettonInfo, StakingMasterTemplate, storeStakeJetton } from '../build/Sample/tact_StakingMasterTemplate'; 8 | import { StakingWalletTemplate } from '../build/Sample/tact_StakingWalletTemplate'; 9 | 10 | describe('Staking', () => { 11 | 12 | let blockchain: Blockchain; 13 | 14 | let stakeMasterContract: SandboxContract; 15 | let jettonMasterContract: SandboxContract; 16 | let stakeJettonWallet: SandboxContract; 17 | let userStakeWallet: SandboxContract; 18 | let userJettonWallet: SandboxContract; 19 | 20 | let admin: SandboxContract; 21 | let user: SandboxContract; 22 | 23 | beforeAll(async () => { 24 | blockchain = await Blockchain.create(); 25 | 26 | admin = await blockchain.treasury('deployer'); 27 | user = await blockchain.treasury('user'); 28 | 29 | jettonMasterContract = blockchain.openContract( 30 | await JettonMasterTemplate.fromInit( 31 | admin.address, 32 | { 33 | $$type: "Tep64TokenData", 34 | flag: BigInt(1), 35 | content: "https://s3.laisky.com/uploads/2024/09/jetton-sample.json", 36 | }, 37 | ) 38 | ); 39 | 40 | stakeMasterContract = blockchain.openContract( 41 | await StakingMasterTemplate.fromInit( 42 | admin.address, 43 | ) 44 | ); 45 | 46 | userStakeWallet = blockchain.openContract( 47 | await StakingWalletTemplate.fromInit( 48 | stakeMasterContract.address, 49 | user.address, 50 | ) 51 | ); 52 | 53 | stakeJettonWallet = blockchain.openContract( 54 | await JettonWalletTemplate.fromInit( 55 | jettonMasterContract.address, 56 | stakeMasterContract.address, 57 | ) 58 | ); 59 | 60 | userJettonWallet = blockchain.openContract( 61 | await JettonWalletTemplate.fromInit( 62 | jettonMasterContract.address, 63 | user.address, 64 | ) 65 | ); 66 | 67 | console.log(`admin: ${admin.address}`); 68 | console.log(`user: ${user.address}`); 69 | console.log(`stakeMasterContract: ${stakeMasterContract.address}`); 70 | console.log(`jettonMasterContract: ${jettonMasterContract.address}`); 71 | console.log(`userStakeWallet: ${userStakeWallet.address}`); 72 | console.log(`stakeJettonWallet: ${stakeJettonWallet.address}`); 73 | console.log(`userJettonWallet: ${userJettonWallet.address}`); 74 | }); 75 | 76 | it("prepare jetton", async () => { 77 | const tx = await jettonMasterContract.send( 78 | admin.getSender(), 79 | { 80 | value: toNano("1"), 81 | bounce: false, 82 | }, 83 | { 84 | $$type: "MintJetton", 85 | queryId: BigInt(Math.ceil(Math.random() * 1000000)), 86 | amount: toNano("10"), 87 | receiver: user.address, 88 | responseDestination: admin.address, 89 | forwardAmount: toNano("0.1"), 90 | forwardPayload: null, 91 | } 92 | ); 93 | console.log("prepare jetton"); 94 | printTransactionFees(tx.transactions); 95 | 96 | console.log(`jettonMasterContract deployed at ${jettonMasterContract.address}`); 97 | console.log(`userJettonWallet: ${userJettonWallet.address}`); 98 | 99 | expect(tx.transactions).toHaveTransaction({ 100 | from: jettonMasterContract.address, 101 | to: userJettonWallet.address, 102 | success: true, 103 | op: 0x178d4519, // TokenTransferInternal 104 | }); 105 | expect(tx.transactions).toHaveTransaction({ 106 | from: userJettonWallet.address, 107 | to: user.address, 108 | success: true, 109 | op: 0x7362d09c, // TransferNotification 110 | }); 111 | expect(tx.transactions).toHaveTransaction({ 112 | from: userJettonWallet.address, 113 | to: admin.address, 114 | success: true, 115 | op: 0xd53276db, // Excesses 116 | }); 117 | 118 | const userJettonData = await userJettonWallet.getGetWalletData(); 119 | expect(userJettonData.balance).toEqual(toNano("10")); 120 | }); 121 | 122 | it("staking toncoin", async () => { 123 | const tx = await stakeMasterContract.send( 124 | user.getSender(), 125 | { 126 | value: toNano("2"), 127 | bounce: false, 128 | }, 129 | { 130 | $$type: "StakeToncoin", 131 | queryId: BigInt(Math.ceil(Math.random() * 1000000)), 132 | amount: toNano("0.5"), 133 | responseDestination: user.address, 134 | forwardAmount: toNano("0.1"), 135 | forwardPayload: comment("forward_payload"), 136 | } 137 | ); 138 | printTransactionFees(tx.transactions); 139 | 140 | expect(tx.transactions).toHaveTransaction({ 141 | from: user.address, 142 | to: stakeMasterContract.address, 143 | success: true, 144 | op: 0x7ac4404c, // StakeToncoin 145 | }); 146 | expect(tx.transactions).toHaveTransaction({ 147 | from: stakeMasterContract.address, 148 | to: userStakeWallet.address, 149 | success: true, 150 | op: 0xa576751e, // StakeInternal 151 | }); 152 | expect(tx.transactions).toHaveTransaction({ 153 | from: userStakeWallet.address, 154 | to: user.address, 155 | success: true, 156 | op: 0xd53276db, // Excesses 157 | }); 158 | expect(tx.transactions).toHaveTransaction({ 159 | from: userStakeWallet.address, 160 | to: user.address, 161 | success: true, 162 | op: 0x2c7981f1, // StakeNotification 163 | }); 164 | 165 | const userStakedInfo = await userStakeWallet.getStakedInfo(); 166 | expect(userStakedInfo.stakedTonAmount).toEqual(toNano("0.5")); 167 | }); 168 | 169 | it("staking jetton", async () => { 170 | // const beforeMasterJettonData = await stakeJettonWallet.getBalance(); 171 | // expect(beforeMasterJettonData).toEqual(toNano("0")); 172 | 173 | const beforeUserJettonData = await userJettonWallet.getGetWalletData(); 174 | expect(beforeUserJettonData.balance).toEqual(toNano("10")); 175 | 176 | const tx = await userJettonWallet.send( 177 | user.getSender(), 178 | { 179 | value: toNano("1"), 180 | bounce: false, 181 | }, 182 | { 183 | $$type: "TokenTransfer", 184 | queryId: BigInt(Math.ceil(Math.random() * 1000000)), 185 | amount: toNano("1"), 186 | destination: stakeMasterContract.address, 187 | responseDestination: user.address, 188 | forwardAmount: toNano("0.5"), 189 | forwardPayload: beginCell() 190 | .store(storeStakeJetton({ 191 | $$type: "StakeJetton", 192 | tonAmount: toNano("0.1"), 193 | responseDestination: user.address, 194 | forwardAmount: toNano("0.1"), 195 | forwardPayload: comment("forward_payload"), 196 | })) 197 | .endCell(), 198 | customPayload: null, 199 | } 200 | ); 201 | console.log("staking jetton"); 202 | printTransactionFees(tx.transactions); 203 | 204 | expect(tx.transactions).toHaveTransaction({ 205 | from: user.address, 206 | to: userJettonWallet.address, 207 | success: true, 208 | op: 0xf8a7ea5, // TokenTransfer 209 | }); 210 | expect(tx.transactions).toHaveTransaction({ 211 | from: userJettonWallet.address, 212 | to: stakeJettonWallet.address, 213 | success: true, 214 | op: 0x178d4519, // TokenTransferInternal 215 | }); 216 | expect(tx.transactions).toHaveTransaction({ 217 | from: stakeJettonWallet.address, 218 | to: user.address, 219 | success: true, 220 | op: 0xd53276db, // Excesses 221 | }); 222 | expect(tx.transactions).toHaveTransaction({ 223 | from: stakeJettonWallet.address, 224 | to: stakeMasterContract.address, 225 | success: true, 226 | op: 0x7362d09c, // TransferNotification 227 | }); 228 | expect(tx.transactions).toHaveTransaction({ 229 | from: stakeMasterContract.address, 230 | to: userStakeWallet.address, 231 | success: true, 232 | op: 0xa576751e, // StakeInternal 233 | }); 234 | expect(tx.transactions).toHaveTransaction({ 235 | from: userStakeWallet.address, 236 | to: user.address, 237 | success: true, 238 | op: 0x2c7981f1, // StakeNotification 239 | }); 240 | expect(tx.transactions).toHaveTransaction({ 241 | from: userStakeWallet.address, 242 | to: user.address, 243 | success: true, 244 | op: 0xd53276db, // Excesses 245 | }); 246 | 247 | const userStakedInfo = await userStakeWallet.getStakedInfo(); 248 | expect(userStakedInfo.stakedTonAmount).toEqual(toNano("0.6")); 249 | expect(userStakedInfo.stakedJettons.get(stakeJettonWallet.address)!!.jettonAmount).toEqual(toNano("1")); 250 | 251 | const afterMasterJettonData = await stakeJettonWallet.getGetWalletData(); 252 | expect(afterMasterJettonData.balance).toEqual(toNano("1")); 253 | 254 | const afterUserJettonData = await userJettonWallet.getGetWalletData(); 255 | expect(afterUserJettonData.balance).toEqual(toNano("9")); 256 | }); 257 | 258 | it("release", async () => { 259 | let releaseJettons = Dictionary.empty(); 260 | releaseJettons.set(BigInt("0"), { 261 | $$type: "StakeReleaseJettonInfo", 262 | tonAmount: toNano("0.2"), 263 | jettonAmount: toNano("1"), 264 | jettonWallet: stakeJettonWallet.address, 265 | forwardAmount: toNano("0.1"), 266 | destination: user.address, 267 | customPayload: null, 268 | forwardPayload: comment("forward_payload"), 269 | }); 270 | 271 | const tx = await userStakeWallet.send( 272 | user.getSender(), 273 | { 274 | value: toNano("2"), 275 | bounce: false, 276 | }, 277 | { 278 | $$type: "StakeRelease", 279 | queryId: BigInt(Math.ceil(Math.random() * 1000000)), 280 | owner: user.address, 281 | amount: toNano("0.5"), 282 | jettons: releaseJettons, 283 | jettonsIdx: BigInt('1'), 284 | destination: user.address, 285 | responseDestination: user.address, 286 | customPayload: comment("custom_payload"), 287 | forwardPayload: comment("forward_payload"), 288 | forwardAmount: toNano("0.1"), 289 | } 290 | ); 291 | console.log("release"); 292 | printTransactionFees(tx.transactions); 293 | 294 | expect(tx.transactions).toHaveTransaction({ 295 | from: user.address, 296 | to: userStakeWallet.address, 297 | success: true, 298 | op: 0x51fa3a81, // StakeRelease 299 | }); 300 | expect(tx.transactions).toHaveTransaction({ 301 | from: userStakeWallet.address, 302 | to: stakeMasterContract.address, 303 | success: true, 304 | op: 0x51fa3a81, // StakeRelease 305 | }); 306 | expect(tx.transactions).toHaveTransaction({ 307 | from: stakeMasterContract.address, 308 | to: user.address, 309 | success: true, 310 | op: 0xe656dfa2, // StakeReleaseNotification 311 | }); 312 | expect(tx.transactions).toHaveTransaction({ 313 | from: stakeMasterContract.address, 314 | to: user.address, 315 | success: true, 316 | op: 0xd53276db, // Excesses 317 | }); 318 | expect(tx.transactions).toHaveTransaction({ 319 | from: stakeMasterContract.address, 320 | to: stakeJettonWallet.address, 321 | success: true, 322 | op: 0xf8a7ea5, // TokenTransfer 323 | }); 324 | expect(tx.transactions).toHaveTransaction({ 325 | from: stakeJettonWallet.address, 326 | to: userJettonWallet.address, 327 | success: true, 328 | op: 0x178d4519, // TokenTransferInternal 329 | }); 330 | expect(tx.transactions).toHaveTransaction({ 331 | from: userJettonWallet.address, 332 | to: user.address, 333 | success: true, 334 | op: 0x7362d09c, // TransferNotification 335 | }); 336 | expect(tx.transactions).toHaveTransaction({ 337 | from: userJettonWallet.address, 338 | to: user.address, 339 | success: true, 340 | op: 0xd53276db, // Excesses 341 | }); 342 | 343 | const userStakedInfo = await userStakeWallet.getStakedInfo(); 344 | expect(userStakedInfo.stakedTonAmount).toEqual(toNano("0.1")); 345 | expect(userStakedInfo.stakedJettons.get(stakeJettonWallet.address)!!.jettonAmount).toEqual(toNano("0")); 346 | }); 347 | }); 348 | -------------------------------------------------------------------------------- /contracts/jetton/jetton.tact: -------------------------------------------------------------------------------- 1 | // ===================================== 2 | // https://blog.laisky.com/p/ton-tact/ 3 | // ===================================== 4 | 5 | import "@stdlib/ownable"; 6 | 7 | import "../common/traits.tact"; 8 | import "../common/messages.tact"; 9 | 10 | import "./errcodes.tact"; 11 | import "./messages.tact"; 12 | 13 | 14 | // ===================================== 15 | // Contracts 16 | // 17 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md 18 | // ===================================== 19 | 20 | 21 | // This is your custom jetton's master contract. 22 | contract JettonMasterTemplate with JettonMaster { 23 | owner: Address; 24 | staticTax: Int as coins = ton("0.001"); 25 | lockedValue: Int as coins = 0; 26 | 27 | // Cell to store arbitrary data related to the jetton 28 | // 29 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md#jetton-metadata-example-offchain 30 | content: Cell; 31 | // Total number of tokens in existence. 32 | totalSupply: Int as coins; 33 | mintable: Bool; 34 | 35 | init(owner: Address, content: Tep64TokenData) { 36 | self.owner = owner; 37 | 38 | self.content = content.toCell(); 39 | self.totalSupply = 0; 40 | self.mintable = true; 41 | } 42 | } 43 | 44 | contract JettonWalletTemplate with JettonWallet { 45 | // owner is the address of the user who owns the wallet. 46 | owner: Address; 47 | // master is the address of the master contract that deployed this wallet. 48 | master: Address; 49 | // balance is the number of tokens that the wallet currently holds. 50 | // unlike the centralized ledger of Ethereum Tokens, 51 | // TON users keep track of the number of tokens they own in their own wallets. 52 | balance: Int; 53 | // staticTax is the fee that will be charged for each transaction. 54 | staticTax: Int as coins = ton("0.001"); 55 | lockedValue: Int as coins = 0; 56 | 57 | // The parameters of the init function should not be too complex, 58 | // as it will be called frequently when generating the jetton wallet address. 59 | init(master: Address, owner: Address) { 60 | self.balance = 0; 61 | self.owner = owner; 62 | self.master = master; 63 | } 64 | } 65 | 66 | // ===================================== 67 | // Traits 68 | // 69 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md 70 | // ===================================== 71 | 72 | @interface("org.ton.jetton.master") 73 | trait JettonMaster with Common { 74 | owner: Address; 75 | staticTax: Int; 76 | lockedValue: Int; 77 | 78 | content: Cell; 79 | totalSupply: Int; 80 | mintable: Bool; 81 | 82 | // this is a non-standard method, it's used to mint new tokens to specified user. 83 | receive(msg: MintJetton) { 84 | self.receiveMintJetton(msg); 85 | } 86 | 87 | // this is a non-standard method, it's used to mint new tokens to multiple users. 88 | receive(msg: MultiMint) { 89 | self.receiveMultiMint(msg); 90 | } 91 | 92 | // this is a TEP-074 standard receiver method, 93 | // it's used to update the jetton total supply when burning tokens. 94 | receive(msg: TokenBurnNotification) { 95 | self.receiveBurnInternal(msg); 96 | } 97 | 98 | // this is a TEP-89 standard receiver method, 99 | // it's used to provide the wallet address of the specified owner. 100 | receive(msg: ProvideWalletAddress) { 101 | self.receiveProvideWalletAddress(msg); 102 | } 103 | 104 | // this is a TEP-074 standard getter method 105 | get fun get_jetton_data(): JettonMasterData { 106 | return self.getJettonData(); 107 | } 108 | 109 | // this is a TEP-074 standard getter method, 110 | // generate the jetton wallet address for any user. 111 | get fun get_wallet_address(owner: Address): Address { 112 | return self.getWalletAddress(owner); 113 | } 114 | 115 | virtual fun getWalletAddress(owner: Address): Address { 116 | return contractAddress(self.getJettonWalletContract(owner)); 117 | } 118 | 119 | virtual fun receiveProvideWalletAddress(msg: ProvideWalletAddress) { 120 | let walletAddr = self.get_wallet_address(msg.ownerAddress); 121 | 122 | self.reserveValue(0); 123 | send(SendParameters{ 124 | to: sender(), 125 | value: 0, 126 | bounce: false, 127 | mode: SendRemainingBalance, 128 | body: TakeWalletAddress{ 129 | queryId: msg.queryId, 130 | walletAddress: walletAddr, 131 | ownerAddress: msg.includeAddress ? msg.ownerAddress : null, 132 | }.toCell(), 133 | }); 134 | } 135 | 136 | virtual fun getJettonWalletContract(owner: Address): StateInit { 137 | return initOf JettonWalletTemplate(myAddress(), owner); 138 | } 139 | 140 | virtual fun receiveBurnInternal(msg: TokenBurnNotification) { 141 | let ctx: Context = context(); 142 | nativeThrowUnless(codeJettonBalanceInsufficient, self.totalSupply >= msg.amount); 143 | 144 | let walletAddr = self.get_wallet_address(msg.sender); 145 | nativeThrowUnless(codeUnauthorized, sender() == walletAddr); 146 | 147 | self.totalSupply -= msg.amount; 148 | 149 | // refund the excess TON to the sender. 150 | self.reserveValue(0); 151 | send(SendParameters{ 152 | to: msg.responseDestination, 153 | value: 0, 154 | bounce: false, 155 | mode: SendRemainingBalance, 156 | body: Excesses{ 157 | queryId: msg.queryId, 158 | }.toCell(), 159 | }); 160 | } 161 | 162 | virtual fun receiveMintJetton(msg: MintJetton) { 163 | let ctx: Context = context(); 164 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 165 | nativeThrowUnless(codeNotMintable, self.mintable); 166 | nativeThrowUnless(codeAmountShouldBePositive, msg.amount > 0); 167 | nativeThrowUnless(codeInflowValueNotSufficient, 168 | ctx.value >= msg.forwardAmount + self.staticTax); 169 | 170 | self.totalSupply += msg.amount; 171 | let jettonWallet = self.getJettonWalletContract(msg.receiver); 172 | 173 | // deploy the wallet if it's not deployed yet, 174 | // then send the minted tokens to the wallet. 175 | self.reserveValue(0); 176 | send(SendParameters{ 177 | to: contractAddress(jettonWallet), 178 | value: 0, 179 | bounce: false, 180 | mode: SendRemainingBalance, 181 | body: TokenTransferInternal{ 182 | queryId: msg.queryId, 183 | amount: msg.amount, 184 | from: sender(), 185 | responseDestination: msg.responseDestination, 186 | forwardAmount: msg.forwardAmount, 187 | forwardPayload: msg.forwardPayload, 188 | }.toCell(), 189 | code: jettonWallet.code, 190 | data: jettonWallet.data 191 | }); 192 | } 193 | 194 | virtual fun receiveMultiMint(msg: MultiMint) { 195 | let ctx: Context = context(); 196 | nativeThrowUnless(codeNotMintable, self.mintable); 197 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 198 | 199 | let totalTonCost: Int = 0; 200 | let i: Int = 0; 201 | while (i < msg.receiverCount) { 202 | let receiver = msg.receivers.get(i); 203 | nativeThrowUnless(codeMapIndexNotExists, receiver != null); 204 | nativeThrowUnless(codeAmountShouldBePositive, receiver!!.amount > 0); 205 | nativeThrowUnless(codeAmountShouldBePositive, receiver!!.tonAmount > 0); 206 | nativeThrowUnless(codeReceiverInsufficientReceiverTonAmount, 207 | receiver!!.tonAmount > receiver!!.forwardAmount); 208 | 209 | self.totalSupply += receiver!!.amount; 210 | totalTonCost += receiver!!.tonAmount; 211 | 212 | let jettonWallet = self.getJettonWalletContract(receiver!!.receiver); 213 | send(SendParameters{ 214 | to: contractAddress(jettonWallet), 215 | value: receiver!!.tonAmount, 216 | bounce: false, 217 | body: TokenTransferInternal{ 218 | queryId: msg.queryId, 219 | amount: receiver!!.amount, 220 | from: sender(), 221 | responseDestination: receiver!!.responseDestination, 222 | forwardAmount: receiver!!.forwardAmount, 223 | forwardPayload: receiver!!.forwardPayload, 224 | }.toCell(), 225 | code: jettonWallet.code, 226 | data: jettonWallet.data 227 | }); 228 | 229 | i = i + 1; 230 | } 231 | 232 | nativeThrowUnless(codeInflowValueNotSufficient, 233 | ctx.value >= totalTonCost + self.staticTax); 234 | 235 | // refund 236 | self.reserveValue(0); 237 | send(SendParameters{ 238 | to: sender(), 239 | value: 0, 240 | bounce: false, 241 | mode: SendRemainingBalance, 242 | body: Excesses{ 243 | queryId: msg.queryId, 244 | }.toCell(), 245 | }); 246 | } 247 | 248 | virtual fun getJettonData(): JettonMasterData { 249 | return JettonMasterData{ 250 | totalSupply: self.totalSupply, 251 | mintable: self.mintable, 252 | owner: self.owner, 253 | content: self.content, 254 | walletCode: self.getJettonWalletContract(myAddress()).code 255 | }; 256 | } 257 | } 258 | 259 | @interface("org.ton.jetton.wallet") 260 | trait JettonWallet with Common { 261 | // owner is the address of the user who owns the wallet. 262 | owner: Address; 263 | // master is the address of the master contract that deployed this wallet. 264 | master: Address; 265 | // balance is the number of tokens that the wallet currently holds. 266 | // unlike the centralized ledger of Ethereum Tokens, 267 | // TON users keep track of the number of tokens they own in their own wallets. 268 | balance: Int; 269 | // staticTax is the fee that will be charged for each transaction. 270 | staticTax: Int; 271 | lockedValue: Int; 272 | 273 | // this is a TEP-074 standard receiver method, 274 | // owner can transfer tokens to another jetton wallet 275 | // by sending TokenTransfer message to the contract. 276 | receive(msg: TokenTransfer) { 277 | self.receiveTokenTransfer(msg); 278 | } 279 | 280 | // this is unspecified by standard, but suggested format of internal message. 281 | // receive tokens from another jetton wallet 282 | receive(msg: TokenTransferInternal) { 283 | self.receiveTokenTransferInternal(msg); 284 | } 285 | 286 | // this is a TEP-074 standard receiver method 287 | receive(msg: Burn) { 288 | self.receiveBurn(msg); 289 | } 290 | 291 | // this is a TEP-074 standard getter method 292 | get fun get_wallet_data(): JettonWalletData { 293 | return self.getWalletData(); 294 | } 295 | 296 | bounced(src: bounced) { 297 | self.balance += src.amount; 298 | } 299 | 300 | bounced(src: bounced) { 301 | self.balance += src.amount; 302 | } 303 | 304 | virtual fun getJettonWalletContract(owner: Address): StateInit { 305 | return initOf JettonWalletTemplate(self.master, owner); 306 | } 307 | 308 | virtual fun receiveTokenTransfer(msg: TokenTransfer) { 309 | let ctx: Context = context(); 310 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 311 | nativeThrowUnless(codeJettonBalanceInsufficient, self.balance >= msg.amount); 312 | nativeThrowUnless(codeAmountShouldBePositive, msg.amount > 0); 313 | nativeThrowUnless(codeInflowValueNotSufficient, 314 | ctx.value >= msg.forwardAmount + self.staticTax); 315 | 316 | self.balance -= msg.amount; 317 | 318 | let destJettonContract = self.getJettonWalletContract(msg.destination); 319 | 320 | // deploy the wallet if it's not deployed yet, 321 | // then transfer the tokens to the wallet. 322 | self.reserveValue(0); 323 | send(SendParameters{ 324 | to: contractAddress(destJettonContract), 325 | value: 0, 326 | mode: SendRemainingBalance, 327 | // amount could be negative, it is impossible to pre-confirm whether 328 | // the receiver has enough balance to cover the negative amount, 329 | // which means the transfer may fail. If that happens, 330 | // the contract's balance must be adjusted using a bounced message. 331 | bounce: true, 332 | body: TokenTransferInternal{ 333 | queryId: msg.queryId, 334 | amount: msg.amount, 335 | from: self.owner, 336 | responseDestination: msg.responseDestination, 337 | forwardAmount: msg.forwardAmount, 338 | forwardPayload: msg.forwardPayload 339 | }.toCell(), 340 | code: destJettonContract.code, 341 | data: destJettonContract.data 342 | }); 343 | } 344 | 345 | virtual fun receiveTokenTransferInternal(msg: TokenTransferInternal){ 346 | let ctx: Context = context(); 347 | nativeThrowUnless(codeAmountShouldBePositive, msg.amount > 0); 348 | nativeThrowUnless(codeInflowValueNotSufficient, 349 | ctx.value > msg.forwardAmount + self.staticTax); 350 | 351 | // only the owner or another jetton wallet can send TokenTransferInternal 352 | if (ctx.sender != self.master) { 353 | let peerJettonContractAddr = contractAddress(self.getJettonWalletContract(msg.from)); 354 | nativeThrowUnless(codeUnauthorized, ctx.sender == peerJettonContractAddr); 355 | } 356 | 357 | // Update balance 358 | self.balance += msg.amount; 359 | 360 | // https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md 361 | // if forward_amount > 0 ensure that receiver's jetton-wallet 362 | // send message to destination address, which means the new owner of the token 363 | if (msg.forwardAmount > 0) { 364 | send(SendParameters{ 365 | to: self.owner, 366 | value: msg.forwardAmount, 367 | bounce: false, 368 | mode: SendIgnoreErrors, 369 | body: TransferNotification{ 370 | queryId: msg.queryId, 371 | amount: msg.amount, 372 | sender: msg.from, 373 | forwardPayload: msg.forwardPayload 374 | }.toCell() 375 | }); 376 | } 377 | 378 | // refund the excess TON to the sender. 379 | // 380 | // using the SendRemainingBalance mode will transfer all balances 381 | // that are not locked by nativeReserve. 382 | // https://docs.tact-lang.org/ref/core-advanced#nativereserve 383 | self.reserveValue(0); 384 | send(SendParameters{ 385 | to: msg.responseDestination, 386 | value: 0, 387 | bounce: false, 388 | mode: SendRemainingBalance, 389 | body: Excesses{queryId: msg.queryId}.toCell(), 390 | }); 391 | } 392 | 393 | virtual fun receiveBurn(msg: Burn) { 394 | let ctx: Context = context(); 395 | nativeThrowUnless(codeUnauthorized, ctx.sender == self.owner); 396 | nativeThrowUnless(codeBalanceNotSufficient, self.balance >= msg.amount); 397 | nativeThrowUnless(codeAmountShouldBePositive, msg.amount > 0); 398 | 399 | // Update balance 400 | self.balance -= msg.amount; 401 | 402 | // notify master to update totalSupply 403 | self.reserveValue(0); 404 | send(SendParameters{ 405 | to: self.master, 406 | value: 0, 407 | mode: SendRemainingBalance, 408 | bounce: true, 409 | body: TokenBurnNotification{ 410 | queryId: msg.queryId, 411 | amount: msg.amount, 412 | sender: self.owner, 413 | responseDestination: msg.responseDestination, 414 | }.toCell(), 415 | }); 416 | } 417 | 418 | virtual fun getWalletData(): JettonWalletData { 419 | return JettonWalletData{ 420 | balance: self.balance, 421 | owner: self.owner, 422 | master: self.master, 423 | walletCode: self.getJettonWalletContract(self.owner).code, 424 | }; 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /tests/Jetton.spec.ts: -------------------------------------------------------------------------------- 1 | import { comment, toNano } from '@ton/core'; 2 | import { 3 | Blockchain, 4 | printTransactionFees, 5 | SandboxContract, 6 | TreasuryContract 7 | } from '@ton/sandbox'; 8 | import '@ton/test-utils'; 9 | 10 | import { JettonMasterTemplate, loadTakeWalletAddress, loadTep64TokenData } from '../build/Sample/tact_JettonMasterTemplate'; 11 | import { JettonWalletTemplate } from '../build/Sample/tact_JettonWalletTemplate'; 12 | import exp from 'constants'; 13 | 14 | describe('Jetton', () => { 15 | 16 | let blockchain: Blockchain; 17 | let admin: SandboxContract; 18 | let user: SandboxContract; 19 | let responseDestination: SandboxContract; 20 | let jettonMasterContract: SandboxContract; 21 | let adminJettonWallet: SandboxContract; 22 | let userJettonWallet: SandboxContract; 23 | let nJettonOwnerHas: bigint = toNano(Math.random() * 1000); 24 | 25 | beforeAll(async () => { 26 | blockchain = await Blockchain.create(); 27 | admin = await blockchain.treasury('admin'); 28 | user = await blockchain.treasury('user'); 29 | responseDestination = await blockchain.treasury('responseDestination'); 30 | 31 | jettonMasterContract = blockchain.openContract( 32 | await JettonMasterTemplate.fromInit( 33 | admin.address, 34 | { 35 | $$type: "Tep64TokenData", 36 | flag: BigInt(1), 37 | content: "https://s3.laisky.com/uploads/2024/09/jetton-sample.json", 38 | }, 39 | ) 40 | ); 41 | 42 | adminJettonWallet = blockchain.openContract( 43 | await JettonWalletTemplate.fromInit( 44 | jettonMasterContract.address, 45 | admin.address, 46 | ) 47 | ); 48 | 49 | userJettonWallet = blockchain.openContract( 50 | await JettonWalletTemplate.fromInit( 51 | jettonMasterContract.address, 52 | user.address, 53 | ) 54 | ); 55 | 56 | console.log(`jettonMasterContract: ${jettonMasterContract.address}`); 57 | console.log(`adminJettonWallet: ${adminJettonWallet.address}`); 58 | console.log(`userJettonWallet: ${userJettonWallet.address}`); 59 | console.log(`admin: ${admin.address}`); 60 | console.log(`user: ${user.address}`); 61 | console.log(`responseDestination: ${responseDestination.address}`); 62 | }); 63 | 64 | it("signature for contracts code", async () => { 65 | const codeHash1 = adminJettonWallet.init!!.code.hash(); 66 | const codeHash2 = userJettonWallet.init!!.code.hash(); 67 | expect(codeHash1.equals(codeHash2)).toBeTruthy(); 68 | 69 | const dataHash1 = adminJettonWallet.init!!.data.hash(); 70 | const dataHash2 = userJettonWallet.init!!.data.hash(); 71 | expect(dataHash1.equals(dataHash2)).toBeFalsy(); 72 | }); 73 | 74 | it("deploy master contract", async () => { 75 | const tx = await jettonMasterContract.send( 76 | admin.getSender(), 77 | { 78 | value: toNano("1"), 79 | bounce: false, 80 | }, 81 | { 82 | $$type: "Deploy", 83 | queryId: BigInt(Math.floor(Date.now() / 1000)), 84 | }, 85 | ); 86 | console.log("deploy master contract"); 87 | printTransactionFees(tx.transactions); 88 | 89 | expect(tx.transactions).toHaveTransaction({ 90 | from: admin.address, 91 | to: jettonMasterContract.address, 92 | success: true, 93 | op: 0x946a98b6, 94 | }); 95 | 96 | const staticTax = await jettonMasterContract.getStaticTax() 97 | expect(staticTax).toEqual(toNano("0.001")); 98 | 99 | const jettonData = await jettonMasterContract.getGetJettonData(); 100 | expect(jettonData.totalSupply).toEqual(BigInt(0)); 101 | expect(jettonData.mintable).toBeTruthy(); 102 | expect(jettonData.owner.equals(admin.address)).toBeTruthy(); 103 | 104 | const jettonContent = loadTep64TokenData(jettonData.content.asSlice()); 105 | expect(jettonContent.flag).toEqual(BigInt(1)); 106 | expect(jettonContent.content).toEqual("https://s3.laisky.com/uploads/2024/09/jetton-sample.json"); 107 | }); 108 | 109 | it("tep-89 includeAddress==true", async () => { 110 | const tx = await jettonMasterContract.send( 111 | admin.getSender(), 112 | { 113 | value: toNano("1"), 114 | bounce: false, 115 | }, 116 | { 117 | $$type: "ProvideWalletAddress", 118 | queryId: BigInt(Math.floor(Date.now() / 1000)), 119 | ownerAddress: user.address, 120 | includeAddress: true 121 | }, 122 | ); 123 | console.log("tep-89 includeAddress==true"); 124 | printTransactionFees(tx.transactions); 125 | 126 | expect(tx.transactions).toHaveTransaction({ 127 | from: admin.address, 128 | to: jettonMasterContract.address, 129 | success: true, 130 | op: 0x2c76b973, // ProvideWalletAddress 131 | }); 132 | expect(tx.transactions).toHaveTransaction({ 133 | from: jettonMasterContract.address, 134 | to: admin.address, 135 | success: true, 136 | op: 0xd1735400, // TakeWalletAddress 137 | }); 138 | 139 | const body = tx.transactions[1].outMessages.get(0)!!.body 140 | const resp = loadTakeWalletAddress(body.asSlice()); 141 | 142 | expect(resp.ownerAddress!!.equals(user.address)).toBeTruthy(); 143 | expect(resp.walletAddress!!.equals(userJettonWallet.address)).toBeTruthy(); 144 | }); 145 | 146 | it("tep-89 includeAddress==false", async () => { 147 | const tx = await jettonMasterContract.send( 148 | admin.getSender(), 149 | { 150 | value: toNano("1"), 151 | bounce: false, 152 | }, 153 | { 154 | $$type: "ProvideWalletAddress", 155 | queryId: BigInt(Math.floor(Date.now() / 1000)), 156 | ownerAddress: user.address, 157 | includeAddress: false 158 | }, 159 | ); 160 | console.log("tep-89 includeAddress==true"); 161 | printTransactionFees(tx.transactions); 162 | 163 | expect(tx.transactions).toHaveTransaction({ 164 | from: admin.address, 165 | to: jettonMasterContract.address, 166 | success: true, 167 | op: 0x2c76b973, // ProvideWalletAddress 168 | }); 169 | expect(tx.transactions).toHaveTransaction({ 170 | from: jettonMasterContract.address, 171 | to: admin.address, 172 | success: true, 173 | op: 0xd1735400, // TakeWalletAddress 174 | }); 175 | 176 | const body = tx.transactions[1].outMessages.get(0)!!.body 177 | const resp = loadTakeWalletAddress(body.asSlice()); 178 | 179 | expect(resp.walletAddress!!.equals(userJettonWallet.address)).toBeTruthy(); 180 | expect(resp.ownerAddress).toBeNull(); 181 | }); 182 | 183 | it("mint to owner", async () => { 184 | const tx = await jettonMasterContract.send( 185 | admin.getSender(), 186 | { 187 | value: toNano("1"), 188 | bounce: false, 189 | }, 190 | { 191 | $$type: "MintJetton", 192 | queryId: BigInt(Math.floor(Date.now() / 1000)), 193 | amount: nJettonOwnerHas, 194 | receiver: admin.address, 195 | responseDestination: responseDestination.address, 196 | forwardAmount: toNano("0.1"), 197 | forwardPayload: comment("jetton forward msg"), 198 | }, 199 | ); 200 | console.log("mint to owner"); 201 | printTransactionFees(tx.transactions); 202 | 203 | expect(tx.transactions).toHaveTransaction({ 204 | from: admin.address, 205 | to: jettonMasterContract.address, 206 | success: true, 207 | op: 0xa593886f, // MintJetton 208 | }); 209 | expect(tx.transactions).toHaveTransaction({ 210 | from: jettonMasterContract.address, 211 | to: adminJettonWallet.address, 212 | success: true, 213 | op: 0x178d4519, // TokenTransferInternal 214 | }); 215 | expect(tx.transactions).toHaveTransaction({ 216 | from: adminJettonWallet.address, 217 | to: admin.address, 218 | success: true, 219 | op: 0x7362d09c, // TransferNotification 220 | }); 221 | expect(tx.transactions).toHaveTransaction({ 222 | from: adminJettonWallet.address, 223 | to: responseDestination.address, 224 | success: true, 225 | op: 0xd53276db, // Excesses 226 | }); 227 | 228 | const jettonData = await adminJettonWallet.getGetWalletData(); 229 | expect(jettonData.owner.equals(admin.address)).toBeTruthy(); 230 | expect(jettonData.balance).toEqual(nJettonOwnerHas); 231 | expect(jettonData.master.equals(jettonMasterContract.address)).toBeTruthy(); 232 | }); 233 | 234 | it("transfer to user", async () => { 235 | const tx = await adminJettonWallet.send( 236 | admin.getSender(), 237 | { 238 | value: toNano("1"), 239 | bounce: false, 240 | }, 241 | { 242 | $$type: "TokenTransfer", 243 | queryId: BigInt(Math.floor(Date.now() / 1000)), 244 | amount: nJettonOwnerHas, 245 | destination: user.address, 246 | responseDestination: responseDestination.address, 247 | forwardAmount: toNano("0.1"), 248 | forwardPayload: comment("jetton forward msg"), 249 | customPayload: null, 250 | }, 251 | ); 252 | console.log("transfer to user"); 253 | printTransactionFees(tx.transactions); 254 | 255 | expect(tx.transactions).toHaveTransaction({ 256 | from: admin.address, 257 | to: adminJettonWallet.address, 258 | success: true, 259 | op: 0xf8a7ea5, // TokenTransfer 260 | }); 261 | expect(tx.transactions).toHaveTransaction({ 262 | from: adminJettonWallet.address, 263 | to: userJettonWallet.address, 264 | success: true, 265 | op: 0x178d4519, // TokenTransferInternal 266 | }); 267 | expect(tx.transactions).toHaveTransaction({ 268 | from: userJettonWallet.address, 269 | to: user.address, 270 | success: true, 271 | op: 0x7362d09c, // TransferNotification 272 | }); 273 | expect(tx.transactions).toHaveTransaction({ 274 | from: userJettonWallet.address, 275 | to: responseDestination.address, 276 | success: true, 277 | op: 0xd53276db, // Excesses 278 | }); 279 | 280 | const jettonMasterData = await jettonMasterContract.getGetJettonData(); 281 | expect(jettonMasterData.totalSupply).toEqual(nJettonOwnerHas); 282 | 283 | const ownerJettonData = await adminJettonWallet.getGetWalletData(); 284 | expect(ownerJettonData.balance).toEqual(BigInt(0)); 285 | 286 | const jettonData = await userJettonWallet.getGetWalletData(); 287 | expect(jettonData.owner.equals(user.address)).toBeTruthy(); 288 | expect(jettonData.balance).toEqual(nJettonOwnerHas); 289 | expect(jettonData.master.equals(jettonMasterContract.address)).toBeTruthy(); 290 | }); 291 | 292 | it("user transfer back to admin", async () => { 293 | const tx = await userJettonWallet.send( 294 | user.getSender(), 295 | { 296 | value: toNano("1"), 297 | bounce: false, 298 | }, 299 | { 300 | $$type: "TokenTransfer", 301 | queryId: BigInt(Math.floor(Date.now() / 1000)), 302 | amount: toNano("10"), 303 | destination: admin.address, 304 | responseDestination: responseDestination.address, 305 | forwardAmount: toNano("0.1"), 306 | forwardPayload: comment("jetton forward msg"), 307 | customPayload: null, 308 | }, 309 | ); 310 | console.log("user transfer back to admin"); 311 | printTransactionFees(tx.transactions); 312 | 313 | expect(tx.transactions).toHaveTransaction({ 314 | from: user.address, 315 | to: userJettonWallet.address, 316 | success: true, 317 | op: 0xf8a7ea5, // TokenTransfer 318 | }); 319 | expect(tx.transactions).toHaveTransaction({ 320 | from: userJettonWallet.address, 321 | to: adminJettonWallet.address, 322 | success: true, 323 | op: 0x178d4519, // TokenTransferInternal 324 | }); 325 | expect(tx.transactions).toHaveTransaction({ 326 | from: adminJettonWallet.address, 327 | to: admin.address, 328 | success: true, 329 | op: 0x7362d09c, // TransferNotification 330 | }); 331 | expect(tx.transactions).toHaveTransaction({ 332 | from: adminJettonWallet.address, 333 | to: responseDestination.address, 334 | success: true, 335 | op: 0xd53276db, // Excesses 336 | }); 337 | 338 | const jettonMasterData = await jettonMasterContract.getGetJettonData(); 339 | expect(jettonMasterData.totalSupply).toEqual(nJettonOwnerHas); 340 | 341 | const ownerJettonData = await adminJettonWallet.getGetWalletData(); 342 | expect(ownerJettonData.balance).toEqual(toNano("10")); 343 | 344 | const jettonData = await userJettonWallet.getGetWalletData(); 345 | expect(jettonData.owner.equals(user.address)).toBeTruthy(); 346 | expect(jettonData.balance).toEqual(nJettonOwnerHas - toNano("10")); 347 | expect(jettonData.master.equals(jettonMasterContract.address)).toBeTruthy(); 348 | }); 349 | 350 | it("admin transfer to user with tiny forward ton", async () => { 351 | const tx = await adminJettonWallet.send( 352 | admin.getSender(), 353 | { 354 | value: toNano("1"), 355 | bounce: false, 356 | }, 357 | { 358 | $$type: "TokenTransfer", 359 | queryId: BigInt(Math.floor(Date.now() / 1000)), 360 | amount: toNano("10"), 361 | destination: user.address, 362 | responseDestination: responseDestination.address, 363 | forwardAmount: BigInt("1"), // insufficient forward ton 364 | forwardPayload: comment("jetton forward msg"), 365 | customPayload: null, 366 | }, 367 | ); 368 | console.log("admin transfer to user with tiny forward ton"); 369 | printTransactionFees(tx.transactions); 370 | 371 | expect(tx.transactions).toHaveTransaction({ 372 | from: admin.address, 373 | to: adminJettonWallet.address, 374 | success: true, 375 | op: 0xf8a7ea5, // TokenTransfer 376 | }); 377 | expect(tx.transactions).toHaveTransaction({ 378 | from: adminJettonWallet.address, 379 | to: userJettonWallet.address, 380 | success: true, 381 | op: 0x178d4519, // TokenTransferInternal 382 | }); 383 | expect(tx.transactions).not.toHaveTransaction({ 384 | from: userJettonWallet.address, 385 | to: user.address, 386 | success: true, 387 | op: 0x7362d09c, // TransferNotification 388 | }); 389 | expect(tx.transactions).toHaveTransaction({ 390 | from: userJettonWallet.address, 391 | to: responseDestination.address, 392 | success: true, 393 | op: 0xd53276db, // Excesses 394 | }); 395 | 396 | const jettonMasterData = await jettonMasterContract.getGetJettonData(); 397 | expect(jettonMasterData.totalSupply).toEqual(nJettonOwnerHas); 398 | 399 | const ownerJettonData = await adminJettonWallet.getGetWalletData(); 400 | expect(ownerJettonData.balance).toEqual(toNano("0")); 401 | 402 | const jettonData = await userJettonWallet.getGetWalletData(); 403 | expect(jettonData.owner.equals(user.address)).toBeTruthy(); 404 | expect(jettonData.balance).toEqual(nJettonOwnerHas); 405 | expect(jettonData.master.equals(jettonMasterContract.address)).toBeTruthy(); 406 | }); 407 | 408 | it("burn from user", async () => { 409 | const tx = await userJettonWallet.send( 410 | user.getSender(), 411 | { 412 | value: toNano("1"), 413 | bounce: false, 414 | }, 415 | { 416 | $$type: "Burn", 417 | queryId: BigInt(Math.floor(Date.now() / 1000)), 418 | amount: nJettonOwnerHas, 419 | responseDestination: responseDestination.address, 420 | customPayload: null, 421 | }, 422 | ); 423 | console.log("burn from user"); 424 | printTransactionFees(tx.transactions); 425 | 426 | expect(tx.transactions).toHaveTransaction({ 427 | from: user.address, 428 | to: userJettonWallet.address, 429 | success: true, 430 | op: 0x595f07bc, // Burn 431 | }); 432 | expect(tx.transactions).toHaveTransaction({ 433 | from: userJettonWallet.address, 434 | to: jettonMasterContract.address, 435 | success: true, 436 | op: 0x7bdd97de, // TokenBurnNotification 437 | }); 438 | expect(tx.transactions).toHaveTransaction({ 439 | from: jettonMasterContract.address, 440 | to: responseDestination.address, 441 | success: true, 442 | op: 0xd53276db, // Excesses 443 | }); 444 | 445 | const jettonMasterData = await jettonMasterContract.getGetJettonData(); 446 | expect(jettonMasterData.totalSupply).toEqual(BigInt("0")); 447 | 448 | const jettonData = await userJettonWallet.getGetWalletData(); 449 | expect(jettonData.owner.equals(user.address)).toBeTruthy(); 450 | expect(jettonData.balance).toEqual(BigInt(0)); 451 | expect(jettonData.master.equals(jettonMasterContract.address)).toBeTruthy(); 452 | }); 453 | 454 | it("withdraw by unauthorized user", async () => { 455 | const balanceBefore = await userJettonWallet.getTonBalance(); 456 | 457 | const tx = await userJettonWallet.send( 458 | admin.getSender(), 459 | { 460 | value: toNano("1"), 461 | bounce: true, 462 | }, 463 | "withdraw", 464 | ); 465 | console.log("withdraw by unauthorized user"); 466 | printTransactionFees(tx.transactions); 467 | 468 | expect(tx.transactions).toHaveTransaction({ 469 | from: admin.address, 470 | to: userJettonWallet.address, 471 | success: false, 472 | }); 473 | 474 | const balance = await userJettonWallet.getTonBalance(); 475 | expect(balance).toEqual(balanceBefore); 476 | }); 477 | 478 | it("user withdraw", async () => { 479 | const tx = await userJettonWallet.send( 480 | user.getSender(), 481 | { 482 | value: toNano("1"), 483 | bounce: false, 484 | }, 485 | "withdraw", 486 | ); 487 | console.log("withdraw"); 488 | printTransactionFees(tx.transactions); 489 | 490 | expect(tx.transactions).toHaveTransaction({ 491 | from: user.address, 492 | to: userJettonWallet.address, 493 | success: true, 494 | }); 495 | expect(tx.transactions).toHaveTransaction({ 496 | from: userJettonWallet.address, 497 | to: user.address, 498 | success: true, 499 | op: 0xd53276db, // Excesses 500 | }); 501 | 502 | const balance = await userJettonWallet.getTonBalance(); 503 | expect(balance).toEqual(toNano("0")); 504 | }); 505 | }); 506 | --------------------------------------------------------------------------------