├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── publish.yml │ ├── qa.yaml │ └── reward.yml ├── .gitignore ├── .yarn └── releases │ └── yarn-4.9.2.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── collect-metric-api.md ├── msg-trace.png ├── tact-testing-examples.md └── testing-key-points.md ├── eslint.config.js ├── examples ├── .gitignore ├── README.md ├── contracts │ ├── Elector.ts │ ├── NftCollection.ts │ ├── NftItem.ts │ ├── NftMarketplace.ts │ ├── NftSale.ts │ ├── NftSaleV3.ts │ └── README.md ├── jest.config.js ├── nft-sale │ ├── README.md │ └── Sale.spec.ts ├── package.json ├── remote-storage │ ├── README.md │ └── RemoteStorage.spec.ts ├── tsconfig.json └── yarn.lock ├── jest-environment.js ├── jest-reporter.js ├── jest.config.js ├── package.json ├── scripts ├── pack-config.ts └── pack-wasm.ts ├── src ├── blockchain │ ├── Blockchain.spec.ts │ ├── Blockchain.ts │ ├── BlockchainContractProvider.ts │ ├── BlockchainSender.ts │ ├── BlockchainStorage.ts │ ├── SmartContract.ts │ └── test_utils │ │ ├── compileTolk.ts │ │ └── contracts │ │ └── prevblocks.tolk ├── config │ ├── defaultConfig.ts │ └── slimConfig.ts ├── event │ └── Event.ts ├── executor │ ├── Executor.spec.ts │ ├── Executor.ts │ ├── emulator-emscripten.js │ ├── emulator-emscripten.wasm │ └── emulator-emscripten.wasm.js ├── index.ts ├── jest │ ├── BenchmarkCommand.spec.ts │ ├── BenchmarkCommand.ts │ ├── BenchmarkEnvironment.spec.ts │ ├── BenchmarkEnvironment.ts │ └── BenchmarkReporter.ts ├── meta │ └── ContractsMeta.ts ├── metric │ ├── ContractDatabase.ts │ ├── __snapshots__ │ │ ├── collectMetric.spec.ts.snap │ │ ├── deltaResult.spec.ts.snap │ │ └── gasReportTable.spec.ts.snap │ ├── collectMetric.spec.ts │ ├── collectMetric.ts │ ├── defaultColor.ts │ ├── deltaResult.spec.ts │ ├── deltaResult.ts │ ├── fixtures │ │ ├── complex.fixture.ts │ │ ├── data.fixture.ts │ │ └── snapshot.fixture.ts │ ├── gasReportTable.spec.ts │ ├── gasReportTable.ts │ ├── index.ts │ ├── readSnapshots.spec.ts │ └── readSnapshots.ts ├── treasury │ └── Treasury.ts └── utils │ ├── AsyncLock.spec.ts │ ├── AsyncLock.ts │ ├── base64.ts │ ├── config.ts │ ├── crc16.spec.ts │ ├── crc16.ts │ ├── ec.ts │ ├── message.ts │ ├── prettyLogTransaction.ts │ ├── printTransactionFees.spec.ts │ ├── printTransactionFees.ts │ ├── readJsonl.spec.ts │ ├── readJsonl.ts │ ├── selector.ts │ └── testTreasurySubwalletId.ts ├── tsconfig.json └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps and/or code to reproduce the bug. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Actual behavior** 20 | A clear and concise description of what actually happened. 21 | 22 | **System information** 23 | - Package version 24 | - Node version 25 | - OS name and version 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. There is a missing API that would be helpful [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # Checklist: 17 | 18 | - [ ] My code follows the style guidelines of this project 19 | - [ ] I have performed a self-review of my code 20 | - [ ] I have commented my code in hard-to-understand areas 21 | - [ ] I have made corresponding changes to the documentation 22 | - [ ] I have added tests that prove my fix is effective or that my feature works 23 | - [ ] New and existing unit tests pass locally with my changes 24 | - [ ] Build succeeds locally with my changes 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: Publish to NPM 6 | jobs: 7 | publish: 8 | name: Publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Use Node.js 18 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: '18.x' 16 | always-auth: true 17 | - name: Install Yarn 18 | run: npm install -g yarn 19 | - name: Install dependencies 20 | run: yarn 21 | - name: Run tests 22 | run: yarn test 23 | - name: Build 24 | run: yarn build 25 | - name: Setup .yarnrc.yml 26 | run: | 27 | yarn config set npmAuthToken $NPM_AUTH_TOKEN 28 | yarn config set npmAlwaysAuth true 29 | env: 30 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 31 | - name: Publish 32 | env: 33 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 34 | run: yarn npm publish --access public 35 | -------------------------------------------------------------------------------- /.github/workflows/qa.yaml: -------------------------------------------------------------------------------- 1 | name: QA 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | - develop 12 | 13 | jobs: 14 | lint: 15 | name: Lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: the-ton-tech/toolchain/lint@v1.4.0 19 | build: 20 | name: Test & Build 21 | needs: lint 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: the-ton-tech/toolchain/build@v1.4.0 25 | -------------------------------------------------------------------------------- /.github/workflows/reward.yml: -------------------------------------------------------------------------------- 1 | name: Reward 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | 8 | jobs: 9 | reward: 10 | name: Reward 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | pull-requests: write 15 | steps: 16 | - uses: the-ton-tech/toolchain/reward@v1.3.0 17 | with: 18 | activity_id: sandbox 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | society_api_key: ${{ secrets.X_API_KEY }} 21 | society_partner_id: ${{ secrets.X_PARTNER_ID }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .idea 4 | coverage/ 5 | .sandbox-metric-raw.jsonl 6 | 7 | # yarn 8 | .yarn/* 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/sdks 13 | !.yarn/versions 14 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.9.2.cjs 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ### Added 11 | 12 | - Added `fetchConfig` and `setGlobalVersion` utility functions 13 | 14 | ## [0.32.1] - 2025-06-05 15 | 16 | ### Fixed 17 | 18 | - Fixed missing `chalk` dependency 19 | 20 | ## [0.32.0] - 2025-06-02 21 | 22 | ### Added 23 | 24 | - Added contract meta gathering via meta field in blockchain 25 | - Added ability to set `PREVBLOCKS` information 26 | 27 | ## [0.31.0] - 2025-05-20 28 | 29 | ### Added 30 | 31 | - Added methods to collect metrics of contracts 32 | - Added `@ton/sandbox/jest-environment` and `@ton/sandbox/jest-reporter` to write metric snapshots from test run results 33 | - Added contract method ABI auto-mapping mechanism for detailed benchmark metrics 34 | - Added methods to generate delta reports from metrics of contracts 35 | 36 | ## [0.30.0] - 2025-05-12 37 | 38 | ### Changed 39 | 40 | - Updated config 41 | 42 | ## [0.29.0] - 2025-04-30 43 | 44 | ### Changed 45 | 46 | - Updated API docs 47 | - Updated emulator WASM binary (TON v2025.04) 48 | 49 | ## [0.28.0] - 2025-04-01 50 | 51 | ### Added 52 | 53 | - Added `getVersion` method to `Executor` 54 | 55 | ### Changed 56 | 57 | - Updated emulator WASM binary (TON v2025.03) 58 | - Updated config 59 | 60 | ## [0.27.1] - 2025-02-25 61 | 62 | ### Fixed 63 | 64 | - Fixed a bug pertaining to blockchain snapshot loading 65 | 66 | ## [0.27.0] - 2025-02-20 67 | 68 | ### Added 69 | 70 | - Added better extra currency support 71 | 72 | ### Changed 73 | 74 | - Updated dependencies 75 | 76 | ## [0.26.0] - 2025-02-12 77 | 78 | ### Changed 79 | 80 | - Updated emulator WASM binary (TON v2025.02) 81 | 82 | ## [0.25.0] - 2025-01-30 83 | 84 | ### Changed 85 | 86 | - Extra currencies are now available in get methods 87 | 88 | ## [0.24.0] - 2025-01-17 89 | 90 | ### Added 91 | 92 | - Added `SmartContract.ec` getter and setter to work with extra currencies 93 | - Added an optional `ec` parameter to `internal` helper to set extra currencies 94 | 95 | ## [0.23.0] - 2024-12-18 96 | 97 | ### Updated 98 | 99 | - Updated emulator WASM binary 100 | 101 | ## [0.22.0] - 2024-09-17 102 | 103 | ### Added 104 | 105 | - Added `blockchain.recordStorage` flag. If set to `true`, `BlockchainTransaction` will have `oldStorage` and `newStorage` fields. Note that enabling this flag will disable a certain optimization, which will slow down contract emulation 106 | 107 | ## [0.21.0] - 2024-09-16 108 | 109 | ### Added 110 | 111 | - `SandboxContract` now wraps methods starting with `is` (having the same semantics as `get`) as well as `send` and `get` 112 | 113 | ### Changed 114 | 115 | - Updated dependencies 116 | 117 | ## [0.20.0] - 2024-05-31 118 | 119 | ### Added 120 | 121 | - Added the ability to create `Blockchain` using a custom `IExecutor` instead of the default `Executor` 122 | - Added more information to `EmulationError`, extended its error message 123 | 124 | ## [0.19.0] - 2024-04-27 125 | 126 | ### Fixed 127 | 128 | - Fixed a bug in the emulator that caused send mode 16 to not properly work 129 | 130 | ## [0.18.0] - 2024-04-23 131 | 132 | ### Changed 133 | 134 | - Changed the default and slim configs to use the latest config at the time of release, which reduces gas fees by a factor of 2.5x 135 | 136 | ## [0.17.0] - 2024-03-27 137 | 138 | ### Changed 139 | 140 | - Updated emulator WASM binary 141 | - Changed the default and slim configs to use the latest config at the time of release, which enables TVM v6 opcodes 142 | 143 | ## [0.16.0] - 2024-03-01 144 | 145 | This release contains a breaking change. 146 | 147 | ### Added 148 | 149 | - Added `IExecutor` interface with the prospect of creating custom executor 150 | - Added `open` and `getTransactions` to sandbox's `ContractProvider` 151 | - Added `toSandboxContract` helper function to cast `OpenedContract` to `SandboxContract` when applicable 152 | 153 | ### Changed 154 | 155 | - Changed the default executor to have `async` methods (it still has sync nature) 156 | - Improved get method return object 157 | 158 | ## [0.15.0] - 2023-12-24 159 | 160 | ### Changed 161 | 162 | - Changed the default and slim configs to use the latest config at the time of release, which enables new TVM opcodes 163 | 164 | ## [0.14.0] - 2023-12-04 165 | 166 | ### Changed 167 | 168 | - Updated emulator WASM binary 169 | 170 | ## [0.13.1] - 2023-10-10 171 | 172 | ### Fixed 173 | 174 | - Fixed a bug in `Blockchain` that led to storage fetch errors (for example, network errors in `RemoteBlockchainStorage`) being cached and breaking that contract address forever 175 | 176 | ## [0.13.0] - 2023-10-05 177 | 178 | ### Changed 179 | 180 | - On transaction emulation error, an `EmulationError` is now thrown that has an `error` string, `vmLogs`, and `exitCode` (the latter two being optional). The error is no longer being dumped into console 181 | 182 | ## [0.12.0] - 2023-10-03 183 | 184 | ### Added 185 | 186 | - Step by step execution (`blockchain.sendMessageIter`) 187 | - Better docs 188 | 189 | ### Fixed 190 | 191 | - `now` from `Blockchain` is now honored in `SmartContract.receiveMessage` 192 | - Exit code 1 is now counted as success in get methods 193 | 194 | ## [0.11.1] - 2023-07-26 195 | 196 | ### Changed 197 | 198 | - Migrated dependencies to @ton organization packages 199 | - Bumped @ton/test-utils version to 0.3.1 200 | 201 | ## [0.11.0] - 2023-05-11 202 | 203 | ### Added 204 | 205 | - Added the ability to emulate ticktock transactions. There are 3 ways to do that: `blockchain.runTickTock(Address | Address[], TickOrTock, MessageParams?)`, `smartContract.runTickTock(TickOrTock, MessageParams?)`, or you can change `ContractProvider` in your wrapper classes to be `SandboxContractProvider` and invoke `tickTock(TickOrTock)` on it. `TickOrTock` is a union type `'tick' | 'tock'` 206 | - Added new verbosity levels: `'vm_logs_location'` (same as `'vm_logs'` but also display code cell hash and offset), `'vm_logs_gas'` (same as `'vm_logs_location'` but also display gas remaining), `'vm_logs_verbose'` (same as `'vm_logs_full'` but display stack values in a more verbose way) 207 | 208 | ### Changed 209 | 210 | - Changed emulator WASM binary 211 | 212 | ## [0.10.0] - 2023-05-04 213 | 214 | ### Changed 215 | 216 | - Changed emulator WASM binary 217 | - Changed treasury code 218 | 219 | ### Fixed 220 | 221 | - Fixed certain interactions between snapshots and treasuries 222 | 223 | ## [0.9.0] - 2023-04-27 224 | 225 | ### Added 226 | 227 | - Added `printTransactionFees` helper for easier calculation of fees of different operations 228 | - Added `blockchain.snapshot`, `blockchain.loadFrom`, `smartContract.snapshot`, `smartContract.loadFrom` methods to create state snapshots of the respective objects and restore from them at a later point in time. They return and accept new types, `BlockchainSnapshot` and `SmartContractSnapshot` 229 | 230 | ## [0.8.0] - 2023-04-07 231 | 232 | This release contains a breaking change. 233 | 234 | ### Added 235 | 236 | - Added `blockchain.createWallets` method which accepts a number `n` and optional `TreasuryParams`. It creates `n` treasuries and returns them as an array 237 | 238 | ### Changed 239 | 240 | - `RemoteBlockchainStorage` now requires a `RemoteBlockchainStorageClient` instead of `TonClient4`. There is a helper function, `wrapTonClient4ForRemote`, to wrap a `TonClient4` into `RemoteBlockchainStorageClient`. This is a breaking change 241 | - Updated default config 242 | - `Blockchain.create` now accepts an optional `BlockchainConfig = Cell | 'default' | 'slim'` as the config. If nothing or `'default'` is specified, the default config is used, if `'slim'` is specified, the slim config is used (it is much smaller than the default config, which improves performance), if a `Cell` is passed, then it is used as the config 243 | 244 | ### Removed 245 | 246 | - Removed ton as a peer dependency 247 | 248 | ## [0.7.0] - 2023-03-27 249 | 250 | ### Added 251 | 252 | - Added `externals: ExternalOut[]` field to `BlockchainTransaction` and `SendMessageResult`. `ExternalOut` is a specialized type for external out messages compatible with `Message` from ton-core 253 | 254 | ### Changed 255 | 256 | - Get methods now throw a specialized error type `GetMethodError` when exit code is not 0 257 | - Smart contracts now throw a specialized error type `TimeError` when trying to run a transaction at a unix timestamp that is less than the unix timestamp of the last transaction 258 | - Get methods now return `gasUsed` and `logs` from the `ContractProvider` on opened contracts 259 | 260 | ### Other 261 | 262 | - Consecutive transaction emulations have been optimized 263 | 264 | ## [0.6.1] - 2023-03-16 265 | 266 | ### Fixed 267 | 268 | - Fixed `blockchain.now` override for get methods in opened contracts 269 | 270 | ## [0.6.0] - 2023-03-13 271 | 272 | ### Added 273 | 274 | - Added `treasury.getBalance` method 275 | - Added `blockchain.now` getter and setter to override current unix time as seen during contract execution (both transactions and get methods). Note that this is unix timestamp, not JS timestamp, so you need to use `Math.floor(Date.now() / 1000)` instead of `Date.now()` to set current time 276 | 277 | ### Changed 278 | 279 | - `RemoteBlockchainStorage` constructor now accepts a second optional parameter, `blockSeqno?: number`. If passed, all accounts will be pulled from that block number instead of the latest one 280 | 281 | ## [0.5.1] - 2023-03-02 282 | 283 | ### Changed 284 | 285 | - Changed ton and ton-core dev and peer dependencies to versions 13.4.1 and 0.48.0 respectively 286 | 287 | ### Fixed 288 | 289 | - Fixed typos in `SendMode.PAY_GAS_SEPARATLY` (missing E) from ton-core 290 | 291 | ## [0.5.0] - 2023-02-22 292 | 293 | This release contains multiple breaking changes. 294 | 295 | ### Added 296 | 297 | - Added `blockchain.libs: Cell | undefined` getter and setter for global libraries dictionary (as a `Cell`) 298 | 299 | ### Changed 300 | 301 | - `blockchain.treasury` now accepts an optional `TreasuryParams` argument (see below for definition) instead of the old optional `workchain?: number` argument. This is a breaking change 302 | ```typescript 303 | export type TreasuryParams = Partial<{ 304 | workchain: number 305 | predeploy: boolean 306 | balance: bigint 307 | resetBalanceIfZero: boolean 308 | }> 309 | ``` 310 | - `OpenedContract` was renamed to `SandboxContract`. This is a breaking change 311 | - `LogsVerbosity` now has a new field, `print: boolean` (defaults to `true` on the `Blockchain` instance), which controls whether to `console.log` any logs at all (both from transactions and get methods). This is a breaking change 312 | - `smartContract.get` and `blockchain.runGetMethod` now return `GetMethodResult` (see below for definition). The differences from the previous return type are as follows: 313 | - `logs` renamed to `vmLogs`. This is a breaking change 314 | - `gasUsed` is now of type `bigint`. This is a breaking change 315 | - `blockchainLogs: string` and `debugLogs: string` were added 316 | ```typescript 317 | export type GetMethodResult = { 318 | stack: TupleItem[] 319 | stackReader: TupleReader 320 | exitCode: number 321 | gasUsed: bigint 322 | blockchainLogs: string 323 | vmLogs: string 324 | debugLogs: string 325 | } 326 | ``` 327 | - Properties `storage` and `messageQueue` on `Blockchain` are now protected. This is a breaking change 328 | - All properties and methods of `Blockchain` that were private are now protected to improve extensibility. Note that any invariants expected by `Blockchain` must be upheld 329 | - `blockchain.sendMessage` and `smartContract.receiveMessage` now accept an optional `MessageParams` argument (see below for definition). These parameters are used for every transaction in the chain in case of `blockchain.sendMessage` 330 | ```typescript 331 | export type MessageParams = Partial<{ 332 | now: number 333 | randomSeed: Buffer 334 | ignoreChksig: boolean 335 | }> 336 | ``` 337 | - `blockchain.runGetMethod` and `smartContract.get` now accept an optional `GetMethodParams` argument (see below for definition) 338 | ```typescript 339 | export type GetMethodParams = Partial<{ 340 | now: number 341 | randomSeed: Buffer 342 | gasLimit: bigint 343 | }> 344 | ``` 345 | - `SendMessageResult` now has `transactions: BlockchainTransaction[]` instead of `transactions: Transaction[]`. Definition of `BlockchainTransaction`: 346 | ```typescript 347 | export type BlockchainTransaction = Transaction & { 348 | blockchainLogs: string 349 | vmLogs: string 350 | debugLogs: string 351 | events: Event[] 352 | parent?: BlockchainTransaction 353 | children: BlockchainTransaction[] 354 | } 355 | ``` 356 | - `smartContract.receiveMessage` now returns `SmartContractTransaction` (see below for definition) 357 | ```typescript 358 | export type SmartContractTransaction = Transaction & { 359 | blockchainLogs: string 360 | vmLogs: string 361 | debugLogs: string 362 | } 363 | ``` 364 | - Emulator WASM binary has been updated 365 | 366 | ### Fixed 367 | 368 | - Fixed empty message bodies in bounced messages. This fix is contained in the emulator WASM binary 369 | 370 | ## [0.4.0] - 2023-02-09 371 | 372 | ### Changed 373 | 374 | - Treasuries obtained by `blockchain.treasury` calls are now initialized during this call and will no longer produce an extra transaction when first sending a message 375 | - Transaction processing loop now prefetches contracts, which should provide a performance boost in some scenarios 376 | 377 | ## [0.3.0] - 2023-02-05 378 | 379 | ### Changed 380 | 381 | - `Blockchain` and `SmartContract` now use `LogsVerbosity` (see below for definition) as the verbosity type, which allows for more control over what kinds of logs are printed. Logs from TVM debug primitives are now enabled by default, again. (You can disable them globally by setting verbosity with `debugLogs: false` on the `Blockchain` instance) 382 | 383 | Definition of `LogsVerbosity`: 384 | ```typescript 385 | type LogsVerbosity = { 386 | blockchainLogs: boolean 387 | vmLogs: Verbosity 388 | debugLogs: boolean 389 | } 390 | ``` 391 | 392 | ## [0.2.2] - 2023-02-03 393 | 394 | ### Added 395 | 396 | - Added `blockchain.runGetMethod(address, method, stack)` to run a get method on the specified address 397 | - Added `blockchain.setShardAccount(address, account)` to directly set the state of smart contracts 398 | - Added `account: ShardAccount` getter and setter to `SmartContract` 399 | - Exported helper methods `createEmptyShardAccount`, `createShardAccount` for use with `blockchain.setShardAccount` and `smartContract.account` setter 400 | - Added the ability to pass `Cell`s into `blockchain.sendMessage` 401 | 402 | ### Changed 403 | 404 | - Removed unnecessary `async` modifiers from `smartContract.receiveMessage` and `smartContract.get` 405 | - Logs from TVM debug primitives (for example, `DUMP` and `STRDUMP` with corresponding FunC functions `~dump()` and `~strdump()`) now respect the `verbosity` parameter and will only work when it is not `none` 406 | - Logs from TVM debug primitives are now printed using a single `console.log` call per one TVM execution to avoid cluttering the terminal during unit tests 407 | 408 | ### Fixed 409 | 410 | - Fixed potential race conditions between execution of different transaction chains on the same `Blockchain` instance by use of an `AsyncLock` 411 | 412 | ### Removed 413 | 414 | - Changed `blockchain.pushMessage`, `blockchain.processQueue`, `blockchain.runQueue` to be private 415 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to sandbox 2 | 3 | We highly appreciate any contribution to sandbox ❤️ 4 | 5 | ## A typical workflow 6 | 7 | 1) Make sure your fork is up to date with the main repository: 8 | 9 | ``` 10 | cd sandbox 11 | git remote add upstream https://github.com/ton-community/sandbox.git 12 | git fetch upstream 13 | git pull --rebase upstream main 14 | ``` 15 | 16 | NOTE: The directory `sandbox` represents your fork's local copy. 17 | 18 | 2) Branch out from `main` into `fix/some-bug-#123` or `feat/some-feat` for features: 19 | Postfixing #123 will associate your PR with the issue #123 and make everyone's life easier 20 | ``` 21 | git checkout -b fix/some-bug-#123 22 | ``` 23 | 24 | 3) Make your changes, add your files, commit, and push to your fork. 25 | 26 | ``` 27 | git add SomeFile.js 28 | git commit "fix: issue #123" 29 | git push origin fix/some-bug-#123 30 | ``` 31 | 32 | 4) Make sure tests pass and build succeeds: 33 | 34 | ```bash 35 | yarn build 36 | ``` 37 | (`build` runs tests automatically) 38 | 39 | 5) Go to [github.com/ton-community/sandbox](https://github.com/ton-community/sandbox) in your web browser and issue a new pull request. 40 | 41 | *IMPORTANT* Read the PR template very carefully and make sure to follow all the instructions. 42 | 43 | 6) Maintainers will review your code and possibly ask for changes before your code is pulled in to the main repository. We'll check that all tests pass, review the coding style, and check for general code correctness. If everything is OK, we'll merge your pull request and your code will be part of sandbox. 44 | 45 | ## All set! 46 | 47 | If you have any questions, feel free to join ton-community dev chat at Telegram: https://t.me/ton_dev_community. 48 | 49 | Thanks for your time and code! 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ton Tech 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. -------------------------------------------------------------------------------- /docs/collect-metric-api.md: -------------------------------------------------------------------------------- 1 | ## Collect Metric API 2 | 3 | The `@ton/sandbox` package provides a built-in way to collect detailed metrics during contract execution. This is useful for benchmarking gas usage, VM steps, message forwarding, and storage impact of smart contracts. 4 | 5 | > ℹ️ See also: [Benchmark Contracts documentation](../README.md#benchmark-contracts) 6 | 7 | ### Example 8 | 9 | ```ts 10 | import { beginCell, toNano } from '@ton/core'; 11 | import { 12 | Blockchain, 13 | createMetricStore, 14 | makeSnapshotMetric, 15 | ContractDatabase, 16 | defaultColor, 17 | makeGasReport, 18 | gasReportTable, 19 | SnapshotMetric, 20 | resetMetricStore, 21 | } from '@ton/sandbox'; 22 | 23 | async function main() { 24 | const blockchain = await Blockchain.create(); 25 | const [alice, bob] = await blockchain.createWallets(2); 26 | 27 | // describe knowledge contracts 28 | const contractDatabase = ContractDatabase.from({ 29 | '0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912': 'TreasuryContract', 30 | TreasuryContract: { 31 | name: 'TreasuryContract', 32 | types: [ 33 | { name: 'ping', header: 0x70696e67, fields: [] }, 34 | { name: 'pong', header: 0x706f6e67, fields: [] }, 35 | ], 36 | receivers: [ 37 | { receiver: 'internal', message: { kind: 'typed', type: 'ping' } }, 38 | { receiver: 'internal', message: { kind: 'typed', type: 'pong' } }, 39 | ], 40 | }, 41 | }); 42 | 43 | // initialize metric store 44 | let store = createMetricStore(); 45 | const list: SnapshotMetric[] = []; 46 | 47 | // first snapshot 48 | await alice.send({ 49 | to: bob.address, 50 | value: toNano(1), 51 | body: beginCell().storeUint(0x70696e67, 32).endCell(), // "ping" 52 | }); 53 | await bob.send({ 54 | to: alice.address, 55 | value: toNano(1), 56 | body: beginCell().storeUint(0x706f6e67, 32).endCell(), // "pong" 57 | }); 58 | list.push(makeSnapshotMetric(store, { contractDatabase, label: 'first' })); 59 | 60 | // second snapshot 61 | resetMetricStore(); 62 | await alice.send({ 63 | to: bob.address, 64 | value: toNano(1), 65 | body: beginCell().storeUint(0x70696e67, 32).endCell(), // "ping" 66 | }); 67 | await bob.send({ 68 | to: alice.address, 69 | value: toNano(1), 70 | body: beginCell().storeUint(0x706f6e67, 32).endCell(), // "pong" 71 | }); 72 | list.push(makeSnapshotMetric(store, { contractDatabase })); 73 | // make report 74 | const delta = makeGasReport(list); 75 | console.log(JSON.stringify(contractDatabase.data, null, 2)); 76 | console.log(gasReportTable(delta, defaultColor)); 77 | } 78 | 79 | main().catch((error) => { 80 | console.log(error.message); 81 | }); 82 | ``` 83 | 84 | ### How it works 85 | 86 | * `const store = createMetricStore()` initializes an in-memory global metric storage (per test context or worker) 87 | * The sandbox automatically collects metrics from each transaction triggered via the blockchain during test execution (via `collectMetric()`) 88 | * `const snapshot = makeSnapshotMetric('comment', store)` produces a de-duplicated and ABI auto-mapping, timestamped snapshot of collected metrics 89 | 90 | ### Snapshot Structure 91 | 92 | ```ts 93 | type SnapshotMetric = { 94 | label: string; 95 | createdAt: Date; 96 | items: Metric[]; 97 | } 98 | ``` 99 | 100 | A snapshot consists of: 101 | 102 | * `comment`: a user-defined label 103 | * `createdAt`: the timestamp when the snapshot was generated 104 | * `items`: an array of unique `Metric` objects collected during execution 105 | 106 | Each `Metric` includes: 107 | 108 | ```ts 109 | type Metric = { 110 | // the name of the current test (if available in Jest context) 111 | testName?: string 112 | // address of contract in user friendly format 113 | address: string 114 | // hex-formatted hash of contract code 115 | codeHash?: `0x${string}` 116 | // total cells and bits usage of the contract's code and data 117 | state: { 118 | code: { 119 | cells: number 120 | bits: number 121 | } 122 | data: { 123 | cells: number 124 | bits: number 125 | } 126 | } 127 | contractName?: string 128 | methodName?: string 129 | receiver?: 'internal' | 'external-in' | 'external-out' 130 | opCode: `0x${string}` 131 | // information from transaction phases 132 | execute: { 133 | compute: { 134 | type: string 135 | success?: boolean 136 | gasUsed?: number 137 | exitCode?: number 138 | vmSteps?: number 139 | }; 140 | action?: { 141 | success: boolean 142 | totalActions: number 143 | skippedActions: number 144 | resultCode: number 145 | totalFwdFees?: number 146 | totalActionFees: number 147 | totalMessageSize: { 148 | cells: number 149 | bits: number 150 | } 151 | } 152 | } 153 | // total cells and bits usage of inbound and outbound messages 154 | message: { 155 | in: { 156 | cells: number 157 | bits: number 158 | } 159 | out: { 160 | cells: number 161 | bits: number 162 | } 163 | } 164 | } 165 | ``` 166 | 167 | ### Advanced Configuration 168 | 169 | #### Contract Exclusion (optional) 170 | 171 | You can exclude contracts from the snapshot using: 172 | 173 | ```ts 174 | makeSnapshotMetric('label', store, { 175 | contractExcludes: [ 176 | 'ContractName1', 177 | 'ContractName2', 178 | ], 179 | }); 180 | ``` 181 | 182 | #### ABI auto-mapping 183 | 184 | Use `ContractDatabase.from()` to define a map of known `CodeHash` → [ContractABI](https://github.com/ton-org/ton-core/blob/c627c266030cb95d07dbea950dc8af36a3307d37/src/contract/ContractABI.ts), so method names and contract names are resolved automatically: 185 | 186 | ```ts 187 | makeSnapshotMetric('label', store, { 188 | contractDatabase: ContractDatabase.from({ 189 | '0xCodeHashv': {...ContractABI}, // map CodeHash and ABI_1 190 | 'ContractName': {...ContractABI}, // map ContractName and ABI_2 191 | '0xCodeHashN1': '0xCodeHash', // aliase for ABI_1 192 | '0xCodeHashN2': 'ContractName', // aliase for ABI_2 193 | }), 194 | }); 195 | ``` 196 | 197 | ### Where to go next 198 | 199 | * Read more about [benchmarking smart contracts](../README.md#benchmark-contracts) 200 | * Integrate with [blueprint](https://github.com/ton-org/blueprint#benchmark-contracts) `blueprint metric` and `blueprint bench` commands to compare snapshots over time 201 | -------------------------------------------------------------------------------- /docs/msg-trace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ton-org/sandbox/9740e198881e8d79719e3138a7fd620accdfcc72/docs/msg-trace.png -------------------------------------------------------------------------------- /docs/tact-testing-examples.md: -------------------------------------------------------------------------------- 1 | 2 | ## Writing Tests for Tact 3 | 4 | This page demonstrate writing test for Tact contracts created in with [Blueprint](https://github.com/ton-org/blueprint) ([Sandbox](https://github.com/ton-org/sandbox)). 5 | Test suites built for demo contract [Fireworks](https://github.com/reveloper/tact-fireworks/blob/main/contracts/fireworks.tact). The fireworks expect to receive message with 'Launch' command, which handled to send back leasts funds with separate message with different sendmodes. 6 | 7 | Once a new Tact project is created via `npm create ton@latest`, a test file `test/contract.ts.spec` will be autogenerated in the project directory for testing the contract: 8 | 9 | ```typescript 10 | import ... 11 | 12 | describe('Fireworks', () => { 13 | ... 14 | 15 | 16 | expect(deployResult.transactions).toHaveTransaction({ 17 | ... 18 | }); 19 | 20 | }); 21 | 22 | it('should deploy', async () => { 23 | // the check is done inside beforeEach 24 | // blockchain and fireworks are ready to use 25 | }); 26 | ``` 27 | 28 | 29 | Running tests using the following command: 30 | 31 | ```bash 32 | npx blueprint test 33 | ``` 34 | 35 | 36 | ### Transaction Success Test 37 | 38 | This test checks if the fireworks are successfully launched by sending a transaction with a value of 1 TON (converted to nanoTON) and the 'Launch' type. It then checks if the transaction was successful by validating the `from`, `to`, and `success` fields using the transaction matcher. 39 | 40 | ```typescript 41 | 42 | 43 | it('should launch fireworks', async () => { 44 | 45 | const launcher = await blockchain.treasury('fireworks'); 46 | console.log('launcher = ', launcher.address); 47 | console.log('Fireworks = ', fireworks.address); 48 | 49 | 50 | const launchResult = await fireworks.send( 51 | launcher.getSender(), 52 | { 53 | value: toNano('1'), 54 | }, 55 | { 56 | $$type: 'Launch', 57 | } 58 | ); 59 | 60 | expect(launchResult.transactions).toHaveTransaction({ 61 | from: launcher.address, 62 | to: fireworks.address, 63 | success: true, 64 | }); 65 | }); 66 | 67 | ``` 68 | 69 | 70 | ### Account Status Tests 71 | 72 | This test checks if the contract is destroyed after launching the fireworks. 73 | 74 | ```typescript 75 | 76 | it('should destroy after launching', async () => { 77 | 78 | const launcher = await blockchain.treasury('fireworks'); 79 | 80 | const launchResult = await fireworks.send( 81 | launcher.getSender(), 82 | { 83 | value: toNano('1'), 84 | }, 85 | { 86 | $$type: 'Launch', 87 | } 88 | ); 89 | 90 | expect(launchResult.transactions).toHaveTransaction({ 91 | from: launcher.address, 92 | to: fireworks.address, 93 | success: true, 94 | endStatus: 'non-existing', 95 | destroyed: true 96 | }); 97 | 98 | }); 99 | 100 | ``` 101 | 102 | The full list of Account Status related fields: 103 | 104 | * `destroyed` - `true` - if the existing contract was destroyed due to executing a certain transaction. Otherwise - `false`. 105 | * `deploy` - Custom Sandbox flag that indicates whether the contract was deployed during this transaction. `true` if contract before this transaction was not initialized and after this transaction became initialized. Otherwise - `false`. 106 | * `oldStatus` - AccountStatus before transaction execution. Values: `'uninitialized'`, `'frozen'`, `'active'`, `'non-existing'`. 107 | * `endStatus` - AccountStatus after transaction execution. Values: `'uninitialized'`, `'frozen'`, `'active'`, `'non-existing'`. 108 | 109 | 110 | ### Operation Code Tests 111 | 112 | This test shows how to check whether the operation code (op code) of incoming message is equal to the expected op code. 113 | 114 | ```typescript 115 | 116 | it('should be correct Launch op code for the launching', async () => { 117 | 118 | const launcher = await blockchain.treasury('fireworks'); 119 | 120 | const launchResult = await fireworks.send( 121 | launcher.getSender(), 122 | { 123 | value: toNano('1'), 124 | }, 125 | { 126 | $$type: 'Launch', 127 | } 128 | ); 129 | 130 | expect(launchResult.transactions).toHaveTransaction({ 131 | from: launcher.address, 132 | to: fireworks.address, 133 | success: true, 134 | op: 0xa911b47f // 'Launch' op code 135 | }); 136 | 137 | expect(launchResult.transactions).toHaveTransaction({ 138 | from: fireworks.address, 139 | to: launcher.address, 140 | success: true, 141 | op: 0 // 0x00000000 - comment op code 142 | }); 143 | 144 | }); 145 | ``` 146 | 147 | > For Tact contracts, crc32 representation could be found in the project build directory, autogenerated with build contract.md file. 148 | > Read more about [crc32](https://docs.ton.org/develop/data-formats/crc32) and op codes in the TON documentation. 149 | 150 | ### Message Counter Tests 151 | 152 | This test checks if the correct number of messages are sent in the transaction. 153 | 154 | ```typescript 155 | it('should send 4 messages to wallet', async() => { 156 | 157 | const launcher = await blockchain.treasury('fireworks'); 158 | 159 | const launchResult = await fireworks.send( 160 | launcher.getSender(), 161 | { 162 | value: toNano('1'), 163 | }, 164 | { 165 | $$type: 'Launch', 166 | } 167 | ); 168 | 169 | expect(launchResult.transactions).toHaveTransaction({ 170 | from: launcher.address, 171 | to: fireworks.address, 172 | success: true, 173 | outMessagesCount: 4 174 | }); 175 | }) 176 | ``` 177 | 178 | 179 | ### Multi Transaction and Payload Tests 180 | 181 | This test checks if the fireworks contract sends multiple messages with comments correctly. The body field contains a Cell that is built with @ton/core primitives. 182 | 183 | 184 | ```typescript 185 | 186 | it('fireworks contract should send msgs with comments', async() => { 187 | 188 | const launcher = await blockchain.treasury('fireworks'); 189 | 190 | const launchResult = await fireworks.send( 191 | launcher.getSender(), 192 | { 193 | value: toNano('1'), 194 | }, 195 | { 196 | $$type: 'Launch', 197 | } 198 | ); 199 | 200 | 201 | expect(launchResult.transactions).toHaveTransaction({ 202 | from: fireworks.address, 203 | to: launcher.address, 204 | success: true, 205 | body: beginCell().storeUint(0,32).storeStringTail("send mode = 0").endCell() // 0x00000000 comment opcode and encoded comment 206 | 207 | }); 208 | 209 | expect(launchResult.transactions).toHaveTransaction({ 210 | from: fireworks.address, 211 | to: launcher.address, 212 | success: true, 213 | body: beginCell().storeUint(0,32).storeStringTail("send mode = 1").endCell() 214 | }); 215 | 216 | expect(launchResult.transactions).toHaveTransaction({ 217 | from: fireworks.address, 218 | to: launcher.address, 219 | success: true, 220 | body: beginCell().storeUint(0,32).storeStringTail("send mode = 2").endCell() 221 | }); 222 | 223 | expect(launchResult.transactions).toHaveTransaction({ 224 | from: fireworks.address, 225 | to: launcher.address, 226 | success: true, 227 | body: beginCell().storeUint(0,32).storeStringTail("send mode = 128 + 32").endCell() 228 | }); 229 | }) 230 | 231 | ``` 232 | 233 | ### Printing and Reading Transaction Fees 234 | 235 | During the test, reading the details about fees can be useful for optimizing the contract. The printTransactionFees function prints the entire transaction chain in a convenient manner." 236 | ```typescript 237 | 238 | it('should be executed and print fees', async() => { 239 | 240 | const launcher = await blockchain.treasury('fireworks'); 241 | 242 | const launchResult = await fireworks.send( 243 | launcher.getSender(), 244 | { 245 | value: toNano('1'), 246 | }, 247 | { 248 | $$type: 'Launch', 249 | } 250 | ); 251 | 252 | console.log(printTransactionFees(launchResult.transactions)); 253 | 254 | }); 255 | 256 | ``` 257 | 258 | For instance, in case of `launchResult` the following table will be printed: 259 | 260 | ┌─────────┬──────────────┬────────────────┬────────────────┬────────────────┬────────────────┬───────────────┬────────────┬────────────────┬──────────┬────────────┐ 261 | │ (index) │ op │ valueIn │ valueOut │ totalFees │ inForwardFee │ outForwardFee │ outActions │ computeFee │ exitCode │ actionCode │ 262 | ├─────────┼──────────────┼────────────────┼────────────────┼────────────────┼────────────────┼───────────────┼────────────┼────────────────┼──────────┼────────────┤ 263 | │ 0 │ 'N/A' │ 'N/A' │ '1 TON' │ '0.003935 TON' │ 'N/A' │ '0.001 TON' │ 1 │ '0.001937 TON' │ 0 │ 0 │ 264 | │ 1 │ '0xa911b47f' │ '1 TON' │ '0.980644 TON' │ '0.016023 TON' │ '0.000667 TON' │ '0.005 TON' │ 4 │ '0.014356 TON' │ 0 │ 0 │ 265 | │ 2 │ '0x0' │ '0.098764 TON' │ '0 TON' │ '0.000309 TON' │ '0.000825 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 266 | │ 3 │ '0x0' │ '0.1 TON' │ '0 TON' │ '0.000309 TON' │ '0.000825 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 267 | │ 4 │ '0x0' │ '0.098764 TON' │ '0 TON' │ '0.000309 TON' │ '0.000825 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 268 | │ 5 │ '0x0' │ '0.683116 TON' │ '0 TON' │ '0.000309 TON' │ '0.000862 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 269 | └─────────┴──────────────┴────────────────┴────────────────┴────────────────┴────────────────┴───────────────┴────────────┴────────────────┴──────────┴────────────┘ 270 | 271 | ![](msg-trace.png) 272 | 273 | index - is an ID of a transaction in the launchResult array. 274 | * `0` - External request to the treasury (the Launcher) that resulted in a message to Fireworks 275 | * `1` - The Fireworks transaction that resulted in 4 messages to the Launcher 276 | * `2` - Transaction on Launcher with incoming message from Fireworks, message sent with `send mode = 0` 277 | * `3` - Transaction on Launcher with incoming message from Fireworks, message sent with `send mode = 1` 278 | * `4` - Transaction on Launcher with incoming message from Fireworks, message sent with `send mode = 2` 279 | * `5` - Transaction on Launcher with incoming message from Fireworks, message sent with `send mode = 128 + 32` 280 | 281 | 282 | ### Transaction Fees Tests 283 | 284 | This test verifies whether the transaction fees for launching the fireworks are as expected. It is possible to define custom assertions for different parts of commission fees. 285 | 286 | ```typescript 287 | 288 | it('should be executed with expected fees', async() => { 289 | 290 | const launcher = await blockchain.treasury('fireworks'); 291 | 292 | const launchResult = await fireworks.send( 293 | launcher.getSender(), 294 | { 295 | value: toNano('1'), 296 | }, 297 | { 298 | $$type: 'Launch', 299 | } 300 | ); 301 | 302 | //totalFee 303 | console.log('total fees = ', launchResult.transactions[1].totalFees); 304 | 305 | const tx1 = launchResult.transactions[1]; 306 | if (tx1.description.type !== 'generic') { 307 | throw new Error('Generic transaction expected'); 308 | } 309 | 310 | //computeFee 311 | const computeFee = tx1.description.computePhase.type === 'vm' ? tx1.description.computePhase.gasFees : undefined; 312 | console.log('computeFee = ', computeFee); 313 | 314 | //actionFee 315 | const actionFee = tx1.description.actionPhase?.totalActionFees; 316 | console.log('actionFee = ', actionFee); 317 | 318 | //The check, if Compute Phase and Action Phase fees exceed 1 TON 319 | expect(computeFee + actionFee).toBeLessThan(toNano('1')); 320 | 321 | 322 | }); 323 | 324 | 325 | 326 | 327 | 328 | ``` 329 | 330 | 331 | 332 | -------------------------------------------------------------------------------- /docs/testing-key-points.md: -------------------------------------------------------------------------------- 1 | ## Testing flow 2 | 3 | This page describes testing of distributor smart contract. The goal of contract is to distribute some ton among users. 4 | Code of contract located [here](https://github.com/ton-community/onboarding-sandbox/blob/main/sandbox-examples/contracts/distributor.fc). 5 | Code of contract wrapper located [here](https://github.com/ton-community/onboarding-sandbox/blob/main/sandbox-examples/wrappers/Distributor.ts). 6 | 7 | ### Positive testing 8 | 9 | First of all let's make sure our contract is deployed. 10 | Note that we are using `toHaveTransaction` from `@ton/test-utils` to make sure result have transaction that matches pattern. 11 | 12 | ```typescript 13 | it('should deploy', async () => { 14 | const result = await distributor.sendDeploy(owner.getSender(), toNano('0.05')); 15 | 16 | expect(result.transactions).toHaveTransaction({ 17 | from: owner.address, 18 | on: distributor.address, 19 | deploy: true, 20 | success: true 21 | }); 22 | }); 23 | ``` 24 | 25 | Let's check that our get methods are working. 26 | Note that we are using `toEqualAddress` from `@ton/test-utils`. Under the hood it uses `Address.equals` to compare addresses. 27 | 28 | ```typescript 29 | it('should get owner', async () => { 30 | const ownerFromContract = await distributor.getOwner(); 31 | 32 | expect(ownerFromContract).toEqualAddress(owner.address); 33 | }); 34 | 35 | it('should get shares dict', async () => { 36 | const shares = await distributor.getShares(); 37 | 38 | expect(shares.keys().length).toEqual(0); 39 | }); 40 | ``` 41 | 42 | To share our coins we need to add some users to shares dict 43 | ```typescript 44 | it('should add firstUser', async () => { 45 | const result = await distributor.sendAddUser(owner.getSender(), { 46 | value: toNano('0.05'), 47 | userAddress: firstUser.address, 48 | }); 49 | 50 | expect(result.transactions).toHaveTransaction({ 51 | from: owner.address, 52 | on: distributor.address, 53 | success: true, 54 | }); 55 | 56 | const shares = await distributor.getShares(); 57 | 58 | expect(shares.keys()[0]).toEqualAddress(firstUser.address); 59 | }); 60 | ``` 61 | 62 | After all preparations we now can finally share coins to users. 63 | ```typescript 64 | it('should share coins to one user', async () => { 65 | const result = await distributor.sendShareCoins(owner.getSender(), { 66 | value: toNano('10'), 67 | }); 68 | 69 | expect(result.transactions).toHaveTransaction({ 70 | from: owner.address, 71 | on: distributor.address, 72 | outMessagesCount: 1, 73 | success: true 74 | }); 75 | expect(result.transactions).toHaveTransaction({ 76 | from: distributor.address, 77 | on: firstUser.address, 78 | op: 0x0, 79 | success: true 80 | }); 81 | }); 82 | ``` 83 | 84 | Additionally, lets validate that our contract share coins to multiple users correctly. 85 | Function `findTransactionRequired` from `@ton/test-utils` will throw error if tx is not found. Also, it has silent version `findTransaction`. 86 | 87 | ```typescript 88 | it('should add secondUser', async () => { 89 | const result = await distributor.sendAddUser(owner.getSender(), { 90 | value: toNano('0.05'), 91 | userAddress: secondUser.address 92 | }); 93 | 94 | expect(result.transactions).toHaveTransaction({ 95 | from: owner.address, 96 | on: distributor.address, 97 | success: true 98 | }); 99 | 100 | const shares = await distributor.getShares(); 101 | 102 | expect( 103 | shares.keys().some((addr) => secondUser.address.equals(addr)) 104 | ).toBeTruthy(); 105 | }); 106 | 107 | 108 | it('should share coins to 2 users', async () => { 109 | const result = await distributor.sendShareCoins(owner.getSender(), { 110 | value: toNano('10') 111 | }); 112 | 113 | expect(result.transactions).toHaveTransaction({ 114 | from: owner.address, 115 | on: distributor.address, 116 | success: true, 117 | outMessagesCount: 2 118 | }); 119 | expect(result.transactions).toHaveTransaction({ 120 | from: distributor.address, 121 | on: firstUser.address, 122 | op: 0x0, 123 | success: true 124 | }); 125 | expect(result.transactions).toHaveTransaction({ 126 | from: distributor.address, 127 | on: secondUser.address, 128 | op: 0x0, 129 | success: true 130 | }); 131 | 132 | const firstUserTransaction = findTransactionRequired(result.transactions, { on: firstUser.address }); 133 | const secondUserTransaction = findTransactionRequired(result.transactions, { on: secondUser.address }); 134 | 135 | expect(flattenTransaction(firstUserTransaction).value).toEqual(flattenTransaction(secondUserTransaction).value); 136 | }); 137 | ``` 138 | 139 | By this simple steps positive flow was validated. But what if someone other than the owner tries to add user to shares? Negative testing comes in place. 140 | 141 | ## Negative testing 142 | 143 | Below we make sure that no one except admin can call `add_user`. Function `randomAddress` from `@ton/test-utils` is useful for random address generation. 144 | 145 | ```typescript 146 | it('should not add user as not owner', async () => { 147 | const notOwner = await blockchain.treasury(`not-owner`); 148 | 149 | const result = await distributor.sendAddUser(notOwner.getSender(), { 150 | value: toNano('0.5'), 151 | userAddress: randomAddress(), 152 | }); 153 | 154 | expect(result.transactions).toHaveTransaction({ 155 | from: notOwner.address, 156 | on: distributor.address, 157 | success: false, 158 | exitCode: ExitCode.MUST_BE_OWNER, 159 | }); 160 | }); 161 | ``` 162 | 163 | We should put impure specifier in function below because it throws an exception. 164 | If impure is not specified compiler will delete this function call. This can lead to security issues so do not forget to test it! 165 | 166 | ```func 167 | () throw_unless_owner(slice address) impure inline { 168 | throw_unless(err::must_be_owner, equal_slice_bits(address, storage::owner)); 169 | } 170 | ``` 171 | 172 | Make sure that your contract does not output more than 255 out actions, or else action phase will fail with 33 exit code. 173 | 174 | ```typescript 175 | it('should add 255 users', async () => { 176 | for (let i = 0; i < 255; ++i) { 177 | const userAddress = randomAddress(); 178 | const result = await distributor.sendAddUser(owner.getSender(), { 179 | value: toNano('0.5'), 180 | userAddress, 181 | }); 182 | expect(result.transactions).toHaveTransaction({ 183 | from: owner.address, 184 | on: distributor.address, 185 | success: true 186 | }); 187 | } 188 | }); 189 | 190 | it('should not add one more user', async () => { 191 | const userAddress = randomAddress(); 192 | const result = await distributor.sendAddUser(owner.getSender(), { 193 | value: toNano('0.5'), 194 | userAddress, 195 | }); 196 | expect(result.transactions).toHaveTransaction({ 197 | from: owner.address, 198 | on: distributor.address, 199 | success: false, 200 | exitCode: ExitCode.SHARES_SIZE_EXCEEDED_LIMIT 201 | }); 202 | }); 203 | ``` 204 | 205 | Always test edge cases too. In this case we test 255 out actions. Function `filterTransactions` from `@ton/test-utils` provides nice interface for filtering transactions. 206 | 207 | ```typescript 208 | it('should share money to 255 users', async () => { 209 | const result = await distributor.sendShareCoins(owner.getSender(), { 210 | value: toNano('1000') 211 | }); 212 | 213 | expect(result.transactions).toHaveTransaction({ 214 | from: owner.address, 215 | on: distributor.address, 216 | success: true 217 | }); 218 | 219 | printTransactionFees(result.transactions); 220 | 221 | const transferTransaction = filterTransactions(result.transactions, { op: 0x0 }); 222 | expect(transferTransaction.length).toEqual(255); 223 | }); 224 | ``` 225 | 226 | Function `printTransactionFees` is useful for debugging costs of transactions. It provides output like this: 227 | ``` 228 | ┌─────────┬─────────────┬────────────────┬────────────────┬────────────────┬────────────────┬───────────────┬────────────┬────────────────┬──────────┬────────────┐ 229 | │ (index) │ op │ valueIn │ valueOut │ totalFees │ inForwardFee │ outForwardFee │ outActions │ computeFee │ exitCode │ actionCode │ 230 | ├─────────┼─────────────┼────────────────┼────────────────┼────────────────┼────────────────┼───────────────┼────────────┼────────────────┼──────────┼────────────┤ 231 | │ 0 │ 'N/A' │ 'N/A' │ '1000 TON' │ '0.004007 TON' │ 'N/A' │ '0.001 TON' │ 1 │ '0.001937 TON' │ 0 │ 0 │ 232 | │ 1 │ '0x45ab564' │ '1000 TON' │ '998.8485 TON' │ '1.051473 TON' │ '0.000667 TON' │ '0.255 TON' │ 255 │ '0.966474 TON' │ 0 │ 0 │ 233 | │ 2 │ '0x0' │ '3.917053 TON' │ '0 TON' │ '0.00031 TON' │ '0.000667 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 234 | │ 3 │ '0x0' │ '3.917053 TON' │ '0 TON' │ '0.00031 TON' │ '0.000667 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 235 | │ 4 │ '0x0' │ '3.917053 TON' │ '0 TON' │ '0.00031 TON' │ '0.000667 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 236 | │ 5 │ '0x0' │ '3.917053 TON' │ '0 TON' │ '0.00031 TON' │ '0.000667 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 237 | │ 6 │ '0x0' │ '3.917053 TON' │ '0 TON' │ '0.00031 TON' │ '0.000667 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 238 | ... 239 | ``` 240 | 241 | 242 | 243 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const base = require('@ton/toolchain'); 2 | 3 | module.exports = [...base, { ignores: ['src/executor/emulator-emscripten*'] }]; 4 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | # yarn 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/sdks 10 | !.yarn/versions 11 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Sandbox examples 2 | 3 | This directory contains a few examples that demonstrate how to use `@ton-community/sandbox`. 4 | 5 | To run all the tests, use the following: 6 | ``` 7 | git clone https://github.com/ton-community/sandbox 8 | cd sandbox/examples 9 | yarn && yarn examples 10 | ``` 11 | 12 | Notice that these examples are separate from sandbox itself and are not included in the npm package, so you need to clone the whole repo (as shown above) to use them. -------------------------------------------------------------------------------- /examples/contracts/Elector.ts: -------------------------------------------------------------------------------- 1 | import { Address, Contract, ContractProvider, StateInit } from '@ton/core'; 2 | 3 | export class Elector implements Contract { 4 | constructor( 5 | readonly address: Address, 6 | readonly init?: StateInit, 7 | ) {} 8 | 9 | static createFromAddress(address: Address) { 10 | return new Elector(address); 11 | } 12 | 13 | async getActiveElectionId(provider: ContractProvider) { 14 | const { stack } = await provider.get('active_election_id', []); 15 | return { 16 | electionId: stack.readBigNumber(), 17 | }; 18 | } 19 | 20 | async getStake(provider: ContractProvider, address: Address) { 21 | const { stack } = await provider.get('compute_returned_stake', [ 22 | { type: 'int', value: BigInt('0x' + address.hash.toString('hex')) }, 23 | ]); 24 | return { 25 | value: stack.readBigNumber(), 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/contracts/NftCollection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Address, 3 | beginCell, 4 | Cell, 5 | Contract, 6 | contractAddress, 7 | ContractProvider, 8 | Sender, 9 | StateInit, 10 | toNano, 11 | } from '@ton/core'; 12 | 13 | import { NftItem } from './NftItem'; 14 | 15 | export type NftCollectionData = { 16 | nextItemIndex: number; 17 | content: Cell; 18 | owner: Address; 19 | }; 20 | 21 | export type NftCollectionConfig = { 22 | owner: Address; 23 | nextItemIndex?: number; 24 | content?: Cell; 25 | itemCode?: Cell; 26 | royaltyParams?: Cell; 27 | }; 28 | 29 | function nftCollectionConfigToCell(config: NftCollectionConfig): Cell { 30 | return beginCell() 31 | .storeAddress(config.owner) 32 | .storeUint(config.nextItemIndex ?? 0, 64) 33 | .storeRef(config.content ?? beginCell().storeRef(new Cell())) 34 | .storeRef(config.itemCode ?? NftItem.code) 35 | .storeRef(config.royaltyParams ?? new Cell()) 36 | .endCell(); 37 | } 38 | 39 | export class NftCollection implements Contract { 40 | static readonly code = Cell.fromBase64( 41 | 'te6ccgECEwEAAf4AART/APSkE/S88sgLAQIBYgIDAgLNBAUCASANDgPr0QY4BIrfAA6GmBgLjYSK3wfSAYAOmP6Z/2omh9IGmf6mpqGEEINJ6cqClAXUcUG6+CgOhBCFRlgFa4QAhkZYKoAueLEn0BCmW1CeWP5Z+A54tkwCB9gHAbKLnjgvlwyJLgAPGBEuABcYEZAmAB8YEvgsIH+XhAYHCAIBIAkKAGA1AtM/UxO78uGSUxO6AfoA1DAoEDRZ8AaOEgGkQ0PIUAXPFhPLP8zMzMntVJJfBeIApjVwA9QwjjeAQPSWb6UgjikGpCCBAPq+k/LBj96BAZMhoFMlu/L0AvoA1DAiVEsw8AYjupMCpALeBJJsIeKz5jAyUERDE8hQBc8WE8s/zMzMye1UACgB+kAwQUTIUAXPFhPLP8zMzMntVAIBIAsMAD1FrwBHAh8AV3gBjIywVYzxZQBPoCE8trEszMyXH7AIAC0AcjLP/gozxbJcCDIywET9AD0AMsAyYAAbPkAdMjLAhLKB8v/ydCACASAPEAAlvILfaiaH0gaZ/qamoYLehqGCxABDuLXTHtRND6QNM/1NTUMBAkXwTQ1DHUMNBxyMsHAc8WzMmAIBIBESAC+12v2omh9IGmf6mpqGDYg6GmH6Yf9IBhAALbT0faiaH0gaZ/qamoYCi+CeAI4APgCw', 42 | ); 43 | 44 | nextItemIndex: number = 0; 45 | 46 | constructor( 47 | readonly address: Address, 48 | readonly init?: StateInit, 49 | ) {} 50 | 51 | static createFromAddress(address: Address) { 52 | return new NftCollection(address); 53 | } 54 | 55 | static createFromConfig(config: NftCollectionConfig, code: Cell, workchain = 0) { 56 | const data = nftCollectionConfigToCell(config); 57 | const init = { code, data }; 58 | const collection = new NftCollection(contractAddress(workchain, init), init); 59 | if (config.nextItemIndex !== undefined) { 60 | collection.nextItemIndex = config.nextItemIndex; 61 | } 62 | return collection; 63 | } 64 | 65 | async sendMint( 66 | provider: ContractProvider, 67 | via: Sender, 68 | to: Address, 69 | params?: Partial<{ 70 | value: bigint; 71 | itemValue: bigint; 72 | content: Cell; 73 | }>, 74 | ) { 75 | const index = this.nextItemIndex++; 76 | await provider.internal(via, { 77 | value: params?.value ?? toNano('0.05'), 78 | body: beginCell() 79 | .storeUint(1, 32) // op 80 | .storeUint(0, 64) // query id 81 | .storeUint(index, 64) 82 | .storeCoins(params?.itemValue ?? toNano('0.02')) 83 | .storeRef( 84 | beginCell() 85 | .storeAddress(to) 86 | .storeRef(params?.content ?? new Cell()), 87 | ) 88 | .endCell(), 89 | }); 90 | return index; 91 | } 92 | 93 | async getItemAddress(provider: ContractProvider, index: number): Promise
{ 94 | const res = await provider.get('get_nft_address_by_index', [{ type: 'int', value: BigInt(index) }]); 95 | return res.stack.readAddress(); 96 | } 97 | 98 | async getCollectionData(provider: ContractProvider): Promise { 99 | const { stack } = await provider.get('get_collection_data', []); 100 | return { 101 | nextItemIndex: stack.readNumber(), 102 | content: stack.readCell(), 103 | owner: stack.readAddress(), 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /examples/contracts/NftItem.ts: -------------------------------------------------------------------------------- 1 | import { Address, beginCell, Cell, Contract, ContractProvider, Sender, toNano, Builder, StateInit } from '@ton/core'; 2 | 3 | export type NftItemData = { 4 | inited: boolean; 5 | index: number; 6 | collection: Address | null; 7 | owner: Address | null; 8 | content: Cell | null; 9 | }; 10 | 11 | export class NftItem implements Contract { 12 | static readonly code = Cell.fromBase64( 13 | 'te6ccgECDgEAAdwAART/APSkE/S88sgLAQIBYgIDAgLOBAUACaEfn+AFAgEgBgcCASAMDQLPDIhxwCSXwPg0NMDAXGwkl8D4PpA+kAx+gAxcdch+gAx+gAwc6m0APACBLOOFDBsIjRSMscF8uGVAfpA1DAQI/AD4AbTH9M/ghBfzD0UUjC64wIwNDQ1NYIQL8smohK64wJfBIQP8vCAICQARPpEMHC68uFNgAqwyEDdeMkATUTXHBfLhkfpAIfAB+kDSADH6ACDXScIA8uLEggr68IAboSGUUxWgod4i1wsBwwAgkgahkTbiIML/8uGSIZQQKjdb4w0CkzAyNOMNVQLwAwoLAHJwghCLdxc1BcjL/1AEzxYQJIBAcIAQyMsFUAfPFlAF+gIVy2oSyx/LPyJus5RYzxcBkTLiAckB+wAAfIIQBRONkchQCc8WUAvPFnEkSRRURqBwgBDIywVQB88WUAX6AhXLahLLH8s/Im6zlFjPFwGRMuIByQH7ABBHAGom8AGCENUydtsQN0QAbXFwgBDIywVQB88WUAX6AhXLahLLH8s/Im6zlFjPFwGRMuIByQH7AAA7O1E0NM/+kAg10nCAJp/AfpA1DAQJBAj4DBwWW1tgAB0A8jLP1jPFgHPFszJ7VSA=', 14 | ); 15 | 16 | constructor( 17 | readonly address: Address, 18 | readonly init?: StateInit, 19 | ) {} 20 | 21 | static createFromAddress(address: Address) { 22 | return new NftItem(address); 23 | } 24 | 25 | async sendTransfer( 26 | provider: ContractProvider, 27 | via: Sender, 28 | params: { 29 | value?: bigint; 30 | to: Address; 31 | responseTo?: Address; 32 | forwardAmount?: bigint; 33 | forwardBody?: Cell | Builder; 34 | }, 35 | ) { 36 | await provider.internal(via, { 37 | value: params.value ?? toNano('0.05'), 38 | body: beginCell() 39 | .storeUint(0x5fcc3d14, 32) // op 40 | .storeUint(0, 64) // query id 41 | .storeAddress(params.to) 42 | .storeAddress(params.responseTo) 43 | .storeBit(false) // custom payload 44 | .storeCoins(params.forwardAmount ?? 0n) 45 | .storeMaybeRef(params.forwardBody) 46 | .endCell(), 47 | }); 48 | } 49 | 50 | async getData(provider: ContractProvider): Promise { 51 | const { stack } = await provider.get('get_nft_data', []); 52 | return { 53 | inited: stack.readBoolean(), 54 | index: stack.readNumber(), 55 | collection: stack.readAddressOpt(), 56 | owner: stack.readAddressOpt(), 57 | content: stack.readCellOpt(), 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/contracts/NftMarketplace.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Address, 3 | beginCell, 4 | Cell, 5 | Contract, 6 | contractAddress, 7 | ContractProvider, 8 | Sender, 9 | StateInit, 10 | storeStateInit, 11 | toNano, 12 | } from '@ton/core'; 13 | 14 | export type NftMarketplaceConfig = { 15 | owner: Address; 16 | }; 17 | 18 | function nftMarketplaceConfigToCell(config: NftMarketplaceConfig): Cell { 19 | return beginCell().storeAddress(config.owner).endCell(); 20 | } 21 | 22 | export class NftMarketplace implements Contract { 23 | static readonly code = Cell.fromBase64( 24 | 'te6ccgEBBAEAagABFP8A9KQT9LzyyAsBAgEgAgMApNIyIccAkVvg0NMDAXGwkVvg+kAw7UTQ+kAwxwXy4ZHTHwHAAY4p+gDU1DAh+QBwyMoHy//J0Hd0gBjIywXLAljPFlAE+gITy2vMzMlx+wCRMOIABPIw', 25 | ); 26 | 27 | constructor( 28 | readonly address: Address, 29 | readonly init?: StateInit, 30 | ) {} 31 | 32 | static createFromAddress(address: Address) { 33 | return new NftMarketplace(address); 34 | } 35 | 36 | static createFromConfig(config: NftMarketplaceConfig, code: Cell, workchain = 0) { 37 | const data = nftMarketplaceConfigToCell(config); 38 | const init = { code, data }; 39 | return new NftMarketplace(contractAddress(workchain, init), init); 40 | } 41 | 42 | async sendDeploy( 43 | provider: ContractProvider, 44 | via: Sender, 45 | params: { 46 | init: StateInit; 47 | body?: Cell; 48 | value?: bigint; 49 | deployValue?: bigint; 50 | }, 51 | ) { 52 | await provider.internal(via, { 53 | value: params.value ?? toNano('0.1'), 54 | body: beginCell() 55 | .storeUint(1, 32) // op 56 | .storeCoins(params.deployValue ?? toNano('0.05')) 57 | .storeRef(beginCell().storeWritable(storeStateInit(params.init))) 58 | .storeRef(params.body ?? new Cell()) 59 | .endCell(), 60 | }); 61 | return contractAddress(0, params.init); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /examples/contracts/NftSale.ts: -------------------------------------------------------------------------------- 1 | import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, StateInit } from '@ton/core'; 2 | 3 | export type NftSaleConfig = { 4 | marketplace: Address; 5 | nft: Address; 6 | nftOwner?: Address; // init as null 7 | price: bigint; 8 | marketplaceFee: bigint; 9 | royaltyAddress: Address; 10 | royaltyAmount: bigint; 11 | }; 12 | 13 | function nftSaleConfigToCell(config: NftSaleConfig): Cell { 14 | return beginCell() 15 | .storeAddress(config.marketplace) 16 | .storeAddress(config.nft) 17 | .storeAddress(config.nftOwner) 18 | .storeCoins(config.price) 19 | .storeRef( 20 | beginCell() 21 | .storeCoins(config.marketplaceFee) 22 | .storeAddress(config.royaltyAddress) 23 | .storeCoins(config.royaltyAmount), 24 | ) 25 | .endCell(); 26 | } 27 | 28 | export class NftSale implements Contract { 29 | static readonly code = Cell.fromBase64( 30 | 'te6ccgECCgEAAbQAART/APSkE/S88sgLAQIBIAIDAgFIBAUABPIwAgLNBgcAL6A4WdqJofSB9IH0gfQBqGGh9AH0gfQAYQH30A6GmBgLjYSS+CcH0gGHaiaH0gfSB9IH0AahgRa6ThAVnHHZkbGymQ44LJL4NwKJFjgvlw+gFpj8EIAonGyIldeXD66Z+Y/SAYIBpkKALniygB54sA54sA/QFmZPaqcBNjgEybCBsimYI4eAJwA2mP6Z+YEOAAyS+FcBDAgB9dQQgdzWUAKShQKRhfeXDhAeh9AH0gfQAYOEAIZGWCqATniyi50JDQqFrQilAK/QEK5bVkuP2AOEAIZGWCrGeLKAP9AQtltWS4/YA4QAhkZYKoAueLAP0BCeW1ZLj9gDgQQQgv5h6KEMAMZGWCqALnixF9AQpltQnlj4pAkAyMACmjEQRxA2RUAS8ATgMjY3BMADjkeCEDuaygAVvvLhyVMSxwVZxwWx8uHKcCCCEF/MPRQhgBDIywVQBs8WIvoCFctqFMsfFMs/Ic8WAc8WygAh+gLKAMmBAKD7AOBfBoQP8vAAKss/Is8WWM8WygAh+gLKAMmBAKD7AA==', 31 | ); 32 | 33 | constructor( 34 | readonly address: Address, 35 | readonly init?: StateInit, 36 | ) {} 37 | 38 | static createFromAddress(address: Address) { 39 | return new NftSale(address); 40 | } 41 | 42 | static createFromConfig(config: NftSaleConfig, code: Cell, workchain = 0) { 43 | const data = nftSaleConfigToCell(config); 44 | const init = { code, data }; 45 | return new NftSale(contractAddress(workchain, init), init); 46 | } 47 | 48 | async getData(provider: ContractProvider): Promise { 49 | const { stack } = await provider.get('get_sale_data', []); 50 | return { 51 | marketplace: stack.readAddress(), 52 | nft: stack.readAddress(), 53 | nftOwner: stack.readAddressOpt() ?? undefined, 54 | price: stack.readBigNumber(), 55 | marketplaceFee: stack.readBigNumber(), 56 | royaltyAddress: stack.readAddress(), 57 | royaltyAmount: stack.readBigNumber(), 58 | }; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/contracts/NftSaleV3.ts: -------------------------------------------------------------------------------- 1 | import { Address, Contract, ContractProvider, StateInit } from '@ton/core'; 2 | 3 | import { NftSaleConfig } from './NftSale'; 4 | 5 | export class NftSaleV3 implements Contract { 6 | constructor( 7 | readonly address: Address, 8 | readonly init?: StateInit, 9 | ) {} 10 | 11 | static createFromAddress(address: Address) { 12 | return new NftSaleV3(address); 13 | } 14 | 15 | async getData(provider: ContractProvider): Promise { 16 | const { stack } = await provider.get('get_sale_data', []); 17 | const [, , , marketplace, nft, nftOwner, price, , marketplaceFee, royaltyAddress, royaltyAmount] = [ 18 | stack.pop(), 19 | stack.pop(), 20 | stack.pop(), 21 | stack.readAddress(), 22 | stack.readAddress(), 23 | stack.readAddressOpt(), 24 | stack.readBigNumber(), 25 | stack.pop(), 26 | stack.readBigNumber(), 27 | stack.readAddress(), 28 | stack.readBigNumber(), 29 | ]; 30 | return { 31 | marketplace, 32 | nft, 33 | nftOwner: nftOwner ?? undefined, 34 | price, 35 | marketplaceFee, 36 | royaltyAddress, 37 | royaltyAmount, 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/contracts/README.md: -------------------------------------------------------------------------------- 1 | # Contracts 2 | 3 | This directory contains implementations of all smart contract wrappers used in other examples. You can draw inspiration on how to write your own wrappers from here. -------------------------------------------------------------------------------- /examples/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /examples/nft-sale/README.md: -------------------------------------------------------------------------------- 1 | # NFT sale example 2 | 3 | This is an example demonstrating how to write unit tests for smart contracts using `@ton-community/sandbox` and `@ton/test-utils` to test them in an emulated TON network. -------------------------------------------------------------------------------- /examples/nft-sale/Sale.spec.ts: -------------------------------------------------------------------------------- 1 | import { beginCell, SendMode, toNano } from '@ton/core'; 2 | import { Blockchain } from '@ton/sandbox'; 3 | 4 | import { NftCollection } from '../contracts/NftCollection'; 5 | import { NftItem } from '../contracts/NftItem'; 6 | import { NftMarketplace } from '../contracts/NftMarketplace'; 7 | import { NftSale } from '../contracts/NftSale'; 8 | import '@ton/test-utils'; // register matchers 9 | 10 | describe('Collection', () => { 11 | it('should work', async () => { 12 | const blkch = await Blockchain.create(); 13 | 14 | const minter = await blkch.treasury('minter'); 15 | const admin = await blkch.treasury('admin'); 16 | const buyer = await blkch.treasury('buyer'); 17 | 18 | const collection = blkch.openContract( 19 | NftCollection.createFromConfig( 20 | { 21 | owner: minter.address, 22 | }, 23 | NftCollection.code, 24 | ), 25 | ); 26 | const marketplace = blkch.openContract( 27 | NftMarketplace.createFromConfig( 28 | { 29 | owner: admin.address, 30 | }, 31 | NftMarketplace.code, 32 | ), 33 | ); 34 | 35 | const itemContent = beginCell().storeUint(123, 8).endCell(); 36 | const mintResult = await collection.sendMint(minter.getSender(), minter.address, { 37 | content: itemContent, 38 | }); 39 | const collectionData = await collection.getCollectionData(); 40 | const itemAddress = await collection.getItemAddress(collectionData.nextItemIndex - 1); 41 | expect(mintResult.transactions).toHaveTransaction({ 42 | from: collection.address, 43 | to: itemAddress, 44 | deploy: true, 45 | }); 46 | const item = blkch.openContract(NftItem.createFromAddress(itemAddress)); 47 | const itemData = await item.getData(); 48 | expect(itemData.content?.equals(itemContent)).toBeTruthy(); 49 | expect(itemData.owner?.equals(minter.address)).toBeTruthy(); 50 | 51 | const price = toNano('1'); 52 | 53 | const fee = toNano('0.05'); 54 | 55 | const sale = blkch.openContract( 56 | NftSale.createFromConfig( 57 | { 58 | marketplace: marketplace.address, 59 | nft: itemAddress, 60 | price, 61 | marketplaceFee: fee, 62 | royaltyAddress: collection.address, 63 | royaltyAmount: fee, 64 | }, 65 | NftSale.code, 66 | ), 67 | ); 68 | 69 | const deploySaleResult = await marketplace.sendDeploy(admin.getSender(), { 70 | init: sale.init!, 71 | }); 72 | 73 | expect(deploySaleResult.transactions).toHaveTransaction({ 74 | from: marketplace.address, 75 | to: sale.address, 76 | deploy: true, 77 | }); 78 | 79 | const saleDataBefore = await sale.getData(); 80 | expect(saleDataBefore.nftOwner).toBe(undefined); // nft_owner == null (undefined in js) means that sale has not yet started 81 | expect(saleDataBefore.marketplace.equals(marketplace.address)).toBeTruthy(); 82 | expect(saleDataBefore.nft.equals(itemAddress)).toBeTruthy(); 83 | expect(saleDataBefore.royaltyAddress.equals(collection.address)).toBeTruthy(); 84 | 85 | await item.sendTransfer(minter.getSender(), { 86 | to: sale.address, 87 | value: toNano('0.1'), 88 | forwardAmount: toNano('0.03'), 89 | }); 90 | 91 | expect((await item.getData()).owner?.equals(sale.address)).toBeTruthy(); 92 | 93 | const saleDataAfter = await sale.getData(); 94 | expect(saleDataAfter.nftOwner?.equals(minter.address)).toBeTruthy(); 95 | 96 | const buyResult = await buyer.send({ 97 | to: sale.address, 98 | value: price + toNano('1'), 99 | sendMode: SendMode.PAY_GAS_SEPARATELY, 100 | }); 101 | 102 | expect((await item.getData()).owner?.equals(buyer.address)).toBeTruthy(); 103 | expect(buyResult.transactions).toHaveTransaction({ 104 | from: sale.address, 105 | to: marketplace.address, 106 | value: fee, 107 | }); 108 | expect(buyResult.transactions).toHaveTransaction({ 109 | from: sale.address, 110 | to: collection.address, 111 | value: fee, 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ton/sandbox-examples", 3 | "version": "0.0.1", 4 | "license": "MIT", 5 | "devDependencies": { 6 | "@ton/core": "^0.52.2", 7 | "@ton/crypto": "^3.2.0", 8 | "@ton/sandbox": "^0.11.0", 9 | "@ton/test-utils": "^0.3.1", 10 | "@ton/ton": "^13.5.1", 11 | "@types/jest": "^29.5.1", 12 | "jest": "^29.5.0", 13 | "ts-jest": "^29.1.0", 14 | "typescript": "^4.9.5" 15 | }, 16 | "scripts": { 17 | "examples": "yarn jest" 18 | }, 19 | "private": true 20 | } 21 | -------------------------------------------------------------------------------- /examples/remote-storage/README.md: -------------------------------------------------------------------------------- 1 | # Remote storage example 2 | 3 | This example demonstrates how to use `RemoteBlockchainStorage` to emulate interactions with the contracts that are located in real (mainnet or testnet) TON networks. -------------------------------------------------------------------------------- /examples/remote-storage/RemoteStorage.spec.ts: -------------------------------------------------------------------------------- 1 | import { TonClient4 } from '@ton/ton'; 2 | import { Address, toNano, SendMode } from '@ton/core'; 3 | import { Blockchain, RemoteBlockchainStorage, wrapTonClient4ForRemote } from '@ton/sandbox'; 4 | 5 | import { NftSaleV3 } from '../contracts/NftSaleV3'; 6 | import { NftItem } from '../contracts/NftItem'; 7 | import '@ton/test-utils'; // register matchers 8 | import { Elector } from '../contracts/Elector'; 9 | 10 | describe('RemoteStorage', () => { 11 | it('should pull a contract from the real network and interact with it', async () => { 12 | const blkch = await Blockchain.create({ 13 | storage: new RemoteBlockchainStorage( 14 | wrapTonClient4ForRemote( 15 | new TonClient4({ 16 | endpoint: 'https://mainnet-v4.tonhubapi.com', 17 | }), 18 | ), 19 | ), 20 | }); 21 | 22 | const saleAddress = Address.parse('EQCvEM2Q7GOmQIx9WVFTF9I1AtpTa1oqZUo3Hz7wo79AZICl'); 23 | const sale = blkch.openContract(NftSaleV3.createFromAddress(saleAddress)); 24 | 25 | const saleData = await sale.getData(); 26 | 27 | const buyer = await blkch.treasury('buyer'); 28 | 29 | const buyerContract = await blkch.getContract(buyer.address); 30 | // by default each treasury gets 1000000 (one million) TONs, and NFT in question costs exactly that, but it's not enough to actually buy it (see below) 31 | buyerContract.balance = saleData.price * 100n; 32 | 33 | await buyer.send({ 34 | to: sale.address, 35 | value: saleData.price + toNano('1'), 36 | sendMode: SendMode.PAY_GAS_SEPARATELY, 37 | }); 38 | 39 | const item = blkch.openContract(new NftItem(saleData.nft)); 40 | const itemData = await item.getData(); 41 | 42 | expect(itemData.owner?.equals(buyer.address)).toBeTruthy(); 43 | }); 44 | 45 | it('should pull a elector contract from the real network on a specific block and interact with it', async () => { 46 | const blkch = await Blockchain.create({ 47 | storage: new RemoteBlockchainStorage( 48 | wrapTonClient4ForRemote( 49 | new TonClient4({ 50 | endpoint: 'https://mainnet-v4.tonhubapi.com', 51 | }), 52 | ), 53 | 27672122, 54 | ), 55 | }); 56 | const electorAddress = Address.parse('Ef8zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM0vF'); 57 | const elector = blkch.openContract(Elector.createFromAddress(electorAddress)); 58 | 59 | let { electionId } = await elector.getActiveElectionId(); 60 | expect(electionId).toBe(0n); // Elections are currently closed 61 | 62 | let validator = Address.parse('Ef-uUzYrfNhd1mXpJ24F-51a6WtLY31NU03073PqXPj4vS60'); 63 | let { value } = await elector.getStake(validator); 64 | 65 | expect(value).toBe(7903051587317n); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /jest-environment.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/jest/BenchmarkEnvironment').default; 2 | -------------------------------------------------------------------------------- /jest-reporter.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/jest/BenchmarkReporter').default; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ton/sandbox", 3 | "version": "0.32.1", 4 | "description": "TON transaction emulator", 5 | "main": "dist/index.js", 6 | "license": "MIT", 7 | "author": "TonTech", 8 | "files": [ 9 | "jest-environment.js", 10 | "jest-reporter.js", 11 | "dist/**/*" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/ton-org/sandbox" 16 | }, 17 | "prettier": "@ton/toolchain/prettier", 18 | "devDependencies": { 19 | "@ton/core": "^0.60.1", 20 | "@ton/crypto": "3.3.0", 21 | "@ton/test-utils": "^0.7.0", 22 | "@ton/tolk-js": "^0.13.0", 23 | "@ton/ton": "^15.2.1", 24 | "@ton/toolchain": "the-ton-tech/toolchain#v1.4.0", 25 | "@types/jest": "^29.5.0", 26 | "@types/node": "^18.15.11", 27 | "eslint": "^9.28.0", 28 | "jest": "^29.5.0", 29 | "jest-config": "^29.7.0", 30 | "jest-environment-node": "^29.7.0", 31 | "ts-jest": "^29.0.5", 32 | "ts-node": "^10.9.1", 33 | "typescript": "^4.9.5" 34 | }, 35 | "peerDependencies": { 36 | "@ton/crypto": ">=3.3.0", 37 | "@ton/test-utils": ">=0.7.0", 38 | "jest": "^29.5.0" 39 | }, 40 | "peerDependenciesMeta": { 41 | "@ton/test-utils": { 42 | "optional": true 43 | }, 44 | "jest": { 45 | "optional": true 46 | } 47 | }, 48 | "scripts": { 49 | "wasm:pack": "ts-node ./scripts/pack-wasm.ts", 50 | "wasm:copy": "cp src/executor/emulator-emscripten.js src/executor/emulator-emscripten.wasm.js ./dist/executor", 51 | "test": "yarn wasm:pack && yarn jest src", 52 | "build": "rm -rf dist && yarn wasm:pack && tsc && yarn wasm:copy", 53 | "bt": "yarn build && yarn test", 54 | "lint": "eslint . --max-warnings 0", 55 | "lint:fix": "eslint . --max-warnings 0 --fix", 56 | "config:pack": "ts-node ./scripts/pack-config.ts" 57 | }, 58 | "packageManager": "yarn@4.9.2", 59 | "dependencies": { 60 | "chalk": "^4.1.2", 61 | "table": "^6.9.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /scripts/pack-config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { beginCell, Cell, Dictionary, DictionaryValue, TonClient4 } from '@ton/ton'; 4 | 5 | const CellRef: DictionaryValue = { 6 | serialize: (src, builder) => { 7 | builder.storeRef(src); 8 | }, 9 | parse: (src) => src.loadRef(), 10 | }; 11 | 12 | function makeSlim(config: Cell): Cell { 13 | const configDict = Dictionary.loadDirect(Dictionary.Keys.Int(32), CellRef, config); 14 | 15 | const configDictSlim = Dictionary.empty(Dictionary.Keys.Int(32), CellRef); 16 | 17 | for (let k = 0; k < 32; k++) { 18 | const prev = configDict.get(k); 19 | if (prev !== undefined) { 20 | configDictSlim.set(k, prev); 21 | } 22 | } 23 | 24 | return beginCell().storeDictDirect(configDictSlim).endCell(); 25 | } 26 | 27 | function writeConfig(name: string, config: Cell, seqno: number) { 28 | const out = `export const ${name}ConfigSeqno = ${seqno};\nexport const ${name}Config = \`${config.toBoc({ idx: false }).toString('base64')}\`;`; 29 | 30 | fs.writeFileSync(`./src/config/${name}Config.ts`, out); 31 | } 32 | 33 | const main = async () => { 34 | const client = new TonClient4({ 35 | endpoint: 'https://mainnet-v4.tonhubapi.com', 36 | }); 37 | 38 | const lastBlock = await client.getLastBlock(); 39 | 40 | const lastBlockConfig = await client.getConfig(lastBlock.last.seqno); 41 | 42 | const configCell = Cell.fromBase64(lastBlockConfig.config.cell); 43 | 44 | writeConfig('default', configCell, lastBlock.last.seqno); 45 | 46 | writeConfig('slim', makeSlim(configCell), lastBlock.last.seqno); 47 | }; 48 | 49 | main(); 50 | -------------------------------------------------------------------------------- /scripts/pack-wasm.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | const wasmData = fs.readFileSync('./src/executor/emulator-emscripten.wasm'); 4 | const out = `module.exports = { EmulatorEmscriptenWasm: '${wasmData.toString('base64')}' }`; 5 | 6 | fs.writeFileSync('./src/executor/emulator-emscripten.wasm.js', out); 7 | -------------------------------------------------------------------------------- /src/blockchain/BlockchainContractProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AccountState, 3 | Address, 4 | Cell, 5 | comment, 6 | Contract, 7 | ContractGetMethodResult, 8 | ContractProvider, 9 | ContractState, 10 | ExtraCurrency, 11 | Message, 12 | OpenedContract, 13 | Sender, 14 | SendMode, 15 | StateInit, 16 | toNano, 17 | Transaction, 18 | TupleItem, 19 | } from '@ton/core'; 20 | 21 | import { TickOrTock } from '../executor/Executor'; 22 | import { GetMethodResult, SmartContract } from './SmartContract'; 23 | 24 | function bigintToBuffer(x: bigint, n = 32): Buffer { 25 | const b = Buffer.alloc(n); 26 | for (let i = 0; i < n; i++) { 27 | b[n - i - 1] = Number((x >> BigInt(i * 8)) & 0xffn); 28 | } 29 | return b; 30 | } 31 | 32 | function convertState(state: AccountState | undefined): ContractState['state'] { 33 | if (state === undefined) 34 | return { 35 | type: 'uninit', 36 | }; 37 | 38 | switch (state.type) { 39 | case 'uninit': 40 | return { 41 | type: 'uninit', 42 | }; 43 | case 'active': 44 | return { 45 | type: 'active', 46 | code: state.state.code?.toBoc(), 47 | data: state.state.data?.toBoc(), 48 | }; 49 | case 'frozen': 50 | return { 51 | type: 'frozen', 52 | stateHash: bigintToBuffer(state.stateHash), 53 | }; 54 | } 55 | } 56 | 57 | export interface SandboxContractProvider extends ContractProvider { 58 | tickTock(which: TickOrTock): Promise; 59 | } 60 | 61 | /** 62 | * Provider used in contracts to send messages or invoke getters. For additional information see {@link Blockchain.provider} 63 | */ 64 | export class BlockchainContractProvider implements SandboxContractProvider { 65 | constructor( 66 | private readonly blockchain: { 67 | getContract(address: Address): Promise; 68 | pushMessage(message: Message): Promise; 69 | runGetMethod(address: Address, method: string, args: TupleItem[]): Promise; 70 | pushTickTock(on: Address, which: TickOrTock): Promise; 71 | openContract(contract: T): OpenedContract; 72 | }, 73 | private readonly address: Address, 74 | private readonly init?: StateInit | null, 75 | ) {} 76 | 77 | /** 78 | * Opens contract. For additional information see {@link Blockchain.open} 79 | */ 80 | open(contract: T): OpenedContract { 81 | return this.blockchain.openContract(contract); 82 | } 83 | 84 | async getState(): Promise { 85 | const contract = await this.blockchain.getContract(this.address); 86 | return { 87 | balance: contract.balance, 88 | extracurrency: contract.ec, 89 | last: { 90 | lt: contract.lastTransactionLt, 91 | hash: bigintToBuffer(contract.lastTransactionHash), 92 | }, 93 | state: convertState(contract.accountState), 94 | }; 95 | } 96 | 97 | /** 98 | * Invokes get method. 99 | * @param name Name of get method 100 | * @param args Args to invoke get method. 101 | */ 102 | async get(name: string, args: TupleItem[]): Promise { 103 | const result = await this.blockchain.runGetMethod(this.address, name, args); 104 | const ret = { 105 | ...result, 106 | stack: result.stackReader, 107 | stackItems: result.stack, 108 | logs: result.vmLogs, 109 | }; 110 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 111 | delete (ret as any).stackReader; 112 | return ret; 113 | } 114 | 115 | /** 116 | * Dummy implementation of getTransactions. Sandbox does not store transactions, so its ContractProvider cannot fetch any. 117 | * Throws error in every call. 118 | * 119 | * @throws {Error} 120 | */ 121 | getTransactions( 122 | _address: Address, 123 | _lt: bigint, 124 | _hash: Buffer, 125 | _limit?: number | undefined, 126 | ): Promise { 127 | throw new Error( 128 | '`getTransactions` is not implemented in `BlockchainContractProvider`, do not use it in the tests', 129 | ); 130 | } 131 | 132 | /** 133 | * Pushes external-in message to message queue. 134 | * @param message Message to push 135 | */ 136 | async external(message: Cell) { 137 | const init = (await this.getState()).state.type !== 'active' && this.init ? this.init : undefined; 138 | 139 | await this.blockchain.pushMessage({ 140 | info: { 141 | type: 'external-in', 142 | dest: this.address, 143 | importFee: 0n, 144 | }, 145 | init, 146 | body: message, 147 | }); 148 | } 149 | 150 | /** 151 | * Pushes internal message to message queue. 152 | */ 153 | async internal( 154 | via: Sender, 155 | args: { 156 | value: string | bigint; 157 | extracurrency?: ExtraCurrency; 158 | bounce?: boolean | null; 159 | sendMode?: SendMode; 160 | body?: string | Cell | null; 161 | }, 162 | ) { 163 | const init = (await this.getState()).state.type !== 'active' && this.init ? this.init : undefined; 164 | 165 | const bounce = args.bounce !== null && args.bounce !== undefined ? args.bounce : true; 166 | 167 | const value = typeof args.value === 'string' ? toNano(args.value) : args.value; 168 | 169 | const body = typeof args.body === 'string' ? comment(args.body) : args.body; 170 | 171 | await via.send({ 172 | to: this.address, 173 | value, 174 | bounce, 175 | extracurrency: args.extracurrency, 176 | sendMode: args.sendMode, 177 | init, 178 | body, 179 | }); 180 | } 181 | 182 | /** 183 | * Pushes tick-tock message to message queue. 184 | */ 185 | async tickTock(which: TickOrTock) { 186 | await this.blockchain.pushTickTock(this.address, which); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/blockchain/BlockchainSender.ts: -------------------------------------------------------------------------------- 1 | import { Address, Cell, Message, packExtraCurrencyDict, Sender, SenderArguments } from '@ton/core'; 2 | 3 | /** 4 | * Sender for sandbox blockchain. For additional information see {@link Blockchain.sender} 5 | */ 6 | export class BlockchainSender implements Sender { 7 | constructor( 8 | private readonly blockchain: { 9 | pushMessage(message: Message): Promise; 10 | }, 11 | readonly address: Address, 12 | ) {} 13 | 14 | async send(args: SenderArguments) { 15 | await this.blockchain.pushMessage({ 16 | info: { 17 | type: 'internal', 18 | ihrDisabled: true, 19 | ihrFee: 0n, 20 | bounce: args.bounce ?? true, 21 | bounced: false, 22 | src: this.address, 23 | dest: args.to, 24 | value: { 25 | coins: args.value, 26 | other: args.extracurrency ? packExtraCurrencyDict(args.extracurrency) : undefined, 27 | }, 28 | forwardFee: 0n, 29 | createdAt: 0, 30 | createdLt: 0n, 31 | }, 32 | body: args.body ?? new Cell(), 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/blockchain/BlockchainStorage.ts: -------------------------------------------------------------------------------- 1 | import { AccountState, Address, Cell } from '@ton/core'; 2 | 3 | import { SmartContract } from './SmartContract'; 4 | import { Blockchain } from './Blockchain'; 5 | 6 | /** 7 | * @interface BlockchainStorage Provides information about contracts by blockchain 8 | */ 9 | export interface BlockchainStorage { 10 | /** 11 | * Retrieves a smart contract by blockchain and address. 12 | * 13 | * @param {Blockchain} blockchain - The blockchain instance. 14 | * @param {Address} address - The address of the smart contract. 15 | * @returns {Promise} - The smart contract instance. 16 | */ 17 | getContract(blockchain: Blockchain, address: Address): Promise; 18 | /** 19 | * Lists all known smart contracts. 20 | * 21 | * @returns {SmartContract[]} - An array of known smart contracts. 22 | */ 23 | knownContracts(): SmartContract[]; 24 | 25 | /** 26 | * Clears the internal cache of known contracts. 27 | */ 28 | clearKnownContracts(): void; 29 | } 30 | 31 | /** 32 | * In-memory storage for blockchain smart contracts. 33 | */ 34 | export class LocalBlockchainStorage implements BlockchainStorage { 35 | private contracts: Map = new Map(); 36 | 37 | async getContract(blockchain: Blockchain, address: Address) { 38 | let existing = this.contracts.get(address.toString()); 39 | if (!existing) { 40 | existing = SmartContract.empty(blockchain, address); 41 | this.contracts.set(address.toString(), existing); 42 | } 43 | 44 | return existing; 45 | } 46 | 47 | knownContracts() { 48 | return Array.from(this.contracts.values()); 49 | } 50 | 51 | clearKnownContracts() { 52 | this.contracts.clear(); 53 | } 54 | } 55 | 56 | export interface RemoteBlockchainStorageClient { 57 | getLastBlockSeqno(): Promise; 58 | getAccount( 59 | seqno: number, 60 | address: Address, 61 | ): Promise<{ 62 | state: AccountState; 63 | balance: bigint; 64 | lastTransaction?: { lt: bigint; hash: Buffer }; 65 | }>; 66 | } 67 | 68 | function convertTonClient4State( 69 | state: 70 | | { 71 | type: 'uninit'; 72 | } 73 | | { 74 | type: 'active'; 75 | code: string | null; 76 | data: string | null; 77 | } 78 | | { 79 | type: 'frozen'; 80 | stateHash: string; 81 | }, 82 | ): AccountState { 83 | switch (state.type) { 84 | case 'uninit': 85 | return { type: 'uninit' }; 86 | case 'active': 87 | return { 88 | type: 'active', 89 | state: { 90 | code: state.code === null ? undefined : Cell.fromBase64(state.code), 91 | data: state.data === null ? undefined : Cell.fromBase64(state.data), 92 | }, 93 | }; 94 | case 'frozen': 95 | return { type: 'frozen', stateHash: BigInt('0x' + Buffer.from(state.stateHash, 'base64').toString('hex')) }; 96 | default: 97 | throw new Error(`Unknown type ${state}`); 98 | } 99 | } 100 | 101 | /** 102 | * Wraps ton client for remote storage. 103 | * 104 | * @example 105 | * let client = new TonClient4({ 106 | * endpoint: 'https://mainnet-v4.tonhubapi.com' 107 | * }) 108 | * 109 | * let remoteStorageClient = wrapTonClient4ForRemote(client); 110 | * 111 | * @param client TonClient4 to wrap 112 | */ 113 | export function wrapTonClient4ForRemote(client: { 114 | getLastBlock(): Promise<{ 115 | last: { 116 | seqno: number; 117 | }; 118 | }>; 119 | getAccount( 120 | seqno: number, 121 | address: Address, 122 | ): Promise<{ 123 | account: { 124 | state: 125 | | { 126 | type: 'uninit'; 127 | } 128 | | { 129 | type: 'active'; 130 | code: string | null; 131 | data: string | null; 132 | } 133 | | { 134 | type: 'frozen'; 135 | stateHash: string; 136 | }; 137 | balance: { 138 | coins: string; 139 | }; 140 | last: { 141 | lt: string; 142 | hash: string; 143 | } | null; 144 | }; 145 | }>; 146 | }): RemoteBlockchainStorageClient { 147 | return { 148 | getLastBlockSeqno: async () => { 149 | const last = await client.getLastBlock(); 150 | return last.last.seqno; 151 | }, 152 | getAccount: async (seqno: number, address: Address) => { 153 | const { account } = await client.getAccount(seqno, address); 154 | return { 155 | state: convertTonClient4State(account.state), 156 | balance: BigInt(account.balance.coins), 157 | lastTransaction: 158 | account.last === null 159 | ? undefined 160 | : { 161 | lt: BigInt(account.last.lt), 162 | hash: Buffer.from(account.last.hash, 'base64'), 163 | }, 164 | }; 165 | }, 166 | }; 167 | } 168 | 169 | /** 170 | * @class {RemoteBlockchainStorage} Remote blockchain storage implementation. 171 | * 172 | * @example 173 | * let client = new TonClient4({ 174 | * endpoint: 'https://mainnet-v4.tonhubapi.com' 175 | * }) 176 | * 177 | * let blockchain = await Blockchain.create({ 178 | * storage: new RemoteBlockchainStorage(wrapTonClient4ForRemote(client), 34892000) 179 | * }); 180 | */ 181 | export class RemoteBlockchainStorage implements BlockchainStorage { 182 | private contracts: Map = new Map(); 183 | private client: RemoteBlockchainStorageClient; 184 | private blockSeqno?: number; 185 | 186 | constructor(client: RemoteBlockchainStorageClient, blockSeqno?: number) { 187 | this.client = client; 188 | this.blockSeqno = blockSeqno; 189 | } 190 | 191 | private async getLastBlockSeqno() { 192 | return this.blockSeqno ?? (await this.client.getLastBlockSeqno()); 193 | } 194 | 195 | async getContract(blockchain: Blockchain, address: Address) { 196 | let existing = this.contracts.get(address.toString()); 197 | if (!existing) { 198 | let blockSeqno = await this.getLastBlockSeqno(); 199 | let account = await this.client.getAccount(blockSeqno, address); 200 | 201 | const lt = account.lastTransaction?.lt ?? 0n; 202 | 203 | existing = new SmartContract( 204 | { 205 | lastTransactionHash: BigInt('0x' + (account.lastTransaction?.hash?.toString('hex') ?? '0')), 206 | lastTransactionLt: lt, 207 | account: { 208 | addr: address, 209 | storageStats: { 210 | used: { 211 | cells: 0n, 212 | bits: 0n, 213 | publicCells: 0n, 214 | }, 215 | lastPaid: 0, 216 | duePayment: null, 217 | }, 218 | storage: { 219 | lastTransLt: lt === 0n ? 0n : lt + 1n, 220 | balance: { coins: account.balance }, 221 | state: account.state, 222 | }, 223 | }, 224 | }, 225 | blockchain, 226 | ); 227 | 228 | this.contracts.set(address.toString(), existing); 229 | } 230 | 231 | return existing; 232 | } 233 | 234 | knownContracts() { 235 | return Array.from(this.contracts.values()); 236 | } 237 | 238 | clearKnownContracts() { 239 | this.contracts.clear(); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/blockchain/test_utils/compileTolk.ts: -------------------------------------------------------------------------------- 1 | import { Cell } from '@ton/core'; 2 | import { runTolkCompiler } from '@ton/tolk-js'; 3 | 4 | export async function compileTolk(source: string) { 5 | const r = await runTolkCompiler({ 6 | entrypointFileName: 'main.tolk', 7 | fsReadCallback: (path) => { 8 | if (path === 'main.tolk') { 9 | return source; 10 | } 11 | 12 | throw new Error(`File ${path} not found`); 13 | }, 14 | }); 15 | 16 | if (r.status === 'error') { 17 | throw new Error(r.message); 18 | } 19 | 20 | return Cell.fromBase64(r.codeBoc64); 21 | } 22 | -------------------------------------------------------------------------------- /src/blockchain/test_utils/contracts/prevblocks.tolk: -------------------------------------------------------------------------------- 1 | @pure 2 | fun prevKeyBlock(): tuple 3 | asm "PREVKEYBLOCK"; 4 | 5 | @pure 6 | fun prevMcBlocks(): tuple 7 | asm "PREVMCBLOCKS"; 8 | 9 | @pure 10 | fun blockIdSeqno(blockId: tuple): int { 11 | return blockId.get(2); 12 | } 13 | 14 | fun onInternalMessage(myBalance: int, msgValue: int, msgFull: cell, msgBody: slice) { 15 | if (msgBody.isEnd()) { 16 | return; 17 | } 18 | 19 | var cs = msgFull.beginParse(); 20 | val flags = cs.loadMessageFlags(); 21 | if (isMessageBounced(flags)) { 22 | return; 23 | } 24 | 25 | val sender = cs.loadAddress(); 26 | 27 | val op = msgBody.loadMessageOp(); 28 | 29 | if (op == 1) { 30 | sendRawMessage( 31 | beginCell() 32 | .storeUint(0x18, 6) 33 | .storeAddress(sender) 34 | .storeCoins(0) 35 | .storeUint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1) 36 | .storeUint(1, 32) 37 | .storeUint(blockIdSeqno(prevKeyBlock()), 32) 38 | .storeUint(blockIdSeqno(prevMcBlocks().get(0)), 32) 39 | .endCell(), 40 | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE 41 | ); 42 | return; 43 | } 44 | 45 | throw 0xffff; 46 | } 47 | 48 | get prevBlockSeqnos(): (int, int) { 49 | return ( 50 | blockIdSeqno(prevKeyBlock()), 51 | blockIdSeqno(prevMcBlocks().get(0)) 52 | ); 53 | } -------------------------------------------------------------------------------- /src/config/slimConfig.ts: -------------------------------------------------------------------------------- 1 | export const slimConfigSeqno = 47728899; 2 | export const slimConfig = `te6cckECeAEABZwAAgPNwAFKAgEgAhYCASADCwIBIAQJAgEgBQcBASAGAEBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQEBIAgAQDMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzAQFICgBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACASAMEQIBIA0PAQEgDgBA5WdU+DQm9psJJnvYdqyXxEghNFt+JmvZVqe/v7mN81wBASAQAFMB//////////////////////////////////////////+AAAAAgAAAAUABAVgSAQHAEwIBIBQVABW+AAADvLNnDcFVUAAVv////7y9GpSiABACASAXPgIBIBglAgEgGRsBASAaABrEAAAACgAAAAAAAAHuAQEgHAIDzUAdJAIBICoeAgEgHyICASAgIQIBIDA1AgEgMDACASAjNgIBSDIyAAOooAIBICY6AQEgJwIBICg3AgLZKTMCASAqMQIBICssAgHUMjICASAtLwIBIC41AAFYAgEgNTACASAyMgIBzjIyAAEgAgFiNDYCASA1NQABSAAB1AIJt///8GA4OQAB/AAB3AEBIDsCApE8PQAqNgIGAgUAD0JAAJiWgAAAAAEAAAH0ACo2BAcDBQBMS0ABMS0AAAAAAgAAA+gCASA/RQIBIEBDAQEgQQEBwEIAt9BTLudOzwEBAnAAKtiftocOhhpk4QsHt8jHSWwV/O7nxvFyZKUf75zoqiN3Bfb/JZk7D9mvTw7EDHU5BlaNBz2ml2s54kRzl0iBoQAAAAAP////+AAAAAAAAAAEAQEgRAATGkO5rKABASAfSAIBIEZIAQEgRwAUa0ZVPxAEO5rKAAEBIEkAIAABAAAAAIAAAAAgAAAAgAACASBLXwIBIExUAgEgTVICASBOUAEBIE8ADAGQAGQASwEBIFEAN3ARDZMW7AAHI4byb8EAAIAQp0GkYngAAAAwAAgBAUhTAE3QZgAAAAAAAAAAAAAAAIAAAAAAAAD6AAAAAAAAAfQAAAAAAAPQkEACASBVWgIBIFZYAQEgVwCU0QAAAAAAAABkAAAAAAAPQkDeAAAAACcQAAAAAAAAAA9CQAAAAAAELB2AAAAAAAAAJxAAAAAAACYloAAAAAAF9eEAAAAAADuaygABASBZAJTRAAAAAAAAAGQAAAAAAACcQN4AAAAAAZAAAAAAAAAAD0JAAAAAAAAPQkAAAAAAAAAnEAAAAAAAmJaAAAAAAAX14QAAAAAAO5rKAAIBIFtdAQEgXABQXcMAAgAAAAgAAAAQAADDAAMNQAAPQkAAJiWgwwAAA+gAABOIAAAnEAEBIF4AUF3DAAIAAAAIAAAAEAAAwwAehIAAmJaAATEtAMMAAAPoAAATiAAAJxACASBgZQIBSGFjAQEgYgBC6gAAAAAAmJaAAAAAACcQAAAAAAAPQkAAAAABgABVVVVVAQEgZABC6gAAAAAABhqAAAAAAAGQAAAAAAAAnEAAAAABgABVVVVVAgEgZmsCASBnaQEBIGgAJMIBAAAA+gAAAPoAAAPoAAAAFwEBIGoAStkBAwAAB9AAAD6AAAAAAwAAAAgAAAAEACAAAAAgAAAABAAAJxABAVhsAQHAbQIBIG53AgEgb3YCASBwcwIBSHFyAAPfcABBvvXr/85ThwN08RVEkXrXOpCNTrUaVASnRwrD2wNe3bMUAgFYdHUAQb7ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZnABBvtzd/oVqmcXLgHhBmcB3C+ox8o4Nczj3N4RIDe9EzZAUAEK/jVwCELNdrdqiGfrEWdug/e+x+uTpeg0Hl3Of4FDWlMoAQ7/ukmJQ+VxHxbgpqJ2uY2LRjeIllTluWmxgGcV4eGm/YsDRi6UL`; 3 | -------------------------------------------------------------------------------- /src/event/Event.ts: -------------------------------------------------------------------------------- 1 | import { AccountStatus, Address, Cell, Transaction } from '@ton/core'; 2 | 3 | export type EventAccountCreated = { 4 | type: 'account_created'; 5 | account: Address; 6 | }; 7 | 8 | export type EventAccountDestroyed = { 9 | type: 'account_destroyed'; 10 | account: Address; 11 | }; 12 | 13 | export type EventMessageSent = { 14 | type: 'message_sent'; 15 | from: Address; 16 | to: Address; 17 | value: bigint; 18 | body: Cell; 19 | bounced: boolean; 20 | }; 21 | 22 | export type Event = EventAccountCreated | EventAccountDestroyed | EventMessageSent; 23 | 24 | type EventExtractor = (tx: Transaction) => Event[]; 25 | 26 | const extractors: EventExtractor[] = [extractAccountCreated, extractMessageSent, extractAccountDestroyed]; 27 | 28 | export function extractEvents(tx: Transaction): Event[] { 29 | return extractors.map((f) => f(tx)).flat(); 30 | } 31 | 32 | function doesAccountExist(state: AccountStatus): boolean { 33 | return !(state === 'uninitialized' || state === 'non-existing'); 34 | } 35 | 36 | function extractAccountCreated(tx: Transaction): Event[] { 37 | if (!doesAccountExist(tx.oldStatus) && doesAccountExist(tx.endStatus)) 38 | return [ 39 | { 40 | type: 'account_created', 41 | account: tx.inMessage!.info.dest! as Address, 42 | }, 43 | ]; 44 | 45 | return []; 46 | } 47 | 48 | function extractAccountDestroyed(tx: Transaction): Event[] { 49 | if (doesAccountExist(tx.oldStatus) && !doesAccountExist(tx.endStatus)) 50 | return [ 51 | { 52 | type: 'account_destroyed', 53 | account: tx.inMessage!.info.dest! as Address, 54 | }, 55 | ]; 56 | 57 | return []; 58 | } 59 | 60 | function extractMessageSent(tx: Transaction): Event[] { 61 | return tx.outMessages.values().flatMap((m) => { 62 | if (m.info.type !== 'internal') { 63 | return []; 64 | } 65 | 66 | return [ 67 | { 68 | type: 'message_sent', 69 | from: m.info.src, 70 | to: m.info.dest, 71 | value: m.info.value.coins, 72 | body: m.body, 73 | bounced: m.info.bounced, 74 | }, 75 | ]; 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /src/executor/Executor.spec.ts: -------------------------------------------------------------------------------- 1 | import { Address, beginCell, Cell, contractAddress, storeMessage, storeShardAccount } from '@ton/core'; 2 | 3 | import { Executor } from './Executor'; 4 | import { defaultConfig } from '../config/defaultConfig'; 5 | 6 | describe('Executor', () => { 7 | let executor: Executor; 8 | beforeAll(async () => { 9 | executor = await Executor.create(); 10 | }); 11 | 12 | it('should run get method', async () => { 13 | let code = Cell.fromBoc(Buffer.from('te6ccsEBAgEAEQANEQEU/wD0pBP0vPLICwEABNOgu3u26g==', 'base64'))[0]; 14 | let data = beginCell().endCell(); 15 | 16 | let res = await executor.runGetMethod({ 17 | verbosity: 'full_location', 18 | code, 19 | data, 20 | address: contractAddress(0, { code, data }), 21 | config: defaultConfig, 22 | methodId: 0, 23 | stack: [ 24 | { type: 'int', value: 1n }, 25 | { type: 'int', value: 2n }, 26 | ], 27 | balance: 0n, 28 | gasLimit: 0n, 29 | unixTime: 0, 30 | randomSeed: Buffer.alloc(32), 31 | debugEnabled: true, 32 | }); 33 | expect(res.output.success).toBe(true); 34 | }); 35 | 36 | it('should run transaction', async () => { 37 | let res = await executor.runTransaction({ 38 | config: defaultConfig, 39 | libs: null, 40 | verbosity: 'full_location', 41 | shardAccount: beginCell() 42 | .store( 43 | storeShardAccount({ 44 | account: null, 45 | lastTransactionHash: 0n, 46 | lastTransactionLt: 0n, 47 | }), 48 | ) 49 | .endCell() 50 | .toBoc() 51 | .toString('base64'), 52 | message: beginCell() 53 | .store( 54 | storeMessage({ 55 | info: { 56 | type: 'internal', 57 | src: new Address(0, Buffer.alloc(32)), 58 | dest: new Address(0, Buffer.alloc(32)), 59 | value: { coins: 1000000000n }, 60 | bounce: false, 61 | ihrDisabled: true, 62 | ihrFee: 0n, 63 | bounced: false, 64 | forwardFee: 0n, 65 | createdAt: 0, 66 | createdLt: 0n, 67 | }, 68 | body: new Cell(), 69 | }), 70 | ) 71 | .endCell(), 72 | now: 0, 73 | lt: 0n, 74 | randomSeed: Buffer.alloc(32), 75 | ignoreChksig: false, 76 | debugEnabled: true, 77 | }); 78 | expect(res.result.success).toBe(true); 79 | }); 80 | 81 | it('reports version', () => { 82 | const v = executor.getVersion(); 83 | expect(typeof v.commitHash).toBe('string'); 84 | expect(typeof v.commitDate).toBe('string'); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/executor/emulator-emscripten.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ton-org/sandbox/9740e198881e8d79719e3138a7fd620accdfcc72/src/executor/emulator-emscripten.wasm -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { defaultConfig, defaultConfigSeqno } from './config/defaultConfig'; 2 | 3 | export { 4 | Blockchain, 5 | toSandboxContract, 6 | SendMessageResult, 7 | BlockchainTransaction, 8 | PendingMessage, 9 | SandboxContract, 10 | ExternalOut, 11 | ExternalOutInfo, 12 | BlockchainConfig, 13 | BlockchainSnapshot, 14 | } from './blockchain/Blockchain'; 15 | 16 | export { BlockchainContractProvider, SandboxContractProvider } from './blockchain/BlockchainContractProvider'; 17 | 18 | export { BlockchainSender } from './blockchain/BlockchainSender'; 19 | 20 | export { 21 | BlockchainStorage, 22 | LocalBlockchainStorage, 23 | RemoteBlockchainStorage, 24 | RemoteBlockchainStorageClient, 25 | wrapTonClient4ForRemote, 26 | } from './blockchain/BlockchainStorage'; 27 | 28 | export { 29 | Verbosity, 30 | LogsVerbosity, 31 | SmartContract, 32 | SmartContractTransaction, 33 | MessageParams, 34 | GetMethodParams, 35 | GetMethodResult, 36 | createEmptyShardAccount, 37 | createShardAccount, 38 | GetMethodError, 39 | TimeError, 40 | SmartContractSnapshot, 41 | EmulationError, 42 | } from './blockchain/SmartContract'; 43 | 44 | export { 45 | TickOrTock, 46 | IExecutor, 47 | Executor, 48 | GetMethodArgs as ExecutorGetMethodArgs, 49 | GetMethodResult as ExecutorGetMethodResult, 50 | RunTickTockArgs as ExecutorRunTickTockArgs, 51 | EmulationResult as ExecutorEmulationResult, 52 | RunTransactionArgs as ExecutorRunTransactionArgs, 53 | ExecutorVerbosity, 54 | BlockId, 55 | PrevBlocksInfo, 56 | } from './executor/Executor'; 57 | 58 | export { Event, EventAccountCreated, EventAccountDestroyed, EventMessageSent } from './event/Event'; 59 | 60 | export { Treasury, TreasuryContract } from './treasury/Treasury'; 61 | 62 | export { prettyLogTransaction, prettyLogTransactions } from './utils/prettyLogTransaction'; 63 | 64 | export { printTransactionFees } from './utils/printTransactionFees'; 65 | 66 | export { internal } from './utils/message'; 67 | 68 | export { fetchConfig, setGlobalVersion } from './utils/config'; 69 | 70 | export { ExtraCurrency } from './utils/ec'; 71 | 72 | export * from './metric'; 73 | -------------------------------------------------------------------------------- /src/jest/BenchmarkCommand.spec.ts: -------------------------------------------------------------------------------- 1 | import { BenchmarkCommand } from './BenchmarkCommand'; 2 | 3 | describe('BenchmarkCommand', () => { 4 | it.each([ 5 | // label, diff, expected 6 | [undefined, undefined, [undefined, false, false]], 7 | [undefined, 'false', [undefined, false, false]], 8 | ['some', 'false', ['some', false, true]], 9 | [undefined, 'true', [undefined, true, true]], 10 | ['some', 'true', ['some', true, true]], 11 | ])('command variant label:%s & diff:%s ', (label, diff, expected) => { 12 | if (label) { 13 | process.env['BENCH_NEW'] = label; 14 | } 15 | if (diff) { 16 | process.env['BENCH_DIFF'] = diff; 17 | } 18 | const actual = new BenchmarkCommand(); 19 | expect([actual.label, actual.doDiff, actual.doBenchmark]).toEqual(expected); 20 | }); 21 | 22 | afterEach(() => { 23 | delete process.env['BENCH_NEW']; 24 | delete process.env['BENCH_DIFF']; 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/jest/BenchmarkCommand.ts: -------------------------------------------------------------------------------- 1 | export interface BenchmarkCommandOption { 2 | label: string; 3 | doDiff: boolean; 4 | } 5 | 6 | export class BenchmarkCommand { 7 | readonly label?: string; 8 | readonly doDiff: boolean; 9 | 10 | constructor(option?: Partial) { 11 | option = option || {}; 12 | this.label = option?.label ?? process.env?.BENCH_NEW; 13 | this.doDiff = option?.doDiff ?? process.env?.BENCH_DIFF === 'true'; 14 | } 15 | 16 | get doBenchmark() { 17 | return this.doDiff || typeof this.label !== 'undefined'; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/jest/BenchmarkEnvironment.spec.ts: -------------------------------------------------------------------------------- 1 | import { mkdtempSync } from 'fs'; 2 | import { tmpdir } from 'os'; 3 | import { join } from 'path'; 4 | 5 | import { EnvironmentContext } from '@jest/environment'; 6 | import { readConfig } from 'jest-config'; 7 | import { Config } from '@jest/types'; 8 | 9 | import BenchmarkEnvironment, { BenchmarkEnvironmentConfig } from './BenchmarkEnvironment'; 10 | import { BenchmarkCommandOption } from './BenchmarkCommand'; 11 | import { simpleCase } from '../metric/fixtures/data.fixture'; 12 | import { getMetricStore } from '../metric'; 13 | 14 | const testPath = mkdtempSync(join(tmpdir(), 'jest-')); 15 | 16 | const context: EnvironmentContext = { 17 | console, 18 | docblockPragmas: {}, 19 | testPath, 20 | }; 21 | 22 | async function makeConfig(command: Partial = {}): Promise { 23 | let config = await readConfig( 24 | {} as Config.Argv, 25 | {} /* packageRootOrConfig */, 26 | false /* skipArgvConfigOption */, 27 | testPath /* parentConfigPath */, 28 | ); 29 | return { 30 | projectConfig: { 31 | ...config.projectConfig, 32 | testEnvironmentCommand: command, 33 | }, 34 | globalConfig: { 35 | ...config.globalConfig, 36 | reporters: [['./BenchmarkReporter', {}]], 37 | }, 38 | }; 39 | } 40 | 41 | describe('BenchmarkEnvironment', () => { 42 | it('uses a copy of the process object', async () => { 43 | const config = await makeConfig(); 44 | const env1 = new BenchmarkEnvironment(config, context); 45 | const env2 = new BenchmarkEnvironment(config, context); 46 | expect(env1.global.process).not.toBe(env2.global.process); 47 | }); 48 | 49 | it('exposes process.on', async () => { 50 | const config = await makeConfig(); 51 | const env1 = new BenchmarkEnvironment(config, context); 52 | expect(env1.global.process.on).not.toBeNull(); 53 | }); 54 | 55 | it('exposes global.global', async () => { 56 | const config = await makeConfig({ 57 | label: 'foo', 58 | }); 59 | const env1 = new BenchmarkEnvironment(config, context); 60 | expect(env1.global.global).toBe(env1.global); 61 | await env1.setup(); 62 | const origProto = Object.getPrototypeOf(globalThis); 63 | Object.setPrototypeOf(globalThis, env1.global); 64 | try { 65 | await simpleCase(); 66 | } finally { 67 | Object.setPrototypeOf(globalThis, origProto); 68 | } 69 | const store = getMetricStore(env1.global) || []; 70 | expect(store.length).toEqual(4); 71 | await env1.teardown(); 72 | expect(store.length).toEqual(0); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/jest/BenchmarkEnvironment.ts: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'path'; 2 | import { existsSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'; 3 | 4 | import type { EnvironmentContext } from '@jest/environment'; 5 | import NodeEnvironment from 'jest-environment-node'; 6 | import { Config } from '@jest/types'; 7 | 8 | import { BenchmarkCommand, BenchmarkCommandOption } from './BenchmarkCommand'; 9 | import { createMetricStore, getMetricStore, resetMetricStore } from '../metric'; 10 | 11 | export const sandboxMetricRawFile = '.sandbox-metric-raw.jsonl'; 12 | 13 | export type BenchmarkEnvironmentConfig = { 14 | projectConfig: Config.ProjectConfig & { testEnvironmentCommand?: Partial }; 15 | globalConfig: Config.GlobalConfig; 16 | }; 17 | 18 | export default class BenchmarkEnvironment extends NodeEnvironment { 19 | protected command: BenchmarkCommand; 20 | protected rootDir: string; 21 | 22 | constructor(config: BenchmarkEnvironmentConfig, context: EnvironmentContext) { 23 | super(config, context); 24 | this.command = new BenchmarkCommand(config.projectConfig.testEnvironmentCommand); 25 | this.rootDir = config.globalConfig.rootDir; 26 | } 27 | 28 | async setup() { 29 | if (this.command.doBenchmark) { 30 | createMetricStore(this.global); 31 | } 32 | } 33 | 34 | get resultFile() { 35 | return join(this.rootDir, sandboxMetricRawFile); 36 | } 37 | 38 | async teardown() { 39 | if (this.command.doBenchmark) { 40 | const store = getMetricStore(this.global) || []; 41 | const fileName = this.resultFile; 42 | const folder = dirname(fileName); 43 | if (!existsSync(folder)) { 44 | mkdirSync(folder, { recursive: true }); 45 | } 46 | if (!existsSync(fileName)) { 47 | writeFileSync(fileName, ''); 48 | } 49 | for (const item of store) { 50 | appendFileSync(fileName, JSON.stringify(item) + '\n'); 51 | } 52 | resetMetricStore(this.global); 53 | } 54 | await super.teardown(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/jest/BenchmarkReporter.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; 3 | 4 | import chalk, { supportsColor } from 'chalk'; 5 | import { BaseReporter } from '@jest/reporters'; 6 | import type { Config } from '@jest/types'; 7 | import { ContractABI } from '@ton/core'; 8 | 9 | import { BenchmarkCommand } from './BenchmarkCommand'; 10 | import { sandboxMetricRawFile } from './BenchmarkEnvironment'; 11 | import { readJsonl } from '../utils/readJsonl'; 12 | import { 13 | Metric, 14 | makeSnapshotMetric, 15 | CodeHash, 16 | SnapshotMetric, 17 | sortByCreatedAt, 18 | ContractDatabase, 19 | readSnapshots, 20 | gasReportTable, 21 | makeGasReport, 22 | defaultColor, 23 | ContractData, 24 | } from '../metric'; 25 | 26 | export const defaultSnapshotDir = '.snapshot'; 27 | export const defaultReportName = 'gas-report'; 28 | export const defaultContractDatabaseName = 'contract.abi.json'; 29 | export const defaultDepthCompare = 2; 30 | export const minComparisonDepth = 1; 31 | 32 | const PASS_TEXT = 'PASS'; 33 | const PASS = supportsColor ? chalk.reset.inverse.bold.green(` ${PASS_TEXT} `) : PASS_TEXT; 34 | 35 | const SKIP_TEXT = 'SKIP'; 36 | const SKIP = supportsColor ? chalk.reset.inverse.bold.yellow(` ${SKIP_TEXT} `) : SKIP_TEXT; 37 | 38 | type ReportMode = 'gas' | 'average'; 39 | 40 | export interface Options { 41 | reportMode?: ReportMode; 42 | reportName?: string; 43 | snapshotDir?: string; 44 | depthCompare?: number; 45 | contractExcludes?: string[]; 46 | removeRawResult?: boolean; 47 | contractDatabase?: Record | string; 48 | } 49 | 50 | export default class BenchmarkReporter extends BaseReporter { 51 | protected rootDirPath: string; 52 | protected options: Options; 53 | protected command: BenchmarkCommand; 54 | contractDatabase: ContractDatabase; 55 | 56 | constructor(globalConfig: Config.GlobalConfig, options: Options = {}) { 57 | super(); 58 | this.rootDirPath = globalConfig.rootDir; 59 | this.options = options; 60 | this.command = new BenchmarkCommand(); 61 | if (this.depthCompare < minComparisonDepth) { 62 | throw new Error(`The minimum depth compare must be greater than or equal to ${minComparisonDepth}`); 63 | } 64 | this.contractDatabase = this.readContractDatabase(); 65 | } 66 | 67 | get reportMode() { 68 | return this.options.reportMode || 'gas'; 69 | } 70 | 71 | get reportName() { 72 | return this.options.reportName || defaultReportName; 73 | } 74 | 75 | get snapshotDir() { 76 | return this.options.snapshotDir || defaultSnapshotDir; 77 | } 78 | 79 | get snapshotDirPath() { 80 | const dirPath = join(this.rootDirPath, this.snapshotDir); 81 | try { 82 | if (!existsSync(dirPath)) { 83 | mkdirSync(dirPath, { recursive: true }); 84 | } 85 | } catch (_) { 86 | throw new Error(`Can not create directory: ${dirPath}`); 87 | } 88 | return dirPath; 89 | } 90 | 91 | get depthCompare() { 92 | return this.options.depthCompare || defaultDepthCompare; 93 | } 94 | 95 | get snapshotFiles() { 96 | return readSnapshots(this.snapshotDirPath); 97 | } 98 | 99 | get snapshots() { 100 | return this.snapshotFiles.then((list) => Object.values(list).map((item) => item.content)); 101 | } 102 | 103 | get snapshotCurrent() { 104 | return this.metricStore.then((store) => 105 | makeSnapshotMetric(store, { 106 | contractDatabase: this.contractDatabase, 107 | contractExcludes: this.contractExcludes, 108 | }), 109 | ); 110 | } 111 | 112 | get removeRawResult() { 113 | return this.options.removeRawResult || true; 114 | } 115 | 116 | get contractExcludes() { 117 | return this.options.contractExcludes || []; 118 | } 119 | 120 | get sandboxMetricRawFile() { 121 | return join(this.rootDirPath, sandboxMetricRawFile); 122 | } 123 | 124 | get metricStore() { 125 | if (existsSync(this.sandboxMetricRawFile)) { 126 | return readJsonl(this.sandboxMetricRawFile); 127 | } 128 | return new Promise((resolve) => resolve([])); 129 | } 130 | 131 | readContractDatabase() { 132 | let data: ContractData = {}; 133 | const filePath = this.options.contractDatabase || defaultContractDatabaseName; 134 | if (typeof filePath === 'string') { 135 | try { 136 | if (existsSync(join(this.rootDirPath, filePath))) { 137 | data = JSON.parse(readFileSync(join(this.rootDirPath, filePath), 'utf-8')); 138 | } 139 | } catch (_) { 140 | throw new Error(`Could not parse contract database: ${filePath}`); 141 | } 142 | } 143 | return ContractDatabase.from(data); 144 | } 145 | 146 | saveContractDatabase(): void { 147 | const contractDatabase = this.options.contractDatabase; 148 | let filePath = typeof contractDatabase === 'string' ? contractDatabase : defaultContractDatabaseName; 149 | try { 150 | const content = JSON.stringify(this.contractDatabase.data, null, 2) + '\n'; 151 | writeFileSync(join(this.rootDirPath, filePath), content, { 152 | encoding: 'utf8', 153 | }); 154 | } catch (_) { 155 | throw new Error(`Can not write: ${filePath}`); 156 | } 157 | } 158 | 159 | async onRunComplete(): Promise { 160 | const log = []; 161 | let status = SKIP; 162 | if (this.command.doBenchmark) { 163 | const list = await this.snapshots; 164 | const snapshots = [await this.snapshotCurrent, ...list]; 165 | let doDiff = this.command.doDiff; 166 | const depthCompare = Math.min(snapshots.length, this.depthCompare); 167 | if (doDiff) { 168 | log.push(`Comparison metric mode: ${this.reportMode} depth: ${depthCompare}`); 169 | switch (this.reportMode) { 170 | case 'gas': 171 | log.push(...this.gasReportReport(snapshots, depthCompare)); 172 | status = PASS; 173 | break; 174 | default: 175 | throw new Error(`Report mode ${this.reportMode} not supported`); 176 | } 177 | } else if (this.command.label) { 178 | log.push(`Collect metric mode: "${this.reportMode}"`); 179 | const file = await this.saveSnapshot(this.command.label); 180 | log.push(`Report write in '${file}'`); 181 | status = PASS; 182 | } 183 | if (this.removeRawResult) { 184 | unlinkSync(this.sandboxMetricRawFile); 185 | } 186 | this.saveContractDatabase(); 187 | } else { 188 | log.push(`Reporter mode: ${this.reportMode}`); 189 | } 190 | this.log(`${status} ${log.join('\n')}`); 191 | } 192 | 193 | gasReportReport(data: SnapshotMetric[], benchmarkDepth: number) { 194 | const log = []; 195 | const reportFile = `${this.reportName}.json`; 196 | const report = makeGasReport(data); 197 | try { 198 | const reportFilePath = join(this.rootDirPath, reportFile); 199 | writeFileSync(reportFilePath, JSON.stringify(report, null, 2) + '\n', { 200 | encoding: 'utf8', 201 | }); 202 | log.push(`Gas report write in '${reportFile}'`); 203 | } catch (_) { 204 | throw new Error(`Can not write: ${reportFile}`); 205 | } 206 | const list = report.sort(sortByCreatedAt(true)).slice(0, benchmarkDepth); 207 | log.push(gasReportTable(list, defaultColor)); 208 | return log; 209 | } 210 | 211 | async saveSnapshot(label: string) { 212 | const snapshot = await this.snapshotCurrent; 213 | snapshot.label = label; 214 | const list = await this.snapshotFiles; 215 | let snapshotFile = `${snapshot.createdAt.getTime()}.json`; 216 | if (list[snapshot.label]) { 217 | snapshotFile = list[snapshot.label].name; 218 | } 219 | const snapshotFilePath = join(this.snapshotDirPath, snapshotFile); 220 | try { 221 | writeFileSync(snapshotFilePath, JSON.stringify(snapshot, null, 2) + '\n', { 222 | encoding: 'utf8', 223 | }); 224 | } catch (_) { 225 | throw new Error(`Can not write: ${join(this.snapshotDir, snapshotFile)}`); 226 | } 227 | return join(this.snapshotDir, snapshotFile); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/meta/ContractsMeta.ts: -------------------------------------------------------------------------------- 1 | import { Address, ContractABI } from '@ton/core'; 2 | 3 | export type ContractMeta = { 4 | wrapperName?: string; 5 | abi?: ContractABI | null; 6 | treasurySeed?: string; 7 | }; 8 | 9 | export interface ContractsMeta { 10 | get(key: Address): ContractMeta | undefined; 11 | upsert(key: Address, value: Partial): void; 12 | } 13 | -------------------------------------------------------------------------------- /src/metric/ContractDatabase.ts: -------------------------------------------------------------------------------- 1 | import { ContractABI, ABIReceiver, ABIType } from '@ton/core'; 2 | 3 | import { CodeHash, Metric, OpCode, isCodeHash } from './collectMetric'; 4 | 5 | type Condition = { 6 | codeHash: CodeHash; 7 | opCode: OpCode; 8 | receiver: 'internal' | 'external-in' | 'external-out'; 9 | }; 10 | 11 | export type ContractDataKey = CodeHash | string; 12 | 13 | export type ContractData = Record; 14 | 15 | export class ContractDatabase { 16 | protected list: Map; 17 | protected match: Map; 18 | 19 | constructor(abiList: Map, codeHashMatch: Map) { 20 | this.list = abiList; 21 | this.match = codeHashMatch; 22 | } 23 | 24 | static from(data: ContractData): ContractDatabase { 25 | const list = new Map(); 26 | const match = new Map(); 27 | (Object.entries(data) as [ContractDataKey, ContractABI | ContractDataKey][]).forEach(([key, value]) => { 28 | if ((isCodeHash(key) && typeof value === 'string') || (isCodeHash(value) && typeof key === 'string')) { 29 | match.set(key, value); 30 | } else if (!isCodeHash(value) && typeof value !== 'string') { 31 | list.set(key, value); 32 | } 33 | }); 34 | return new ContractDatabase(list, match); 35 | } 36 | 37 | get data(): ContractData { 38 | const out: ContractData = {}; 39 | for (const [key, value] of this.match) { 40 | out[key] = value; 41 | } 42 | for (const [key, value] of this.list) { 43 | out[key] = value; 44 | } 45 | return out; 46 | } 47 | 48 | origin(needle: ContractDataKey) { 49 | return this.match.get(needle) || needle; 50 | } 51 | 52 | get(needle: ContractDataKey) { 53 | return this.list.get(this.origin(needle)); 54 | } 55 | 56 | extract(metric: Metric) { 57 | const abiKeyNeedle = metric.contractName || metric.codeHash; 58 | if (!abiKeyNeedle) { 59 | return; 60 | } 61 | const codeHash = metric.codeHash; 62 | if (isCodeHash(codeHash) && abiKeyNeedle !== codeHash) { 63 | this.match.set(codeHash, abiKeyNeedle); 64 | } 65 | const abiKey = this.origin(abiKeyNeedle); 66 | const abi = this.list.get(abiKey) || ({} as ContractABI); 67 | 68 | if (!abi.receivers) { 69 | abi.receivers = []; 70 | } 71 | if (!abi.types) { 72 | abi.types = []; 73 | } 74 | const find = this.by(metric); 75 | if (!find.methodName) { 76 | if (!abi.name) { 77 | abi.name = metric.contractName || metric.codeHash; 78 | } 79 | if (metric.opCode !== '0x0') { 80 | abi.types.push({ 81 | name: metric.methodName || metric.opCode, 82 | header: Number(metric.opCode), 83 | } as ABIType); 84 | abi.receivers.push({ 85 | receiver: metric.receiver == 'internal' ? 'internal' : 'external', 86 | message: { 87 | kind: 'typed', 88 | type: metric.methodName || metric.opCode, 89 | }, 90 | } as ABIReceiver); 91 | } 92 | } 93 | this.list.set(abiKey, abi); 94 | } 95 | 96 | by(where: Partial): Partial { 97 | if (!where.codeHash) { 98 | return {}; 99 | } 100 | const abi = this.get(where.codeHash); 101 | if (!abi) { 102 | return {}; 103 | } 104 | const out: Partial = {}; 105 | out.contractName = abi.name ? abi.name : undefined; 106 | let abiType: ABIType | undefined; 107 | if (where.opCode) { 108 | for (const item of abi.types || []) { 109 | if (item.header && item.header === Number(where.opCode)) { 110 | abiType = item; 111 | break; 112 | } 113 | } 114 | } 115 | if (abiType) { 116 | const receiver = where.receiver ? (where.receiver == 'internal' ? 'internal' : 'external') : undefined; 117 | for (const item of abi.receivers || []) { 118 | if (receiver && receiver !== item.receiver) { 119 | continue; 120 | } 121 | if (item.message.kind === 'typed' && item.message.type == abiType.name) { 122 | out.methodName = item.message.type; 123 | break; 124 | } 125 | } 126 | } 127 | return out; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/metric/__snapshots__/collectMetric.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`collectMetric should be collect abi 1`] = ` 4 | { 5 | "0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912": "TreasuryContract", 6 | "TreasuryContract": { 7 | "name": "TreasuryContract", 8 | "receivers": [ 9 | { 10 | "message": { 11 | "kind": "typed", 12 | "type": "0xdeadface", 13 | }, 14 | "receiver": "internal", 15 | }, 16 | { 17 | "message": { 18 | "kind": "typed", 19 | "type": "notSupported", 20 | }, 21 | "receiver": "internal", 22 | }, 23 | ], 24 | "types": [ 25 | { 26 | "header": 3735943886, 27 | "name": "0xdeadface", 28 | }, 29 | { 30 | "header": 4294967295, 31 | "name": "notSupported", 32 | }, 33 | ], 34 | }, 35 | } 36 | `; 37 | 38 | exports[`collectMetric should be collect metric 1`] = ` 39 | [ 40 | { 41 | "address": "EQBiA46W-PQaaZZNFIDglnVknV9CR6J5hs81bSv70FwfNTrD", 42 | "codeHash": "0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912", 43 | "contractName": "TreasuryContract", 44 | "execute": { 45 | "action": { 46 | "resultCode": 0, 47 | "skippedActions": 0, 48 | "success": true, 49 | "totalActionFees": 1, 50 | "totalActions": 1, 51 | "totalFwdFees": 400000, 52 | "totalMessageSize": { 53 | "bits": 737, 54 | "cells": 1, 55 | }, 56 | }, 57 | "compute": { 58 | "exitCode": 0, 59 | "gasUsed": 1937, 60 | "success": true, 61 | "type": "vm", 62 | "vmSteps": 50, 63 | }, 64 | }, 65 | "message": { 66 | "in": { 67 | "bits": 998, 68 | "cells": 3, 69 | }, 70 | "out": { 71 | "bits": 744, 72 | "cells": 2, 73 | }, 74 | }, 75 | "methodName": "send", 76 | "opCode": "0x0", 77 | "receiver": "external-in", 78 | "state": { 79 | "code": { 80 | "bits": 458, 81 | "cells": 4, 82 | }, 83 | "data": { 84 | "bits": 256, 85 | "cells": 1, 86 | }, 87 | }, 88 | "testName": "collectMetric should be collect metric", 89 | }, 90 | { 91 | "address": "EQBc3CG3NOeF3wwkBM8zjXrsWUhjLuN45LobSkZHXCR0jhvg", 92 | "codeHash": "0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912", 93 | "contractName": "TreasuryContract", 94 | "execute": { 95 | "action": { 96 | "resultCode": 0, 97 | "skippedActions": 0, 98 | "success": true, 99 | "totalActionFees": 0, 100 | "totalActions": 0, 101 | "totalFwdFees": undefined, 102 | "totalMessageSize": { 103 | "bits": 0, 104 | "cells": 0, 105 | }, 106 | }, 107 | "compute": { 108 | "exitCode": 0, 109 | "gasUsed": 309, 110 | "success": true, 111 | "type": "vm", 112 | "vmSteps": 5, 113 | }, 114 | }, 115 | "message": { 116 | "in": { 117 | "bits": 737, 118 | "cells": 1, 119 | }, 120 | "out": { 121 | "bits": 0, 122 | "cells": 0, 123 | }, 124 | }, 125 | "methodName": "foo", 126 | "opCode": "0xdeadface", 127 | "receiver": "internal", 128 | "state": { 129 | "code": { 130 | "bits": 458, 131 | "cells": 4, 132 | }, 133 | "data": { 134 | "bits": 256, 135 | "cells": 1, 136 | }, 137 | }, 138 | "testName": "collectMetric should be collect metric", 139 | }, 140 | { 141 | "address": "EQBc3CG3NOeF3wwkBM8zjXrsWUhjLuN45LobSkZHXCR0jhvg", 142 | "codeHash": "0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912", 143 | "contractName": "TreasuryContract", 144 | "execute": { 145 | "action": { 146 | "resultCode": 0, 147 | "skippedActions": 0, 148 | "success": true, 149 | "totalActionFees": 1, 150 | "totalActions": 1, 151 | "totalFwdFees": 400000, 152 | "totalMessageSize": { 153 | "bits": 737, 154 | "cells": 1, 155 | }, 156 | }, 157 | "compute": { 158 | "exitCode": 0, 159 | "gasUsed": 1937, 160 | "success": true, 161 | "type": "vm", 162 | "vmSteps": 50, 163 | }, 164 | }, 165 | "message": { 166 | "in": { 167 | "bits": 998, 168 | "cells": 3, 169 | }, 170 | "out": { 171 | "bits": 744, 172 | "cells": 2, 173 | }, 174 | }, 175 | "methodName": "send", 176 | "opCode": "0x0", 177 | "receiver": "external-in", 178 | "state": { 179 | "code": { 180 | "bits": 458, 181 | "cells": 4, 182 | }, 183 | "data": { 184 | "bits": 256, 185 | "cells": 1, 186 | }, 187 | }, 188 | "testName": "collectMetric should be collect metric", 189 | }, 190 | { 191 | "address": "EQBiA46W-PQaaZZNFIDglnVknV9CR6J5hs81bSv70FwfNTrD", 192 | "codeHash": "0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912", 193 | "contractName": "TreasuryContract", 194 | "execute": { 195 | "action": { 196 | "resultCode": 0, 197 | "skippedActions": 0, 198 | "success": true, 199 | "totalActionFees": 0, 200 | "totalActions": 0, 201 | "totalFwdFees": undefined, 202 | "totalMessageSize": { 203 | "bits": 0, 204 | "cells": 0, 205 | }, 206 | }, 207 | "compute": { 208 | "exitCode": 0, 209 | "gasUsed": 309, 210 | "success": true, 211 | "type": "vm", 212 | "vmSteps": 5, 213 | }, 214 | }, 215 | "message": { 216 | "in": { 217 | "bits": 737, 218 | "cells": 1, 219 | }, 220 | "out": { 221 | "bits": 0, 222 | "cells": 0, 223 | }, 224 | }, 225 | "methodName": "notSupported", 226 | "opCode": "0xffffffff", 227 | "receiver": "internal", 228 | "state": { 229 | "code": { 230 | "bits": 458, 231 | "cells": 4, 232 | }, 233 | "data": { 234 | "bits": 256, 235 | "cells": 1, 236 | }, 237 | }, 238 | "testName": "collectMetric should be collect metric", 239 | }, 240 | ] 241 | `; 242 | -------------------------------------------------------------------------------- /src/metric/__snapshots__/gasReportTable.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`gasReportTable table complex 1`] = ` 4 | "┌───────────────┬───────────────────────┬──────────────────────────────────────┬───────────────────────────┐ 5 | │ │ │ current │ v1 │ 6 | │ Contract │ Method ├──────────────┬─────────┬─────────────┼──────────┬────────┬───────┤ 7 | │ │ │ gasUsed │ cells │ bits │ gasUsed │ cells │ bits │ 8 | ├───────────────┼───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 9 | │ │ sendDeploy │ 1937 same │ 36 same │ 7650 same │ 1937 │ 36 │ 7650 │ 10 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 11 | │ │ send │ 489 same │ 36 same │ 7650 same │ 489 │ 36 │ 7650 │ 12 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 13 | │ │ sendDeployNft │ 1937 same │ 36 same │ 7650 same │ 1937 │ 36 │ 7650 │ 14 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 15 | │ │ 0x1 │ 7251 same │ 36 same │ 7650 same │ 7251 │ 36 │ 7650 │ 16 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 17 | │ │ 0x800637ac │ 3208 -0.31% │ 36 same │ 7459 +1.47% │ 3218 │ 36 │ 7351 │ 18 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 19 | │ │ sendGetRoyaltyParams │ 1937 same │ 37 same │ 7758 +1.41% │ 1937 │ 37 │ 7650 │ 20 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 21 | │ │ 0x693d3950 │ 3126 +10.42% │ 37 same │ 7758 +1.41% │ 2831 │ 37 │ 7650 │ 22 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 23 | │ │ 0xa8cb00ad │ 309 same │ 37 same │ 7758 +1.41% │ 309 │ 37 │ 7650 │ 24 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 25 | │ │ 0x800f8178 │ 3208 │ 37 │ 7758 │ ~ │ ~ │ ~ │ 26 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 27 | │ │ notSupported │ 849 +14.57% │ 37 same │ 7758 +1.41% │ 741 │ 37 │ 7650 │ 28 | │ NFTCollection ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 29 | │ │ 0x8008751c │ 3218 same │ 36 same │ 7650 same │ 3218 │ 36 │ 7650 │ 30 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 31 | │ │ sendBatchDeployNFT │ 1937 same │ 36 same │ 7650 same │ 1937 │ 36 │ 7650 │ 32 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 33 | │ │ 0x2 │ 727481 same │ 36 same │ 7650 same │ 727481 │ 36 │ 7650 │ 34 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 35 | │ │ 0x801a18cb │ 3208 │ 37 │ 7758 │ ~ │ ~ │ ~ │ 36 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 37 | │ │ 0x800299f8 │ 3208 │ 37 │ 7758 │ ~ │ ~ │ ~ │ 38 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 39 | │ │ sendChangeOwner │ 1937 same │ 37 same │ 7758 +1.41% │ 1937 │ 37 │ 7650 │ 40 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 41 | │ │ 0x3 │ 2582 +5.47% │ 37 same │ 7758 +1.41% │ 2448 │ 37 │ 7650 │ 42 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 43 | │ │ 0x8017bad0 │ ~ │ ~ │ ~ │ 3218 │ 37 │ 7650 │ 44 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 45 | │ │ 0x80105974 │ ~ │ ~ │ ~ │ 3218 │ 37 │ 7650 │ 46 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 47 | │ │ 0x8002d39c │ ~ │ ~ │ ~ │ 3218 │ 37 │ 7650 │ 48 | ├───────────────┼───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 49 | │ │ sendTransferOwnership │ 1937 same │ 15 same │ 3782 same │ 1937 │ 15 │ 3782 │ 50 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 51 | │ │ 0x5fcc3d14 │ 6850 same │ 15 same │ 3782 same │ 6850 │ 15 │ 3782 │ 52 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 53 | │ │ 0xd53276db │ 309 same │ 15 same │ 3782 same │ 309 │ 15 │ 3782 │ 54 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 55 | │ │ 0x5138d91 │ 0 same │ 16 same │ 3983 -1.63% │ 0 │ 16 │ 4049 │ 56 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 57 | │ │ notSupported │ 309 same │ 16 same │ 3983 -1.63% │ 309 │ 16 │ 4049 │ 58 | │ NFTItem ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 59 | │ │ sendGetStaticData │ 1937 same │ 15 same │ 3782 same │ 1937 │ 15 │ 3782 │ 60 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 61 | │ │ 0x2fcb26a2 │ 4226 same │ 15 same │ 3782 same │ 4226 │ 15 │ 3782 │ 62 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 63 | │ │ 0x8b771735 │ 309 same │ 15 same │ 3782 same │ 309 │ 15 │ 3782 │ 64 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 65 | │ │ sendDeploy │ 1937 same │ 15 same │ 3782 same │ 1937 │ 15 │ 3782 │ 66 | │ ├───────────────────────┼──────────────┼─────────┼─────────────┼──────────┼────────┼───────┤ 67 | │ │ 0x8008751c │ 3218 same │ 15 same │ 3782 same │ 3218 │ 15 │ 3782 │ 68 | └───────────────┴───────────────────────┴──────────────┴─────────┴─────────────┴──────────┴────────┴───────┘ 69 | " 70 | `; 71 | 72 | exports[`gasReportTable table delta result increase 1`] = ` 73 | "┌───────────────────────────┬─────────┬──────────────────────────────────────┬───────────────────────────┐ 74 | │ │ │ one second │ zero first │ 75 | │ Contract │ Method ├────────────┬────────────┬────────────┼──────────┬────────┬───────┤ 76 | │ │ │ gasUsed │ cells │ bits │ gasUsed │ cells │ bits │ 77 | ├───────────────────────────┼─────────┼────────────┼────────────┼────────────┼──────────┼────────┼───────┤ 78 | │ EQBGhqLAZseEqRXz4ByFPT... │ 0x0 │ 1 +100.00% │ 2 +100.00% │ 2 +100.00% │ 0 │ 0 │ 0 │ 79 | └───────────────────────────┴─────────┴────────────┴────────────┴────────────┴──────────┴────────┴───────┘ 80 | " 81 | `; 82 | 83 | exports[`gasReportTable table delta result same 1`] = ` 84 | "┌───────────────────────────┬─────────┬────────────────────────────┬───────────────────────────┐ 85 | │ │ │ one second │ one first │ 86 | │ Contract │ Method ├──────────┬────────┬────────┼──────────┬────────┬───────┤ 87 | │ │ │ gasUsed │ cells │ bits │ gasUsed │ cells │ bits │ 88 | ├───────────────────────────┼─────────┼──────────┼────────┼────────┼──────────┼────────┼───────┤ 89 | │ EQBGhqLAZseEqRXz4ByFPT... │ 0x0 │ 1 same │ 2 same │ 2 same │ 1 │ 2 │ 2 │ 90 | └───────────────────────────┴─────────┴──────────┴────────┴────────┴──────────┴────────┴───────┘ 91 | " 92 | `; 93 | 94 | exports[`gasReportTable table sample 1`] = ` 95 | "┌─────────────┬────────────────┬────────────────────────────────────┬───────────────────────────┐ 96 | │ │ │ current │ first │ 97 | │ Contract │ Method ├─────────────┬────────┬─────────────┼──────────┬────────┬───────┤ 98 | │ │ │ gasUsed │ cells │ bits │ gasUsed │ cells │ bits │ 99 | ├─────────────┼────────────────┼─────────────┼────────┼─────────────┼──────────┼────────┼───────┤ 100 | │ │ sendDeploy │ 1937 same │ 9 same │ 1023 -4.48% │ 1937 │ 9 │ 1071 │ 101 | │ ├────────────────┼─────────────┼────────┼─────────────┼──────────┼────────┼───────┤ 102 | │ │ send │ 589 -8.11% │ 9 same │ 1023 -4.48% │ 641 │ 9 │ 1071 │ 103 | │ ├────────────────┼─────────────┼────────┼─────────────┼──────────┼────────┼───────┤ 104 | │ │ sendIncrAction │ 1937 same │ 9 same │ 1023 -4.48% │ 1937 │ 9 │ 1071 │ 105 | │ SampleBench ├────────────────┼─────────────┼────────┼─────────────┼──────────┼────────┼───────┤ 106 | │ │ Incr │ 2743 -1.58% │ 9 same │ 1023 -4.48% │ 2787 │ 9 │ 1071 │ 107 | │ ├────────────────┼─────────────┼────────┼─────────────┼──────────┼────────┼───────┤ 108 | │ │ sendDecrAction │ 1937 same │ 9 same │ 1023 -4.48% │ 1937 │ 9 │ 1071 │ 109 | │ ├────────────────┼─────────────┼────────┼─────────────┼──────────┼────────┼───────┤ 110 | │ │ Decr │ 2820 -2.25% │ 9 same │ 1023 -4.48% │ 2885 │ 9 │ 1071 │ 111 | └─────────────┴────────────────┴─────────────┴────────┴─────────────┴──────────┴────────┴───────┘ 112 | " 113 | `; 114 | 115 | exports[`gasReportTable table single result 1`] = ` 116 | "┌───────────────────────────┬─────────┬───────────────────────────┐ 117 | │ │ │ one first │ 118 | │ Contract │ Method ├──────────┬────────┬───────┤ 119 | │ │ │ gasUsed │ cells │ bits │ 120 | ├───────────────────────────┼─────────┼──────────┼────────┼───────┤ 121 | │ EQBGhqLAZseEqRXz4ByFPT... │ 0x0 │ 1 │ 2 │ 2 │ 122 | └───────────────────────────┴─────────┴──────────┴────────┴───────┘ 123 | " 124 | `; 125 | -------------------------------------------------------------------------------- /src/metric/collectMetric.spec.ts: -------------------------------------------------------------------------------- 1 | import '@ton/test-utils'; 2 | import { createMetricStore, getMetricStore, makeSnapshotMetric, resetMetricStore } from './collectMetric'; 3 | import { BenchmarkCommand } from '../jest/BenchmarkCommand'; 4 | import { ContractDatabase } from './ContractDatabase'; 5 | import { itIf, simpleCase } from './fixtures/data.fixture'; 6 | 7 | const bc = new BenchmarkCommand(); 8 | 9 | describe('collectMetric', () => { 10 | itIf(!bc.doBenchmark)('should not collect metric', async () => { 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | let context: any = {}; 13 | await simpleCase(); 14 | expect(getMetricStore(context)).toEqual(undefined); 15 | }); 16 | 17 | itIf(!bc.doBenchmark)('should be collect metric', async () => { 18 | const store = createMetricStore(); 19 | resetMetricStore(); 20 | expect(store.length).toEqual(0); 21 | await simpleCase(); 22 | expect(store.length).toEqual(4); 23 | const contractDatabase = ContractDatabase.from({ 24 | '0xd992502b94ea96e7b34e5d62ffb0c6fc73d78b3e61f11f0848fb3a1eb1afc912': 'TreasuryContract', 25 | TreasuryContract: { 26 | name: 'TreasuryContract', 27 | types: [{ name: 'foo', header: Number('0xdeadface'), fields: [] }], 28 | receivers: [ 29 | { 30 | receiver: 'internal', 31 | message: { kind: 'typed', type: 'foo' }, 32 | }, 33 | ], 34 | }, 35 | }); 36 | const snapshot = makeSnapshotMetric(store, { 37 | label: 'foo', 38 | contractDatabase, 39 | }); 40 | expect(snapshot.label).toEqual('foo'); 41 | expect(snapshot.createdAt.getTime()).toBeLessThanOrEqual(new Date().getTime()); 42 | expect(snapshot.items).toMatchSnapshot(); 43 | }); 44 | 45 | itIf(!bc.doBenchmark)('should be collect abi', async () => { 46 | const store = createMetricStore(); 47 | resetMetricStore(); 48 | expect(store.length).toEqual(0); 49 | await simpleCase(); 50 | expect(store.length).toEqual(4); 51 | const contractDatabase = ContractDatabase.from({}); 52 | const snapshot = makeSnapshotMetric(store, { 53 | label: 'foo', 54 | contractDatabase, 55 | }); 56 | expect(snapshot.label).toEqual('foo'); 57 | expect(snapshot.createdAt.getTime()).toBeLessThanOrEqual(new Date().getTime()); 58 | expect(contractDatabase.data).toMatchSnapshot(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/metric/collectMetric.ts: -------------------------------------------------------------------------------- 1 | import { Address, beginCell, Cell, Contract, storeMessage, Message } from '@ton/core'; 2 | import { Dictionary, DictionaryKeyTypes, TransactionComputePhase } from '@ton/core'; 3 | import { Maybe } from '@ton/core/src/utils/maybe'; 4 | 5 | import { Blockchain, SendMessageResult } from '../blockchain/Blockchain'; 6 | import { ContractDatabase } from './ContractDatabase'; 7 | 8 | export type MetricContext = { 9 | contract: T; 10 | methodName: string; 11 | }; 12 | 13 | type StateShort = { 14 | code: Cell; 15 | data: Cell; 16 | }; 17 | 18 | export type CellMetric = { 19 | cells: number; 20 | bits: number; 21 | }; 22 | 23 | export type ComputePhaseMetric = { 24 | type: string; 25 | success?: boolean; 26 | gasUsed?: number; 27 | exitCode?: number; 28 | vmSteps?: number; 29 | }; 30 | 31 | export type ActionPhaseMetric = { 32 | success: boolean; 33 | totalActions: number; 34 | skippedActions: number; 35 | resultCode: number; 36 | totalFwdFees?: number; 37 | totalActionFees: number; 38 | totalMessageSize: CellMetric; 39 | }; 40 | 41 | export type AddressFriendly = string; 42 | 43 | export function isAddressFriendly(value: unknown): value is AddressFriendly { 44 | return typeof value === 'string' && Address.isFriendly(value); 45 | } 46 | 47 | export type ContractName = string; 48 | 49 | export type ContractMethodName = string; 50 | 51 | export type CodeHash = `0x${string}`; 52 | 53 | export type OpCode = `0x${string}`; 54 | 55 | export function isCodeHash(value: unknown): value is CodeHash { 56 | return typeof value === 'string' && value.length === 66 && /^0x[0-9a-fA-F]+$/.test(value); 57 | } 58 | 59 | export type StateMetric = { 60 | code: CellMetric; 61 | data: CellMetric; 62 | }; 63 | 64 | export type Metric = { 65 | // the name of the current test (if available in Jest context) 66 | testName?: string; 67 | // address of contract 68 | address: AddressFriendly; 69 | // hex-formatted hash of contract code 70 | codeHash?: CodeHash; 71 | // total cells and bits usage of the contract's code and data 72 | state: StateMetric; 73 | contractName?: ContractName; 74 | methodName?: ContractMethodName; 75 | receiver?: 'internal' | 'external-in' | 'external-out'; 76 | opCode: OpCode; 77 | // information from transaction phases 78 | execute: { 79 | compute: ComputePhaseMetric; 80 | action?: ActionPhaseMetric; 81 | }; 82 | // total cells and bits usage of inbound and outbound messages 83 | message: { 84 | in: CellMetric; 85 | out: CellMetric; 86 | }; 87 | }; 88 | 89 | export type SnapshotMetric = { 90 | label: string; 91 | createdAt: Date; 92 | items: Metric[]; 93 | }; 94 | 95 | interface HasCreatedAt { 96 | createdAt: Date; 97 | } 98 | 99 | export function sortByCreatedAt(reverse = false) { 100 | return (a: HasCreatedAt, b: HasCreatedAt) => (a.createdAt.getTime() - b.createdAt.getTime()) * (reverse ? -1 : 1); 101 | } 102 | 103 | export type SnapshotMetricFile = { 104 | name: string; 105 | content: SnapshotMetric; 106 | }; 107 | 108 | export type SnapshotMetricList = Record; 109 | 110 | export type SnapshotMetricConfig = { 111 | label: string; 112 | contractExcludes: ContractName[]; 113 | contractDatabase: ContractDatabase; 114 | }; 115 | 116 | const STORE_METRIC = Symbol.for('ton-sandbox-metric-store'); 117 | 118 | export function makeSnapshotMetric(store: Metric[], config: Partial = {}): SnapshotMetric { 119 | const label = config.label || 'current'; 120 | const contractExcludes = config.contractExcludes || new Array(); 121 | const contractDatabase = config.contractDatabase || ContractDatabase.from({}); 122 | const snapshot: SnapshotMetric = { 123 | label, 124 | createdAt: new Date(), 125 | items: new Array(), 126 | }; 127 | // remove duplicates and extract ABI 128 | const seen = new Set(); 129 | for (const metric of store) { 130 | const key = JSON.stringify(metric); 131 | if (seen.has(key)) continue; 132 | snapshot.items.push(metric); 133 | seen.add(key); 134 | if (metric.codeHash) { 135 | contractDatabase.extract(metric); 136 | } 137 | } 138 | // ABI auto-mapping 139 | for (const item of snapshot.items) { 140 | const find = contractDatabase.by(item); 141 | if (!item.contractName && find.contractName) { 142 | item.contractName = find.contractName; 143 | } 144 | if (!item.methodName && find.methodName) { 145 | item.methodName = find.methodName; 146 | } 147 | } 148 | if (contractExcludes.length > 0) { 149 | snapshot.items = snapshot.items.filter( 150 | (it) => typeof it.contractName === 'undefined' || !contractExcludes.includes(it.contractName), 151 | ); 152 | } 153 | return snapshot; 154 | } 155 | 156 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 157 | export function getMetricStore(context: any = globalThis): Array | undefined { 158 | return context[STORE_METRIC]; 159 | } 160 | 161 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 162 | export function createMetricStore(context: any = globalThis): Array { 163 | if (!Array.isArray(context[STORE_METRIC])) { 164 | context[STORE_METRIC] = new Array(); 165 | } 166 | return context[STORE_METRIC]; 167 | } 168 | 169 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 170 | export function resetMetricStore(context: any = globalThis): Array { 171 | const store = getMetricStore(context); 172 | if (store) store.length = 0; 173 | return createMetricStore(context); 174 | } 175 | 176 | export function calcMessageSize(msg: Maybe) { 177 | if (msg) { 178 | return calcCellSize(beginCell().store(storeMessage(msg)).endCell()); 179 | } 180 | return { cells: 0, bits: 0 }; 181 | } 182 | 183 | export function calcDictSize(dict: Dictionary) { 184 | if (dict.size > 0) { 185 | return calcCellSize(beginCell().storeDict(dict).endCell().asSlice().loadRef()); 186 | } 187 | return { cells: 0, bits: 0 }; 188 | } 189 | 190 | export function calcCellSize(root: Cell, visited: Set = new Set()) { 191 | const hash = root.hash().toString('hex'); 192 | if (visited.has(hash)) { 193 | return { cells: 0, bits: 0 }; 194 | } 195 | visited.add(hash); 196 | const out = { 197 | cells: 1, 198 | bits: root.bits.length, 199 | }; 200 | for (const ref of root.refs) { 201 | const childRes = calcCellSize(ref, visited); 202 | out.cells += childRes.cells; 203 | out.bits += childRes.bits; 204 | } 205 | return out; 206 | } 207 | 208 | export function calcStateSize(state: StateShort): StateMetric { 209 | const codeSize = calcCellSize(state.code); 210 | const dataSize = calcCellSize(state.data); 211 | return { 212 | code: codeSize, 213 | data: dataSize, 214 | }; 215 | } 216 | 217 | export function calcComputePhase(phase: TransactionComputePhase): ComputePhaseMetric { 218 | if (phase.type === 'vm') { 219 | return { 220 | type: phase.type, 221 | success: phase.success, 222 | gasUsed: Number(phase.gasUsed), 223 | exitCode: phase.exitCode, 224 | vmSteps: phase.vmSteps, 225 | }; 226 | } 227 | return { 228 | type: phase.type, 229 | }; 230 | } 231 | 232 | export enum OpCodeReserved { 233 | send = 0x0, 234 | notSupported = 0xffffffff, 235 | notAllowed = 0xfffffffe, 236 | } 237 | 238 | export async function collectMetric( 239 | blockchain: Blockchain, 240 | ctx: MetricContext, 241 | result: SendMessageResult, 242 | ) { 243 | const store = getMetricStore(); 244 | if (!Array.isArray(store)) { 245 | return; 246 | } 247 | let state: StateMetric = { data: { cells: 0, bits: 0 }, code: { cells: 0, bits: 0 } }; 248 | let codeHash: CodeHash | undefined; 249 | if (ctx.contract.init && ctx.contract.init.code && ctx.contract.init.data) { 250 | codeHash = `0x${ctx.contract.init.code.hash().toString('hex')}`; 251 | state = calcStateSize({ code: ctx.contract.init.code, data: ctx.contract.init.data }); 252 | } else { 253 | const account = (await blockchain.getContract(ctx.contract.address)).accountState; 254 | if (account && account.type === 'active' && account.state.code && account.state.data) { 255 | codeHash = `0x${account.state.code.hash().toString('hex')}`; 256 | state = calcStateSize({ code: account.state.code, data: account.state.data }); 257 | } 258 | } 259 | let testName; 260 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 261 | if ((globalThis as any)['expect']) { 262 | // eslint-disable-next-line no-undef 263 | testName = expect.getState().currentTestName; 264 | } 265 | let contractName: ContractName | undefined = ctx.contract.constructor.name; 266 | let methodName: ContractMethodName | undefined = ctx.methodName; 267 | 268 | for (const tx of result.transactions) { 269 | if (tx.description.type !== 'generic') continue; 270 | const receiver = tx.inMessage?.info.type; 271 | const body = tx.inMessage?.body ? tx.inMessage.body.beginParse() : undefined; 272 | let opCode: OpCode = '0x0'; 273 | if (receiver === 'internal') { 274 | opCode = `0x${(body && body.remainingBits >= 32 ? body.preloadUint(32) : 0).toString(16)}`; 275 | } 276 | if (!methodName && Object.values(OpCodeReserved).includes(Number(opCode))) { 277 | methodName = OpCodeReserved[Number(opCode)]; 278 | } 279 | const address = Address.parseRaw(`0:${tx.address.toString(16).padStart(64, '0')}`); 280 | 281 | const { computePhase, actionPhase } = tx.description; 282 | const action: ActionPhaseMetric | undefined = actionPhase 283 | ? { 284 | success: actionPhase.success, 285 | totalActions: actionPhase.totalActions, 286 | skippedActions: actionPhase.skippedActions, 287 | resultCode: actionPhase.resultCode, 288 | totalActionFees: actionPhase.totalActions, 289 | totalFwdFees: actionPhase.totalFwdFees ? Number(actionPhase.totalFwdFees) : undefined, 290 | totalMessageSize: { 291 | cells: Number(actionPhase.totalMessageSize.cells), 292 | bits: Number(actionPhase.totalMessageSize.bits), 293 | }, 294 | } 295 | : undefined; 296 | const compute = calcComputePhase(computePhase); 297 | const metric: Metric = { 298 | testName, 299 | address: address.toString(), 300 | codeHash, 301 | contractName, 302 | methodName, 303 | receiver, 304 | opCode, 305 | execute: { 306 | compute, 307 | action, 308 | }, 309 | message: { 310 | in: calcMessageSize(tx.inMessage), 311 | out: calcDictSize(tx.outMessages), 312 | }, 313 | state, 314 | }; 315 | store.push(metric); 316 | methodName = undefined; 317 | if (!address.equals(ctx.contract.address)) { 318 | contractName = ctx.contract.constructor.name; 319 | } else { 320 | contractName = undefined; 321 | } 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/metric/defaultColor.ts: -------------------------------------------------------------------------------- 1 | import chalk, { supportsColor } from 'chalk'; 2 | 3 | import { DeltaMetric } from './deltaResult'; 4 | 5 | export function defaultColor(metric: DeltaMetric) { 6 | if (!supportsColor) { 7 | return metric.value; 8 | } 9 | const { green, redBright, gray, yellowBright } = chalk; 10 | let color = gray; 11 | if (metric.kind === 'decrease') { 12 | color = green; 13 | } else if (metric.kind === 'increase') { 14 | color = redBright; 15 | } else if (metric.kind === 'undefined') { 16 | color = yellowBright; 17 | } 18 | return color(metric.value); 19 | } 20 | -------------------------------------------------------------------------------- /src/metric/deltaResult.spec.ts: -------------------------------------------------------------------------------- 1 | import { aggregatedCompareMetric, makeGasReport } from './deltaResult'; 2 | import { zero, one, oneFirst, zeroFirst, oneSecond, zeroSecond } from './fixtures/data.fixture'; 3 | import { sampleList } from './fixtures/snapshot.fixture'; 4 | import complexList from './fixtures/complex.fixture'; 5 | 6 | describe('deltaResult', () => { 7 | describe('aggregatedCompareMetric', () => { 8 | it('diff metric should be same zero', () => { 9 | const actual = aggregatedCompareMetric(zero, zero); 10 | expect(Object.keys(actual).length).toEqual(36); 11 | expect(actual).toMatchSnapshot(); 12 | }); 13 | 14 | it('diff metric should be same one', () => { 15 | const actual = aggregatedCompareMetric(one, one); 16 | expect(Object.keys(actual).length).toEqual(36); 17 | expect(actual).toMatchSnapshot(); 18 | }); 19 | 20 | it('diff metric should be increase', () => { 21 | const actual = aggregatedCompareMetric(zero, one); 22 | expect(Object.keys(actual).length).toEqual(36); 23 | expect(actual).toMatchSnapshot(); 24 | }); 25 | 26 | it('diff metric should be decrease', () => { 27 | const actual = aggregatedCompareMetric(one, zero); 28 | expect(Object.keys(actual).length).toEqual(36); 29 | expect(actual).toMatchSnapshot(); 30 | }); 31 | 32 | it('not pair should be skip', () => { 33 | delete one.execute.action; 34 | const actual = aggregatedCompareMetric(one, zero); 35 | expect(Object.keys(actual).length).toEqual(29); 36 | }); 37 | }); 38 | 39 | describe('makeGasReport', () => { 40 | it('makeGasReport only after', () => { 41 | const actual = makeGasReport([oneFirst]); 42 | expect(actual).toMatchSnapshot(); 43 | }); 44 | 45 | it('makeGasReport same', () => { 46 | const actual = makeGasReport([oneSecond, oneFirst]); 47 | expect(actual).toMatchSnapshot(); 48 | }); 49 | 50 | it('makeGasReport increase', () => { 51 | const actual = makeGasReport([zeroSecond, oneFirst]); 52 | expect(actual).toMatchSnapshot(); 53 | }); 54 | 55 | it('makeGasReport decrease', () => { 56 | const actual = makeGasReport([oneSecond, zeroFirst]); 57 | expect(actual).toMatchSnapshot(); 58 | }); 59 | 60 | it('makeGasReport sample', () => { 61 | const actual = makeGasReport(sampleList); 62 | expect(actual).toMatchSnapshot(); 63 | }); 64 | 65 | it('makeGasReport complex', () => { 66 | const actual = makeGasReport(complexList); 67 | expect(actual).toMatchSnapshot(); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/metric/deltaResult.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddressFriendly, 3 | ContractMethodName, 4 | ContractName, 5 | Metric, 6 | OpCode, 7 | SnapshotMetric, 8 | sortByCreatedAt, 9 | } from './collectMetric'; 10 | 11 | export type KindDelta = 'undefined' | 'init' | 'same' | 'increase' | 'decrease'; 12 | 13 | export type PathDelta = string; 14 | 15 | export type ItemDelta = { 16 | kind: KindDelta; 17 | path: PathDelta; 18 | before: number; 19 | after: number; 20 | }; 21 | 22 | export type ListDelta = Record; 23 | 24 | export type DeltaMetric = { 25 | kind: KindDelta; 26 | value: string; 27 | }; 28 | 29 | export const undefinedDeltaMetric = () => 30 | ({ 31 | kind: 'undefined', 32 | value: '~', 33 | }) as DeltaMetric; 34 | 35 | export type ColorDelta = (metric: DeltaMetric) => string; 36 | 37 | export type DeltaMetrics = Record; 38 | 39 | export type MethodDelta = Record; 40 | 41 | export type ContractDelta = Record; 42 | 43 | export type DeltaResult = { 44 | label: string; 45 | createdAt: Date; 46 | result: ContractDelta; 47 | }; 48 | 49 | export type DeltaRow = [contract: string, method: string, ...values: DeltaMetric[]]; 50 | 51 | export type FlatDeltaResult = { 52 | header: string[]; 53 | rows: DeltaRow[]; 54 | }; 55 | 56 | export function toFlatDeltaResult(deltas: DeltaResult[]): { header: string[]; rows: DeltaRow[] } { 57 | const out: FlatDeltaResult = { 58 | header: ['Contract', 'Method'], 59 | rows: [], 60 | }; 61 | const contractSet = new Set(); 62 | const methodMap = new Map>(); 63 | const metricSet = new Set(); 64 | 65 | for (const delta of deltas) { 66 | for (const [contract, methods] of Object.entries(delta.result)) { 67 | contractSet.add(contract); 68 | if (!methodMap.has(contract)) { 69 | methodMap.set(contract, new Set()); 70 | } 71 | 72 | for (const [method, metrics] of Object.entries(methods)) { 73 | methodMap.get(contract)!.add(method); 74 | 75 | for (const metric of Object.keys(metrics)) { 76 | metricSet.add(metric); 77 | } 78 | } 79 | } 80 | } 81 | const metricNames = Array.from(metricSet.values()); 82 | out.header.push(...Array.from({ length: deltas.length }, () => metricNames).flat()); 83 | 84 | for (const contract of contractSet) { 85 | const methods = methodMap.get(contract)!; 86 | for (const method of methods) { 87 | const metrics: DeltaMetric[] = []; 88 | for (const delta of deltas) { 89 | for (const metric of metricNames) { 90 | metrics.push(delta.result?.[contract]?.[method]?.[metric] ?? undefinedDeltaMetric()); 91 | } 92 | } 93 | out.rows.push([contract, method, ...metrics]); 94 | } 95 | } 96 | return out; 97 | } 98 | 99 | const aggregateTarget = ['cells', 'bits', 'gasUsed']; 100 | const statusTarget = ['success', 'exitCode', 'resultCode']; 101 | const deltaTarget = [ 102 | ...aggregateTarget, 103 | ...statusTarget, 104 | 'vmSteps', 105 | 'totalActions', 106 | 'skippedActions', 107 | 'totalActionFees', 108 | 'data', 109 | 'code', 110 | 'state', 111 | 'message', 112 | 'in', 113 | 'out', 114 | 'execute', 115 | 'compute', 116 | 'action', 117 | ]; 118 | 119 | /** 120 | * Recursively collects the sum of all numeric fields named `needle` within an arbitrary data structure 121 | */ 122 | function sumDeep(source: unknown, needle: string): number { 123 | if (source === null || typeof source !== 'object') { 124 | return 0; 125 | } 126 | 127 | if (Array.isArray(source)) { 128 | return source.map((item) => sumDeep(item, needle)).reduce((total, current) => total + current, 0); 129 | } 130 | 131 | let total = 0; 132 | for (const [key, value] of Object.entries(source as Record)) { 133 | if (key === needle && typeof value === 'number') { 134 | total += value; 135 | continue; 136 | } 137 | total += sumDeep(value, needle); 138 | } 139 | 140 | return total; 141 | } 142 | 143 | export function aggregatedCompareMetric(before: Metric, after: Metric, basePath: string[] = []): ListDelta { 144 | const out: ListDelta = {}; 145 | const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); 146 | keys.forEach((key) => { 147 | if (!deltaTarget.includes(key)) return; 148 | 149 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 150 | const prev = (before as any)[key]; 151 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 152 | const next = (after as any)[key]; 153 | const path = [...basePath, key]; 154 | 155 | if (prev === undefined || next === undefined) return; 156 | 157 | if (typeof prev === 'object' && typeof next === 'object') { 158 | Object.assign(out, aggregatedCompareMetric(prev, next, path)); 159 | aggregateTarget.forEach((aggKey) => { 160 | const beforeSum = sumDeep(prev, aggKey); 161 | const afterSum = sumDeep(next, aggKey); 162 | const aggPath = [...path, aggKey].join('.'); 163 | out[aggPath] = { 164 | kind: beforeSum > afterSum ? 'decrease' : beforeSum < afterSum ? 'increase' : 'same', 165 | path: aggPath, 166 | before: beforeSum, 167 | after: afterSum, 168 | }; 169 | }); 170 | return; 171 | } 172 | 173 | if (typeof prev === 'number' && typeof next === 'number') { 174 | const fullPath = path.join('.'); 175 | out[fullPath] = { 176 | kind: prev > next ? 'decrease' : prev < next ? 'increase' : 'same', 177 | path: fullPath, 178 | before: prev, 179 | after: next, 180 | }; 181 | } 182 | }); 183 | 184 | if (basePath.length === 0) { 185 | aggregateTarget.forEach((aggKey) => { 186 | const beforeSum = sumDeep(before, aggKey); 187 | const afterSum = sumDeep(after, aggKey); 188 | const rootPath = `root.${aggKey}`; 189 | out[rootPath] = { 190 | kind: beforeSum > afterSum ? 'decrease' : beforeSum < afterSum ? 'increase' : 'same', 191 | path: rootPath, 192 | before: beforeSum, 193 | after: afterSum, 194 | }; 195 | }); 196 | } 197 | return out; 198 | } 199 | 200 | function prepareItemDelta(item?: ItemDelta, calcDelta = true): DeltaMetric { 201 | const out: DeltaMetric = undefinedDeltaMetric(); 202 | if (!item) { 203 | return out; 204 | } 205 | out.kind = calcDelta ? item.kind : 'init'; 206 | out.value = item.after.toString(); 207 | if (calcDelta) { 208 | let change = item.kind === 'increase' ? ' +' : ' '; 209 | if (item.kind === 'same') { 210 | change += 'same'; 211 | } else if (item.before === 0) { 212 | change += '100.00%'; 213 | } else { 214 | change += (((item.after - item.before) / item.before) * 100).toFixed(2) + '%'; 215 | } 216 | out.value += change; 217 | } 218 | return out; 219 | } 220 | 221 | export function prepareDelta(pair: { after: SnapshotMetric; before?: SnapshotMetric }): DeltaResult { 222 | const result: ContractDelta = {}; 223 | const out: DeltaResult = { 224 | label: pair.after.label, 225 | createdAt: pair.after.createdAt, 226 | result, 227 | }; 228 | 229 | const beforeMap = new Map>(); 230 | if (pair.before) { 231 | for (const b of pair.before.items) { 232 | const contractKey = b.contractName || b.address; 233 | const methodKey = b.methodName || b.opCode; 234 | if (!beforeMap.has(contractKey)) { 235 | beforeMap.set(contractKey, new Map()); 236 | } 237 | beforeMap.get(contractKey)!.set(methodKey, b); 238 | } 239 | } 240 | 241 | for (const a of pair.after.items) { 242 | const contractKey = a.contractName || a.address; 243 | const methodKey = a.methodName || a.opCode; 244 | 245 | const b = beforeMap.get(contractKey)?.get(methodKey); // может быть undefined 246 | const calcDelta = !!b; 247 | 248 | if (!result[contractKey]) { 249 | result[contractKey] = {}; 250 | } 251 | 252 | const item: Record = {}; 253 | result[contractKey][methodKey] = item; 254 | 255 | const delta = aggregatedCompareMetric(b || a, a); 256 | item.gasUsed = prepareItemDelta(delta['execute.compute.gasUsed'], calcDelta); 257 | item.cells = prepareItemDelta(delta['state.cells'], calcDelta); 258 | item.bits = prepareItemDelta(delta['state.bits'], calcDelta); 259 | } 260 | 261 | return out; 262 | } 263 | 264 | export function makeGasReport(list: SnapshotMetric[]): Array { 265 | list = (list || []).sort(sortByCreatedAt()); 266 | const out: DeltaResult[] = []; 267 | if (list.length === 0) return out; 268 | for (let i = 0; i < list.length; i++) { 269 | const after = list[i]; 270 | const before = i > 0 ? list[i - 1] : undefined; 271 | out.push(prepareDelta({ after, before })); 272 | } 273 | return out; 274 | } 275 | -------------------------------------------------------------------------------- /src/metric/fixtures/data.fixture.ts: -------------------------------------------------------------------------------- 1 | import { beginCell, toNano } from '@ton/core'; 2 | 3 | import { makeSnapshotMetric, Metric, SnapshotMetric } from '../collectMetric'; 4 | import { Blockchain } from '../../blockchain/Blockchain'; 5 | 6 | // eslint-disable-next-line no-undef 7 | export const itIf = (condition: boolean) => (condition ? it : it.skip); 8 | 9 | export const zero: Metric = { 10 | address: 'EQBGhqLAZseEqRXz4ByFPTGV7SVMlI4hrbs-Sps_Xzx01x8G', 11 | opCode: '0x0', 12 | execute: { 13 | compute: { 14 | type: 'vm', 15 | success: true, 16 | gasUsed: 0, 17 | exitCode: 0, 18 | vmSteps: 0, 19 | }, 20 | action: { 21 | success: true, 22 | totalActions: 0, 23 | skippedActions: 0, 24 | resultCode: 0, 25 | totalActionFees: 0, 26 | totalFwdFees: 0, 27 | totalMessageSize: { 28 | cells: 0, 29 | bits: 0, 30 | }, 31 | }, 32 | }, 33 | message: { 34 | in: { 35 | cells: 0, 36 | bits: 0, 37 | }, 38 | out: { 39 | cells: 0, 40 | bits: 0, 41 | }, 42 | }, 43 | state: { 44 | code: { 45 | cells: 0, 46 | bits: 0, 47 | }, 48 | data: { 49 | cells: 0, 50 | bits: 0, 51 | }, 52 | }, 53 | }; 54 | 55 | export const one: Metric = { 56 | address: 'EQBGhqLAZseEqRXz4ByFPTGV7SVMlI4hrbs-Sps_Xzx01x8G', 57 | opCode: '0x0', 58 | execute: { 59 | compute: { 60 | type: 'vm', 61 | success: true, 62 | gasUsed: 1, 63 | exitCode: 0, 64 | vmSteps: 1, 65 | }, 66 | action: { 67 | success: true, 68 | totalActions: 1, 69 | skippedActions: 1, 70 | resultCode: 0, 71 | totalActionFees: 1, 72 | totalFwdFees: 1, 73 | totalMessageSize: { 74 | cells: 1, 75 | bits: 1, 76 | }, 77 | }, 78 | }, 79 | message: { 80 | in: { 81 | cells: 1, 82 | bits: 1, 83 | }, 84 | out: { 85 | cells: 1, 86 | bits: 1, 87 | }, 88 | }, 89 | state: { 90 | code: { 91 | cells: 1, 92 | bits: 1, 93 | }, 94 | data: { 95 | cells: 1, 96 | bits: 1, 97 | }, 98 | }, 99 | }; 100 | 101 | const oneFirst = makeSnapshotMetric([one], { label: 'one first' }); 102 | oneFirst.createdAt = new Date('2009-01-03T00:00:00Z'); 103 | const zeroFirst = makeSnapshotMetric([zero], { label: 'zero first' }); 104 | zeroFirst.createdAt = new Date('2009-01-03T00:00:00Z'); 105 | const oneSecond = makeSnapshotMetric([one], { label: 'one second' }); 106 | oneSecond.createdAt = new Date('2009-01-03T00:00:01Z'); 107 | const zeroSecond = makeSnapshotMetric([zero], { label: 'zero second' }); 108 | zeroSecond.createdAt = new Date('2009-01-03T00:00:01Z'); 109 | 110 | export { oneFirst, zeroFirst, oneSecond, zeroSecond }; 111 | 112 | export const simpleSnapshot: SnapshotMetric = { 113 | label: 'simple', 114 | createdAt: new Date(), 115 | items: [zero], 116 | }; 117 | 118 | export async function simpleCase() { 119 | const blockchain = await Blockchain.create(); 120 | const [alice, bob] = await blockchain.createWallets(2); 121 | await alice.send({ 122 | to: bob.address, 123 | value: toNano('1'), 124 | body: beginCell().storeUint(0xdeadface, 32).endCell(), 125 | }); 126 | await bob.send({ 127 | to: alice.address, 128 | value: toNano('1'), 129 | body: beginCell().storeUint(0xffffffff, 32).endCell(), 130 | }); 131 | } 132 | -------------------------------------------------------------------------------- /src/metric/gasReportTable.spec.ts: -------------------------------------------------------------------------------- 1 | import { gasReportTable } from './gasReportTable'; 2 | import { oneFirst, oneSecond, zeroFirst } from './fixtures/data.fixture'; 3 | import { DeltaResult, makeGasReport } from './deltaResult'; 4 | import { sampleList } from './fixtures/snapshot.fixture'; 5 | import complexList from './fixtures/complex.fixture'; 6 | 7 | describe('gasReportTable', () => { 8 | it.each([ 9 | { list: new Array() }, 10 | { 11 | list: [ 12 | { 13 | label: 'empty', 14 | createdAt: new Date('2009-01-03T00:00:00Z'), 15 | result: {}, 16 | } as DeltaResult, 17 | ], 18 | }, 19 | { 20 | list: [ 21 | { 22 | label: 'empty', 23 | createdAt: new Date('2009-01-03T00:00:00Z'), 24 | result: { contractName: {} }, 25 | }, 26 | ], 27 | }, 28 | ])('empty data', (data) => { 29 | const actual = gasReportTable(data.list); 30 | expect(actual).toEqual('No data available'); 31 | }); 32 | 33 | it('table single result', () => { 34 | const actual = gasReportTable(makeGasReport([oneFirst])); 35 | expect(actual).toMatchSnapshot(); 36 | }); 37 | 38 | it('table delta result same', () => { 39 | const actual = gasReportTable(makeGasReport([oneSecond, oneFirst])); 40 | expect(actual).toMatchSnapshot(); 41 | }); 42 | 43 | it('table delta result increase', () => { 44 | const actual = gasReportTable(makeGasReport([oneSecond, zeroFirst])); 45 | expect(actual).toMatchSnapshot(); 46 | }); 47 | 48 | it('table sample', () => { 49 | const actual = gasReportTable(makeGasReport(sampleList)); 50 | expect(actual).toMatchSnapshot(); 51 | }); 52 | 53 | it('table complex', () => { 54 | const actual = gasReportTable(makeGasReport(complexList)); 55 | expect(actual).toMatchSnapshot(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/metric/gasReportTable.ts: -------------------------------------------------------------------------------- 1 | import { table, TableUserConfig, ColumnUserConfig, SpanningCellConfig } from 'table'; 2 | 3 | import { ColorDelta, DeltaResult, toFlatDeltaResult } from './deltaResult'; 4 | import { sortByCreatedAt } from './collectMetric'; 5 | 6 | const gapCellWidth = 1; 7 | const maxCellWidth = 25; 8 | const border = { 9 | topBody: '─', 10 | topJoin: '┬', 11 | topLeft: '┌', 12 | topRight: '┐', 13 | bottomBody: '─', 14 | bottomJoin: '┴', 15 | bottomLeft: '└', 16 | bottomRight: '┘', 17 | bodyLeft: '│', 18 | bodyRight: '│', 19 | bodyJoin: '│', 20 | joinBody: '─', 21 | joinLeft: '├', 22 | joinRight: '┤', 23 | joinJoin: '┼', 24 | }; 25 | 26 | function wrap(value: string, max: number) { 27 | if (value.length > max) { 28 | return `${value.slice(0, max - 3)}...`; 29 | } 30 | return value; 31 | } 32 | 33 | function prepareResult(list: DeltaResult[], color?: ColorDelta) { 34 | list = list.sort(sortByCreatedAt(true)); 35 | 36 | const flat = toFlatDeltaResult(list); 37 | const rows: string[][] = []; 38 | const widthCols: number[] = []; 39 | 40 | for (const [contract, method, ...values] of flat.rows) { 41 | const row: string[] = [wrap(contract, maxCellWidth), wrap(method, maxCellWidth)]; 42 | widthCols[0] = Math.max(widthCols[0] ?? 0, row[0].length); 43 | widthCols[1] = Math.max(widthCols[1] ?? 0, row[1].length); 44 | values.forEach((metric, idx) => { 45 | const value = color ? color(metric) : metric.value; 46 | row.push(value); 47 | const colIdx = idx + 2; 48 | widthCols[colIdx] = Math.max(widthCols[colIdx] ?? 0, metric.value.length); 49 | }); 50 | rows.push(row); 51 | } 52 | const headers = flat.header; 53 | for (let i = 0; i < headers.length; i++) { 54 | widthCols[i] = Math.max(widthCols[i] ?? 0, headers[i].length + gapCellWidth); 55 | } 56 | const groupIndex: Record = {}; 57 | let current = 0; 58 | while (current < rows.length) { 59 | const contract = rows[current][0]; 60 | let count = 1; 61 | while (rows[current + count]?.[0] === contract) { 62 | count++; 63 | } 64 | groupIndex[contract] = { index: current, size: count }; 65 | current += count; 66 | } 67 | 68 | return { 69 | labels: list.map((s) => s.label), 70 | headers, 71 | widthCols, 72 | rows, 73 | groupIndex, 74 | }; 75 | } 76 | 77 | export function gasReportTable(list: DeltaResult[], color?: ColorDelta) { 78 | const result = prepareResult(list, color); 79 | if (result.rows.length < 1) { 80 | return 'No data available'; 81 | } 82 | const columns: ColumnUserConfig[] = []; 83 | for (let i = 0; i < result.headers.length; i++) { 84 | columns.push({ alignment: 'center', verticalAlignment: 'middle', width: result.widthCols[i] }); 85 | } 86 | const spanningCells: SpanningCellConfig[] = [ 87 | { col: 0, row: 0, rowSpan: 2, verticalAlignment: 'middle' }, // Contract title 88 | { col: 1, row: 0, rowSpan: 2, verticalAlignment: 'middle' }, // Method title 89 | ]; 90 | for (const group of Object.values(result.groupIndex)) { 91 | // rowSpan for Contract name 92 | spanningCells.push({ col: 0, row: group.index + 2, rowSpan: group.size, verticalAlignment: 'middle' }); 93 | } 94 | const data: string[][] = [[], ['', '']]; 95 | data[0].push(...result.headers.slice(0, 2)); 96 | data[1].push(...result.headers.slice(2)); 97 | const metricCount = (result.headers.length - 2) / list.length; 98 | let labelTitleOffset = 2; 99 | for (const label of result.labels) { 100 | spanningCells.push({ 101 | col: labelTitleOffset, 102 | row: 0, 103 | colSpan: metricCount, 104 | verticalAlignment: 'middle', 105 | }); 106 | labelTitleOffset += metricCount; 107 | data[0].push(...[label, ...Array.from({ length: metricCount - 1 }, () => '')]); 108 | } 109 | data.push(...result.rows); 110 | const config: TableUserConfig = { 111 | columns, 112 | spanningCells, 113 | border, 114 | }; 115 | return table(data, config); 116 | } 117 | -------------------------------------------------------------------------------- /src/metric/index.ts: -------------------------------------------------------------------------------- 1 | export * from './collectMetric'; 2 | export * from './ContractDatabase'; 3 | export * from './defaultColor'; 4 | export * from './deltaResult'; 5 | export * from './gasReportTable'; 6 | export * from './readSnapshots'; 7 | -------------------------------------------------------------------------------- /src/metric/readSnapshots.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | import { simpleSnapshot } from './fixtures/data.fixture'; 4 | import { readSnapshots } from './readSnapshots'; 5 | 6 | jest.mock('fs'); 7 | 8 | const mockedFs = fs as jest.Mocked; 9 | 10 | function mockFsFiles(value: string) { 11 | mockedFs.existsSync.mockReturnValue(true); 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | mockedFs.readdirSync.mockReturnValue(['001.json'] as unknown as any); 14 | mockedFs.readFileSync.mockReturnValue(value); 15 | } 16 | 17 | describe('readSnapshots', () => { 18 | const snapshotDir = '.benchmark'; 19 | 20 | beforeEach(() => { 21 | jest.resetAllMocks(); 22 | }); 23 | 24 | it('returns empty object', async () => { 25 | mockedFs.existsSync.mockReturnValue(false); 26 | const result = await readSnapshots(snapshotDir); 27 | expect(result).toEqual({}); 28 | expect(mockedFs.existsSync).toHaveBeenCalledWith(snapshotDir); 29 | }); 30 | 31 | it('reads and parses snapshot', async () => { 32 | mockFsFiles(JSON.stringify(simpleSnapshot)); 33 | const result = await readSnapshots(snapshotDir); 34 | expect(result).toHaveProperty('simple'); 35 | expect(result.simple.name).toEqual('001.json'); 36 | expect(result.simple.content.label).toEqual('simple'); 37 | expect(result.simple.content.createdAt instanceof Date).toEqual(true); 38 | expect(result.simple.content.items.length).toEqual(1); 39 | }); 40 | 41 | it('error for not valid JSON', async () => { 42 | mockFsFiles('invalid-json'); 43 | await expect(readSnapshots(snapshotDir)).rejects.toThrow('Can not parse snapshot file: 001.json'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/metric/readSnapshots.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | import { existsSync, readdirSync, readFileSync } from 'fs'; 3 | 4 | import { SnapshotMetric, SnapshotMetricList } from './collectMetric'; 5 | 6 | export async function readSnapshots(snapshotDir: string) { 7 | const list: SnapshotMetricList = {}; 8 | if (!existsSync(snapshotDir)) { 9 | return list; 10 | } 11 | const snapshotFiles = readdirSync(snapshotDir).filter((f) => f.endsWith('.json')); 12 | for (const snapshotFile of snapshotFiles) { 13 | const data = readFileSync(join(snapshotDir, snapshotFile), 'utf-8'); 14 | try { 15 | const raw = JSON.parse(data) as Omit & { createdAt: string }; 16 | const snapshot: SnapshotMetric = { 17 | ...raw, 18 | createdAt: new Date(raw.createdAt), 19 | }; 20 | if (!list[snapshot.label]) { 21 | list[snapshot.label] = { 22 | name: snapshotFile, 23 | content: snapshot, 24 | }; 25 | } 26 | } catch (_) { 27 | throw new Error(`Can not parse snapshot file: ${snapshotFile}`); 28 | } 29 | } 30 | return list; 31 | } 32 | -------------------------------------------------------------------------------- /src/treasury/Treasury.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Address, 3 | beginCell, 4 | Cell, 5 | Contract, 6 | contractAddress, 7 | ContractProvider, 8 | Dictionary, 9 | DictionaryValue, 10 | internal, 11 | loadMessageRelaxed, 12 | MessageRelaxed, 13 | Sender, 14 | SenderArguments, 15 | SendMode, 16 | StateInit, 17 | storeMessageRelaxed, 18 | } from '@ton/core'; 19 | 20 | const DictionaryMessageValue: DictionaryValue<{ sendMode: SendMode; message: MessageRelaxed }> = { 21 | serialize(src, builder) { 22 | builder.storeUint(src.sendMode, 8); 23 | builder.storeRef(beginCell().store(storeMessageRelaxed(src.message))); 24 | }, 25 | parse(src) { 26 | let sendMode = src.loadUint(8); 27 | let message = loadMessageRelaxed(src.loadRef().beginParse()); 28 | return { sendMode, message }; 29 | }, 30 | }; 31 | 32 | export type Treasury = Sender & { 33 | address: Address; 34 | }; 35 | 36 | function senderArgsToMessageRelaxed(args: SenderArguments): MessageRelaxed { 37 | return internal({ 38 | to: args.to, 39 | value: args.value, 40 | extracurrency: args.extracurrency, 41 | init: args.init, 42 | body: args.body, 43 | bounce: args.bounce, 44 | }); 45 | } 46 | 47 | /** 48 | * @class TreasuryContract is a Wallet alternative. For additional information see {@link Blockchain#treasury} 49 | */ 50 | export class TreasuryContract implements Contract { 51 | static readonly code = Cell.fromBase64( 52 | 'te6cckEBBAEARQABFP8A9KQT9LzyyAsBAgEgAwIAWvLT/+1E0NP/0RK68qL0BNH4AH+OFiGAEPR4b6UgmALTB9QwAfsAkTLiAbPmWwAE0jD+omUe', 53 | ); 54 | 55 | static create(workchain: number, subwalletId: bigint) { 56 | return new TreasuryContract(workchain, subwalletId); 57 | } 58 | 59 | readonly address: Address; 60 | readonly init: StateInit; 61 | readonly subwalletId: bigint; 62 | 63 | constructor(workchain: number, subwalletId: bigint) { 64 | const data = beginCell().storeUint(subwalletId, 256).endCell(); 65 | this.init = { code: TreasuryContract.code, data }; 66 | this.address = contractAddress(workchain, this.init); 67 | this.subwalletId = subwalletId; 68 | } 69 | 70 | /** 71 | * Send bulk messages using one external message. 72 | * @param messages Messages to send 73 | * @param sendMode Send mode of every message 74 | */ 75 | async sendMessages(provider: ContractProvider, messages: MessageRelaxed[], sendMode?: SendMode) { 76 | let transfer = this.createTransfer({ 77 | sendMode: sendMode, 78 | messages: messages, 79 | }); 80 | await provider.external(transfer); 81 | } 82 | 83 | /** 84 | * Sends message by arguments specified. 85 | */ 86 | async send(provider: ContractProvider, args: SenderArguments) { 87 | await this.sendMessages(provider, [senderArgsToMessageRelaxed(args)], args.sendMode ?? undefined); 88 | } 89 | 90 | /** 91 | * @returns Sender 92 | */ 93 | getSender(provider: ContractProvider): Treasury { 94 | return { 95 | address: this.address, 96 | send: async (args) => { 97 | let transfer = this.createTransfer({ 98 | sendMode: args.sendMode ?? undefined, 99 | messages: [senderArgsToMessageRelaxed(args)], 100 | }); 101 | await provider.external(transfer); 102 | }, 103 | }; 104 | } 105 | 106 | /** 107 | * @returns wallet balance in nanoTONs 108 | */ 109 | async getBalance(provider: ContractProvider): Promise { 110 | return (await provider.getState()).balance; 111 | } 112 | 113 | /** 114 | * Creates transfer cell for {@link sendMessages}. 115 | */ 116 | createTransfer(args: { messages: MessageRelaxed[]; sendMode?: SendMode }) { 117 | let sendMode = SendMode.PAY_GAS_SEPARATELY; 118 | if (args.sendMode !== null && args.sendMode !== undefined) { 119 | sendMode = args.sendMode; 120 | } 121 | 122 | if (args.messages.length > 255) { 123 | throw new Error('Maximum number of messages is 255'); 124 | } 125 | let messages = Dictionary.empty(Dictionary.Keys.Int(16), DictionaryMessageValue); 126 | let index = 0; 127 | for (let m of args.messages) { 128 | messages.set(index++, { sendMode, message: m }); 129 | } 130 | 131 | return beginCell().storeUint(this.subwalletId, 256).storeDict(messages).endCell(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/utils/AsyncLock.spec.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLock } from './AsyncLock'; 2 | 3 | describe('AsyncLock', () => { 4 | it('should work', async () => { 5 | const sleep = (ms: number): Promise => 6 | new Promise((resolve) => { 7 | setTimeout(resolve, ms); 8 | }); 9 | 10 | const lock = new AsyncLock(); 11 | 12 | let events: { id: number; when: 'pre' | 'post' }[] = []; 13 | 14 | const deferredAction = async (id: number) => { 15 | await lock.with(async () => { 16 | events.push({ id, when: 'pre' }); 17 | await sleep(10); 18 | events.push({ id, when: 'post' }); 19 | }); 20 | }; 21 | 22 | const ids = [1, 2, 3]; 23 | 24 | for (const id of ids) { 25 | deferredAction(id); 26 | } 27 | 28 | await sleep(100); 29 | 30 | expect(events).toEqual( 31 | ids.flatMap((id) => [ 32 | { id, when: 'pre' }, 33 | { id, when: 'post' }, 34 | ]), 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/utils/AsyncLock.ts: -------------------------------------------------------------------------------- 1 | type Waiter = { promise: Promise; resolve: () => void }; 2 | 3 | function createWaiter(): Waiter { 4 | let resolveFn!: () => void; 5 | const promise = new Promise((res) => { 6 | resolveFn = res; 7 | }); 8 | return { promise, resolve: resolveFn }; 9 | } 10 | 11 | export class AsyncLock { 12 | #waiters: Waiter[] = []; 13 | 14 | async acquire() { 15 | const waiters = this.#waiters.map((w) => w.promise); 16 | this.#waiters.push(createWaiter()); 17 | if (waiters.length > 0) { 18 | await Promise.all(waiters); 19 | } 20 | } 21 | 22 | async release() { 23 | const waiter = this.#waiters.shift(); 24 | if (waiter !== undefined) { 25 | waiter.resolve(); 26 | } else { 27 | throw new Error('The lock is not locked'); 28 | } 29 | } 30 | 31 | async with(fn: () => Promise) { 32 | await this.acquire(); 33 | try { 34 | return await fn(); 35 | } finally { 36 | await this.release(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | // Credits: https://developer.mozilla.org/en-US/docs/Glossary/Base64#solution_2_–_rewriting_atob_and_btoa_using_typedarrays_and_utf-8 2 | 3 | function b64ToUint6(nChr: number) { 4 | return nChr > 64 && nChr < 91 5 | ? nChr - 65 6 | : nChr > 96 && nChr < 123 7 | ? nChr - 71 8 | : nChr > 47 && nChr < 58 9 | ? nChr + 4 10 | : nChr === 43 11 | ? 62 12 | : nChr === 47 13 | ? 63 14 | : 0; 15 | } 16 | 17 | export function base64Decode(sBase64: string) { 18 | const sB64Enc = sBase64.replace(/[^A-Za-z0-9+/]/g, ''); 19 | const nInLen = sB64Enc.length; 20 | const nOutLen = (nInLen * 3 + 1) >> 2; 21 | const taBytes = new Uint8Array(nOutLen); 22 | 23 | let nMod3; 24 | let nMod4; 25 | let nUint24 = 0; 26 | let nOutIdx = 0; 27 | for (let nInIdx = 0; nInIdx < nInLen; nInIdx++) { 28 | nMod4 = nInIdx & 3; 29 | nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (6 * (3 - nMod4)); 30 | if (nMod4 === 3 || nInLen - nInIdx === 1) { 31 | nMod3 = 0; 32 | while (nMod3 < 3 && nOutIdx < nOutLen) { 33 | taBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255; 34 | nMod3++; 35 | nOutIdx++; 36 | } 37 | nUint24 = 0; 38 | } 39 | } 40 | 41 | return taBytes; 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { beginCell, Cell, Dictionary } from '@ton/core'; 2 | 3 | export async function fetchConfig(network: 'mainnet' | 'testnet', maxRetries: number = 5) { 4 | let apiDomain: string; 5 | let retryLeft = maxRetries; 6 | 7 | if (network == 'testnet') { 8 | apiDomain = 'testnet.toncenter.com'; 9 | } else if (network == 'mainnet') { 10 | apiDomain = 'toncenter.com'; 11 | } else { 12 | throw new RangeError(`Unknown network: ${network}`); 13 | } 14 | 15 | const sleep = (timeout: number) => 16 | new Promise((resolve) => { 17 | setTimeout(resolve, timeout); 18 | }); 19 | 20 | const headers = new Headers(); 21 | headers.append('Accept', 'application/json'); 22 | 23 | do { 24 | try { 25 | const resp = await fetch(`https://${apiDomain}/api/v2/getConfigAll`, { 26 | method: 'GET', 27 | headers, 28 | }); 29 | 30 | const jsonResp = await resp.json(); 31 | if (jsonResp.ok) { 32 | return Cell.fromBase64(jsonResp.result.config.bytes); 33 | } else { 34 | throw new Error(JSON.stringify(jsonResp)); 35 | } 36 | } catch (e) { 37 | retryLeft--; 38 | // eslint-disable-next-line no-console 39 | console.error(`Error fetching config:${(e as Error).toString()}`); 40 | await sleep(1000); 41 | } 42 | } while (retryLeft > 0); 43 | 44 | throw new Error(`Failed to fetch config after ${maxRetries} attempts`); 45 | } 46 | 47 | export function setGlobalVersion(config: Cell, version: number, capabilites?: bigint) { 48 | const parsedConfig = Dictionary.loadDirect(Dictionary.Keys.Int(32), Dictionary.Values.Cell(), config); 49 | 50 | let changed = false; 51 | 52 | const param8 = parsedConfig.get(8); 53 | if (!param8) { 54 | throw new Error('[setGlobalVersion] parameter 8 is not found!'); 55 | } 56 | 57 | const ds = param8.beginParse(); 58 | const tag = ds.loadUint(8); 59 | const curVersion = ds.loadUint(32); 60 | 61 | const newValue = beginCell().storeUint(tag, 8); 62 | 63 | if (curVersion != version) { 64 | changed = true; 65 | } 66 | newValue.storeUint(version, 32); 67 | 68 | if (capabilites) { 69 | const curCapabilities = ds.loadUintBig(64); 70 | if (capabilites != curCapabilities) { 71 | changed = true; 72 | } 73 | newValue.storeUint(capabilites, 64); 74 | } else { 75 | newValue.storeSlice(ds); 76 | } 77 | 78 | // If any changes, serialize 79 | if (changed) { 80 | parsedConfig.set(8, newValue.endCell()); 81 | return beginCell().storeDictDirect(parsedConfig).endCell(); 82 | } 83 | 84 | return config; 85 | } 86 | -------------------------------------------------------------------------------- /src/utils/crc16.spec.ts: -------------------------------------------------------------------------------- 1 | import { crc16 } from './crc16'; 2 | 3 | describe('crc16', () => { 4 | it('should hash correctly', async () => { 5 | expect(crc16('get_seq')).toEqual(38947); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/utils/crc16.ts: -------------------------------------------------------------------------------- 1 | const TABLE = new Int16Array([ 2 | 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7, 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 3 | 0xe1ce, 0xf1ef, 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6, 0x9339, 0x8318, 0xb37b, 0xa35a, 4 | 0xd3bd, 0xc39c, 0xf3ff, 0xe3de, 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485, 0xa56a, 0xb54b, 5 | 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d, 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4, 6 | 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc, 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 7 | 0x2802, 0x3823, 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b, 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 8 | 0x1a71, 0x0a50, 0x3a33, 0x2a12, 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a, 0x6ca6, 0x7c87, 9 | 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41, 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49, 10 | 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70, 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 11 | 0x9f59, 0x8f78, 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f, 0x1080, 0x00a1, 0x30c2, 0x20e3, 12 | 0x5004, 0x4025, 0x7046, 0x6067, 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e, 0x02b1, 0x1290, 13 | 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256, 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d, 14 | 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 15 | 0xc71d, 0xd73c, 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634, 0xd94c, 0xc96d, 0xf90e, 0xe92f, 16 | 0x99c8, 0x89e9, 0xb98a, 0xa9ab, 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3, 0xcb7d, 0xdb5c, 17 | 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a, 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92, 18 | 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9, 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 19 | 0x1ce0, 0x0cc1, 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8, 0x6e17, 0x7e36, 0x4e55, 0x5e74, 20 | 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0, 21 | ]); 22 | 23 | export function crc16(data: string | Buffer) { 24 | if (!(data instanceof Buffer)) { 25 | data = Buffer.from(data); 26 | } 27 | 28 | let crc = 0; 29 | 30 | for (let index = 0; index < data.length; index++) { 31 | const byte = data[index]; 32 | crc = (TABLE[((crc >> 8) ^ byte) & 0xff] ^ (crc << 8)) & 0xffff; 33 | } 34 | 35 | return crc; 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/ec.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from '@ton/core'; 2 | 3 | export type ExtraCurrency = { 4 | [key: number]: bigint; 5 | }; 6 | 7 | export function extractEc(cc: Dictionary): ExtraCurrency { 8 | const r: ExtraCurrency = {}; 9 | for (const [k, v] of cc) { 10 | r[k] = v; 11 | } 12 | return r; 13 | } 14 | 15 | export function packEc(ec: Iterable<[number, bigint]>): Dictionary { 16 | const r: Dictionary = Dictionary.empty(Dictionary.Keys.Uint(32), Dictionary.Values.BigVarUint(5)); 17 | for (const [k, v] of ec) { 18 | r.set(k, v); 19 | } 20 | return r; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/message.ts: -------------------------------------------------------------------------------- 1 | import { Address, Cell, Dictionary, Message, StateInit } from '@ton/core'; 2 | 3 | import { ExtraCurrency, packEc } from './ec'; 4 | 5 | /** 6 | * Creates {@link Message} from params. 7 | */ 8 | export function internal(params: { 9 | from: Address; 10 | to: Address; 11 | value: bigint; 12 | body?: Cell; 13 | stateInit?: StateInit; 14 | bounce?: boolean; 15 | bounced?: boolean; 16 | ihrDisabled?: boolean; 17 | ihrFee?: bigint; 18 | forwardFee?: bigint; 19 | createdAt?: number; 20 | createdLt?: bigint; 21 | ec?: Dictionary | [number, bigint][] | ExtraCurrency; 22 | }): Message { 23 | let ecd: Dictionary | undefined = undefined; 24 | if (params.ec !== undefined) { 25 | if (Array.isArray(params.ec)) { 26 | ecd = packEc(params.ec); 27 | } else if (params.ec instanceof Dictionary) { 28 | ecd = params.ec; 29 | } else { 30 | ecd = packEc(Object.entries(params.ec).map(([k, v]) => [Number(k), v])); 31 | } 32 | } 33 | return { 34 | info: { 35 | type: 'internal', 36 | dest: params.to, 37 | src: params.from, 38 | value: { coins: params.value, other: ecd }, 39 | bounce: params.bounce ?? true, 40 | ihrDisabled: params.ihrDisabled ?? true, 41 | bounced: params.bounced ?? false, 42 | ihrFee: params.ihrFee ?? 0n, 43 | forwardFee: params.forwardFee ?? 0n, 44 | createdAt: params.createdAt ?? 0, 45 | createdLt: params.createdLt ?? 0n, 46 | }, 47 | body: params.body ?? new Cell(), 48 | init: params.stateInit, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/prettyLogTransaction.ts: -------------------------------------------------------------------------------- 1 | import { Transaction, fromNano } from '@ton/core'; 2 | 3 | /** 4 | * @param tx Transaction to create log string 5 | * @returns Transaction log string 6 | */ 7 | export function prettyLogTransaction(tx: Transaction) { 8 | let res = `${tx.inMessage?.info.src} ➡️ ${tx.inMessage?.info.dest}\n`; 9 | 10 | for (let message of tx.outMessages.values()) { 11 | if (message.info.type === 'internal') { 12 | res += ` ➡️ ${fromNano(message.info.value.coins)} 💎 ${message.info.dest}\n`; 13 | } else { 14 | res += ` ➡️ ${message.info.dest}\n`; 15 | } 16 | } 17 | 18 | return res; 19 | } 20 | 21 | /** 22 | * Log transaction using `console.log`. Logs base on result of {@link prettyLogTransaction}. 23 | * 24 | * @example Output 25 | * null ➡️ EQBGhqLAZseEqRXz4ByFPTGV7SVMlI4hrbs-Sps_Xzx01x8G 26 | * ➡️ 0.05 💎 EQC2VluVfpj2FoHNMAiDMpcMzwvjLZxxTG8ecq477RE3NvVt 27 | * 28 | * 29 | * @param txs Transactions to log 30 | */ 31 | export function prettyLogTransactions(txs: Transaction[]) { 32 | let out = ''; 33 | 34 | for (let tx of txs) { 35 | out += prettyLogTransaction(tx) + '\n\n'; 36 | } 37 | 38 | // eslint-disable-next-line no-console 39 | console.log(out); 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/printTransactionFees.spec.ts: -------------------------------------------------------------------------------- 1 | import { formatCoinsPure } from './printTransactionFees'; 2 | 3 | describe('formatCoins', () => { 4 | it('should format coins correctly', () => { 5 | expect(formatCoinsPure(1000000000n)).toBe('1'); 6 | 7 | // rounding 8 | expect(formatCoinsPure(1n)).toBe('0.000001'); 9 | expect(formatCoinsPure(1000000001n)).toBe('1.000001'); 10 | expect(formatCoinsPure(1000000100n)).toBe('1.000001'); 11 | expect(formatCoinsPure(1000001000n)).toBe('1.000001'); 12 | 13 | expect(formatCoinsPure(1999999001n)).toBe('2'); 14 | expect(formatCoinsPure(1499999001n)).toBe('1.5'); 15 | expect(formatCoinsPure(1999499001n)).toBe('1.9995'); 16 | expect(formatCoinsPure(1234567891n)).toBe('1.234568'); 17 | 18 | expect(formatCoinsPure(1n, 1)).toBe('0.1'); 19 | expect(formatCoinsPure(1n, 0)).toBe('1'); 20 | 21 | expect(formatCoinsPure(0n)).toBe('0'); 22 | expect(formatCoinsPure(0n, 1)).toBe('0'); 23 | expect(formatCoinsPure(0n, 0)).toBe('0'); 24 | 25 | expect(formatCoinsPure(1234000000n)).toBe('1.234'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/printTransactionFees.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from '@ton/core'; 2 | 3 | const decimalCount = 9; 4 | const decimal = pow10(decimalCount); 5 | 6 | function pow10(n: number): bigint { 7 | let v = 1n; 8 | for (let i = 0; i < n; i++) { 9 | v *= 10n; 10 | } 11 | return v; 12 | } 13 | 14 | export function formatCoinsPure(value: bigint, precision = 6): string { 15 | let whole = value / decimal; 16 | 17 | let frac = value % decimal; 18 | const precisionDecimal = pow10(decimalCount - precision); 19 | if (frac % precisionDecimal > 0n) { 20 | // round up 21 | frac += precisionDecimal; 22 | if (frac >= decimal) { 23 | frac -= decimal; 24 | whole += 1n; 25 | } 26 | } 27 | frac /= precisionDecimal; 28 | 29 | return `${whole.toString()}${frac !== 0n ? '.' + frac.toString().padStart(precision, '0').replace(/0+$/, '') : ''}`; 30 | } 31 | 32 | function formatCoins(value: bigint | undefined, precision = 6): string { 33 | if (value === undefined) return 'N/A'; 34 | 35 | return formatCoinsPure(value, precision) + ' TON'; 36 | } 37 | 38 | /** 39 | * Prints transaction fees. 40 | 41 | * @example Output 42 | * ┌─────────┬─────────────┬────────────────┬────────────────┬────────────────┬────────────────┬───────────────┬────────────┬────────────────┬──────────┬────────────┐ 43 | * │ (index) │ op │ valueIn │ valueOut │ totalFees │ inForwardFee │ outForwardFee │ outActions │ computeFee │ exitCode │ actionCode │ 44 | * ├─────────┼─────────────┼────────────────┼────────────────┼────────────────┼────────────────┼───────────────┼────────────┼────────────────┼──────────┼────────────┤ 45 | * │ 0 │ 'N/A' │ 'N/A' │ '1000 TON' │ '0.004007 TON' │ 'N/A' │ '0.001 TON' │ 1 │ '0.001937 TON' │ 0 │ 0 │ 46 | * │ 1 │ '0x45ab564' │ '1000 TON' │ '998.8485 TON' │ '1.051473 TON' │ '0.000667 TON' │ '0.255 TON' │ 255 │ '0.966474 TON' │ 0 │ 0 │ 47 | * │ 2 │ '0x0' │ '3.917053 TON' │ '0 TON' │ '0.00031 TON' │ '0.000667 TON' │ 'N/A' │ 0 │ '0.000309 TON' │ 0 │ 0 │ 48 | * 49 | * @param transactions List of transaction to print fees 50 | */ 51 | export function printTransactionFees(transactions: Transaction[]) { 52 | // eslint-disable-next-line no-console 53 | console.table( 54 | transactions 55 | .map((tx) => { 56 | if (tx.description.type !== 'generic') return undefined; 57 | 58 | const body = tx.inMessage?.info.type === 'internal' ? tx.inMessage?.body.beginParse() : undefined; 59 | const op = body === undefined ? 'N/A' : body.remainingBits >= 32 ? body.preloadUint(32) : 'no body'; 60 | 61 | const totalFees = formatCoins(tx.totalFees.coins); 62 | 63 | const computeFees = formatCoins( 64 | tx.description.computePhase.type === 'vm' ? tx.description.computePhase.gasFees : undefined, 65 | ); 66 | 67 | const totalFwdFees = formatCoins(tx.description.actionPhase?.totalFwdFees ?? undefined); 68 | 69 | const valueIn = formatCoins( 70 | tx.inMessage?.info.type === 'internal' ? tx.inMessage.info.value.coins : undefined, 71 | ); 72 | 73 | const valueOut = formatCoins( 74 | tx.outMessages 75 | .values() 76 | .reduce( 77 | (total, message) => 78 | total + (message.info.type === 'internal' ? message.info.value.coins : 0n), 79 | 0n, 80 | ), 81 | ); 82 | 83 | const forwardIn = formatCoins( 84 | tx.inMessage?.info.type === 'internal' ? tx.inMessage.info.forwardFee : undefined, 85 | ); 86 | 87 | return { 88 | op: typeof op === 'number' ? '0x' + op.toString(16) : op, 89 | valueIn, 90 | valueOut, 91 | totalFees: totalFees, 92 | inForwardFee: forwardIn, 93 | outForwardFee: totalFwdFees, 94 | outActions: tx.description.actionPhase?.totalActions ?? 'N/A', 95 | computeFee: computeFees, 96 | exitCode: tx.description.computePhase.type === 'vm' ? tx.description.computePhase.exitCode : 'N/A', 97 | actionCode: tx.description.actionPhase?.resultCode ?? 'N/A', 98 | }; 99 | }) 100 | .filter((v) => v !== undefined), 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/readJsonl.spec.ts: -------------------------------------------------------------------------------- 1 | import fs, { ReadStream } from 'fs'; 2 | import { Readable } from 'stream'; 3 | 4 | import { readJsonl } from './readJsonl'; 5 | 6 | function mockReadStream(chunk: string): ReadStream { 7 | const stream = new Readable({ 8 | read() { 9 | this.push(chunk); 10 | this.push(null); 11 | }, 12 | }) as unknown as ReadStream; 13 | Object.assign(stream, { 14 | close: jest.fn(), 15 | bytesRead: 0, 16 | path: 'mocked-path', 17 | pending: false, 18 | }); 19 | return stream; 20 | } 21 | 22 | jest.mock('fs'); 23 | 24 | describe('readJsonl', () => { 25 | const data = '{"foo":"bar"}\n{"foo":"quz"}\n'; 26 | const mock = fs as jest.Mocked; 27 | 28 | afterEach(() => jest.resetAllMocks()); 29 | 30 | it('should success read JSONL', async () => { 31 | mock.createReadStream.mockReturnValueOnce(mockReadStream(data)); 32 | expect(await readJsonl<{ foo: string }>('foo.jsonl')).toEqual([{ foo: 'bar' }, { foo: 'quz' }]); 33 | }); 34 | 35 | it('should throw on broken JSONL', async () => { 36 | mock.createReadStream.mockReturnValueOnce(mockReadStream(data.slice(1))); 37 | await expect(readJsonl<{ foo: string }>('foo.jsonl')).rejects.toThrowError( 38 | 'Could not parse line: "foo":"bar"}', 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/utils/readJsonl.ts: -------------------------------------------------------------------------------- 1 | import { createReadStream } from 'fs'; 2 | import { createInterface } from 'readline'; 3 | 4 | export async function readJsonl(filePath: string): Promise { 5 | const input = createReadStream(filePath, { encoding: 'utf8' }); 6 | const readLine = createInterface({ 7 | input, 8 | crlfDelay: Infinity, 9 | }); 10 | const result: T[] = []; 11 | for await (const line of readLine) { 12 | if (!line.trim()) continue; 13 | try { 14 | result.push(JSON.parse(line) as T); 15 | } catch (_) { 16 | throw new Error(`Could not parse line: ${line}`); 17 | } 18 | } 19 | return result; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/selector.ts: -------------------------------------------------------------------------------- 1 | import { crc16 } from './crc16'; 2 | 3 | export function getSelectorForMethod(methodName: string) { 4 | if (methodName === 'main') { 5 | return 0; 6 | } else if (methodName === 'recv_internal') { 7 | return 0; 8 | } else if (methodName === 'recv_external') { 9 | return -1; 10 | } else { 11 | return (crc16(methodName) & 0xffff) | 0x10000; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/testTreasurySubwalletId.ts: -------------------------------------------------------------------------------- 1 | import { sha256_sync } from '@ton/crypto'; 2 | 3 | const prefix = 'TESTSEED'; 4 | 5 | export function testSubwalletId(seed: string): bigint { 6 | return BigInt('0x' + sha256_sync(prefix + seed).toString('hex')); 7 | } 8 | -------------------------------------------------------------------------------- /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 | "removeComments": false, 12 | }, 13 | "include": [ 14 | "src/**/*" 15 | ], 16 | "exclude": [ 17 | "**/*.fixture.ts", 18 | "**/*.spec.ts", 19 | "**/test_utils/**/*" 20 | ] 21 | } 22 | --------------------------------------------------------------------------------