diff --git a/guidebook/jetton-processing/.env.example b/guidebook/jetton-processing/.env.example new file mode 100644 index 0000000..a10ea24 --- /dev/null +++ b/guidebook/jetton-processing/.env.example @@ -0,0 +1,15 @@ +# Toncoin Payment Processing Configuration + +# Network Configuration +# Set to "false" for mainnet, leave as "true" or omit for testnet +IS_TESTNET=true + +# TonCenter API Key +# Get your API key at https://toncenter.com (mainnet) or https://testnet.toncenter.com (testnet) +# Or run your own API: https://github.com/toncenter/ton-http-api +API_KEY=your_api_key_here + +# Wallet Address +# Your wallet address that will receive deposits (for single-wallet examples) +# Or your HOT wallet address (for multi-wallet examples) +WALLET_ADDRESS=UQB22lH8P_P2OitCe8UYRxpuDF5GVqCfYTL7PDz3OzbuHebu diff --git a/guidebook/jetton-processing/.gitignore b/guidebook/jetton-processing/.gitignore new file mode 100644 index 0000000..cf94e93 --- /dev/null +++ b/guidebook/jetton-processing/.gitignore @@ -0,0 +1,23 @@ +node_modules +temp +build +dist +.DS_Store +package.ts + +# VS Code +.vscode/* +.history/ +*.vsix + +# IDEA files +.idea + +# VIM +Session.vim +.vim/ + +# Other private editor folders +.nvim/ +.emacs/ +.helix/ diff --git a/guidebook/jetton-processing/.prettierignore b/guidebook/jetton-processing/.prettierignore new file mode 100644 index 0000000..378eac2 --- /dev/null +++ b/guidebook/jetton-processing/.prettierignore @@ -0,0 +1 @@ +build diff --git a/guidebook/jetton-processing/.prettierrc b/guidebook/jetton-processing/.prettierrc new file mode 100644 index 0000000..24a6660 --- /dev/null +++ b/guidebook/jetton-processing/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 120, + "tabWidth": 4, + "singleQuote": true, + "bracketSpacing": true, + "semi": true +} diff --git a/guidebook/jetton-processing/README.md b/guidebook/jetton-processing/README.md new file mode 100644 index 0000000..da4ee63 --- /dev/null +++ b/guidebook/jetton-processing/README.md @@ -0,0 +1,62 @@ +# Jetton Payment Processing Examples + +Educational TypeScript examples demonstrating jetton (token) payment processing on the TON blockchain. + +## Libraries Used + +- [@ton/ton](https://github.com/ton-org/ton) - High-level TON blockchain API client +- [@ton/core](https://github.com/ton-org/ton-core) - Core primitives for TON blockchain +- [@ton/crypto](https://github.com/ton-org/ton-crypto) - Cryptographic primitives for TON + +## Examples + +### 1. Single Wallet with Invoices (`src/deposits/jetton-invoices.ts`) + +Demonstrates accepting jetton deposits to a single wallet using unique text comments (UUIDs) to identify each payment. + +**Use case**: Payment processing where each jetton payment is tracked by a unique identifier in the transfer notification comment. + +### 2. Multi-Wallet Jetton Deposits (`src/deposits/jetton-unique-addresses.ts`) + +Demonstrates accepting jetton deposits where each user has their own unique deposit wallet with associated jetton wallets. + +**Use case**: Exchange or service where users need permanent deposit addresses for multiple jetton types. + +## Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Configure your environment: + - Copy `.env.example` to `.env` + - Set your API key and wallet address + - Configure supported jetton minters + - Choose mainnet or testnet + +3. Run an example: +```bash +# Single wallet invoices example +npm start + +# Multi-wallet example +npm run start:unique +``` + +## Development Scripts + +- `npm start` - Run the single-wallet invoices example +- `npm run start:unique` - Run the multi-wallet jetton deposits example +- `npm run build` - Type-check the project (does not produce executable output) +- `npm run format` - Format code with Prettier + +## ⚠️ Educational Use Only + +These examples are for learning purposes. Do not deploy to production without: +- Thorough security review +- Proper error handling +- Database persistence +- Monitoring and alerting +- Rate limiting and retry strategies +- Proper jetton wallet validation diff --git a/guidebook/jetton-processing/package-lock.json b/guidebook/jetton-processing/package-lock.json new file mode 100644 index 0000000..8eadedd --- /dev/null +++ b/guidebook/jetton-processing/package-lock.json @@ -0,0 +1,627 @@ +{ + "name": "jetton-processing", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jetton-processing", + "version": "1.0.0", + "dependencies": { + "@ton/core": "^0.62.0", + "@ton/crypto": "^3.3.0", + "@ton/ton": "^15.4.0" + }, + "devDependencies": { + "@types/node": "^22.17.2", + "prettier": "^3.6.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@ton/core": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@ton/core/-/core-0.62.0.tgz", + "integrity": "sha512-GCYlzzx11rSESKkiHvNy9tL8zWth+ZtUbvV29WH478FvBp8xTw24AyoigwXWNV+OLCAcnwlGhZpTpxjD3wzCwA==", + "license": "MIT", + "dependencies": { + "symbol.inspect": "1.0.1" + }, + "peerDependencies": { + "@ton/crypto": ">=3.2.0" + } + }, + "node_modules/@ton/crypto": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@ton/crypto/-/crypto-3.3.0.tgz", + "integrity": "sha512-/A6CYGgA/H36OZ9BbTaGerKtzWp50rg67ZCH2oIjV1NcrBaCK9Z343M+CxedvM7Haf3f/Ee9EhxyeTp0GKMUpA==", + "license": "MIT", + "dependencies": { + "@ton/crypto-primitives": "2.1.0", + "jssha": "3.2.0", + "tweetnacl": "1.0.3" + } + }, + "node_modules/@ton/crypto-primitives": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ton/crypto-primitives/-/crypto-primitives-2.1.0.tgz", + "integrity": "sha512-PQesoyPgqyI6vzYtCXw4/ZzevePc4VGcJtFwf08v10OevVJHVfW238KBdpj1kEDQkxWLeuNHEpTECNFKnP6tow==", + "license": "MIT", + "dependencies": { + "jssha": "3.2.0" + } + }, + "node_modules/@ton/ton": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@ton/ton/-/ton-15.4.0.tgz", + "integrity": "sha512-f19y2Rez88KZK+lv3CT3ghXi07LcToJtJhlgRSfK+3GzjdIcoW/wbmXG1ffi6fkc8W2LO5z6Q3gZaIEvNGnz6w==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.7", + "dataloader": "^2.0.0", + "symbol.inspect": "1.0.1", + "teslabot": "^1.3.0", + "zod": "^3.21.4" + }, + "peerDependencies": { + "@ton/core": ">=0.62.0 <1.0.0", + "@ton/crypto": ">=3.2.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dataloader": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.3.tgz", + "integrity": "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jssha": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", + "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/symbol.inspect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/symbol.inspect/-/symbol.inspect-1.0.1.tgz", + "integrity": "sha512-YQSL4duoHmLhsTD1Pw8RW6TZ5MaTX5rXJnqacJottr2P2LZBF/Yvrc3ku4NUpMOm8aM0KOCqM+UAkMA5HWQCzQ==", + "license": "ISC" + }, + "node_modules/teslabot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/teslabot/-/teslabot-1.5.0.tgz", + "integrity": "sha512-e2MmELhCgrgZEGo7PQu/6bmYG36IDH+YrBI1iGm6jovXkeDIGa3pZ2WSqRjzkuw2vt1EqfkZoV5GpXgqL8QJVg==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/guidebook/jetton-processing/package.json b/guidebook/jetton-processing/package.json new file mode 100644 index 0000000..9636808 --- /dev/null +++ b/guidebook/jetton-processing/package.json @@ -0,0 +1,22 @@ +{ + "name": "jetton-processing", + "version": "1.0.0", + "description": "Jetton Payment Processing Examples with TypeScript", + "scripts": { + "start": "ts-node src/deposits/jetton-invoices.ts", + "start:unique": "ts-node src/deposits/jetton-unique-addresses.ts", + "build": "tsc", + "format": "prettier --write \"src/**/*.ts\"" + }, + "dependencies": { + "@ton/core": "^0.62.0", + "@ton/ton": "^15.4.0", + "@ton/crypto": "^3.3.0" + }, + "devDependencies": { + "@types/node": "^22.17.2", + "prettier": "^3.6.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.2" + } +} diff --git a/guidebook/jetton-processing/src/deposits/jetton-invoices.ts b/guidebook/jetton-processing/src/deposits/jetton-invoices.ts new file mode 100644 index 0000000..a8d8134 --- /dev/null +++ b/guidebook/jetton-processing/src/deposits/jetton-invoices.ts @@ -0,0 +1,307 @@ +/** + * Single-wallet jetton deposits with invoice tracking + * + * Monitors a single wallet for incoming jetton transfer notifications + * and processes each deposit along with the optional text comment (UUID). + * This mirrors the Toncoin invoices example but for jetton tokens. + */ + +import { Address, Cell } from '@ton/core'; +import { createAccountSubscription } from '../subscription/account-subscription'; +import { JettonMaster, TonClient, Transaction } from '@ton/ton'; +import { loadConfig } from '../utils/config'; + +// Jetton transfer notification opcode as per TEP-74 +const TRANSFER_NOTIFICATION_OPCODE = 0x7362d09c; +// Text comment opcode +const COMMENT_OPCODE = 0; + +const LAST_PROCESSED_LT: string | undefined = undefined; +const LAST_PROCESSED_HASH: string | undefined = undefined; + +/** + * Configuration for a supported jetton + */ +interface JettonConfig { + /** Human-readable token symbol (e.g., "USDT") */ + readonly symbol: string; + /** Address of the jetton minter contract */ + readonly minterAddress: string; + /** Number of decimal places for display (e.g., 6 for USDT) */ + readonly decimals: number; + /** Minimum deposit amount in base units */ + readonly minDeposit: bigint; +} + +/** + * List of jettons this service accepts + * In production, load this from database or configuration file + */ +const SUPPORTED_JETTONS: readonly JettonConfig[] = [ + { + symbol: 'TestJetton', + minterAddress: 'UQB22lH8P_P2OitCe8UYRxpuDF5GVqCfYTL7PDz3OzbuHebu', + decimals: 6, + minDeposit: 1n, + }, + { + symbol: 'KOTE', + minterAddress: 'UQB22lH8P_P2OitCe8UYRxpuDF5GVqCfYTL7PDz3OzbuHebu', + decimals: 9, + minDeposit: 1n, + }, +] as const; + +/** + * Maps a jetton wallet address to its configuration + */ +interface JettonWalletInfo { + readonly config: JettonConfig; + readonly jettonWalletAddress: Address; +} + +/** + * Parsed jetton transfer notification + */ +interface TransferNotification { + readonly queryId: bigint; + readonly amount: bigint; + readonly sender: Address; + readonly comment: string | undefined; +} + +/** + * Information about a jetton deposit + */ +interface JettonDepositInfo { + readonly jettonSymbol: string; + readonly amount: bigint; + readonly jettonWalletAddress: string; + readonly senderAddress: string; + readonly comment: string; + readonly queryId: string; + readonly txHash: string; + readonly txLt: string; + readonly timestamp: Date; +} + +/** + * Decodes a jetton transfer notification message body + * Per TEP-74: https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md + * + * @param body - Message body cell + * @returns Parsed notification or null if invalid + */ +function decodeTransferNotification(body: Cell): TransferNotification | null { + try { + const slice = body.beginParse(); + const opcode = slice.loadUint(32); + + if (opcode !== TRANSFER_NOTIFICATION_OPCODE) { + return null; + } + + const queryId = slice.loadUintBig(64); + const amount = slice.loadCoins(); + const sender = slice.loadAddress(); + const payloadInRef = slice.loadBit(); + const payloadSlice = payloadInRef ? slice.loadRef().beginParse() : slice; + + let comment: string | undefined; + try { + const payloadOpcode = payloadSlice.loadUint(32); + if (payloadOpcode === COMMENT_OPCODE) { + comment = payloadSlice.loadStringTail(); + } + } catch (error) { + // Comment parsing failed - not critical, deposit is still valid + if (error instanceof Error && !error.message.includes('slice')) { + console.warn('Unexpected error parsing comment:', error); + } + } + + return { + queryId, + amount, + sender, + comment, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('slice')) { + // Expected: malformed message + return null; + } + // Unexpected error - log and rethrow + console.error('Unexpected error decoding jetton transfer notification:', error); + throw error; + } +} + +/** + * Extracts jetton deposit information from a transaction + */ +function extractJettonDeposit( + tx: Transaction, + jettonWalletMap: ReadonlyMap, +): JettonDepositInfo | null { + const inMessage = tx.inMessage; + if (!inMessage || inMessage.info.type !== 'internal' || !inMessage.info.src || !inMessage.body) { + return null; + } + + const source = inMessage.info.src; + const jettonWalletInfo = jettonWalletMap.get(source.toRawString()); + + if (!jettonWalletInfo) { + return null; + } + + const notification = decodeTransferNotification(inMessage.body); + if (!notification) { + return null; + } + + if (notification.amount < jettonWalletInfo.config.minDeposit) { + console.log(`Deposit below minimum threshold for ${jettonWalletInfo.config.symbol}, ignoring`); + return null; + } + + return { + jettonSymbol: jettonWalletInfo.config.symbol, + amount: notification.amount, + jettonWalletAddress: source.toRawString(), + senderAddress: notification.sender.toRawString(), + comment: notification.comment ?? 'no comment', + queryId: notification.queryId.toString(), + txHash: tx.hash().toString('base64'), + txLt: tx.lt.toString(), + timestamp: new Date(tx.now * 1000), + }; +} + +/** + * Transaction handler - processes each transaction + */ +async function onTransaction(tx: Transaction, jettonWalletMap: ReadonlyMap): Promise { + const depositInfo = extractJettonDeposit(tx, jettonWalletMap); + + if (!depositInfo) { + return; + } + + console.log('\n=== Jetton Deposit Detected ==='); + console.log(`Jetton: ${depositInfo.jettonSymbol}`); + console.log(`Amount: ${depositInfo.amount.toString()}`); + console.log(`Jetton wallet: ${depositInfo.jettonWalletAddress}`); + console.log(`Sender: ${depositInfo.senderAddress}`); + console.log(`Comment/UUID: ${depositInfo.comment}`); + console.log(`Query ID: ${depositInfo.queryId}`); + console.log(`Transaction hash: ${depositInfo.txHash}`); + console.log(`Transaction LT: ${depositInfo.txLt}`); + console.log(`Timestamp: ${depositInfo.timestamp.toISOString()}`); + console.log('===============================\n'); + + // In production: + // 1. Find the payment in your database by the UUID (comment) + // 2. Verify that the payment hasn't been processed yet + // 3. Check that the amount matches what was expected + // 4. Mark the payment as processed in your database + // 5. Credit the user's account + // + // Example with database: + // const payment = await db.findPaymentByUUID(depositInfo.comment); + // if (!payment) { + // console.log('Unknown payment UUID'); + // return; + // } + // if (payment.processed) { + // console.log('Payment already processed'); + // return; + // } + // if (payment.expectedAmount !== depositInfo.amount) { + // console.log('Amount mismatch'); + // return; + // } + // await db.markPaymentAsProcessed(payment.id, depositInfo.txHash); + // await db.creditUserAccount(payment.userId, depositInfo.amount); +} + +/** + * Resolves jetton wallet addresses for the owner + * Maps jettonWalletAddress -> JettonWalletInfo + */ +async function resolveJettonWallets( + client: TonClient, + ownerAddress: Address, +): Promise> { + const result = new Map(); + + for (const config of SUPPORTED_JETTONS) { + const master = JettonMaster.create(Address.parse(config.minterAddress)); + const openedMaster = client.open(master); + const jettonWalletAddress = await openedMaster.getWalletAddress(ownerAddress); + + result.set(jettonWalletAddress.toRawString(), { + config, + jettonWalletAddress, + }); + } + + return result; +} + +/** + * Main function + */ +async function main(): Promise { + const config = loadConfig(); + + console.log('=== Jetton Invoices Demo ===\n'); + console.log(`Network: ${config.isTestnet ? 'TESTNET' : 'MAINNET'}`); + console.log(`Wallet: ${config.walletAddress}\n`); + + const client = new TonClient({ + endpoint: config.apiUrl, + apiKey: config.apiKey, + }); + + const ownerAddress = Address.parse(config.walletAddress); + // This is called only once + const jettonWalletMap = await resolveJettonWallets(client, ownerAddress); + + console.log('Watching jetton wallets:'); + for (const info of jettonWalletMap.values()) { + console.log(` ${info.config.symbol}: ${info.jettonWalletAddress.toRawString()}`); + } + console.log(''); + + const accountSub = createAccountSubscription( + client, + config.walletAddress, + async (tx) => onTransaction(tx, jettonWalletMap), + { + limit: 10, + lastLt: LAST_PROCESSED_LT, + lastHash: LAST_PROCESSED_HASH, + }, + ); + + const unsubscribe = await accountSub.start(10_000); + + console.log('Monitoring jetton deposits... Press Ctrl+C to stop.'); + + process.on('SIGINT', () => { + console.log('\nStopping jetton deposit monitoring...'); + const cursor = accountSub.getLastProcessed(); + console.log('Last processed cursor:', cursor); + console.log('Persist this cursor to your database for safe resumption.'); + unsubscribe(); + }); +} + +if (require.main === module) { + main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); +} diff --git a/guidebook/jetton-processing/src/deposits/jetton-unique-addresses.ts b/guidebook/jetton-processing/src/deposits/jetton-unique-addresses.ts new file mode 100644 index 0000000..64bbb82 --- /dev/null +++ b/guidebook/jetton-processing/src/deposits/jetton-unique-addresses.ts @@ -0,0 +1,349 @@ +/** + * Multi-wallet jetton deposits with unique addresses per user + * + * Each user gets their own TON wallet that holds Toncoin for fees and has associated jetton wallets for every + * supported jetton type. When a jetton transfer arrives, the deposit is logged and can be credited to the user. + * + * Note: This example demonstrates deposit tracking only. In production, implement sweeping logic to move + * jetton funds from user wallets to a cold storage wallet. + */ + +import { Address, Cell } from '@ton/core'; +import { JettonMaster, TonClient, Transaction, WalletContractV5R1, OpenedContract } from '@ton/ton'; +import { mnemonicNew, mnemonicToPrivateKey } from '@ton/crypto'; +import { createBlockSubscription } from '../subscription/block-subscription'; +import { loadConfig } from '../utils/config'; + +// Jetton transfer notification opcode as per TEP-74 +const TRANSFER_NOTIFICATION_OPCODE = 0x7362d09c; + +/** + * Configuration for a supported jetton + */ +interface JettonConfig { + /** Human-readable token symbol for display */ + readonly symbol: string; + /** Address of the jetton minter contract (unique identifier) */ + readonly minterAddress: string; + /** Number of decimal places for display */ + readonly decimals: number; + /** Minimum deposit amount in base units */ + readonly minDeposit: bigint; +} + +/** + * List of jettons this service accepts + */ +const SUPPORTED_JETTONS: readonly JettonConfig[] = [ + { + symbol: 'TestJetton', + minterAddress: 'UQB22lH8P_P2OitCe8UYRxpuDF5GVqCfYTL7PDz3OzbuHebu', + decimals: 6, + minDeposit: 1n, + }, + { + symbol: 'KOTE', + minterAddress: 'UQB22lH8P_P2OitCe8UYRxpuDF5GVqCfYTL7PDz3OzbuHebu', + decimals: 9, + minDeposit: 1n, + }, +] as const; + +/** + * User's deposit wallet with associated jetton wallet addresses + * In production, store this in a database + */ +interface DepositWallet { + readonly userId: number; + readonly tonWallet: WalletContractV5R1; + readonly publicKey: Buffer; + readonly secretKey: Buffer; // Store securely in production (HSM, KMS) + /** Maps jetton minter address -> jetton wallet address */ + readonly jettonWallets: ReadonlyMap; +} + +/** + * Maps jetton wallet address -> metadata about that wallet + */ +interface JettonWalletMetadata { + readonly minterAddress: string; + readonly userId: number; + readonly depositWallet: DepositWallet; + readonly jettonWalletAddress: Address; +} + +/** + * Parsed jetton transfer notification + */ +interface TransferNotification { + readonly queryId: bigint; + readonly amount: bigint; + readonly sender: Address; +} + +/** + * Information about a jetton deposit + */ +interface JettonDepositInfo { + readonly jettonSymbol: string; + readonly amount: bigint; + readonly jettonWalletAddress: string; + readonly userId: number; + readonly senderAddress: string; + readonly queryId: string; + readonly txHash: string; + readonly txLt: string; + readonly blockInfo: string; + readonly timestamp: Date; +} + +/** + * Creates a new deposit wallet for a user + * In production, persist this to your database + */ +async function createDepositWallet(userId: number, networkGlobalId: number, client: TonClient): Promise { + const mnemonic = await mnemonicNew(); + const keyPair = await mnemonicToPrivateKey(mnemonic); + + const tonWallet = WalletContractV5R1.create({ + walletId: { + networkGlobalId, + }, + publicKey: keyPair.publicKey, + workchain: 0, + }); + + // Resolve jetton wallet addresses for all supported jettons + const jettonWallets = new Map(); + + for (const config of SUPPORTED_JETTONS) { + const master = JettonMaster.create(Address.parse(config.minterAddress)); + const openedMaster = client.open(master); + const jettonWalletAddress = await openedMaster.getWalletAddress(tonWallet.address); + jettonWallets.set(config.minterAddress, jettonWalletAddress); + } + + return { + userId, + tonWallet, + publicKey: keyPair.publicKey, + secretKey: keyPair.secretKey, + jettonWallets, + }; +} + +/** + * Builds a reverse index from jetton wallet address to metadata + */ +function buildJettonWalletIndex(depositWallets: readonly DepositWallet[]): ReadonlyMap { + const index = new Map(); + + for (const depositWallet of depositWallets) { + for (const [minterAddress, jettonWalletAddress] of depositWallet.jettonWallets.entries()) { + index.set(jettonWalletAddress.toRawString(), { + minterAddress, + userId: depositWallet.userId, + depositWallet, + jettonWalletAddress, + }); + } + } + + return index; +} + +/** + * Decodes a jetton transfer notification message body + * Per TEP-74: https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md + */ +function decodeTransferNotification(body: Cell): TransferNotification | null { + try { + const slice = body.beginParse(); + const opcode = slice.loadUint(32); + + if (opcode !== TRANSFER_NOTIFICATION_OPCODE) { + return null; + } + + const queryId = slice.loadUintBig(64); + const amount = slice.loadCoins(); + const sender = slice.loadAddress(); + + return { + queryId, + amount, + sender, + }; + } catch (error) { + if (error instanceof Error && error.message.includes('slice')) { + // Expected: malformed message + return null; + } + // Unexpected error - log and rethrow + console.error('Unexpected error decoding jetton transfer notification:', error); + throw error; + } +} + +/** + * Processes a jetton deposit transaction + * @returns Deposit information or null if transaction should be skipped + */ +function processJettonDeposit( + tx: Transaction, + metadata: JettonWalletMetadata, + config: JettonConfig, + block: { workchain: number; shard: string; seqno: number }, +): JettonDepositInfo | null { + const inMessage = tx.inMessage; + if (!inMessage || inMessage.info.type !== 'internal' || !inMessage.body) { + return null; + } + + const notification = decodeTransferNotification(inMessage.body); + if (!notification) { + return null; + } + + if (notification.amount < config.minDeposit) { + return null; + } + + const jettonWalletAddress = inMessage.info.src.toRawString(); + const senderAddress = notification.sender.toRawString(); + + return { + jettonSymbol: config.symbol, + amount: notification.amount, + jettonWalletAddress, + userId: metadata.userId, + senderAddress, + queryId: notification.queryId.toString(), + txHash: tx.hash().toString('base64'), + txLt: tx.lt.toString(), + blockInfo: `${block.workchain}:${block.shard}:${block.seqno}`, + timestamp: new Date(tx.now * 1000), + }; +} + +/** + * Main function + */ +async function main(): Promise { + const config = loadConfig(); + const networkGlobalId = config.isTestnet ? -3 : -239; + + console.log('=== Jetton Multi-Wallet Deposits Demo ===\n'); + console.log(`Network: ${config.isTestnet ? 'TESTNET' : 'MAINNET'}`); + console.log(`HOT Wallet: ${config.walletAddress}\n`); + + const client = new TonClient({ + endpoint: config.apiUrl, + apiKey: config.apiKey, + }); + + // Create example deposit wallets for 3 users + // In production, create these when users register + console.log('Creating example deposit wallets...\n'); + const wallet1 = await createDepositWallet(101, networkGlobalId, client); + const wallet2 = await createDepositWallet(102, networkGlobalId, client); + const wallet3 = await createDepositWallet(103, networkGlobalId, client); + + const depositWallets: readonly DepositWallet[] = [wallet1, wallet2, wallet3]; + + // Display created wallets + const configByMinter = new Map(SUPPORTED_JETTONS.map((c) => [c.minterAddress, c])); + + for (const wallet of depositWallets) { + console.log(`User ${wallet.userId} TON wallet: ${wallet.tonWallet.address.toRawString()}`); + for (const [minterAddress, address] of wallet.jettonWallets.entries()) { + const config = configByMinter.get(minterAddress); + const symbol = config ? config.symbol : minterAddress.slice(0, 10); + console.log(` ${symbol} jetton wallet: ${address.toRawString()}`); + } + } + console.log('===================================\n'); + + // Build reverse index for fast lookup + const jettonWalletIndex = buildJettonWalletIndex(depositWallets); + + const masterchainInfo = await client.getMasterchainInfo(); + const startBlock = masterchainInfo.latestSeqno; + console.log(`Starting from masterchain block ${startBlock}\n`); + + // Transaction handler + const handleTransaction = async ( + tx: Transaction, + block: { workchain: number; shard: string; seqno: number }, + ): Promise => { + // outMessages check (internal message filter) + if (tx.outMessages.size > 0) { + return; + } + + const inMessage = tx.inMessage; + // not internal message check + if (!inMessage || inMessage.info.type !== 'internal') { + return; + } + + // Check if destination is a known jetton wallet + const metadata = jettonWalletIndex.get(inMessage.info.dest.toRawString()); + if (!metadata) { + return; + } + + // Get jetton configuration + const jettonConfig = configByMinter.get(metadata.minterAddress); + if (!jettonConfig) { + // This shouldn't happen if our index is built correctly + console.error(`Config not found for minter: ${metadata.minterAddress}`); + return; + } + + const depositInfo = processJettonDeposit(tx, metadata, jettonConfig, block); + if (!depositInfo) { + return; + } + + console.log('\n=== Jetton Deposit Detected ==='); + console.log(`Jetton: ${depositInfo.jettonSymbol}`); + console.log(`Amount: ${depositInfo.amount.toString()}`); + console.log(`Jetton wallet: ${depositInfo.jettonWalletAddress}`); + console.log(`User ID: ${depositInfo.userId}`); + console.log(`Sender: ${depositInfo.senderAddress}`); + console.log(`Query ID: ${depositInfo.queryId}`); + console.log(`Transaction hash: ${depositInfo.txHash}`); + console.log(`Transaction LT: ${depositInfo.txLt}`); + console.log(`Block: ${depositInfo.blockInfo}`); + console.log(`Timestamp: ${depositInfo.timestamp.toISOString()}`); + console.log('===============================\n'); + + // In production: + // 1. Mark transaction as processed in your database + // 2. Credit user's account with the deposited amount + // 3. Send notification to user + // 4. Optionally sweep jettons to a cold wallet + }; + + const blockSub = createBlockSubscription(client, startBlock, handleTransaction); + const unsubscribe = await blockSub.start(1000); + + console.log('Monitoring blockchain for jetton deposits...'); + console.log('Press Ctrl+C to stop\n'); + + process.on('SIGINT', () => { + console.log('\nShutting down...'); + unsubscribe(); + console.log(`Last processed block: ${blockSub.getLastProcessedBlock()}`); + console.log('Persist this block number to resume safely after restart.'); + console.log('Goodbye!'); + }); +} + +if (require.main === module) { + main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); + }); +} diff --git a/guidebook/jetton-processing/src/subscription/account-subscription.ts b/guidebook/jetton-processing/src/subscription/account-subscription.ts new file mode 100644 index 0000000..2f2809c --- /dev/null +++ b/guidebook/jetton-processing/src/subscription/account-subscription.ts @@ -0,0 +1,129 @@ +/** + * Account subscription (functional API) for monitoring wallet transactions + */ + +import { TonClient, Transaction } from '@ton/ton'; +import { Address } from '@ton/core'; + +const wait = (milliseconds: number): Promise => new Promise((r) => setTimeout(r, milliseconds)); + +export interface AccountSubscriptionOptions { + readonly limit?: number; + readonly lastLt?: string; + readonly lastHash?: string; + readonly archival?: boolean; +} + +export type Unsubscribe = () => void; + +export function createAccountSubscription( + client: TonClient, + accountAddress: string, + onTransaction: (tx: Transaction) => Promise, + options: AccountSubscriptionOptions = {}, +) { + const address = Address.parse(accountAddress); + const limit = options.limit ?? 10; + const useArchival = options.archival ?? true; + + let lastProcessedLt = options.lastLt; + let lastProcessedHash = options.lastHash; + let isProcessing = false; + let intervalId: NodeJS.Timeout | undefined; + + const getLastProcessed = (): { readonly lt?: string; readonly hash?: string } => ({ + lt: lastProcessedLt, + hash: lastProcessedHash, + }); + + const isAlreadyProcessed = (tx: Transaction): boolean => { + if (!lastProcessedLt || !lastProcessedHash) return false; + const currentLt = tx.lt.toString(); + const currentHash = tx.hash().toString('base64'); + return currentLt === lastProcessedLt && currentHash === lastProcessedHash; + }; + + const fetchNewTransactions = async ( + offsetLt?: string, + offsetHash?: string, + attemptNumber: number = 1, + ): Promise => { + if (offsetLt && offsetHash) { + console.log(`Fetching ${limit} transactions before LT:${offsetLt} Hash:${offsetHash}`); + } else { + console.log(`Fetching last ${limit} transactions`); + } + + let transactions: Transaction[]; + try { + transactions = await client.getTransactions(address, { + limit, + lt: offsetLt, + hash: offsetHash, + archival: useArchival, + }); + } catch (error) { + console.error(`API error (attempt ${attemptNumber}/3):`, error); + if (attemptNumber < 3) { + const delayMs = Math.min(1000 * 2 ** (attemptNumber - 1), 10000); + console.log(`Retrying in ${delayMs}ms...`); + await wait(delayMs); + return fetchNewTransactions(offsetLt, offsetHash, attemptNumber + 1); + } + console.error('Failed to fetch transactions after 3 attempts, will retry on next poll cycle'); + return []; + } + + console.log(`Received ${transactions.length} transactions`); + if (transactions.length === 0) return []; + + const newTransactions: Transaction[] = []; + for (const tx of transactions) { + if (isAlreadyProcessed(tx)) return newTransactions; + newTransactions.push(tx); + } + + if (transactions.length === limit) { + const lastTx = transactions[transactions.length - 1]; + const older = await fetchNewTransactions(lastTx.lt.toString(), lastTx.hash().toString('base64'), 1); + return [...newTransactions, ...older]; + } + return newTransactions; + }; + + const tick = async () => { + if (isProcessing) return; + isProcessing = true; + try { + const newTransactions = await fetchNewTransactions(); + if (newTransactions.length > 0) { + const ordered = newTransactions.reverse(); + for (const tx of ordered) { + await onTransaction(tx); + lastProcessedLt = tx.lt.toString(); + lastProcessedHash = tx.hash().toString('base64'); + console.log(`Updated cursor to lt:${lastProcessedLt} hash:${lastProcessedHash}`); + } + } + } catch (error) { + console.error('Error in transaction polling:', error); + } finally { + isProcessing = false; + } + }; + + const stop: Unsubscribe = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = undefined; + } + }; + + const start = async (intervalMs: number = 10000): Promise => { + await tick(); + intervalId = setInterval(tick, intervalMs); + return stop; + }; + + return { start, stop, getLastProcessed }; +} diff --git a/guidebook/jetton-processing/src/subscription/block-subscription.ts b/guidebook/jetton-processing/src/subscription/block-subscription.ts new file mode 100644 index 0000000..33b8218 --- /dev/null +++ b/guidebook/jetton-processing/src/subscription/block-subscription.ts @@ -0,0 +1,90 @@ +/** + * Block subscription (functional API) for monitoring blockchain transactions + * Emits all transactions from new blocks using the native @ton/ton API. + */ + +import { TonClient, Transaction } from '@ton/ton'; + +export type Unsubscribe = () => void; + +export function createBlockSubscription( + client: TonClient, + startBlock: number, + onTransaction: (tx: Transaction, block: { workchain: number; shard: string; seqno: number }) => Promise, +) { + let lastProcessedBlock = startBlock; + let isProcessing = false; + let intervalId: NodeJS.Timeout | undefined; + + const getLastProcessedBlock = (): number => lastProcessedBlock; + + const processShard = async (block: { workchain: number; shard: string; seqno: number }): Promise => { + const blockKey = `${block.workchain}:${block.shard}:${block.seqno}`; + console.log(` Processing block ${blockKey}`); + try { + const transactions = await client.getShardTransactions(block.workchain, block.seqno, block.shard); + for (const shortTx of transactions) { + const fullTx = await client.getTransaction(shortTx.account, shortTx.lt, shortTx.hash); + if (fullTx) { + await onTransaction(fullTx, block); + } + } + return transactions.length; + } catch (error) { + console.error(`Error processing block ${blockKey}:`, error); + return 0; + } + }; + + const processBlock = async (seqno: number): Promise => { + const masterchainShard = '8000000000000000'; + let processedCount = 0; + processedCount += await processShard({ workchain: -1, shard: masterchainShard, seqno }); + const shards = await client.getWorkchainShards(seqno); + for (const shard of shards) { + processedCount += await processShard(shard); + } + return processedCount; + }; + + const processNextBlock = async (): Promise => { + const masterchainInfo = await client.getMasterchainInfo(); + const targetSeqno = masterchainInfo.latestSeqno; + if (targetSeqno <= lastProcessedBlock) { + return; + } + for (let nextSeqno = lastProcessedBlock + 1; nextSeqno <= targetSeqno; nextSeqno += 1) { + const totalCount = await processBlock(nextSeqno); + console.log(`✓ Processed masterchain block ${nextSeqno} (${totalCount} transactions)`); + lastProcessedBlock = nextSeqno; + // Persist lastProcessedBlock in durable storage for production use. + } + }; + + const tick = async () => { + if (isProcessing) return; + isProcessing = true; + try { + await processNextBlock(); + } catch (error) { + console.error('Error processing block:', error); + } finally { + isProcessing = false; + } + }; + + const stop: Unsubscribe = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = undefined; + } + }; + + const start = async (intervalMs: number = 1000): Promise => { + await tick(); + intervalId = setInterval(tick, intervalMs); + return stop; + }; + + return { start, stop, getLastProcessedBlock }; +} diff --git a/guidebook/jetton-processing/src/utils/config.ts b/guidebook/jetton-processing/src/utils/config.ts new file mode 100644 index 0000000..3668116 --- /dev/null +++ b/guidebook/jetton-processing/src/utils/config.ts @@ -0,0 +1,42 @@ +/** + * Configuration loader for Toncoin payment processing + * + * Loads configuration from environment variables. + * Throws errors if required configuration is missing. + */ + +export interface Config { + readonly isTestnet: boolean; + readonly apiKey: string; + readonly walletAddress: string; + readonly apiUrl: string; +} + +/** + * Loads and validates configuration from environment variables + * @throws {Error} if required configuration is missing + */ +export function loadConfig(): Config { + const isTestnet = process.env.IS_TESTNET !== 'false'; + const apiKey = process.env.API_KEY; + const walletAddress = process.env.WALLET_ADDRESS; + + if (!apiKey) { + throw new Error('API_KEY environment variable is required. Get your key at https://toncenter.com'); + } + + if (!walletAddress) { + throw new Error('WALLET_ADDRESS environment variable is required'); + } + + const apiUrl = isTestnet + ? 'https://testnet.toncenter.com/api/v2/jsonRPC' + : 'https://toncenter.com/api/v2/jsonRPC'; + + return { + isTestnet, + apiKey, + walletAddress, + apiUrl, + }; +} diff --git a/guidebook/jetton-processing/tsconfig.json b/guidebook/jetton-processing/tsconfig.json new file mode 100644 index 0000000..c5df979 --- /dev/null +++ b/guidebook/jetton-processing/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "outDir": "dist", + "rootDir": "src", + "module": "commonjs", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "lib": ["ES2020"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}