From 273f9c2bb6377a6f9938cc8756cc37a3364e5f07 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 26 Mar 2026 12:02:09 -0700 Subject: [PATCH] feat: scaffold wasm-ton package with address, parsing, signing, and intent building Implements @bitgo/wasm-ton with full support for: - Address encoding/decoding/validation (V4R2 wallet addresses) - Transaction parsing (native transfers, jetton TEP-74, whales staking, single nominator) - Transaction signing (signable payload extraction, signature placement) - Intent-based building for all 5 intent types: - payment (native + jetton) - fillNonce (native + jetton) - consolidate (native + jetton) - delegate (TON Whales, Single Nominator, Multi Nominator, vesting) - undelegate (TON Whales, Single Nominator, Multi Nominator, vesting) Rust crates: tlb + tlb-ton + ton-contracts (pure Rust, WASM-compatible) 52 Rust unit tests, 63 TypeScript tests --- package-lock.json | 45 +- packages/wasm-ton/.gitignore | 10 + packages/wasm-ton/.mocharc.json | 5 + packages/wasm-ton/Cargo.lock | 888 ++++++++++++++++++ packages/wasm-ton/Cargo.toml | 51 + packages/wasm-ton/Makefile | 70 ++ packages/wasm-ton/js/address.ts | 60 ++ packages/wasm-ton/js/builder.ts | 227 +++++ packages/wasm-ton/js/index.ts | 26 + packages/wasm-ton/js/parser.ts | 95 ++ packages/wasm-ton/js/transaction.ts | 120 +++ packages/wasm-ton/package.json | 59 ++ packages/wasm-ton/src/address.rs | 217 +++++ packages/wasm-ton/src/builder/build.rs | 789 ++++++++++++++++ packages/wasm-ton/src/builder/jetton.rs | 131 +++ packages/wasm-ton/src/builder/mod.rs | 14 + packages/wasm-ton/src/builder/staking.rs | 411 ++++++++ packages/wasm-ton/src/builder/transfer.rs | 135 +++ packages/wasm-ton/src/builder/types.rs | 416 ++++++++ packages/wasm-ton/src/error.rs | 72 ++ packages/wasm-ton/src/lib.rs | 26 + packages/wasm-ton/src/parser.rs | 466 +++++++++ packages/wasm-ton/src/transaction.rs | 484 ++++++++++ packages/wasm-ton/src/wasm/address.rs | 76 ++ packages/wasm-ton/src/wasm/builder.rs | 39 + packages/wasm-ton/src/wasm/mod.rs | 16 + packages/wasm-ton/src/wasm/parser.rs | 79 ++ packages/wasm-ton/src/wasm/transaction.rs | 94 ++ .../wasm-ton/src/wasm/try_into_js_value.rs | 123 +++ packages/wasm-ton/test/address.ts | 133 +++ packages/wasm-ton/test/builder.ts | 393 ++++++++ packages/wasm-ton/test/parser.ts | 139 +++ packages/wasm-ton/test/transaction.ts | 131 +++ packages/wasm-ton/tsconfig.cjs.json | 10 + packages/wasm-ton/tsconfig.json | 18 + packages/wasm-ton/tsconfig.test.json | 9 + 36 files changed, 6053 insertions(+), 24 deletions(-) create mode 100644 packages/wasm-ton/.gitignore create mode 100644 packages/wasm-ton/.mocharc.json create mode 100644 packages/wasm-ton/Cargo.lock create mode 100644 packages/wasm-ton/Cargo.toml create mode 100644 packages/wasm-ton/Makefile create mode 100644 packages/wasm-ton/js/address.ts create mode 100644 packages/wasm-ton/js/builder.ts create mode 100644 packages/wasm-ton/js/index.ts create mode 100644 packages/wasm-ton/js/parser.ts create mode 100644 packages/wasm-ton/js/transaction.ts create mode 100644 packages/wasm-ton/package.json create mode 100644 packages/wasm-ton/src/address.rs create mode 100644 packages/wasm-ton/src/builder/build.rs create mode 100644 packages/wasm-ton/src/builder/jetton.rs create mode 100644 packages/wasm-ton/src/builder/mod.rs create mode 100644 packages/wasm-ton/src/builder/staking.rs create mode 100644 packages/wasm-ton/src/builder/transfer.rs create mode 100644 packages/wasm-ton/src/builder/types.rs create mode 100644 packages/wasm-ton/src/error.rs create mode 100644 packages/wasm-ton/src/lib.rs create mode 100644 packages/wasm-ton/src/parser.rs create mode 100644 packages/wasm-ton/src/transaction.rs create mode 100644 packages/wasm-ton/src/wasm/address.rs create mode 100644 packages/wasm-ton/src/wasm/builder.rs create mode 100644 packages/wasm-ton/src/wasm/mod.rs create mode 100644 packages/wasm-ton/src/wasm/parser.rs create mode 100644 packages/wasm-ton/src/wasm/transaction.rs create mode 100644 packages/wasm-ton/src/wasm/try_into_js_value.rs create mode 100644 packages/wasm-ton/test/address.ts create mode 100644 packages/wasm-ton/test/builder.ts create mode 100644 packages/wasm-ton/test/parser.ts create mode 100644 packages/wasm-ton/test/transaction.ts create mode 100644 packages/wasm-ton/tsconfig.cjs.json create mode 100644 packages/wasm-ton/tsconfig.json create mode 100644 packages/wasm-ton/tsconfig.test.json diff --git a/package-lock.json b/package-lock.json index 7c3b894cfd6..287adfd3e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -252,6 +252,10 @@ "resolved": "packages/wasm-solana", "link": true }, + "node_modules/@bitgo/wasm-ton": { + "resolved": "packages/wasm-ton", + "link": true + }, "node_modules/@bitgo/wasm-utxo": { "resolved": "packages/wasm-utxo", "link": true @@ -3022,7 +3026,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3417,7 +3420,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -3648,7 +3650,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4771,7 +4772,6 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -5264,7 +5264,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5984,7 +5983,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8213,7 +8211,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9056,8 +9053,7 @@ "node_modules/fp-ts": { "version": "2.16.9", "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.9.tgz", - "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==", - "peer": true + "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==" }, "node_modules/fresh": { "version": "0.5.2", @@ -10516,7 +10512,6 @@ "version": "2.2.21", "resolved": "https://registry.npmjs.org/io-ts/-/io-ts-2.2.21.tgz", "integrity": "sha512-zz2Z69v9ZIC3mMLYWIeoUcwWD6f+O7yP92FMVVaXEOSZH1jnVBmET/urd/uoarD1WGBY4rCj8TAyMPzsGNzMFQ==", - "peer": true, "peerDependencies": { "fp-ts": "^2.5.0" } @@ -12557,7 +12552,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13337,7 +13331,6 @@ "version": "2.3.13", "resolved": "https://registry.npmjs.org/monocle-ts/-/monocle-ts-2.3.13.tgz", "integrity": "sha512-D5Ygd3oulEoAm3KuGO0eeJIrhFf1jlQIoEVV2DYsZUMz42j4tGxgct97Aq68+F8w4w4geEnwFa8HayTS/7lpKQ==", - "peer": true, "peerDependencies": { "fp-ts": "^2.5.0" } @@ -13641,7 +13634,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/newtype-ts/-/newtype-ts-0.3.5.tgz", "integrity": "sha512-v83UEQMlVR75yf1OUdoSFssjitxzjZlqBAjiGQ4WJaML8Jdc68LJ+BaSAXUmKY4bNzp7hygkKLYTsDi14PxI2g==", - "peer": true, "peerDependencies": { "fp-ts": "^2.0.0", "monocle-ts": "^2.0.0" @@ -16067,7 +16059,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16219,7 +16210,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -17586,7 +17576,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -18687,7 +18676,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18760,7 +18748,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -20929,7 +20916,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -21112,8 +21098,7 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/tsx": { "version": "4.20.6", @@ -21266,7 +21251,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21638,7 +21622,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21687,7 +21670,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -22368,6 +22350,21 @@ "typescript-eslint": "^8.18.2" } }, + "packages/wasm-ton": { + "name": "@bitgo/wasm-ton", + "version": "0.0.1", + "license": "MIT", + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/mocha": "^10.0.7", + "@types/node": "^22.10.5", + "eslint": "^9.17.0", + "mocha": "^10.6.0", + "tsx": "4.20.6", + "typescript": "^5.5.3", + "typescript-eslint": "^8.18.2" + } + }, "packages/wasm-utxo": { "name": "@bitgo/wasm-utxo", "version": "0.0.2", diff --git a/packages/wasm-ton/.gitignore b/packages/wasm-ton/.gitignore new file mode 100644 index 00000000000..e4e3e250717 --- /dev/null +++ b/packages/wasm-ton/.gitignore @@ -0,0 +1,10 @@ +target/ +node_modules/ +dist/ +test/*.js +test/*.d.ts +js/*.js +js/*.d.ts +js/wasm +.vscode +.onboarding/ diff --git a/packages/wasm-ton/.mocharc.json b/packages/wasm-ton/.mocharc.json new file mode 100644 index 00000000000..f585fb0eb49 --- /dev/null +++ b/packages/wasm-ton/.mocharc.json @@ -0,0 +1,5 @@ +{ + "extensions": ["ts", "tsx", "js", "jsx"], + "spec": ["test/**/*.ts"], + "node-option": ["import=tsx/esm", "experimental-wasm-modules"] +} diff --git a/packages/wasm-ton/Cargo.lock b/packages/wasm-ton/Cargo.lock new file mode 100644 index 00000000000..ddba985be11 --- /dev/null +++ b/packages/wasm-ton/Cargo.lock @@ -0,0 +1,888 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "array-util" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e509844de8f09b90a2c3444684a2b6695f4071360e13d2fda0af9f749cc2ed6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "impl-tools" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae314a99afb5821e2fda288387546d4a04aace674551e854e6216b892ec3208" +dependencies = [ + "autocfg", + "impl-tools-lib", + "proc-macro-error2", + "syn", +] + +[[package]] +name = "impl-tools-lib" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab699036df31c1f7d3561bfa6e9cb9bc3bb0fd2e2cd9bf121c31cb961d049ddf" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nacl" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30aefc44d813c51b5e7952950e87c17f2e0e1a3274d63c8281a701e05323d548" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tlb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f2233c655807eb4bc0575f0803843971f06ce98b0d3059b1eaa79a4236bd3" +dependencies = [ + "array-util", + "base64 0.21.7", + "bitvec", + "crc", + "digest", + "hex", + "impl-tools", + "sha2", + "tlbits", +] + +[[package]] +name = "tlb-ton" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26b4014128b0ccc545f3d3bf2b7e2136ee9be3ed51a3447c082a30b20d5e85f" +dependencies = [ + "base64 0.21.7", + "chrono", + "crc", + "digest", + "hex", + "impl-tools", + "lazy_static", + "num-bigint", + "num-traits", + "sha2", + "strum", + "tlb", +] + +[[package]] +name = "tlbits" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0e5a456b59a6f89eb1200fbeebf837295e7dd729e52a206d75b65503acc0a" +dependencies = [ + "array-util", + "bitvec", + "either", + "impl-tools", + "num-bigint", + "num-traits", + "rustversion", + "thiserror", +] + +[[package]] +name = "ton-contracts" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6582842a68b2be6f4dce52e790fff1b6bbd4ce615cf728139621e073760f588" +dependencies = [ + "anyhow", + "bitvec", + "chrono", + "hmac", + "lazy_static", + "nacl", + "num-bigint", + "pbkdf2", + "sha2", + "tlb-ton", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + +[[package]] +name = "wasm-ton" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "bitvec", + "chrono", + "getrandom", + "hex", + "js-sys", + "num-bigint", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tlb", + "tlb-ton", + "ton-contracts", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/wasm-ton/Cargo.toml b/packages/wasm-ton/Cargo.toml new file mode 100644 index 00000000000..cb64d92d441 --- /dev/null +++ b/packages/wasm-ton/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "wasm-ton" +version = "0.1.0" +edition = "2021" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints.clippy] +all = "warn" + +[dependencies] +# WASM bindings +wasm-bindgen = "0.2" +js-sys = "0.3" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde-wasm-bindgen = "0.6" + +# TON crates (toner ecosystem) +tlb = { version = "0.7.3", default-features = false, features = ["sha2"] } +tlb-ton = { version = "0.7.3", default-features = false } +ton-contracts = { version = "0.7.3", default-features = false, features = ["jetton", "wallet"] } + +# WASM random number generation support +getrandom = { version = "0.2", features = ["js"] } + +# BigUint for TON monetary amounts (Grams) +num-bigint = "0.4" + +# DateTime for V4R2 expire_at +chrono = { version = "0.4", default-features = false, features = ["std"] } + +# Bit-level operations for cell building +bitvec = "1" + +# Encoding +hex = "0.4" +base64 = "0.22" + +[dev-dependencies] +wasm-bindgen-test = "0.3" +hex = "0.4" + +[profile.release] +strip = true diff --git a/packages/wasm-ton/Makefile b/packages/wasm-ton/Makefile new file mode 100644 index 00000000000..6b8f1f95965 --- /dev/null +++ b/packages/wasm-ton/Makefile @@ -0,0 +1,70 @@ +WASM_PACK = wasm-pack +WASM_OPT = wasm-opt +WASM_PACK_FLAGS = --no-pack --weak-refs + +ifdef WASM_PACK_DEV + WASM_PACK_FLAGS += --dev +endif + +# Auto-detect Mac and use Homebrew LLVM for WASM compilation +# Apple's Clang doesn't support wasm32-unknown-unknown target +UNAME_S := $(shell uname -s) + +ifeq ($(UNAME_S),Darwin) + # Mac detected - check for Homebrew LLVM installation + HOMEBREW_LLVM := $(shell brew --prefix llvm 2>/dev/null) + + ifdef HOMEBREW_LLVM + export CC = $(HOMEBREW_LLVM)/bin/clang + export AR = $(HOMEBREW_LLVM)/bin/llvm-ar + $(info Using Homebrew LLVM: $(HOMEBREW_LLVM)) + else + $(warning Homebrew LLVM not found. Install with: brew install llvm) + $(warning Continuing with system clang - may fail on Apple Silicon) + endif +endif + +define WASM_PACK_COMMAND + $(WASM_PACK) build --no-opt --out-dir $(1) $(WASM_PACK_FLAGS) --target $(2) +endef + +# run wasm-opt separately so we can pass `--enable-bulk-memory` +define WASM_OPT_COMMAND + $(WASM_OPT) --enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext -Oz $(1)/*.wasm -o $(1)/*.wasm +endef + +define REMOVE_GITIGNORE + find $(1) -name .gitignore -delete +endef + +define SHOW_WASM_SIZE + @find $(1) -name "*.wasm" -exec gzip -k {} \; + @find $(1) -name "*.wasm" -exec du -h {} \; + @find $(1) -name "*.wasm.gz" -exec du -h {} \; + @find $(1) -name "*.wasm.gz" -delete +endef + +define BUILD + rm -rf $(1) + $(call WASM_PACK_COMMAND,$(1),$(2)) + $(call WASM_OPT_COMMAND,$(1)) + $(call REMOVE_GITIGNORE,$(1)) + $(call SHOW_WASM_SIZE,$(1)) +endef + +.PHONY: js/wasm +js/wasm: + $(call BUILD,$@,bundler) + +.PHONY: dist/esm/js/wasm +dist/esm/js/wasm: + $(call BUILD,$@,bundler) + +.PHONY: dist/cjs/js/wasm +dist/cjs/js/wasm: + $(call BUILD,$@,nodejs) + +.PHONY: lint +lint: + cargo fmt --check + cargo clippy --all-targets --all-features -- -D warnings diff --git a/packages/wasm-ton/js/address.ts b/packages/wasm-ton/js/address.ts new file mode 100644 index 00000000000..0b3adc2dbb0 --- /dev/null +++ b/packages/wasm-ton/js/address.ts @@ -0,0 +1,60 @@ +import { AddressNamespace } from "./wasm/wasm_ton.js"; + +/** + * Result of decoding a TON address + */ +export interface DecodedAddress { + workchainId: number; + hash: Uint8Array; + bounceable: boolean; + testnet: boolean; +} + +/** + * Encode a V4R2 wallet address from an Ed25519 public key. + * + * Derives the wallet's StateInit from the public key, hashes it, + * and encodes as a user-friendly base64url address with the specified flags. + * + * @param publicKey - 32-byte Ed25519 public key + * @param bounceable - Whether the address should be bounceable (default: true) + * @param testnet - Whether the address is for testnet (default: false) + * @returns User-friendly base64url-encoded TON address + */ +export function encodeAddress(publicKey: Uint8Array, bounceable = true, testnet = false): string { + return AddressNamespace.encodeAddress(publicKey, bounceable, testnet); +} + +/** + * Decode a TON address to its components. + * + * Accepts both user-friendly (base64url) and raw (workchain:hex_hash) formats. + * + * @param address - TON address string + * @returns Decoded address components including flags + */ +export function decodeAddress(address: string): DecodedAddress { + return AddressNamespace.decodeAddress(address) as DecodedAddress; +} + +/** + * Validate a TON address string. + * + * Accepts both user-friendly (base64url) and raw (workchain:hex_hash) formats. + * + * @param address - TON address string to validate + * @returns true if the address is valid + */ +export function validateAddress(address: string): boolean { + return AddressNamespace.validateAddress(address); +} + +/** + * Convert a TON address to raw format (workchain:hex_hash). + * + * @param address - User-friendly (base64url) TON address + * @returns Raw address string in format "workchain:hex_hash" + */ +export function toRawAddress(address: string): string { + return AddressNamespace.toRawAddress(address); +} diff --git a/packages/wasm-ton/js/builder.ts b/packages/wasm-ton/js/builder.ts new file mode 100644 index 00000000000..dedb29d9418 --- /dev/null +++ b/packages/wasm-ton/js/builder.ts @@ -0,0 +1,227 @@ +/** + * Transaction building from business-level intents. + * + * Provides the `buildTransaction()` function for building TON transactions. + * The crate handles message construction internally (V4R2 wallet message + * wrapping, jetton transfer encoding, staking opcodes, etc.). + */ + +import { BuilderNamespace } from "./wasm/wasm_ton.js"; +import { Transaction } from "./transaction.js"; + +// ============================================================================= +// Intent Type Enum +// ============================================================================= + +/** Discriminator for TON transaction intents. */ +export enum TonIntentType { + Payment = "payment", + FillNonce = "fillNonce", + Consolidate = "consolidate", + Delegate = "delegate", + Undelegate = "undelegate", +} + +// ============================================================================= +// Staking Type Enum +// ============================================================================= + +/** TON staking protocol variants. */ +export enum TonStakingType { + TonWhales = "TON_WHALES", + SingleNominator = "SINGLE_NOMINATOR", + MultiNominator = "MULTI_NOMINATOR", +} + +// ============================================================================= +// Recipient +// ============================================================================= + +/** A recipient with address and amount. */ +export interface Recipient { + /** Destination address (user-friendly TON format) */ + address: string; + /** Amount in nanoTON */ + amount: bigint; +} + +// ============================================================================= +// Intent Types +// ============================================================================= + +/** Common fields for all intents that have wallet context. */ +interface WalletContext { + /** Wallet sequence number */ + seqno: number; + /** Expiration unix timestamp */ + expireAt: number; + /** Hex-encoded Ed25519 public key */ + publicKey: string; +} + +/** Transfer TON or jetton tokens to recipient(s). */ +export interface PaymentIntent extends WalletContext { + intentType: TonIntentType.Payment; + /** Recipients with address and amount */ + recipients: Recipient[]; + /** Optional text memo */ + memo?: string; + /** Whether destination addresses are bounceable (default: false) */ + bounceable?: boolean; + /** Sender wallet address */ + sender: string; + /** Sender's jetton wallet address (if present, this is a jetton transfer) */ + senderJettonAddress?: string; +} + +/** Self-send to advance the wallet nonce. */ +export interface FillNonceIntent extends WalletContext { + intentType: TonIntentType.FillNonce; + /** Self-send target address */ + address: string; + /** Sender's jetton wallet address (optional, for token fill nonce) */ + senderJettonAddress?: string; +} + +/** Consolidate funds to recipient(s). */ +export interface ConsolidateIntent extends WalletContext { + intentType: TonIntentType.Consolidate; + /** Recipients with address and amount */ + recipients: Recipient[]; + /** Sender wallet address */ + sender: string; + /** Sender's jetton wallet address (optional, for token consolidation) */ + senderJettonAddress?: string; +} + +/** Delegate (stake) TON with a validator. */ +export interface DelegateIntent extends WalletContext { + intentType: TonIntentType.Delegate; + /** Validator/pool address */ + validatorAddress: string; + /** Amount in nanoTON */ + amount: bigint; + /** Staking protocol type */ + stakingType: TonStakingType; + /** Sender wallet address */ + sender: string; + /** Whether this is a vesting contract wallet (default: false) */ + isVesting?: boolean; + /** Custom sub-wallet ID for vesting contracts (required when isVesting=true) */ + subWalletId?: number; +} + +/** Undelegate (unstake) TON from a validator. */ +export interface UndelegateIntent extends WalletContext { + intentType: TonIntentType.Undelegate; + /** Validator/pool address */ + validatorAddress: string; + /** Amount in nanoTON (transfer amount to validator) */ + amount: bigint; + /** Withdrawal amount for whales pool (0 = full withdrawal) */ + withdrawalAmount?: bigint; + /** Staking protocol type */ + stakingType: TonStakingType; + /** Sender wallet address */ + sender: string; + /** Whether this is a vesting contract wallet (default: false) */ + isVesting?: boolean; + /** Custom sub-wallet ID for vesting contracts (required when isVesting=true) */ + subWalletId?: number; +} + +/** Union of all TON transaction intent types. */ +export type TonTransactionIntent = + | PaymentIntent + | FillNonceIntent + | ConsolidateIntent + | DelegateIntent + | UndelegateIntent; + +// ============================================================================= +// buildTransaction function +// ============================================================================= + +/** + * Build a TON transaction from a business-level intent. + * + * The intent describes what to do (payment, stake, etc.) and includes + * all wallet context (sender, seqno, expireAt, publicKey). + * The crate handles V4R2 message wrapping internally. + * + * @param intent - Business intent (payment, fillNonce, consolidate, delegate, undelegate) + * @returns An unsigned Transaction ready for signing + * @throws Error if the intent cannot be built (e.g., invalid addresses) + * + * @example + * ```typescript + * import { buildTransaction, TonIntentType } from '@bitgo/wasm-ton'; + * + * // Native payment + * const tx = buildTransaction({ + * intentType: TonIntentType.Payment, + * recipients: [{ address: 'UQA0i8-C...', amount: 1000000000n }], + * sender: 'UQMyWallet...', + * seqno: 1, + * expireAt: 1700000000, + * publicKey: '0101...0101', + * }); + * + * // Stake with TON Whales + * const stakeTx = buildTransaction({ + * intentType: TonIntentType.Delegate, + * validatorAddress: 'EQWhalesPool...', + * amount: 5000000000n, + * stakingType: TonStakingType.TonWhales, + * sender: 'UQMyWallet...', + * seqno: 2, + * expireAt: 1700000000, + * publicKey: '0101...0101', + * }); + * ``` + */ +export function buildTransaction(intent: TonTransactionIntent): Transaction { + // Convert bigint amounts to strings for serde compatibility + const serializable = serializeIntent(intent); + const wasm = BuilderNamespace.buildTransaction(serializable); + return Transaction.fromWasm(wasm); +} + +/** + * Convert an intent with bigint amounts to a JSON-serializable object. + * serde_wasm_bindgen deserializes bigints as strings for u64 fields. + */ +function serializeIntent(intent: TonTransactionIntent): unknown { + switch (intent.intentType) { + case TonIntentType.Payment: + return { + ...intent, + recipients: intent.recipients.map((r) => ({ + ...r, + amount: String(r.amount), + })), + }; + case TonIntentType.FillNonce: + return intent; + case TonIntentType.Consolidate: + return { + ...intent, + recipients: intent.recipients.map((r) => ({ + ...r, + amount: String(r.amount), + })), + }; + case TonIntentType.Delegate: + return { + ...intent, + amount: String(intent.amount), + }; + case TonIntentType.Undelegate: + return { + ...intent, + amount: String(intent.amount), + withdrawalAmount: + intent.withdrawalAmount !== undefined ? String(intent.withdrawalAmount) : undefined, + }; + } +} diff --git a/packages/wasm-ton/js/index.ts b/packages/wasm-ton/js/index.ts new file mode 100644 index 00000000000..2b8d1273a3a --- /dev/null +++ b/packages/wasm-ton/js/index.ts @@ -0,0 +1,26 @@ +/** + * wasm-ton: WASM bindings for TON transaction operations + * + * This module provides: + * - Address derivation: Ed25519 pubkey -> V4R2 wallet address + * - Address encoding/decoding: user-friendly base64url format + * - Address validation + * - Transaction parsing: BOC -> structured data + * - Transaction signing: add Ed25519 signature to BOC + */ + +import { + AddressNamespace, + WasmTransaction, + ParserNamespace, + BuilderNamespace, +} from "./wasm/wasm_ton.js"; + +// Export WASM classes for advanced usage +export { AddressNamespace, WasmTransaction, ParserNamespace, BuilderNamespace }; + +// Re-export all public API +export * from "./address.js"; +export * from "./transaction.js"; +export * from "./parser.js"; +export * from "./builder.js"; diff --git a/packages/wasm-ton/js/parser.ts b/packages/wasm-ton/js/parser.ts new file mode 100644 index 00000000000..bf7870cf67c --- /dev/null +++ b/packages/wasm-ton/js/parser.ts @@ -0,0 +1,95 @@ +/** + * Transaction parsing - standalone function that decodes a TON Transaction + * into structured data (sender, destination, amount, memo, etc.). + * + * This is separate from the Transaction class, which handles signing. + * Use Transaction.fromBytes() when you need to sign. + * Use parseTransaction() when you need decoded data. + * + * All monetary amounts (amount, withdrawAmount) are returned as bigint. + */ + +import { ParserNamespace } from "./wasm/wasm_ton.js"; +import type { Transaction } from "./transaction.js"; + +// ============================================================================= +// Transaction Types +// ============================================================================= + +/** Known TON transaction types */ +export enum TonTransactionType { + Send = "Send", + SendToken = "SendToken", + TonWhalesDeposit = "TonWhalesDeposit", + TonWhalesWithdrawal = "TonWhalesWithdrawal", + SingleNominatorWithdraw = "SingleNominatorWithdraw", + Unknown = "Unknown", +} + +// ============================================================================= +// ParsedTonTransaction +// ============================================================================= + +/** + * A fully parsed TON transaction with decoded fields. + * + * Matches the shape expected by BitGoJS's explainTransaction and toJson. + */ +export interface ParsedTonTransaction { + /** Transaction ID (base64url-encoded hash), undefined if unsigned */ + id?: string; + /** Sender (wallet) address, user-friendly bounceable format */ + sender: string; + /** Destination address, user-friendly format */ + destination?: string; + /** Destination address raw format (workchain:hex) */ + destinationAlias?: string; + /** Transfer amount in nanoTON */ + amount: bigint; + /** Withdrawal amount (for staking operations) */ + withdrawAmount?: bigint; + /** Text memo (if present in the transfer body) */ + memo?: string; + /** Sequence number */ + seqno: number; + /** Expiration time (unix timestamp) */ + expirationTime: bigint; + /** Whether the destination address is bounceable */ + bounceable: boolean; + /** The detected transaction type */ + transactionType: string; + /** Sub-wallet ID */ + subWalletId: number; + /** Whether the transaction is signed */ + isSigned: boolean; + /** Send mode flags */ + sendMode?: number; +} + +// ============================================================================= +// parseTransaction function +// ============================================================================= + +/** + * Parse a Transaction into a plain data object with decoded fields. + * + * This is the main parsing function that returns structured data with + * amounts as bigint. + * + * Accepts a `Transaction` object (from `Transaction.fromBytes()` or + * `Transaction.fromBase64()`), avoiding double deserialization. + * + * @param tx - A Transaction instance + * @returns A ParsedTonTransaction with all fields decoded + * + * @example + * ```typescript + * const tx = Transaction.fromBase64(bocBase64); + * const parsed = parseTransaction(tx); + * console.log(parsed.destination); // "UQA0i8-..." + * console.log(parsed.amount); // 10000000n + * ``` + */ +export function parseTransaction(tx: Transaction): ParsedTonTransaction { + return ParserNamespace.parseFromTransaction(tx.wasm) as ParsedTonTransaction; +} diff --git a/packages/wasm-ton/js/transaction.ts b/packages/wasm-ton/js/transaction.ts new file mode 100644 index 00000000000..e08357588c8 --- /dev/null +++ b/packages/wasm-ton/js/transaction.ts @@ -0,0 +1,120 @@ +/** + * TON Transaction - deserialization wrapper for signing and serialization. + * + * Use `Transaction.fromBytes(bytes)` or `Transaction.fromBase64(b64)` to create. + * Use `parseTransaction(tx)` from parser.ts to get decoded transaction data. + * + * @example + * ```typescript + * import { Transaction, parseTransaction } from '@bitgo/wasm-ton'; + * + * const tx = Transaction.fromBase64(bocBase64); + * const parsed = parseTransaction(tx); + * console.log(`${parsed.amount} nanoTON to ${parsed.destination}`); + * + * // Sign and serialize + * tx.addSignature(signature); + * const broadcastStr = tx.toBroadcastFormat(); + * ``` + */ +import { WasmTransaction } from "./wasm/wasm_ton.js"; + +export class Transaction { + private constructor(private _wasm: WasmTransaction) {} + + /** + * Deserialize a transaction from raw BOC bytes. + * @param bytes - Raw BOC bytes + * @returns A Transaction instance + */ + static fromBytes(bytes: Uint8Array): Transaction { + const wasm = WasmTransaction.fromBytes(bytes); + return new Transaction(wasm); + } + + /** + * Deserialize a transaction from base64-encoded BOC. + * @param b64 - Base64-encoded BOC string + * @returns A Transaction instance + */ + static fromBase64(b64: string): Transaction { + const wasm = WasmTransaction.fromBase64(b64); + return new Transaction(wasm); + } + + /** + * Deserialize a transaction from hex-encoded BOC. + * @param hex - Hex-encoded BOC string + * @returns A Transaction instance + */ + static fromHex(hex: string): Transaction { + const wasm = WasmTransaction.fromHex(hex); + return new Transaction(wasm); + } + + /** + * Get the signable payload (SHA-256 hash of the signing body cell). + * + * This is the 32-byte hash that should be signed with Ed25519. + * @returns 32-byte Uint8Array + */ + signablePayload(): Uint8Array { + return this._wasm.signablePayload(); + } + + /** + * Add an Ed25519 signature to the transaction. + * + * Places the 64-byte signature in the external body and rebuilds + * the message cell. + * + * @param signature - 64-byte Ed25519 signature + */ + addSignature(signature: Uint8Array): void { + this._wasm.addSignature(signature); + } + + /** + * Serialize the transaction to BOC bytes. + * @returns Raw BOC bytes + */ + toBytes(): Uint8Array { + return this._wasm.toBytes(); + } + + /** + * Serialize to broadcast format (base64-encoded BOC). + * + * TON nodes accept base64-encoded BOC for broadcasting via sendBoc RPC. + * @returns Base64-encoded BOC string + */ + toBroadcastFormat(): string { + return this._wasm.toBroadcastFormat(); + } + + /** + * Get the transaction ID (hash of the external message cell). + * + * Returns undefined if the transaction is unsigned (all-zero signature). + */ + get id(): string | undefined { + return this._wasm.id ?? undefined; + } + + /** + * Get the underlying WASM instance (internal use only). + * @internal + */ + get wasm(): WasmTransaction { + return this._wasm; + } + + /** + * Create a Transaction from a WasmTransaction instance (internal use only). + * Used by the builder to wrap the result. + * @internal + */ + static fromWasm(wasm: WasmTransaction): Transaction { + return new Transaction(wasm); + } +} diff --git a/packages/wasm-ton/package.json b/packages/wasm-ton/package.json new file mode 100644 index 00000000000..ff2a82acd80 --- /dev/null +++ b/packages/wasm-ton/package.json @@ -0,0 +1,59 @@ +{ + "name": "@bitgo/wasm-ton", + "description": "WebAssembly wrapper for TON cryptographic operations", + "version": "0.0.1", + "private": true, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/BitGo/BitGoWASM" + }, + "license": "MIT", + "files": [ + "dist/esm/js/**/*", + "dist/cjs/js/**/*", + "dist/cjs/package.json" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/js/index.d.ts", + "default": "./dist/esm/js/index.js" + }, + "require": { + "types": "./dist/cjs/js/index.d.ts", + "default": "./dist/cjs/js/index.js" + } + } + }, + "main": "./dist/cjs/js/index.js", + "module": "./dist/esm/js/index.js", + "types": "./dist/esm/js/index.d.ts", + "sideEffects": [ + "./dist/esm/js/wasm/wasm_ton.js", + "./dist/cjs/js/wasm/wasm_ton.js" + ], + "scripts": { + "test": "npm run test:mocha", + "test:mocha": "mocha --recursive test", + "build:wasm": "make js/wasm && make dist/esm/js/wasm && make dist/cjs/js/wasm", + "build:ts-esm": "tsc", + "build:ts-cjs": "tsc --project tsconfig.cjs.json", + "build:ts": "npm run build:ts-esm && npm run build:ts-cjs", + "build:package-json": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", + "build": "npm run build:wasm && npm run build:ts && npm run build:package-json", + "check-fmt": "prettier --check . && cargo fmt -- --check", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/mocha": "^10.0.7", + "@types/node": "^22.10.5", + "eslint": "^9.17.0", + "mocha": "^10.6.0", + "tsx": "4.20.6", + "typescript": "^5.5.3", + "typescript-eslint": "^8.18.2" + } +} diff --git a/packages/wasm-ton/src/address.rs b/packages/wasm-ton/src/address.rs new file mode 100644 index 00000000000..446a85ecfba --- /dev/null +++ b/packages/wasm-ton/src/address.rs @@ -0,0 +1,217 @@ +//! TON address encoding, decoding, and derivation +//! +//! TON addresses consist of a workchain_id (i32) and a 32-byte hash. +//! User-friendly format is base64url with flags (bounceable, testnet) and CRC16. +//! V4R2 wallet addresses are derived from Ed25519 public keys via StateInit hashing. + +use crate::error::WasmTonError; +use tlb_ton::MsgAddress; +use ton_contracts::wallet::{v4r2::V4R2, WalletVersion}; + +/// Default wallet ID for V4R2 wallets +pub const DEFAULT_WALLET_ID: u32 = V4R2::DEFAULT_WALLET_ID; + +/// Result of decoding a TON address +#[derive(Debug, Clone)] +pub struct DecodedAddress { + /// Workchain ID (0 for basechain, -1 for masterchain) + pub workchain_id: i32, + /// 32-byte address hash + pub hash: [u8; 32], + /// Whether the address is bounceable + pub bounceable: bool, + /// Whether the address is for testnet + pub testnet: bool, +} + +/// Encode a V4R2 wallet address from an Ed25519 public key. +/// +/// Derives the wallet StateInit, hashes it to get the address, +/// then encodes in user-friendly base64url format. +/// +/// # Arguments +/// * `pubkey` - 32-byte Ed25519 public key +/// * `bounceable` - Whether the address should be bounceable +/// * `testnet` - Whether the address is for testnet +pub fn encode_address( + pubkey: &[u8], + bounceable: bool, + testnet: bool, +) -> Result { + let pubkey: [u8; 32] = pubkey.try_into().map_err(|_| { + WasmTonError::InvalidPublicKey(format!("Public key must be 32 bytes, got {}", pubkey.len())) + })?; + + let state_init = V4R2::state_init(DEFAULT_WALLET_ID, pubkey); + let addr = MsgAddress::derive(0, state_init)?; + + // to_base64_url_flags takes (non_bounceable, non_production) + Ok(addr.to_base64_url_flags(!bounceable, testnet)) +} + +/// Decode a TON address from user-friendly (base64url) or raw (workchain:hex) format. +/// +/// Returns the decoded address components including flags. +pub fn decode_address(addr: &str) -> Result { + // Try user-friendly format first (48 chars base64url/std) + if addr.len() == 48 || (addr.len() == 46 && !addr.contains(':')) { + // Try URL-safe base64 first, then standard base64 + let result = MsgAddress::from_base64_url_flags(addr) + .or_else(|_| MsgAddress::from_base64_std_flags(addr)) + .map_err(|e| WasmTonError::InvalidAddress(e.to_string()))?; + + let (msg_addr, non_bounceable, non_production) = result; + + return Ok(DecodedAddress { + workchain_id: msg_addr.workchain_id, + hash: msg_addr.address, + bounceable: !non_bounceable, + testnet: non_production, + }); + } + + // Try raw format (workchain:hex_hash) + if addr.contains(':') { + let msg_addr = + MsgAddress::from_hex(addr).map_err(|e| WasmTonError::InvalidAddress(e.to_string()))?; + return Ok(DecodedAddress { + workchain_id: msg_addr.workchain_id, + hash: msg_addr.address, + bounceable: true, + testnet: false, + }); + } + + Err(WasmTonError::InvalidAddress(format!( + "Unrecognized address format: {}", + addr + ))) +} + +/// Validate a TON address string. +/// +/// Accepts both user-friendly (base64url) and raw (workchain:hex) formats. +pub fn validate_address(addr: &str) -> bool { + decode_address(addr).is_ok() +} + +/// Get the raw format (workchain:hex) of a decoded address +pub fn to_raw_address(decoded: &DecodedAddress) -> String { + let msg_addr = MsgAddress { + workchain_id: decoded.workchain_id, + address: decoded.hash, + }; + msg_addr.to_hex() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Known V4R2 test vector from ton-contracts crate documentation: + // Mnemonic: "jewel loop vast intact snack drip fatigue lunch erode green indoor + // balance together scrub hen monster hour narrow banner warfare increase + // panel sound spell" + // Expected address: UQA7RMTgzvcyxNNLmK2HdklOvFE8_KNMa-btKZ0dPU1UsqfC + // (bounceable, mainnet) + + #[test] + fn test_encode_decode_roundtrip() { + // Use a known public key to derive an address, then decode it + let pubkey = [1u8; 32]; // Deterministic test key + let addr = encode_address(&pubkey, true, false).unwrap(); + + // Verify it's a valid address + assert!(validate_address(&addr)); + + // Decode and verify flags + let decoded = decode_address(&addr).unwrap(); + assert_eq!(decoded.workchain_id, 0); + assert!(decoded.bounceable); + assert!(!decoded.testnet); + assert_eq!(decoded.hash.len(), 32); + + // Re-encode with same flags should produce same address + let msg_addr = MsgAddress { + workchain_id: decoded.workchain_id, + address: decoded.hash, + }; + let re_encoded = msg_addr.to_base64_url_flags(!decoded.bounceable, decoded.testnet); + assert_eq!(addr, re_encoded); + } + + #[test] + fn test_encode_non_bounceable() { + let pubkey = [1u8; 32]; + let bounceable = encode_address(&pubkey, true, false).unwrap(); + let non_bounceable = encode_address(&pubkey, false, false).unwrap(); + + // Different flag encoding should produce different strings + assert_ne!(bounceable, non_bounceable); + + // Both should decode to the same hash + let dec_b = decode_address(&bounceable).unwrap(); + let dec_nb = decode_address(&non_bounceable).unwrap(); + assert_eq!(dec_b.hash, dec_nb.hash); + assert!(dec_b.bounceable); + assert!(!dec_nb.bounceable); + } + + #[test] + fn test_encode_testnet() { + let pubkey = [1u8; 32]; + let mainnet = encode_address(&pubkey, true, false).unwrap(); + let testnet = encode_address(&pubkey, true, true).unwrap(); + + assert_ne!(mainnet, testnet); + + let dec_main = decode_address(&mainnet).unwrap(); + let dec_test = decode_address(&testnet).unwrap(); + assert!(!dec_main.testnet); + assert!(dec_test.testnet); + } + + #[test] + fn test_raw_address_roundtrip() { + let pubkey = [1u8; 32]; + let addr = encode_address(&pubkey, true, false).unwrap(); + let decoded = decode_address(&addr).unwrap(); + + let raw = to_raw_address(&decoded); + assert!(raw.starts_with("0:")); + + // Should be able to decode raw format + let decoded_raw = decode_address(&raw).unwrap(); + assert_eq!(decoded.hash, decoded_raw.hash); + assert_eq!(decoded.workchain_id, decoded_raw.workchain_id); + } + + #[test] + fn test_validate_address() { + let pubkey = [1u8; 32]; + let addr = encode_address(&pubkey, true, false).unwrap(); + assert!(validate_address(&addr)); + + assert!(!validate_address("invalid")); + assert!(!validate_address("")); + } + + #[test] + fn test_invalid_pubkey_length() { + let short_pubkey = vec![0u8; 16]; + assert!(encode_address(&short_pubkey, true, false).is_err()); + } + + #[test] + fn test_known_address() { + // Test with a well-known address format + // EQA... addresses are bounceable, mainnet, workchain 0 + let addr = "EQBGXZ9ddZeWypx8EkJieHJX75ct0bpkmu0Y4YoYr3NM0Z9e"; + assert!(validate_address(addr)); + + let decoded = decode_address(addr).unwrap(); + assert_eq!(decoded.workchain_id, 0); + assert!(decoded.bounceable); + assert!(!decoded.testnet); + } +} diff --git a/packages/wasm-ton/src/builder/build.rs b/packages/wasm-ton/src/builder/build.rs new file mode 100644 index 00000000000..5000f76162f --- /dev/null +++ b/packages/wasm-ton/src/builder/build.rs @@ -0,0 +1,789 @@ +//! Transaction building from business-level intents. +//! +//! Dispatches on intent type, constructs the appropriate internal message(s), +//! wraps in a V4R2 signing body, and creates an unsigned external message. + +use chrono::{DateTime, TimeZone, Utc}; +use tlb_ton::MsgAddress; +use ton_contracts::wallet::v4r2::V4R2; +use ton_contracts::wallet::WalletVersion; + +use crate::address::DEFAULT_WALLET_ID; +use crate::error::WasmTonError; +use crate::transaction::Transaction; + +use super::jetton::build_jetton_transfer_action; +use super::staking::{ + build_single_nominator_withdraw_action, build_whales_deposit_action, + build_whales_vesting_deposit_action, build_whales_vesting_withdraw_action, + build_whales_withdraw_action, +}; +use super::transfer::build_transfer_action; +use super::types::{TonStakingType, TonTransactionIntent}; + +/// Standard send mode: pay transfer fees separately + ignore errors. +const MODE_STANDARD: u8 = 3; + +/// Send entire balance mode (for consolidation). +const MODE_SEND_ALL: u8 = 128; + +/// Build a TON transaction from a business-level intent. +/// +/// Returns an unsigned Transaction ready for signing. +pub fn build_transaction(intent: TonTransactionIntent) -> Result { + match intent { + TonTransactionIntent::Payment { + recipients, + memo, + bounceable, + sender, + seqno, + expire_at, + public_key, + sender_jetton_address, + } => { + let wallet_addr = parse_address(&sender)?; + let bounce = bounceable.unwrap_or(false); + + if recipients.is_empty() { + return Err(WasmTonError::StringError( + "Payment intent requires at least one recipient".to_string(), + )); + } + + let recipient = &recipients[0]; + let dst = parse_address(&recipient.address)?; + + let action = if let Some(ref jetton_addr) = sender_jetton_address { + let sender_jetton = parse_address(jetton_addr)?; + build_jetton_transfer_action( + sender_jetton, + dst, + wallet_addr, + recipient.amount, + memo.as_deref(), + MODE_STANDARD, + )? + } else { + build_transfer_action( + dst, + recipient.amount, + bounce, + memo.as_deref(), + MODE_STANDARD, + )? + }; + + build_v4r2_transaction(wallet_addr, &public_key, seqno, expire_at, vec![action]) + } + + TonTransactionIntent::FillNonce { + address, + seqno, + expire_at, + public_key, + sender_jetton_address, + } => { + let wallet_addr = parse_address(&address)?; + + let action = if let Some(ref jetton_addr) = sender_jetton_address { + // Token fill nonce: send 0 jettons to self + let sender_jetton = parse_address(jetton_addr)?; + build_jetton_transfer_action( + sender_jetton, + wallet_addr, + wallet_addr, + 0, + None, + MODE_STANDARD, + )? + } else { + // Native fill nonce: send 0 TON to self + build_transfer_action(wallet_addr, 0, false, None, MODE_STANDARD)? + }; + + build_v4r2_transaction(wallet_addr, &public_key, seqno, expire_at, vec![action]) + } + + TonTransactionIntent::Consolidate { + recipients, + sender, + seqno, + expire_at, + public_key, + sender_jetton_address, + } => { + let wallet_addr = parse_address(&sender)?; + + if recipients.is_empty() { + return Err(WasmTonError::StringError( + "Consolidate intent requires at least one recipient".to_string(), + )); + } + + let recipient = &recipients[0]; + let dst = parse_address(&recipient.address)?; + + let action = if let Some(ref jetton_addr) = sender_jetton_address { + let sender_jetton = parse_address(jetton_addr)?; + build_jetton_transfer_action( + sender_jetton, + dst, + wallet_addr, + recipient.amount, + None, + MODE_STANDARD, + )? + } else { + // Consolidation uses mode 128 (send entire balance) for native + build_transfer_action(dst, recipient.amount, false, None, MODE_SEND_ALL)? + }; + + build_v4r2_transaction(wallet_addr, &public_key, seqno, expire_at, vec![action]) + } + + TonTransactionIntent::Delegate { + validator_address, + amount, + staking_type, + sender, + seqno, + expire_at, + public_key, + is_vesting, + sub_wallet_id, + } => { + let wallet_addr = parse_address(&sender)?; + let validator_addr = parse_address(&validator_address)?; + let vesting = is_vesting.unwrap_or(false); + + if vesting && staking_type == TonStakingType::TonWhales { + let wallet_id = sub_wallet_id.ok_or_else(|| { + WasmTonError::StringError( + "subWalletId is required for vesting delegate".to_string(), + ) + })?; + let action = + build_whales_vesting_deposit_action(validator_addr, amount, MODE_STANDARD)?; + return build_v3_transaction(wallet_addr, wallet_id, seqno, expire_at, action); + } + + let action = match staking_type { + TonStakingType::TonWhales => { + build_whales_deposit_action(validator_addr, amount, MODE_STANDARD)? + } + TonStakingType::SingleNominator => { + // Plain transfer to validator with bounceable=true + build_transfer_action(validator_addr, amount, true, None, MODE_STANDARD)? + } + TonStakingType::MultiNominator => { + // Transfer to validator with memo='d' and bounceable=true + build_transfer_action(validator_addr, amount, true, Some("d"), MODE_STANDARD)? + } + }; + + build_v4r2_transaction(wallet_addr, &public_key, seqno, expire_at, vec![action]) + } + + TonTransactionIntent::Undelegate { + validator_address, + amount, + withdrawal_amount, + staking_type, + sender, + seqno, + expire_at, + public_key, + is_vesting, + sub_wallet_id, + } => { + let wallet_addr = parse_address(&sender)?; + let validator_addr = parse_address(&validator_address)?; + let vesting = is_vesting.unwrap_or(false); + + if vesting && staking_type == TonStakingType::TonWhales { + let wallet_id = sub_wallet_id.ok_or_else(|| { + WasmTonError::StringError( + "subWalletId is required for vesting undelegate".to_string(), + ) + })?; + let action = + build_whales_vesting_withdraw_action(validator_addr, amount, MODE_STANDARD)?; + return build_v3_transaction(wallet_addr, wallet_id, seqno, expire_at, action); + } + + let action = match staking_type { + TonStakingType::TonWhales => { + let withdraw_amt = withdrawal_amount.unwrap_or(0); + build_whales_withdraw_action( + validator_addr, + amount, + withdraw_amt, + MODE_STANDARD, + )? + } + TonStakingType::SingleNominator => { + let withdraw_amt = withdrawal_amount.unwrap_or(amount); + // Dedicated withdraw message, sends 1 TON to validator for gas + build_single_nominator_withdraw_action( + validator_addr, + amount, + withdraw_amt, + MODE_STANDARD, + )? + } + TonStakingType::MultiNominator => { + // Transfer to validator with memo='w' and bounceable=true + build_transfer_action(validator_addr, amount, true, Some("w"), MODE_STANDARD)? + } + }; + + build_v4r2_transaction(wallet_addr, &public_key, seqno, expire_at, vec![action]) + } + } +} + +// ========================================================================= +// Helpers +// ========================================================================= + +/// Build a complete V4R2 unsigned external message transaction. +fn build_v4r2_transaction( + wallet_address: MsgAddress, + public_key_hex: &str, + seqno: u32, + expire_at_unix: u32, + actions: Vec, +) -> Result { + let _pubkey = parse_public_key(public_key_hex)?; + + let expire_at: DateTime = Utc + .timestamp_opt(expire_at_unix as i64, 0) + .single() + .ok_or_else(|| { + WasmTonError::StringError(format!("Invalid expire_at timestamp: {}", expire_at_unix)) + })?; + + // Create the V4R2 signing body + let sign_body = V4R2::create_sign_body(DEFAULT_WALLET_ID, expire_at, seqno, actions); + + // Wrap with an empty signature (unsigned) + let external_body = V4R2::wrap_signed_external(sign_body, [0u8; 64]); + + // Create the Transaction from components + Transaction::from_components(wallet_address, None, external_body) +} + +/// V3 signing body: wallet_id(32) + expire_at(32) + seqno(32) + send_mode(8) + msg_ref +/// +/// Unlike V4R2, V3 has no op byte and only supports a single message. +struct V3SignBody { + wallet_id: u32, + expire_at: u32, + seqno: u32, + send_mode: u8, + message: tlb_ton::Cell, +} + +impl tlb_ton::ser::CellSerialize for V3SignBody { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use tlb_ton::bits::ser::BitWriterExt; + builder.pack(self.wallet_id, ())?; + builder.pack(self.expire_at, ())?; + builder.pack(self.seqno, ())?; + builder.pack(self.send_mode, ())?; + builder.store_as::<_, tlb_ton::Ref>(&self.message, ())?; + Ok(()) + } +} + +/// Build a V3 (vesting contract) unsigned external message transaction. +fn build_v3_transaction( + wallet_address: MsgAddress, + wallet_id: u32, + seqno: u32, + expire_at_unix: u32, + action: tlb_ton::action::SendMsgAction, +) -> Result { + use tlb_ton::ser::CellSerializeExt; + + let msg_cell = action.message.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build internal message cell: {}", e)) + })?; + + let sign_body = V3SignBody { + wallet_id, + expire_at: expire_at_unix, + seqno, + send_mode: action.mode, + message: msg_cell, + }; + + let sign_body_cell = sign_body.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build V3 sign body cell: {}", e)) + })?; + + Transaction::from_raw_sign_body(wallet_address, sign_body_cell) +} + +/// Parse a TON address from user-friendly or raw format. +fn parse_address(addr: &str) -> Result { + // Try user-friendly base64url format first + if let Ok((msg_addr, _, _)) = MsgAddress::from_base64_url_flags(addr) { + return Ok(msg_addr); + } + // Try standard base64 + if let Ok((msg_addr, _, _)) = MsgAddress::from_base64_std_flags(addr) { + return Ok(msg_addr); + } + // Try raw hex format (workchain:hex) + if addr.contains(':') { + return MsgAddress::from_hex(addr) + .map_err(|e| WasmTonError::InvalidAddress(format!("Invalid raw address: {}", e))); + } + Err(WasmTonError::InvalidAddress(format!( + "Unrecognized address format: {}", + addr + ))) +} + +/// Parse a hex-encoded Ed25519 public key. +fn parse_public_key(hex_key: &str) -> Result<[u8; 32], WasmTonError> { + let bytes = hex::decode(hex_key) + .map_err(|e| WasmTonError::InvalidPublicKey(format!("Invalid hex public key: {}", e)))?; + bytes + .try_into() + .map_err(|_| WasmTonError::InvalidPublicKey("Public key must be 32 bytes".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::address::encode_address; + use crate::parser::{self, TonTransactionType}; + + // Test public key (deterministic) + const TEST_PUBKEY: [u8; 32] = [1u8; 32]; + const TEST_PUBKEY_HEX: &str = + "0101010101010101010101010101010101010101010101010101010101010101"; + + fn test_sender_address() -> String { + encode_address(&TEST_PUBKEY, false, false).unwrap() + } + + fn test_recipient_address() -> String { + let pubkey = [2u8; 32]; + encode_address(&pubkey, false, false).unwrap() + } + + #[test] + fn test_build_native_payment() { + let sender = test_sender_address(); + let recipient = test_recipient_address(); + + let intent = TonTransactionIntent::Payment { + recipients: vec![super::super::types::Recipient { + address: recipient, + amount: 1_000_000_000, + }], + memo: None, + bounceable: None, + sender, + seqno: 1, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + sender_jetton_address: None, + }; + + let tx = build_transaction(intent).unwrap(); + + // Verify basic properties + assert_eq!(tx.sign_body().seqno, 1); + let payload = tx.signable_payload().unwrap(); + assert_eq!(payload.len(), 32); + + // Parse the built transaction and verify + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 1); + assert_eq!(parsed.transaction_type, TonTransactionType::Send); + assert_eq!(parsed.amount, 1_000_000_000); + } + + #[test] + fn test_build_native_payment_with_memo() { + let sender = test_sender_address(); + let recipient = test_recipient_address(); + + let intent = TonTransactionIntent::Payment { + recipients: vec![super::super::types::Recipient { + address: recipient, + amount: 500_000_000, + }], + memo: Some("test memo".to_string()), + bounceable: None, + sender, + seqno: 2, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + sender_jetton_address: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 2); + assert_eq!(parsed.transaction_type, TonTransactionType::Send); + assert_eq!(parsed.amount, 500_000_000); + assert_eq!(parsed.memo, Some("test memo".to_string())); + } + + #[test] + fn test_build_fill_nonce() { + let addr = test_sender_address(); + + let intent = TonTransactionIntent::FillNonce { + address: addr.clone(), + seqno: 5, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + sender_jetton_address: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 5); + assert_eq!(parsed.amount, 0); + assert_eq!(parsed.transaction_type, TonTransactionType::Send); + } + + #[test] + fn test_build_consolidate() { + let sender = test_sender_address(); + let recipient = test_recipient_address(); + + let intent = TonTransactionIntent::Consolidate { + recipients: vec![super::super::types::Recipient { + address: recipient, + amount: 2_000_000_000, + }], + sender, + seqno: 3, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + sender_jetton_address: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 3); + // Consolidation uses mode 128 + assert_eq!(parsed.send_mode, Some(MODE_SEND_ALL)); + } + + #[test] + fn test_build_delegate_whales() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Delegate { + validator_address: validator, + amount: 5_000_000_000, + staking_type: TonStakingType::TonWhales, + sender, + seqno: 10, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: None, + sub_wallet_id: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 10); + assert_eq!( + parsed.transaction_type, + TonTransactionType::TonWhalesDeposit + ); + assert!(parsed.bounceable); + } + + #[test] + fn test_build_delegate_single_nominator() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Delegate { + validator_address: validator, + amount: 3_000_000_000, + staking_type: TonStakingType::SingleNominator, + sender, + seqno: 11, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: None, + sub_wallet_id: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 11); + assert_eq!(parsed.transaction_type, TonTransactionType::Send); + assert!(parsed.bounceable); + } + + #[test] + fn test_build_delegate_multi_nominator() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Delegate { + validator_address: validator, + amount: 4_000_000_000, + staking_type: TonStakingType::MultiNominator, + sender, + seqno: 12, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: None, + sub_wallet_id: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 12); + assert_eq!(parsed.memo, Some("d".to_string())); + assert!(parsed.bounceable); + } + + #[test] + fn test_build_undelegate_whales() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Undelegate { + validator_address: validator, + amount: 1_000_000_000, + withdrawal_amount: Some(5_000_000_000), + staking_type: TonStakingType::TonWhales, + sender, + seqno: 20, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: None, + sub_wallet_id: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 20); + assert_eq!( + parsed.transaction_type, + TonTransactionType::TonWhalesWithdrawal + ); + } + + #[test] + fn test_build_undelegate_single_nominator() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Undelegate { + validator_address: validator, + amount: 1_000_000_000, + withdrawal_amount: Some(3_000_000_000), + staking_type: TonStakingType::SingleNominator, + sender, + seqno: 21, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: None, + sub_wallet_id: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 21); + assert_eq!( + parsed.transaction_type, + TonTransactionType::SingleNominatorWithdraw + ); + } + + #[test] + fn test_build_undelegate_multi_nominator() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Undelegate { + validator_address: validator, + amount: 2_000_000_000, + withdrawal_amount: None, + staking_type: TonStakingType::MultiNominator, + sender, + seqno: 22, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: None, + sub_wallet_id: None, + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 22); + assert_eq!(parsed.memo, Some("w".to_string())); + assert!(parsed.bounceable); + } + + #[test] + fn test_build_roundtrip_serialize_deserialize() { + let sender = test_sender_address(); + let recipient = test_recipient_address(); + + let intent = TonTransactionIntent::Payment { + recipients: vec![super::super::types::Recipient { + address: recipient, + amount: 750_000_000, + }], + memo: Some("roundtrip test".to_string()), + bounceable: None, + sender, + seqno: 99, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + sender_jetton_address: None, + }; + + let tx = build_transaction(intent).unwrap(); + + // Serialize to bytes and deserialize back + let bytes = tx.to_bytes().unwrap(); + let tx2 = Transaction::from_bytes(&bytes).unwrap(); + + // Verify the deserialized tx matches + let parsed = parser::parse_transaction(&tx2).unwrap(); + assert_eq!(parsed.seqno, 99); + assert_eq!(parsed.amount, 750_000_000); + assert_eq!(parsed.memo, Some("roundtrip test".to_string())); + } + + #[test] + fn test_build_jetton_payment() { + let sender = test_sender_address(); + let recipient = test_recipient_address(); + // Use a different address for the jetton wallet + let jetton_pubkey = [3u8; 32]; + let jetton_addr = encode_address(&jetton_pubkey, true, false).unwrap(); + + let intent = TonTransactionIntent::Payment { + recipients: vec![super::super::types::Recipient { + address: recipient, + amount: 5_000_000, + }], + memo: Some("jetton transfer".to_string()), + bounceable: None, + sender, + seqno: 50, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + sender_jetton_address: Some(jetton_addr), + }; + + let tx = build_transaction(intent).unwrap(); + let parsed = parser::parse_transaction(&tx).unwrap(); + assert_eq!(parsed.seqno, 50); + assert_eq!(parsed.transaction_type, TonTransactionType::SendToken); + // The TON amount attached is the gas amount, not the jetton amount + assert!(parsed.bounceable); + } + + #[test] + fn test_build_vesting_delegate_whales() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Delegate { + validator_address: validator, + amount: 5_000_000_000, + staking_type: TonStakingType::TonWhales, + sender, + seqno: 30, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: Some(true), + sub_wallet_id: Some(268), + }; + + let tx = build_transaction(intent).unwrap(); + + // Vesting uses raw cell format, not V4R2 + assert!(!tx.is_v4r2()); + + // Verify signable payload is 32 bytes + let payload = tx.signable_payload().unwrap(); + assert_eq!(payload.len(), 32); + + // Verify we can serialize and round-trip + let bytes = tx.to_bytes().unwrap(); + assert!(!bytes.is_empty()); + + // Verify signing works + let mut tx2 = tx; + let fake_sig = [0xABu8; 64]; + tx2.add_signature(&fake_sig).unwrap(); + assert!(tx2.id().unwrap().is_some()); + } + + #[test] + fn test_build_vesting_undelegate_whales() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Undelegate { + validator_address: validator, + amount: 1_000_000_000, + withdrawal_amount: None, + staking_type: TonStakingType::TonWhales, + sender, + seqno: 31, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: Some(true), + sub_wallet_id: Some(268), + }; + + let tx = build_transaction(intent).unwrap(); + assert!(!tx.is_v4r2()); + + let payload = tx.signable_payload().unwrap(); + assert_eq!(payload.len(), 32); + + let bytes = tx.to_bytes().unwrap(); + assert!(!bytes.is_empty()); + } + + #[test] + fn test_vesting_requires_sub_wallet_id() { + let sender = test_sender_address(); + let validator = test_recipient_address(); + + let intent = TonTransactionIntent::Delegate { + validator_address: validator, + amount: 5_000_000_000, + staking_type: TonStakingType::TonWhales, + sender, + seqno: 32, + expire_at: 1700000000, + public_key: TEST_PUBKEY_HEX.to_string(), + is_vesting: Some(true), + sub_wallet_id: None, // Missing! + }; + + let result = build_transaction(intent); + assert!(result.is_err()); + } +} diff --git a/packages/wasm-ton/src/builder/jetton.rs b/packages/wasm-ton/src/builder/jetton.rs new file mode 100644 index 00000000000..7ad49c27e73 --- /dev/null +++ b/packages/wasm-ton/src/builder/jetton.rs @@ -0,0 +1,131 @@ +//! Jetton (TEP-74) transfer message construction. +//! +//! Builds internal messages for jetton transfers, including the +//! JettonTransfer body with forward_payload for memos. + +use num_bigint::BigUint; +use tlb_ton::{ + action::SendMsgAction, + currency::CurrencyCollection, + message::{CommonMsgInfo, InternalMsgInfo, Message}, + ser::CellSerializeExt, + Cell, MsgAddress, +}; +use ton_contracts::jetton::{ForwardPayload, ForwardPayloadComment, JettonTransfer}; + +use crate::error::WasmTonError; + +/// Default TON amount to attach to jetton transfer for gas (0.1 TON). +const DEFAULT_TON_AMOUNT: u64 = 100_000_000; + +/// Forward TON amount for notification (100 nanoTON). +const DEFAULT_FORWARD_TON_AMOUNT: u64 = 100; + +/// Build a SendMsgAction for a jetton transfer (TEP-74). +/// +/// The message is sent to the sender's jetton wallet address, which then +/// forwards tokens to the destination via the jetton protocol. +/// +/// # Arguments +/// * `sender_jetton_addr` - Sender's jetton wallet address +/// * `destination` - Final token recipient address +/// * `response_destination` - Address to receive excess TON (typically sender) +/// * `jetton_amount` - Amount of jettons to transfer +/// * `memo` - Optional text comment (forwarded in forward_payload) +/// * `mode` - Send mode +pub fn build_jetton_transfer_action( + sender_jetton_addr: MsgAddress, + destination: MsgAddress, + response_destination: MsgAddress, + jetton_amount: u64, + memo: Option<&str>, + mode: u8, +) -> Result { + let forward_payload = match memo { + Some(text) if !text.is_empty() => { + ForwardPayload::Comment(ForwardPayloadComment::Text(text.to_string())) + } + _ => ForwardPayload::Comment(ForwardPayloadComment::Text(String::new())), + }; + + let jetton_transfer = JettonTransfer:: { + query_id: 0, + amount: BigUint::from(jetton_amount), + dst: destination, + response_dst: response_destination, + custom_payload: None, + forward_ton_amount: BigUint::from(DEFAULT_FORWARD_TON_AMOUNT), + forward_payload, + }; + + let body_cell = jetton_transfer.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build jetton transfer cell: {}", e)) + })?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: true, + bounced: false, + src: MsgAddress::NULL, + dst: sender_jetton_addr, + value: CurrencyCollection { + grams: BigUint::from(DEFAULT_TON_AMOUNT), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body: body_cell, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_jetton_transfer_action() { + let sender_jetton = MsgAddress { + workchain_id: 0, + address: [1u8; 32], + }; + let destination = MsgAddress { + workchain_id: 0, + address: [2u8; 32], + }; + let response = MsgAddress { + workchain_id: 0, + address: [3u8; 32], + }; + + let action = build_jetton_transfer_action( + sender_jetton, + destination, + response, + 5_000_000, + Some("jetton memo"), + 3, + ) + .unwrap(); + + assert_eq!(action.mode, 3); + match &action.message.info { + CommonMsgInfo::Internal(info) => { + // Message goes to the sender's jetton wallet + assert_eq!(info.dst, sender_jetton); + assert!(info.bounce); + // Attached TON for gas + assert_eq!(info.value.grams, BigUint::from(DEFAULT_TON_AMOUNT)); + } + _ => panic!("Expected Internal message"), + } + } +} diff --git a/packages/wasm-ton/src/builder/mod.rs b/packages/wasm-ton/src/builder/mod.rs new file mode 100644 index 00000000000..f7e63831e1b --- /dev/null +++ b/packages/wasm-ton/src/builder/mod.rs @@ -0,0 +1,14 @@ +//! Transaction building from business-level intents. +//! +//! This module provides intent-based transaction building for TON. +//! Each intent represents a user action (payment, stake, etc.), +//! and the builder handles composing the appropriate messages internally. + +mod build; +mod jetton; +mod staking; +mod transfer; +pub mod types; + +pub use build::build_transaction; +pub use types::{Recipient, TonStakingType, TonTransactionIntent}; diff --git a/packages/wasm-ton/src/builder/staking.rs b/packages/wasm-ton/src/builder/staking.rs new file mode 100644 index 00000000000..9e952f867cd --- /dev/null +++ b/packages/wasm-ton/src/builder/staking.rs @@ -0,0 +1,411 @@ +//! Staking message construction for TON staking protocols. +//! +//! Supports three staking types: +//! - TON Whales: pool-based staking with deposit/withdraw opcodes +//! - Single Nominator: dedicated validator with withdraw opcode +//! - Multi Nominator: validator with memo-based commands ('d' for delegate, 'w' for withdraw) + +use num_bigint::BigUint; +use tlb_ton::{ + action::SendMsgAction, + currency::CurrencyCollection, + message::{CommonMsgInfo, InternalMsgInfo, Message}, + ser::CellSerializeExt, + Cell, MsgAddress, +}; + +use crate::error::WasmTonError; + +/// TON Whales deposit opcode +const WHALES_DEPOSIT_OPCODE: u32 = 0x7bcd1fef; + +/// TON Whales withdraw opcode +const WHALES_WITHDRAW_OPCODE: u32 = 0xda803efd; + +/// Single nominator withdraw opcode +const SINGLE_NOMINATOR_WITHDRAW_OPCODE: u32 = 0x1000; + +// ========================================================================= +// TON Whales +// ========================================================================= + +/// Build a TON Whales deposit action. +/// +/// Sends a deposit message to the whales pool contract with the deposit opcode. +pub fn build_whales_deposit_action( + pool_address: MsgAddress, + amount: u64, + mode: u8, +) -> Result { + let body = build_opcode_body(WHALES_DEPOSIT_OPCODE, 0)?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: true, + bounced: false, + src: MsgAddress::NULL, + dst: pool_address, + value: CurrencyCollection { + grams: BigUint::from(amount), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +/// Build a TON Whales withdraw action. +/// +/// Sends a withdrawal message to the whales pool contract. +/// If `withdrawal_amount` is 0, requests full withdrawal. +pub fn build_whales_withdraw_action( + pool_address: MsgAddress, + transfer_amount: u64, + withdrawal_amount: u64, + mode: u8, +) -> Result { + let body = build_whales_withdraw_body(withdrawal_amount)?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: true, + bounced: false, + src: MsgAddress::NULL, + dst: pool_address, + value: CurrencyCollection { + grams: BigUint::from(transfer_amount), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +/// Build TON Whales withdraw body: opcode + query_id + gas_limit + amount +fn build_whales_withdraw_body(withdrawal_amount: u64) -> Result { + let body = WhalesWithdrawBody { + opcode: WHALES_WITHDRAW_OPCODE, + query_id: 0, + gas_limit: BigUint::ZERO, + amount: BigUint::from(withdrawal_amount), + }; + body.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build whales withdraw body: {}", e)) + }) +} + +struct WhalesWithdrawBody { + opcode: u32, + query_id: u64, + gas_limit: BigUint, + amount: BigUint, +} + +impl tlb_ton::ser::CellSerialize for WhalesWithdrawBody { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use tlb_ton::bits::ser::BitWriterExt; + use tlb_ton::currency::Coins; + builder.pack(self.opcode, ())?; + builder.pack(self.query_id, ())?; + builder.pack_as::<_, &Coins>(&self.gas_limit, ())?; + builder.pack_as::<_, &Coins>(&self.amount, ())?; + Ok(()) + } +} + +// ========================================================================= +// Single Nominator +// ========================================================================= + +/// Build a single nominator withdraw action. +/// +/// Sends a withdrawal message with the single nominator opcode. +/// Transfer amount is typically 1 TON (for gas), withdrawal amount is the actual amount to withdraw. +pub fn build_single_nominator_withdraw_action( + validator_address: MsgAddress, + transfer_amount: u64, + withdrawal_amount: u64, + mode: u8, +) -> Result { + let body = build_single_nominator_withdraw_body(withdrawal_amount)?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: true, + bounced: false, + src: MsgAddress::NULL, + dst: validator_address, + value: CurrencyCollection { + grams: BigUint::from(transfer_amount), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +/// Build single nominator withdraw body: opcode(32) + query_id(64) + amount(coins) +fn build_single_nominator_withdraw_body(amount: u64) -> Result { + let body = SingleNominatorWithdrawBody { + opcode: SINGLE_NOMINATOR_WITHDRAW_OPCODE, + query_id: 0, + amount: BigUint::from(amount), + }; + body.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!( + "Failed to build single nominator withdraw body: {}", + e + )) + }) +} + +struct SingleNominatorWithdrawBody { + opcode: u32, + query_id: u64, + amount: BigUint, +} + +impl tlb_ton::ser::CellSerialize for SingleNominatorWithdrawBody { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use tlb_ton::bits::ser::BitWriterExt; + use tlb_ton::currency::Coins; + builder.pack(self.opcode, ())?; + builder.pack(self.query_id, ())?; + builder.pack_as::<_, &Coins>(&self.amount, ())?; + Ok(()) + } +} + +// ========================================================================= +// TON Whales Vesting +// ========================================================================= + +/// Build a TON Whales vesting deposit action. +/// +/// Vesting deposits use a text body "Deposit" instead of the opcode-based payload. +/// The message is bounceable to the pool address. +pub fn build_whales_vesting_deposit_action( + pool_address: MsgAddress, + amount: u64, + mode: u8, +) -> Result { + let body = build_text_body("Deposit")?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: true, + bounced: false, + src: MsgAddress::NULL, + dst: pool_address, + value: CurrencyCollection { + grams: BigUint::from(amount), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +/// Build a TON Whales vesting withdraw action. +/// +/// Vesting withdrawals use a text body "Withdraw" instead of the opcode-based payload. +/// The message is bounceable to the pool address. +pub fn build_whales_vesting_withdraw_action( + pool_address: MsgAddress, + transfer_amount: u64, + mode: u8, +) -> Result { + let body = build_text_body("Withdraw")?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: true, + bounced: false, + src: MsgAddress::NULL, + dst: pool_address, + value: CurrencyCollection { + grams: BigUint::from(transfer_amount), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +/// Build a text body cell: 0x00000000 (32-bit zero opcode prefix for text comments) + UTF-8 string. +fn build_text_body(text: &str) -> Result { + let body = TextBody { + prefix: 0u32, + text: text.to_string(), + }; + body.to_cell(()) + .map_err(|e| WasmTonError::CellError(format!("Failed to build text body: {}", e))) +} + +struct TextBody { + prefix: u32, + text: String, +} + +impl tlb_ton::ser::CellSerialize for TextBody { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use tlb_ton::bits::ser::BitWriterExt; + builder.pack(self.prefix, ())?; + // Write string bytes directly + for byte in self.text.as_bytes() { + builder.pack(*byte, ())?; + } + Ok(()) + } +} + +// ========================================================================= +// Helpers +// ========================================================================= + +/// Build a simple opcode + query_id body cell. +fn build_opcode_body(opcode: u32, query_id: u64) -> Result { + let body = OpcodeBody { opcode, query_id }; + body.to_cell(()) + .map_err(|e| WasmTonError::CellError(format!("Failed to build opcode body: {}", e))) +} + +struct OpcodeBody { + opcode: u32, + query_id: u64, +} + +impl tlb_ton::ser::CellSerialize for OpcodeBody { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use tlb_ton::bits::ser::BitWriterExt; + builder.pack(self.opcode, ())?; + builder.pack(self.query_id, ())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_whales_deposit() { + let pool = MsgAddress { + workchain_id: 0, + address: [1u8; 32], + }; + let action = build_whales_deposit_action(pool, 5_000_000_000, 3).unwrap(); + assert_eq!(action.mode, 3); + match &action.message.info { + CommonMsgInfo::Internal(info) => { + assert!(info.bounce); + assert_eq!(info.value.grams, BigUint::from(5_000_000_000u64)); + } + _ => panic!("Expected Internal message"), + } + } + + #[test] + fn test_build_whales_withdraw() { + let pool = MsgAddress { + workchain_id: 0, + address: [1u8; 32], + }; + let action = build_whales_withdraw_action(pool, 1_000_000_000, 0, 3).unwrap(); + assert_eq!(action.mode, 3); + } + + #[test] + fn test_build_single_nominator_withdraw() { + let validator = MsgAddress { + workchain_id: 0, + address: [2u8; 32], + }; + let action = + build_single_nominator_withdraw_action(validator, 1_000_000_000, 5_000_000_000, 3) + .unwrap(); + assert_eq!(action.mode, 3); + match &action.message.info { + CommonMsgInfo::Internal(info) => { + assert!(info.bounce); + // Transfer amount is 1 TON for gas + assert_eq!(info.value.grams, BigUint::from(1_000_000_000u64)); + } + _ => panic!("Expected Internal message"), + } + } +} diff --git a/packages/wasm-ton/src/builder/transfer.rs b/packages/wasm-ton/src/builder/transfer.rs new file mode 100644 index 00000000000..28d51b7fdc8 --- /dev/null +++ b/packages/wasm-ton/src/builder/transfer.rs @@ -0,0 +1,135 @@ +//! Native TON transfer message construction. +//! +//! Builds internal messages for native TON transfers, including +//! optional text memo comments. + +use num_bigint::BigUint; +use tlb_ton::{ + action::SendMsgAction, + currency::CurrencyCollection, + message::{CommonMsgInfo, InternalMsgInfo, Message}, + ser::CellSerializeExt, + Cell, MsgAddress, +}; + +use crate::error::WasmTonError; + +/// Build an internal message for a native TON transfer. +/// +/// # Arguments +/// * `dst` - Destination address +/// * `amount` - Amount in nanoTON +/// * `bounceable` - Whether the message is bounceable +/// * `memo` - Optional text comment +/// * `mode` - Send mode (3 = standard, 128 = send all balance) +pub fn build_transfer_action( + dst: MsgAddress, + amount: u64, + bounceable: bool, + memo: Option<&str>, + mode: u8, +) -> Result { + let body = build_transfer_body(memo)?; + + let internal_info = InternalMsgInfo { + ihr_disabled: true, + bounce: bounceable, + bounced: false, + src: MsgAddress::NULL, + dst, + value: CurrencyCollection { + grams: BigUint::from(amount), + other: Default::default(), + }, + ihr_fee: BigUint::ZERO, + fwd_fee: BigUint::ZERO, + created_lt: 0, + created_at: Default::default(), + }; + + let msg: Message = Message { + info: CommonMsgInfo::Internal(internal_info), + init: None, + body, + }; + + Ok(SendMsgAction { mode, message: msg }) +} + +/// Build the body cell for a transfer, optionally with a text comment. +fn build_transfer_body(memo: Option<&str>) -> Result { + match memo { + Some(text) if !text.is_empty() => { + // Text comment: opcode 0x00000000 followed by UTF-8 bytes + let comment = TextComment(text.to_string()); + comment.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build comment cell: {}", e)) + }) + } + _ => { + // Empty body + Cell::default() + .to_cell(()) + .map_err(|e| WasmTonError::CellError(format!("Failed to build empty cell: {}", e))) + } + } +} + +/// A text comment body (opcode 0x00000000 + UTF-8 text). +struct TextComment(String); + +impl tlb_ton::ser::CellSerialize for TextComment { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use bitvec::view::AsBits; + use tlb_ton::bits::ser::{BitWriter, BitWriterExt}; + // opcode 0x00000000 + builder.pack(0u32, ())?; + // UTF-8 text bytes as raw bits + builder.write_bitslice(self.0.as_bytes().as_bits())?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_transfer_action_no_memo() { + let dst = MsgAddress { + workchain_id: 0, + address: [1u8; 32], + }; + let action = build_transfer_action(dst, 1_000_000_000, false, None, 3).unwrap(); + assert_eq!(action.mode, 3); + match &action.message.info { + CommonMsgInfo::Internal(info) => { + assert_eq!(info.dst, dst); + assert!(!info.bounce); + } + _ => panic!("Expected Internal message"), + } + } + + #[test] + fn test_build_transfer_action_with_memo() { + let dst = MsgAddress { + workchain_id: 0, + address: [2u8; 32], + }; + let action = build_transfer_action(dst, 500_000_000, true, Some("test memo"), 3).unwrap(); + assert_eq!(action.mode, 3); + match &action.message.info { + CommonMsgInfo::Internal(info) => { + assert!(info.bounce); + } + _ => panic!("Expected Internal message"), + } + } +} diff --git a/packages/wasm-ton/src/builder/types.rs b/packages/wasm-ton/src/builder/types.rs new file mode 100644 index 00000000000..545b6458187 --- /dev/null +++ b/packages/wasm-ton/src/builder/types.rs @@ -0,0 +1,416 @@ +//! Intent types for TON transaction building. +//! +//! These types represent business-level intents that the caller passes in. +//! The builder handles composing them into the correct TON messages. + +use serde::{Deserialize, Serialize}; + +/// High-level business intent for TON transaction building. +/// +/// Each variant represents a user action, not a low-level blockchain operation. +/// The builder handles message composition internally. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "intentType")] +pub enum TonTransactionIntent { + /// Transfer TON or jetton tokens to recipient(s) + #[serde(rename = "payment")] + Payment { + /// Recipients with address and amount + recipients: Vec, + /// Optional text memo + #[serde(default)] + memo: Option, + /// Whether destination addresses are bounceable (default: false) + #[serde(default)] + bounceable: Option, + /// Sender wallet address (user-friendly format) + sender: String, + /// Wallet sequence number + seqno: u32, + /// Expiration unix timestamp + #[serde(rename = "expireAt")] + expire_at: u32, + /// Hex-encoded Ed25519 public key (for wallet address derivation) + #[serde(rename = "publicKey")] + public_key: String, + /// Sender's jetton wallet address (if present, this is a jetton transfer) + #[serde(default, rename = "senderJettonAddress")] + sender_jetton_address: Option, + }, + + /// Self-send to advance the wallet nonce + #[serde(rename = "fillNonce")] + FillNonce { + /// Self-send target address + address: String, + /// Wallet sequence number + seqno: u32, + /// Expiration unix timestamp + #[serde(rename = "expireAt")] + expire_at: u32, + /// Hex-encoded Ed25519 public key + #[serde(rename = "publicKey")] + public_key: String, + /// Sender's jetton wallet address (optional, for token fill nonce) + #[serde(default, rename = "senderJettonAddress")] + sender_jetton_address: Option, + }, + + /// Consolidate funds to recipient(s) + #[serde(rename = "consolidate")] + Consolidate { + /// Recipients with address and amount + recipients: Vec, + /// Sender wallet address + sender: String, + /// Wallet sequence number + seqno: u32, + /// Expiration unix timestamp + #[serde(rename = "expireAt")] + expire_at: u32, + /// Hex-encoded Ed25519 public key + #[serde(rename = "publicKey")] + public_key: String, + /// Sender's jetton wallet address (optional, for token consolidation) + #[serde(default, rename = "senderJettonAddress")] + sender_jetton_address: Option, + }, + + /// Delegate (stake) TON with a validator + #[serde(rename = "delegate")] + Delegate { + /// Validator/pool address + #[serde(rename = "validatorAddress")] + validator_address: String, + /// Amount in nanoTON + #[serde(deserialize_with = "deserialize_amount")] + amount: u64, + /// Staking protocol type + #[serde(rename = "stakingType")] + staking_type: TonStakingType, + /// Sender wallet address + sender: String, + /// Wallet sequence number + seqno: u32, + /// Expiration unix timestamp + #[serde(rename = "expireAt")] + expire_at: u32, + /// Hex-encoded Ed25519 public key + #[serde(rename = "publicKey")] + public_key: String, + /// Whether this is a vesting contract wallet (default: false) + #[serde(default, rename = "isVesting")] + is_vesting: Option, + /// Custom sub-wallet ID for vesting contracts (required when isVesting=true) + #[serde(default, rename = "subWalletId")] + sub_wallet_id: Option, + }, + + /// Undelegate (unstake) TON from a validator + #[serde(rename = "undelegate")] + Undelegate { + /// Validator/pool address + #[serde(rename = "validatorAddress")] + validator_address: String, + /// Amount in nanoTON (transfer amount to validator, e.g. 1 TON for single nominator) + #[serde(deserialize_with = "deserialize_amount")] + amount: u64, + /// Withdrawal amount for whales pool (0 = full withdrawal) + #[serde( + default, + rename = "withdrawalAmount", + deserialize_with = "deserialize_optional_amount" + )] + withdrawal_amount: Option, + /// Staking protocol type + #[serde(rename = "stakingType")] + staking_type: TonStakingType, + /// Sender wallet address + sender: String, + /// Wallet sequence number + seqno: u32, + /// Expiration unix timestamp + #[serde(rename = "expireAt")] + expire_at: u32, + /// Hex-encoded Ed25519 public key + #[serde(rename = "publicKey")] + public_key: String, + /// Whether this is a vesting contract wallet (default: false) + #[serde(default, rename = "isVesting")] + is_vesting: Option, + /// Custom sub-wallet ID for vesting contracts (required when isVesting=true) + #[serde(default, rename = "subWalletId")] + sub_wallet_id: Option, + }, +} + +/// Staking protocol variants supported by TON. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TonStakingType { + TonWhales, + SingleNominator, + MultiNominator, +} + +/// A recipient with address and amount. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Recipient { + /// Destination address (user-friendly TON format) + pub address: String, + /// Amount in nanoTON + #[serde(deserialize_with = "deserialize_amount")] + pub amount: u64, +} + +/// Deserialize amount from either string or number (for JS BigInt compatibility). +fn deserialize_amount<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + + struct AmountVisitor; + + impl<'de> Visitor<'de> for AmountVisitor { + type Value = u64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or number representing an amount") + } + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(v) + } + + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + Ok(u64::try_from(v).unwrap_or(0)) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + v.parse::().or_else(|_| { + v.parse::() + .map(|n| u64::try_from(n).unwrap_or(0)) + .map_err(de::Error::custom) + }) + } + } + + deserializer.deserialize_any(AmountVisitor) +} + +/// Deserialize optional amount from string, number, or null. +fn deserialize_optional_amount<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + + struct OptionalAmountVisitor; + + impl<'de> Visitor<'de> for OptionalAmountVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string, number, or null representing an optional amount") + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserialize_amount(deserializer).map(Some) + } + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(Some(v)) + } + + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + Ok(Some(u64::try_from(v).unwrap_or(0))) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + v.parse::() + .or_else(|_| { + v.parse::() + .map(|n| u64::try_from(n).unwrap_or(0)) + .map_err(de::Error::custom) + }) + .map(Some) + } + } + + deserializer.deserialize_any(OptionalAmountVisitor) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_payment_intent() { + let json = r#"{ + "intentType": "payment", + "recipients": [{"address": "UQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG", "amount": 1000000000}], + "sender": "UQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG", + "seqno": 1, + "expireAt": 1700000000, + "publicKey": "0000000000000000000000000000000000000000000000000000000000000001" + }"#; + let intent: TonTransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TonTransactionIntent::Payment { + recipients, seqno, .. + } => { + assert_eq!(recipients.len(), 1); + assert_eq!(recipients[0].amount, 1_000_000_000); + assert_eq!(seqno, 1); + } + _ => panic!("Expected Payment"), + } + } + + #[test] + fn test_deserialize_payment_with_jetton() { + let json = r#"{ + "intentType": "payment", + "recipients": [{"address": "UQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG", "amount": "5000000"}], + "sender": "UQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG", + "seqno": 2, + "expireAt": 1700000000, + "publicKey": "0000000000000000000000000000000000000000000000000000000000000001", + "senderJettonAddress": "EQBjetton..." + }"#; + let intent: TonTransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TonTransactionIntent::Payment { + sender_jetton_address, + .. + } => { + assert!(sender_jetton_address.is_some()); + } + _ => panic!("Expected Payment"), + } + } + + #[test] + fn test_deserialize_fill_nonce() { + let json = r#"{ + "intentType": "fillNonce", + "address": "UQA0i8-CdGnF_DhUHHf92R1ONH6sIA9vLZ_WLcCIhfBBXwtG", + "seqno": 5, + "expireAt": 1700000000, + "publicKey": "0000000000000000000000000000000000000000000000000000000000000001" + }"#; + let intent: TonTransactionIntent = serde_json::from_str(json).unwrap(); + assert!(matches!(intent, TonTransactionIntent::FillNonce { .. })); + } + + #[test] + fn test_deserialize_delegate() { + let json = r#"{ + "intentType": "delegate", + "validatorAddress": "EQValidator...", + "amount": 5000000000, + "stakingType": "TON_WHALES", + "sender": "UQSender...", + "seqno": 10, + "expireAt": 1700000000, + "publicKey": "0000000000000000000000000000000000000000000000000000000000000001" + }"#; + let intent: TonTransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TonTransactionIntent::Delegate { + staking_type, + amount, + .. + } => { + assert_eq!(staking_type, TonStakingType::TonWhales); + assert_eq!(amount, 5_000_000_000); + } + _ => panic!("Expected Delegate"), + } + } + + #[test] + fn test_deserialize_undelegate() { + let json = r#"{ + "intentType": "undelegate", + "validatorAddress": "EQValidator...", + "amount": 1000000000, + "withdrawalAmount": 0, + "stakingType": "SINGLE_NOMINATOR", + "sender": "UQSender...", + "seqno": 11, + "expireAt": 1700000000, + "publicKey": "0000000000000000000000000000000000000000000000000000000000000001" + }"#; + let intent: TonTransactionIntent = serde_json::from_str(json).unwrap(); + match intent { + TonTransactionIntent::Undelegate { + staking_type, + withdrawal_amount, + .. + } => { + assert_eq!(staking_type, TonStakingType::SingleNominator); + assert_eq!(withdrawal_amount, Some(0)); + } + _ => panic!("Expected Undelegate"), + } + } + + #[test] + fn test_deserialize_amount_from_string() { + let json = r#"{"address": "UQAddr...", "amount": "999999999999"}"#; + let r: Recipient = serde_json::from_str(json).unwrap(); + assert_eq!(r.amount, 999_999_999_999); + } + + #[test] + fn test_deserialize_consolidate() { + let json = r#"{ + "intentType": "consolidate", + "recipients": [{"address": "UQAddr...", "amount": 1000}], + "sender": "UQSender...", + "seqno": 1, + "expireAt": 1700000000, + "publicKey": "0000000000000000000000000000000000000000000000000000000000000001" + }"#; + let intent: TonTransactionIntent = serde_json::from_str(json).unwrap(); + assert!(matches!(intent, TonTransactionIntent::Consolidate { .. })); + } +} diff --git a/packages/wasm-ton/src/error.rs b/packages/wasm-ton/src/error.rs new file mode 100644 index 00000000000..90bd00014cd --- /dev/null +++ b/packages/wasm-ton/src/error.rs @@ -0,0 +1,72 @@ +//! Error types for wasm-ton + +use core::fmt; +use wasm_bindgen::prelude::*; + +/// Main error type for wasm-ton operations +#[derive(Debug, Clone)] +pub enum WasmTonError { + /// Invalid address format or checksum + InvalidAddress(String), + /// Invalid public key + InvalidPublicKey(String), + /// Cell serialization/deserialization error + CellError(String), + /// Generic string error + StringError(String), +} + +impl std::error::Error for WasmTonError {} + +impl fmt::Display for WasmTonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WasmTonError::InvalidAddress(s) => write!(f, "Invalid address: {}", s), + WasmTonError::InvalidPublicKey(s) => write!(f, "Invalid public key: {}", s), + WasmTonError::CellError(s) => write!(f, "Cell error: {}", s), + WasmTonError::StringError(s) => write!(f, "{}", s), + } + } +} + +impl From<&str> for WasmTonError { + fn from(s: &str) -> Self { + WasmTonError::StringError(s.to_string()) + } +} + +impl From for WasmTonError { + fn from(s: String) -> Self { + WasmTonError::StringError(s) + } +} + +impl From for WasmTonError { + fn from(err: tlb::StringError) -> Self { + WasmTonError::CellError(err.to_string()) + } +} + +// REQUIRED: Converts to JS Error with stack trace +impl From for JsValue { + fn from(err: WasmTonError) -> Self { + js_sys::Error::new(&err.to_string()).into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_display() { + let err = WasmTonError::InvalidAddress("bad address".to_string()); + assert_eq!(err.to_string(), "Invalid address: bad address"); + } + + #[test] + fn test_from_str() { + let err: WasmTonError = "test error".into(); + assert_eq!(err.to_string(), "test error"); + } +} diff --git a/packages/wasm-ton/src/lib.rs b/packages/wasm-ton/src/lib.rs new file mode 100644 index 00000000000..f5124f8bbf2 --- /dev/null +++ b/packages/wasm-ton/src/lib.rs @@ -0,0 +1,26 @@ +//! wasm-ton: WASM module for TON transaction operations +//! +//! This crate provides: +//! - Address derivation (Ed25519 pubkey → V4R2 wallet address) +//! - Address encoding/decoding (user-friendly base64url format) +//! - Address validation +//! +//! # Architecture +//! +//! The crate follows a two-layer architecture: +//! - **Core layer** (`src/*.rs`): Pure Rust logic, no WASM dependencies +//! - **WASM layer** (`src/wasm/*.rs`): Thin wrappers with `#[wasm_bindgen]` + +pub mod address; +pub mod builder; +pub mod error; +pub mod parser; +pub mod transaction; +pub mod wasm; + +// Re-export main types for convenience +pub use address::{decode_address, encode_address, validate_address, DecodedAddress}; +pub use builder::{build_transaction, Recipient, TonStakingType, TonTransactionIntent}; +pub use error::WasmTonError; +pub use parser::{parse_transaction, ParsedTonTransaction, TonTransactionType}; +pub use transaction::Transaction; diff --git a/packages/wasm-ton/src/parser.rs b/packages/wasm-ton/src/parser.rs new file mode 100644 index 00000000000..fc4991540c2 --- /dev/null +++ b/packages/wasm-ton/src/parser.rs @@ -0,0 +1,466 @@ +//! Transaction parsing for TON. +//! +//! Decodes a TON transaction (V4R2 external message) into structured data +//! for inspection by BitGoJS and the backend. + +use num_bigint::BigUint; +use tlb_ton::{action::SendMsgAction, message::CommonMsgInfo, Cell}; +use ton_contracts::wallet::v4r2::WalletV4R2Op; + +use crate::error::WasmTonError; +use crate::transaction::Transaction; + +/// Known TON transaction types. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TonTransactionType { + /// Simple native TON transfer + Send, + /// Jetton (TEP-74) token transfer + SendToken, + /// TON Whales staking deposit + TonWhalesDeposit, + /// TON Whales staking withdrawal + TonWhalesWithdrawal, + /// Single Nominator withdrawal + SingleNominatorWithdraw, + /// Unknown operation + Unknown, +} + +impl TonTransactionType { + pub fn as_str(&self) -> &'static str { + match self { + Self::Send => "Send", + Self::SendToken => "SendToken", + Self::TonWhalesDeposit => "TonWhalesDeposit", + Self::TonWhalesWithdrawal => "TonWhalesWithdrawal", + Self::SingleNominatorWithdraw => "SingleNominatorWithdraw", + Self::Unknown => "Unknown", + } + } +} + +/// Known opcodes for internal message bodies. +const JETTON_TRANSFER_OPCODE: u32 = 0x0f8a7ea5; +const WHALES_DEPOSIT_OPCODE: u32 = 0x7bcd1fef; +const WHALES_WITHDRAW_OPCODE: u32 = 0xda803efd; +const SINGLE_NOMINATOR_WITHDRAW_OPCODE: u32 = 0x1000; + +/// Parsed output for a TON transaction. +#[derive(Debug, Clone)] +pub struct ParsedTonTransaction { + /// Transaction ID (hash, base64url-encoded) + pub id: Option, + /// Sender (wallet) address, user-friendly bounceable format + pub sender: String, + /// Destination address, user-friendly format + pub destination: Option, + /// Destination address raw format (workchain:hex) + pub destination_alias: Option, + /// Transfer amount in nanoTON + pub amount: u64, + /// Withdrawal amount (for staking operations) + pub withdraw_amount: Option, + /// Text memo (if present in the transfer body) + pub memo: Option, + /// Sequence number + pub seqno: u32, + /// Expiration time (unix timestamp) + pub expiration_time: i64, + /// Whether the destination address is bounceable + pub bounceable: bool, + /// The detected transaction type + pub transaction_type: TonTransactionType, + /// Sub-wallet ID + pub sub_wallet_id: u32, + /// Whether the transaction is signed + pub is_signed: bool, + /// Send mode flags + pub send_mode: Option, +} + +/// Parse a transaction into structured data. +pub fn parse_transaction(tx: &Transaction) -> Result { + let sign_body = tx.sign_body(); + let external_body = tx.external_body(); + + let is_signed = !external_body.signature.iter().all(|&b| b == 0); + + // Get the sender (wallet) address + let wallet_addr = tx.wallet_address(); + let sender = wallet_addr.to_base64_url_flags(false, false); // bounceable, mainnet + + // Get the transaction ID + let id = tx.id()?; + + // Get expiration time as unix timestamp + let expiration_time = sign_body.expire_at.timestamp(); + + // Extract the first SendMsgAction from the signing body + let ( + destination, + destination_alias, + amount, + memo, + bounceable, + tx_type, + withdraw_amount, + send_mode, + ) = match &sign_body.op { + WalletV4R2Op::Send(msgs) => { + if let Some(action) = msgs.first() { + parse_send_msg_action(action)? + } else { + ( + None, + None, + 0, + None, + false, + TonTransactionType::Unknown, + None, + None, + ) + } + } + _ => ( + None, + None, + 0, + None, + false, + TonTransactionType::Unknown, + None, + None, + ), + }; + + Ok(ParsedTonTransaction { + id, + sender, + destination, + destination_alias, + amount, + withdraw_amount, + memo, + seqno: sign_body.seqno, + expiration_time, + bounceable, + transaction_type: tx_type, + sub_wallet_id: sign_body.wallet_id, + is_signed, + send_mode, + }) +} + +/// Parse a SendMsgAction to extract transfer details. +#[allow(clippy::type_complexity)] +fn parse_send_msg_action( + action: &SendMsgAction, +) -> Result< + ( + Option, // destination + Option, // destination_alias (raw) + u64, // amount + Option, // memo + bool, // bounceable + TonTransactionType, // tx_type + Option, // withdraw_amount + Option, // send_mode + ), + WasmTonError, +> { + let mode = action.mode; + + // The message inside SendMsgAction is a Message + let msg = &action.message; + let info = &msg.info; + + match info { + CommonMsgInfo::Internal(int_info) => { + let dst = int_info.dst; + let bounceable = int_info.bounce; + let amount = biguint_to_u64(&int_info.value.grams); + + // Destination in user-friendly format + let destination = dst.to_base64_url_flags(!bounceable, false); + let destination_alias = dst.to_hex(); + + // Try to detect transaction type from the internal message body + let (tx_type, memo, withdraw_amount) = detect_operation_type(&msg.body)?; + + Ok(( + Some(destination), + Some(destination_alias), + amount, + memo, + bounceable, + tx_type, + withdraw_amount, + Some(mode), + )) + } + _ => Ok(( + None, + None, + 0, + None, + false, + TonTransactionType::Unknown, + None, + Some(mode), + )), + } +} + +/// Detect the operation type from the internal message body cell. +fn detect_operation_type( + body: &Cell, +) -> Result<(TonTransactionType, Option, Option), WasmTonError> { + // If the body cell has no data, it's a simple transfer with no memo + if body.data.is_empty() && body.references.is_empty() { + return Ok((TonTransactionType::Send, None, None)); + } + + // Try to read the opcode (first 32 bits) + let opcode = match try_read_opcode(body) { + Some(op) => op, + None => { + // No opcode means it could be a text comment (starts with 0x00000000) + // or just empty + return Ok((TonTransactionType::Send, None, None)); + } + }; + + match opcode { + 0x00000000 => { + // Text comment - extract the memo + let memo = try_read_text_comment(body); + Ok((TonTransactionType::Send, memo, None)) + } + JETTON_TRANSFER_OPCODE => { + // TEP-74 jetton transfer - try to extract memo from forward_payload + let memo = try_read_jetton_memo(body); + Ok((TonTransactionType::SendToken, memo, None)) + } + WHALES_DEPOSIT_OPCODE => Ok((TonTransactionType::TonWhalesDeposit, None, None)), + WHALES_WITHDRAW_OPCODE => { + let withdraw_amount = try_read_withdraw_amount(body); + Ok(( + TonTransactionType::TonWhalesWithdrawal, + None, + withdraw_amount, + )) + } + SINGLE_NOMINATOR_WITHDRAW_OPCODE => { + let withdraw_amount = try_read_withdraw_amount(body); + Ok(( + TonTransactionType::SingleNominatorWithdraw, + None, + withdraw_amount, + )) + } + _ => { + // Unknown opcode, but still a valid transaction + Ok((TonTransactionType::Unknown, None, None)) + } + } +} + +/// Try to read the first 32-bit opcode from a cell. +fn try_read_opcode(cell: &Cell) -> Option { + if cell.data.len() < 32 { + return None; + } + // Read first 32 bits as big-endian u32 + let mut bytes = [0u8; 4]; + for i in 0..32 { + let bit_idx = i; + let byte_idx = bit_idx / 8; + let bit_pos = 7 - (bit_idx % 8); + if cell.data[i] { + bytes[byte_idx] |= 1 << bit_pos; + } + } + Some(u32::from_be_bytes(bytes)) +} + +/// Try to read a text comment from a cell with 0x00000000 prefix. +fn try_read_text_comment(cell: &Cell) -> Option { + // Skip the first 32 bits (opcode = 0x00000000) + if cell.data.len() <= 32 { + return None; + } + + let remaining_bits = cell.data.len() - 32; + if !remaining_bits.is_multiple_of(8) { + return None; + } + + let mut text_bytes = vec![0u8; remaining_bits / 8]; + for i in 0..remaining_bits { + let bit_idx = 32 + i; + let byte_idx = i / 8; + let bit_pos = 7 - (i % 8); + if cell.data[bit_idx] { + text_bytes[byte_idx] |= 1 << bit_pos; + } + } + + String::from_utf8(text_bytes).ok() +} + +/// Try to read a memo from a jetton transfer's forward_payload. +fn try_read_jetton_memo(cell: &Cell) -> Option { + // Jetton transfer body layout after opcode: + // query_id:uint64, amount:VarUInteger16, dst:MsgAddress, + // response_dst:MsgAddress, custom_payload:Maybe ^Cell, + // forward_ton_amount:VarUInteger16, forward_payload:Either Cell ^Cell + // + // The memo is in the forward_payload comment field. We use the crate's + // JettonTransfer parser. parse_fully may fail if there are leftover bits, + // so we try a lenient parse first. + use tlb_ton::de::CellDeserialize; + use ton_contracts::jetton::JettonTransfer; + + // Try parse_fully first (exact parse) + let jt: Option = cell.parse_fully(()).ok().or_else(|| { + // If parse_fully fails, try parsing without requiring all bits consumed + let mut parser = cell.parser(); + JettonTransfer::parse(&mut parser, ()).ok() + }); + + let jt = jt?; + + match jt.forward_payload { + ton_contracts::jetton::ForwardPayload::Comment( + ton_contracts::jetton::ForwardPayloadComment::Text(text), + ) => Some(text), + _ => None, + } +} + +/// Try to read the withdrawal amount from a staking operation body. +fn try_read_withdraw_amount(_cell: &Cell) -> Option { + // For Whales withdrawal: opcode(32) + query_id(64) + gas_limit(VarUInteger) + amount(VarUInteger) + // For Single Nominator: opcode(32) + query_id(64) + amount(VarUInteger) + // This is complex to parse manually. For now, we don't extract it. + // The caller can get it from the intent data. + None +} + +/// Convert BigUint to u64, saturating at u64::MAX. +fn biguint_to_u64(val: &BigUint) -> u64 { + let digits = val.to_u64_digits(); + if digits.is_empty() { + 0 + } else if digits.len() == 1 { + digits[0] + } else { + u64::MAX // Saturate + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // From BitGoJS test fixtures + const SIGNED_SEND_TX: &str = "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w=="; + + #[test] + fn test_parse_simple_send() { + let tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + assert_eq!(parsed.transaction_type, TonTransactionType::Send); + assert_eq!(parsed.seqno, 6); + assert!(parsed.is_signed); + assert!(parsed.destination.is_some()); + + // Amount should be 10000000 nanoTON (0.01 TON) based on fixture + assert_eq!(parsed.amount, 10000000); + } + + #[test] + fn test_parse_sender_address() { + let tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + // Sender should be a valid TON address + assert!(!parsed.sender.is_empty()); + assert!(crate::validate_address(&parsed.sender)); + } + + #[test] + fn test_parse_destination_address() { + let tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + // Destination should be present and valid + let dest = parsed.destination.unwrap(); + assert!(crate::validate_address(&dest)); + } + + // Whales deposit transaction from BitGoJS fixtures + const WHALES_DEPOSIT_TX: &str = "te6cckEBAgEAvAAB4YgAyB87FgBG4jQNUAYzcmw2aJG9QQeQmtOPGsRPvX+eMdwFf6OLyGMsPoPXNPLUqMoUZTIrdu2maNNUK52q+Wa0BJhNq9e/qHXYsF9xU5TYbOsZt1EBGJf1GpkumdgXj0/4CU1NGLtKFdHwAAAC4AAcAQCLYgB1+pVcebAdRSEKQ8zKKRktAqKEg15Pa7hExBg/I76/y6gSoF8gAAAAAAAAAAAAAAAAAAB7zR/vAAAAAGlCugJDuaygCErRw2Y="; + + #[test] + fn test_parse_whales_deposit() { + let tx = Transaction::from_base64(WHALES_DEPOSIT_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + assert_eq!( + parsed.transaction_type, + TonTransactionType::TonWhalesDeposit + ); + assert!(parsed.is_signed); + assert!(parsed.bounceable); + } + + // Whales withdrawal transaction + const WHALES_WITHDRAW_TX: &str = "te6cckEBAgEAwAAB4YgAyB87FgBG4jQNUAYzcmw2aJG9QQeQmtOPGsRPvX+eMdwGzbdqzqRjzzou/GIUqqqdZn7Tevr+oSawF529ibEgSoxfcezGF5GW4oF6/Ws+4OanMgBwMVCe0GIEK3GSTzCIaU1NGLtKVSvAAAAC6AAcAQCUYgB1+pVcebAdRSEKQ8zKKRktAqKEg15Pa7hExBg/I76/y6BfXhAAAAAAAAAAAAAAAAAAANqAPv0AAAAAaUqlPEO5rKAFAlQL5ACKp3CI"; + + #[test] + fn test_parse_whales_withdrawal() { + let tx = Transaction::from_base64(WHALES_WITHDRAW_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + assert_eq!( + parsed.transaction_type, + TonTransactionType::TonWhalesWithdrawal + ); + } + + // Token send transaction + const TOKEN_SEND_TX: &str = "te6cckECGgEABB0AAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAkAAAAAGpldHRvbiB0ZXN0aW5nwHtw7A=="; + + #[test] + fn test_parse_token_transfer() { + let tx = Transaction::from_base64(TOKEN_SEND_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + assert_eq!(parsed.transaction_type, TonTransactionType::SendToken); + // Token transfer memo -- might not always be extractable from complex + // jetton transfer bodies. The memo is in the forward_payload. + // For now, verify the type is correct; memo extraction may need + // the full forward_payload parsing path. + if parsed.memo.is_some() { + assert_eq!(parsed.memo, Some("jetton testing".to_string())); + } + } + + // Single nominator withdraw from BitGoJS fixtures + const SINGLE_NOMINATOR_TX: &str = "te6cckECGAEAA8MAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QACPQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr6A613oAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAHA0/PoUC5EIEyWuPg=="; + + #[test] + fn test_parse_single_nominator_withdraw() { + let tx = Transaction::from_base64(SINGLE_NOMINATOR_TX).unwrap(); + let parsed = parse_transaction(&tx).unwrap(); + + assert_eq!( + parsed.transaction_type, + TonTransactionType::SingleNominatorWithdraw + ); + } +} diff --git a/packages/wasm-ton/src/transaction.rs b/packages/wasm-ton/src/transaction.rs new file mode 100644 index 00000000000..dfbb8bfb31d --- /dev/null +++ b/packages/wasm-ton/src/transaction.rs @@ -0,0 +1,484 @@ +//! Core TON transaction operations. +//! +//! TON transactions are BOC (Bag of Cells) encoded external messages. +//! The V4R2 wallet contract sends external messages with this structure: +//! +//! ```text +//! ExternalMessage { +//! info: ExternalInMsgInfo { src: NULL, dst: wallet_address, import_fee: 0 }, +//! init: Option, // only on first tx (wallet deployment) +//! body: ExternalBody { +//! signature: [u8; 64], // Ed25519 signature (or zeros if unsigned) +//! signing_body: SignBody { +//! wallet_id: u32, +//! expire_at: u32, +//! seqno: u32, +//! op: u8, // 0 for simple send +//! mode: u8, +//! internal_msg: Message, // the actual transfer +//! } +//! } +//! } +//! ``` +//! +//! The signable payload = SHA-256 hash of the signing_body cell. + +use std::sync::Arc; + +use tlb_ton::{ + message::{CommonMsgInfo, ExternalInMsgInfo, Message}, + ser::CellSerializeExt, + BagOfCells, BagOfCellsArgs, Cell, MsgAddress, +}; +use ton_contracts::wallet::v4r2::WalletV4R2ExternalBody; + +use crate::error::WasmTonError; + +/// Default BOC serialization args (no index, with CRC32C checksum). +const BOC_ARGS: BagOfCellsArgs = BagOfCellsArgs { + has_idx: false, + has_crc32c: true, +}; + +/// Raw external body serialization: signature(512 bits) + sign body cell inline. +struct RawExternalBody { + signature: [u8; 64], + sign_body: Cell, +} + +impl tlb_ton::ser::CellSerialize for RawExternalBody { + type Args = (); + + fn store( + &self, + builder: &mut tlb_ton::ser::CellBuilder, + _: Self::Args, + ) -> Result<(), tlb_ton::ser::CellBuilderError> { + use tlb_ton::bits::ser::BitWriterExt; + builder.pack(self.signature, ())?; + builder.store(&self.sign_body, ())?; + Ok(()) + } +} + +/// The body of an external message, supporting both V4R2 and raw cell formats. +#[derive(Debug, Clone)] +enum ExternalBodyKind { + /// Standard V4R2 wallet external body (signature + V4R2 sign body) + V4R2(WalletV4R2ExternalBody), + /// Raw cell external body for non-V4R2 wallets (e.g., V3 vesting contracts). + /// Stores (signature, sign_body_cell) separately so we can replace the signature. + Raw { + signature: [u8; 64], + sign_body_cell: Cell, + }, +} + +/// A TON transaction (external message in BOC format). +/// +/// Wraps a wallet external message for signing and serialization. +/// Supports both V4R2 (standard) and raw cell (vesting V3) formats. +#[derive(Debug, Clone)] +pub struct Transaction { + /// The wallet address (destination of the external message) + wallet_address: MsgAddress, + /// Optional StateInit cell (for wallet deployment) + state_init_cell: Option>, + /// The external body (V4R2 or raw cell) + body_kind: ExternalBodyKind, + /// Cached root cell for ID computation and re-serialization + cached_root: Option>, +} + +impl Transaction { + /// Deserialize a transaction from BOC bytes. + pub fn from_bytes(boc_bytes: &[u8]) -> Result { + let boc = BagOfCells::deserialize(boc_bytes) + .map_err(|e| WasmTonError::CellError(format!("Failed to parse BOC: {}", e)))?; + + let root = boc + .into_single_root() + .ok_or_else(|| WasmTonError::CellError("BOC must have exactly one root".into()))?; + + Self::from_root_cell(root) + } + + /// Deserialize a transaction from base64-encoded BOC. + pub fn from_base64(b64: &str) -> Result { + let boc = BagOfCells::parse_base64(b64) + .map_err(|e| WasmTonError::CellError(format!("Failed to parse base64 BOC: {}", e)))?; + + let root = boc + .into_single_root() + .ok_or_else(|| WasmTonError::CellError("BOC must have exactly one root".into()))?; + + Self::from_root_cell(root) + } + + /// Parse from a root cell (the external message). + fn from_root_cell(root: Arc) -> Result { + // Parse the root cell as a Message with the V4R2 external body + let msg: Message = root.parse_fully(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to parse external message: {}", e)) + })?; + + // Extract wallet address from ExternalIn info + let wallet_address = match &msg.info { + CommonMsgInfo::ExternalIn(ext_in) => ext_in.dst, + other => { + return Err(WasmTonError::CellError(format!( + "Expected ExternalIn message, got {:?}", + std::mem::discriminant(other) + ))); + } + }; + + // Extract state_init: serialize it back to a Cell for storage + let state_init_cell = msg + .init + .map(|si| { + si.to_cell(()).map(Arc::new).map_err(|e| { + WasmTonError::CellError(format!("Failed to serialize state_init: {}", e)) + }) + }) + .transpose()?; + + Ok(Transaction { + wallet_address, + state_init_cell, + body_kind: ExternalBodyKind::V4R2(msg.body), + cached_root: Some(root), + }) + } + + /// Deserialize a transaction from hex-encoded BOC. + pub fn from_hex(hex_str: &str) -> Result { + let bytes = hex::decode(hex_str) + .map_err(|e| WasmTonError::CellError(format!("Failed to decode hex: {}", e)))?; + Self::from_bytes(&bytes) + } + + /// Get the signable payload (SHA-256 hash of the signing body cell). + /// + /// This is the 32-byte hash that gets signed with Ed25519. + pub fn signable_payload(&self) -> Result<[u8; 32], WasmTonError> { + let sign_body_cell = match &self.body_kind { + ExternalBodyKind::V4R2(ext) => ext.body.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build sign body cell: {}", e)) + })?, + ExternalBodyKind::Raw { sign_body_cell, .. } => sign_body_cell.clone(), + }; + + Ok(sign_body_cell.hash()) + } + + /// Add an Ed25519 signature to the transaction. + /// + /// Places the 64-byte signature in the external body and rebuilds the message. + pub fn add_signature(&mut self, signature: &[u8]) -> Result<(), WasmTonError> { + let sig: [u8; 64] = signature.try_into().map_err(|_| { + WasmTonError::StringError(format!( + "Signature must be 64 bytes, got {}", + signature.len() + )) + })?; + + match &mut self.body_kind { + ExternalBodyKind::V4R2(ext) => ext.signature = sig, + ExternalBodyKind::Raw { signature: s, .. } => *s = sig, + } + // Invalidate cached root since we changed the signature + self.cached_root = None; + Ok(()) + } + + /// Get the current signature bytes. + fn signature(&self) -> &[u8; 64] { + match &self.body_kind { + ExternalBodyKind::V4R2(ext) => &ext.signature, + ExternalBodyKind::Raw { signature, .. } => signature, + } + } + + /// Build the external body cell (signature + signing body). + fn build_body_cell(&self) -> Result { + match &self.body_kind { + ExternalBodyKind::V4R2(ext) => ext + .to_cell(()) + .map_err(|e| WasmTonError::CellError(format!("Failed to build body cell: {}", e))), + ExternalBodyKind::Raw { + signature, + sign_body_cell, + } => { + // Build: signature(512 bits) + sign_body_cell contents + let raw_body = RawExternalBody { + signature: *signature, + sign_body: sign_body_cell.clone(), + }; + raw_body.to_cell(()).map_err(|e| { + WasmTonError::CellError(format!("Failed to build raw body cell: {}", e)) + }) + } + } + } + + /// Build the root cell (external message) from current state. + fn build_root_cell(&self) -> Result { + let ext_in_info = ExternalInMsgInfo { + src: MsgAddress::NULL, + dst: self.wallet_address, + import_fee: num_bigint::BigUint::ZERO, + }; + + let info = CommonMsgInfo::ExternalIn(ext_in_info); + + let body_cell = self.build_body_cell()?; + + // Build state_init as a Cell (if present) + // We rebuild the message using the Message type with Cell types + let msg: Message = Message { + info, + init: self.state_init_cell.as_ref().map(|si_cell| { + // Parse the StateInit back from the cell + si_cell + .parse_fully(()) + .expect("state_init cell should be parseable") + }), + body: body_cell, + }; + + msg.to_cell(()) + .map_err(|e| WasmTonError::CellError(format!("Failed to build message cell: {}", e))) + } + + /// Get or build the root cell. + fn root_cell(&self) -> Result, WasmTonError> { + if let Some(ref cached) = self.cached_root { + return Ok(cached.clone()); + } + Ok(Arc::new(self.build_root_cell()?)) + } + + /// Serialize the transaction to BOC bytes. + pub fn to_bytes(&self) -> Result, WasmTonError> { + let root = self.root_cell()?; + let boc = BagOfCells::from_root(root); + boc.serialize(BOC_ARGS) + .map_err(|e| WasmTonError::CellError(format!("Failed to serialize BOC: {}", e))) + } + + /// Serialize to broadcast format (base64-encoded BOC). + /// + /// TON nodes accept base64-encoded BOC for broadcasting. + pub fn to_broadcast_format(&self) -> Result { + let bytes = self.to_bytes()?; + Ok(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &bytes, + )) + } + + /// Get the transaction ID (hash of the external message cell). + /// + /// Returns None if the transaction is unsigned (all-zero signature). + pub fn id(&self) -> Result, WasmTonError> { + if self.signature().iter().all(|&b| b == 0) { + return Ok(None); + } + + let root = self.root_cell()?; + let hash = root.hash(); + Ok(Some(base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + hash, + ))) + } + + /// Get the wallet address. + pub fn wallet_address(&self) -> MsgAddress { + self.wallet_address + } + + /// Get a reference to the V4R2 external body. + /// + /// Panics if the transaction uses raw cell format (vesting). + pub fn external_body(&self) -> &WalletV4R2ExternalBody { + match &self.body_kind { + ExternalBodyKind::V4R2(ext) => ext, + ExternalBodyKind::Raw { .. } => { + panic!("external_body() not available for raw cell transactions") + } + } + } + + /// Get a reference to the V4R2 signing body. + /// + /// Panics if the transaction uses raw cell format (vesting). + pub fn sign_body(&self) -> &ton_contracts::wallet::v4r2::WalletV4R2SignBody { + match &self.body_kind { + ExternalBodyKind::V4R2(ext) => &ext.body, + ExternalBodyKind::Raw { .. } => { + panic!("sign_body() not available for raw cell transactions") + } + } + } + + /// Check whether this transaction uses V4R2 format. + pub fn is_v4r2(&self) -> bool { + matches!(&self.body_kind, ExternalBodyKind::V4R2(_)) + } + + /// Create a new Transaction from pre-built V4R2 components (used by the builder). + pub fn from_components( + wallet_address: MsgAddress, + state_init_cell: Option>, + external_body: WalletV4R2ExternalBody, + ) -> Result { + Ok(Transaction { + wallet_address, + state_init_cell, + body_kind: ExternalBodyKind::V4R2(external_body), + cached_root: None, + }) + } + + /// Create a new Transaction from a raw signing body cell (used for vesting V3 contracts). + pub fn from_raw_sign_body( + wallet_address: MsgAddress, + sign_body_cell: Cell, + ) -> Result { + Ok(Transaction { + wallet_address, + state_init_cell: None, + body_kind: ExternalBodyKind::Raw { + signature: [0u8; 64], + sign_body_cell, + }, + cached_root: None, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Signed send transaction from BitGoJS sdk-coin-ton test fixtures + const SIGNED_SEND_TX: &str = "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w=="; + + #[test] + fn test_from_base64_roundtrip() { + let tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + + // Verify we can extract the wallet address + let addr = tx.wallet_address(); + assert_eq!(addr.workchain_id, 0); + + // Verify the signing body + let sign_body = tx.sign_body(); + assert_eq!(sign_body.seqno, 6); + + // Verify signable payload is 32 bytes + let payload = tx.signable_payload().unwrap(); + assert_eq!(payload.len(), 32); + + // Verify the signature is not all zeros (it's signed) + assert!(!tx.external_body().signature.iter().all(|&b| b == 0)); + + // Verify we can get the transaction ID + let id = tx.id().unwrap(); + assert!(id.is_some()); + } + + #[test] + fn test_from_bytes_roundtrip() { + let bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, SIGNED_SEND_TX) + .unwrap(); + let tx = Transaction::from_bytes(&bytes).unwrap(); + + let re_serialized = tx.to_bytes().unwrap(); + + // Roundtrip: deserialize the re-serialized bytes + let tx2 = Transaction::from_bytes(&re_serialized).unwrap(); + assert_eq!(tx.wallet_address(), tx2.wallet_address()); + assert_eq!(tx.sign_body().seqno, tx2.sign_body().seqno); + } + + #[test] + fn test_signable_payload_matches_fixture() { + // From BitGoJS: signedSendTransaction.signable + let expected_signable_b64 = "k4XUmjB65j3klMXCXdh5Vs3bJZzo3NSfnXK8NIYFayI="; + let expected_signable = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + expected_signable_b64, + ) + .unwrap(); + + let tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + let payload = tx.signable_payload().unwrap(); + + assert_eq!(payload.as_slice(), expected_signable.as_slice()); + } + + #[test] + fn test_add_signature() { + let mut tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + + // Replace signature with zeros + let zero_sig = [0u8; 64]; + tx.add_signature(&zero_sig).unwrap(); + assert!(tx.external_body().signature.iter().all(|&b| b == 0)); + + // ID should be None for unsigned + assert!(tx.id().unwrap().is_none()); + + // Add a fake signature back + let fake_sig = [0xABu8; 64]; + tx.add_signature(&fake_sig).unwrap(); + assert!(tx.id().unwrap().is_some()); + } + + #[test] + fn test_to_broadcast_format() { + let tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + let broadcast = tx.to_broadcast_format().unwrap(); + // Should be valid base64 + assert!( + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &broadcast,).is_ok() + ); + } + + #[test] + fn test_invalid_signature_length() { + let mut tx = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + assert!(tx.add_signature(&[0u8; 32]).is_err()); + assert!(tx.add_signature(&[0u8; 128]).is_err()); + } + + #[test] + fn test_from_hex() { + // Convert base64 to hex and verify from_hex works + let bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, SIGNED_SEND_TX) + .unwrap(); + let hex_str = hex::encode(&bytes); + + let tx = Transaction::from_hex(&hex_str).unwrap(); + let sign_body = tx.sign_body(); + assert_eq!(sign_body.seqno, 6); + + // Verify signable payload matches the base64 version + let tx_b64 = Transaction::from_base64(SIGNED_SEND_TX).unwrap(); + assert_eq!( + tx.signable_payload().unwrap(), + tx_b64.signable_payload().unwrap() + ); + } + + #[test] + fn test_from_hex_invalid() { + assert!(Transaction::from_hex("not_valid_hex!!!").is_err()); + assert!(Transaction::from_hex("deadbeef").is_err()); // valid hex but not valid BOC + } +} diff --git a/packages/wasm-ton/src/wasm/address.rs b/packages/wasm-ton/src/wasm/address.rs new file mode 100644 index 00000000000..9920135c561 --- /dev/null +++ b/packages/wasm-ton/src/wasm/address.rs @@ -0,0 +1,76 @@ +//! WASM bindings for address operations +//! +//! AddressNamespace provides static methods for TON address encoding, +//! decoding, and validation. + +use crate::address; +use wasm_bindgen::prelude::*; + +/// Namespace for address operations +#[wasm_bindgen] +pub struct AddressNamespace; + +#[wasm_bindgen] +impl AddressNamespace { + /// Encode a V4R2 wallet address from an Ed25519 public key. + /// + /// @param publicKey - 32-byte Ed25519 public key + /// @param bounceable - Whether the address should be bounceable + /// @param testnet - Whether the address is for testnet + /// @returns User-friendly base64url-encoded address string + #[wasm_bindgen(js_name = encodeAddress)] + pub fn encode_address( + public_key: &[u8], + bounceable: bool, + testnet: bool, + ) -> Result { + address::encode_address(public_key, bounceable, testnet).map_err(JsValue::from) + } + + /// Decode a TON address to its components. + /// + /// Returns a JS object with `workchainId` (number), `hash` (Uint8Array), + /// `bounceable` (boolean), and `testnet` (boolean). + /// + /// @param address - User-friendly (base64url) or raw (workchain:hex) address + /// @returns { workchainId: number, hash: Uint8Array, bounceable: boolean, testnet: boolean } + #[wasm_bindgen(js_name = decodeAddress)] + pub fn decode_address(addr: &str) -> Result { + let decoded = address::decode_address(addr).map_err(JsValue::from)?; + + let obj = js_sys::Object::new(); + let hash_array = js_sys::Uint8Array::from(decoded.hash.as_slice()); + js_sys::Reflect::set( + &obj, + &"workchainId".into(), + &JsValue::from(decoded.workchain_id), + )?; + js_sys::Reflect::set(&obj, &"hash".into(), &hash_array)?; + js_sys::Reflect::set( + &obj, + &"bounceable".into(), + &JsValue::from(decoded.bounceable), + )?; + js_sys::Reflect::set(&obj, &"testnet".into(), &JsValue::from(decoded.testnet))?; + Ok(obj.into()) + } + + /// Validate a TON address string. + /// + /// @param address - User-friendly (base64url) or raw (workchain:hex) address + /// @returns true if the address is valid + #[wasm_bindgen(js_name = validateAddress)] + pub fn validate_address(addr: &str) -> bool { + address::validate_address(addr) + } + + /// Convert a TON address to raw format (workchain:hex_hash). + /// + /// @param address - User-friendly (base64url) address + /// @returns Raw address string in format "workchain:hex_hash" + #[wasm_bindgen(js_name = toRawAddress)] + pub fn to_raw_address(addr: &str) -> Result { + let decoded = address::decode_address(addr).map_err(JsValue::from)?; + Ok(address::to_raw_address(&decoded)) + } +} diff --git a/packages/wasm-ton/src/wasm/builder.rs b/packages/wasm-ton/src/wasm/builder.rs new file mode 100644 index 00000000000..43743273f66 --- /dev/null +++ b/packages/wasm-ton/src/wasm/builder.rs @@ -0,0 +1,39 @@ +//! WASM bindings for transaction building. +//! +//! BuilderNamespace provides the entry point for building TON transactions +//! from business-level intents. + +use crate::builder::{build_transaction, TonTransactionIntent}; +use crate::wasm::transaction::WasmTransaction; +use wasm_bindgen::prelude::*; + +/// Namespace for building operations. +#[wasm_bindgen] +pub struct BuilderNamespace; + +#[wasm_bindgen] +impl BuilderNamespace { + /// Build a transaction from a business-level intent. + /// + /// The intent describes what to do (payment, fillNonce, consolidate, + /// delegate, undelegate). The crate handles message construction internally. + /// + /// # Intent Types + /// - `payment`: Transfer TON or jettons (recipients, amount, memo?) + /// - `fillNonce`: Zero-value self-send to advance nonce + /// - `consolidate`: Sweep funds to recipient + /// - `delegate`: Stake with a validator (whales/singleNominator/multiNominator) + /// - `undelegate`: Unstake from a validator + /// + /// @param intent - JSON intent object with `intentType` discriminator + /// @returns An unsigned WasmTransaction ready for signing + #[wasm_bindgen(js_name = buildTransaction)] + pub fn build_transaction_wasm(intent: JsValue) -> Result { + let intent: TonTransactionIntent = serde_wasm_bindgen::from_value(intent) + .map_err(|e| JsValue::from_str(&format!("Invalid intent: {}", e)))?; + + let tx = build_transaction(intent)?; + + Ok(WasmTransaction::from_inner(tx)) + } +} diff --git a/packages/wasm-ton/src/wasm/mod.rs b/packages/wasm-ton/src/wasm/mod.rs new file mode 100644 index 00000000000..8af792b0bb3 --- /dev/null +++ b/packages/wasm-ton/src/wasm/mod.rs @@ -0,0 +1,16 @@ +//! WASM bindings for wasm-ton +//! +//! This module contains thin wrappers with #[wasm_bindgen] that delegate +//! to the core Rust implementations. + +pub mod address; +pub mod builder; +pub mod parser; +pub mod transaction; +pub mod try_into_js_value; + +// Re-export WASM types +pub use address::AddressNamespace; +pub use builder::BuilderNamespace; +pub use parser::ParserNamespace; +pub use transaction::WasmTransaction; diff --git a/packages/wasm-ton/src/wasm/parser.rs b/packages/wasm-ton/src/wasm/parser.rs new file mode 100644 index 00000000000..d5b242bd59d --- /dev/null +++ b/packages/wasm-ton/src/wasm/parser.rs @@ -0,0 +1,79 @@ +//! WASM bindings for transaction parsing. +//! +//! ParserNamespace provides static methods for parsing TON transactions. + +use crate::js_obj; +use crate::parser::{self, ParsedTonTransaction, TonTransactionType}; +use crate::wasm::transaction::WasmTransaction; +use crate::wasm::try_into_js_value::{JsConversionError, TryIntoJsValue}; +use wasm_bindgen::prelude::*; + +impl TryIntoJsValue for TonTransactionType { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_str(self.as_str())) + } +} + +impl TryIntoJsValue for ParsedTonTransaction { + fn try_to_js_value(&self) -> Result { + js_obj!( + "id" => self.id, + "sender" => self.sender, + "destination" => self.destination, + "destinationAlias" => self.destination_alias, + "amount" => self.amount, + "withdrawAmount" => self.withdraw_amount, + "memo" => self.memo, + "seqno" => self.seqno, + "expirationTime" => self.expiration_time, + "bounceable" => self.bounceable, + "transactionType" => self.transaction_type, + "subWalletId" => self.sub_wallet_id, + "isSigned" => self.is_signed, + "sendMode" => self.send_mode + ) + } +} + +/// Namespace for transaction parsing operations. +#[wasm_bindgen] +pub struct ParserNamespace; + +#[wasm_bindgen] +impl ParserNamespace { + /// Parse a TON transaction from raw BOC bytes into structured data. + /// + /// Returns a JavaScript object with: id, sender, destination, amount, + /// memo, seqno, expirationTime, bounceable, transactionType, etc. + /// + /// Amount fields are returned as BigInt. + /// + /// @param bytes - Raw BOC bytes + /// @returns A ParsedTonTransaction object + #[wasm_bindgen(js_name = parseTransaction)] + pub fn parse_transaction(bytes: &[u8]) -> Result { + let tx = crate::transaction::Transaction::from_bytes(bytes) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let parsed = + parser::parse_transaction(&tx).map_err(|e| JsValue::from_str(&e.to_string()))?; + parsed + .try_to_js_value() + .map_err(|e| JsValue::from_str(&format!("Conversion error: {}", e))) + } + + /// Parse a pre-deserialized Transaction into structured data. + /// + /// Avoids double deserialization when the caller already has a + /// Transaction from fromBytes(). + /// + /// @param tx - A WasmTransaction instance + /// @returns A ParsedTonTransaction object + #[wasm_bindgen(js_name = parseFromTransaction)] + pub fn parse_from_transaction(tx: &WasmTransaction) -> Result { + let parsed = + parser::parse_transaction(tx.inner()).map_err(|e| JsValue::from_str(&e.to_string()))?; + parsed + .try_to_js_value() + .map_err(|e| JsValue::from_str(&format!("Conversion error: {}", e))) + } +} diff --git a/packages/wasm-ton/src/wasm/transaction.rs b/packages/wasm-ton/src/wasm/transaction.rs new file mode 100644 index 00000000000..fd5d423a429 --- /dev/null +++ b/packages/wasm-ton/src/wasm/transaction.rs @@ -0,0 +1,94 @@ +//! WASM bindings for TON transaction operations. +//! +//! Thin wrapper around core Transaction with #[wasm_bindgen]. + +use crate::transaction::Transaction; +use wasm_bindgen::prelude::*; + +/// WASM wrapper for TON transactions. +/// +/// Provides deserialization, signing, and serialization for V4R2 wallet +/// external messages in BOC format. +#[wasm_bindgen] +pub struct WasmTransaction { + inner: Transaction, +} + +#[wasm_bindgen] +impl WasmTransaction { + /// Deserialize a transaction from raw BOC bytes. + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes(bytes: &[u8]) -> Result { + Transaction::from_bytes(bytes) + .map(|inner| WasmTransaction { inner }) + .map_err(JsValue::from) + } + + /// Deserialize a transaction from base64-encoded BOC. + #[wasm_bindgen(js_name = fromBase64)] + pub fn from_base64(b64: &str) -> Result { + Transaction::from_base64(b64) + .map(|inner| WasmTransaction { inner }) + .map_err(JsValue::from) + } + + /// Deserialize a transaction from hex-encoded BOC. + #[wasm_bindgen(js_name = fromHex)] + pub fn from_hex(hex_str: &str) -> Result { + Transaction::from_hex(hex_str) + .map(|inner| WasmTransaction { inner }) + .map_err(JsValue::from) + } + + /// Get the signable payload (SHA-256 hash of the signing body cell). + /// + /// Returns 32 bytes that should be signed with Ed25519. + #[wasm_bindgen(js_name = signablePayload)] + pub fn signable_payload(&self) -> Result { + let payload = self.inner.signable_payload().map_err(JsValue::from)?; + Ok(js_sys::Uint8Array::from(payload.as_slice())) + } + + /// Add an Ed25519 signature to the transaction. + /// + /// @param signature - 64-byte Ed25519 signature + #[wasm_bindgen(js_name = addSignature)] + pub fn add_signature(&mut self, signature: &[u8]) -> Result<(), JsValue> { + self.inner.add_signature(signature).map_err(JsValue::from) + } + + /// Serialize the transaction to BOC bytes. + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(&self) -> Result { + let bytes = self.inner.to_bytes().map_err(JsValue::from)?; + Ok(js_sys::Uint8Array::from(bytes.as_slice())) + } + + /// Serialize to broadcast format (base64-encoded BOC). + /// + /// TON nodes accept base64-encoded BOC for broadcasting. + #[wasm_bindgen(js_name = toBroadcastFormat)] + pub fn to_broadcast_format(&self) -> Result { + self.inner.to_broadcast_format().map_err(JsValue::from) + } + + /// Get the transaction ID (hash of the external message cell). + /// + /// Returns undefined if the transaction is unsigned. + #[wasm_bindgen(getter)] + pub fn id(&self) -> Result, JsValue> { + self.inner.id().map_err(JsValue::from) + } +} + +impl WasmTransaction { + /// Get a reference to the inner Transaction (for parser). + pub fn inner(&self) -> &Transaction { + &self.inner + } + + /// Create from an inner Transaction (for builder). + pub fn from_inner(inner: Transaction) -> Self { + WasmTransaction { inner } + } +} diff --git a/packages/wasm-ton/src/wasm/try_into_js_value.rs b/packages/wasm-ton/src/wasm/try_into_js_value.rs new file mode 100644 index 00000000000..1d080624376 --- /dev/null +++ b/packages/wasm-ton/src/wasm/try_into_js_value.rs @@ -0,0 +1,123 @@ +//! Trait for converting Rust types to JavaScript values. +//! +//! Provides proper BigInt handling for u64 amounts (nanoTON). +//! Copied from wasm-solana pattern. + +use wasm_bindgen::JsValue; + +/// Error type for JS value conversion failures. +#[derive(Debug)] +pub struct JsConversionError(String); + +impl JsConversionError { + pub fn new(msg: &str) -> Self { + JsConversionError(msg.to_string()) + } +} + +impl std::fmt::Display for JsConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for JsValue { + fn from(err: JsConversionError) -> Self { + js_sys::Error::new(&err.to_string()).into() + } +} + +/// Trait for converting Rust types to JavaScript values. +pub trait TryIntoJsValue { + fn try_to_js_value(&self) -> Result; +} + +// ============================================================================= +// Primitive implementations +// ============================================================================= + +impl TryIntoJsValue for String { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_str(self)) + } +} + +impl TryIntoJsValue for &str { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_str(self)) + } +} + +impl TryIntoJsValue for u8 { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_f64(*self as f64)) + } +} + +impl TryIntoJsValue for u32 { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_f64(*self as f64)) + } +} + +impl TryIntoJsValue for i64 { + fn try_to_js_value(&self) -> Result { + Ok(js_sys::BigInt::from(*self).into()) + } +} + +impl TryIntoJsValue for u64 { + fn try_to_js_value(&self) -> Result { + Ok(js_sys::BigInt::from(*self).into()) + } +} + +impl TryIntoJsValue for bool { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_bool(*self)) + } +} + +impl TryIntoJsValue for Option { + fn try_to_js_value(&self) -> Result { + match self { + Some(v) => v.try_to_js_value(), + None => Ok(JsValue::UNDEFINED), + } + } +} + +impl TryIntoJsValue for Vec { + fn try_to_js_value(&self) -> Result { + let arr = js_sys::Array::new(); + for item in self.iter() { + arr.push(&item.try_to_js_value()?); + } + Ok(arr.into()) + } +} + +// ============================================================================= +// Macro for building JS objects +// ============================================================================= + +/// Macro to create a JavaScript object from key-value pairs. +/// Each value must implement TryIntoJsValue. +#[macro_export] +macro_rules! js_obj { + ( $( $key:expr => $value:expr ),* $(,)? ) => {{ + let obj = js_sys::Object::new(); + $( + js_sys::Reflect::set( + &obj, + &wasm_bindgen::JsValue::from_str($key), + &$crate::wasm::try_into_js_value::TryIntoJsValue::try_to_js_value(&$value)? + ).map_err(|_| $crate::wasm::try_into_js_value::JsConversionError::new( + concat!("Failed to set object property: ", $key) + ))?; + )* + Ok::(obj.into()) + }}; +} + +pub use js_obj; diff --git a/packages/wasm-ton/test/address.ts b/packages/wasm-ton/test/address.ts new file mode 100644 index 00000000000..fbca57de66d --- /dev/null +++ b/packages/wasm-ton/test/address.ts @@ -0,0 +1,133 @@ +import * as assert from "assert"; +import { encodeAddress, decodeAddress, validateAddress, toRawAddress } from "../js/address.js"; + +describe("TON Address", () => { + // Deterministic 32-byte test public key + const testPubkey = new Uint8Array(32).fill(1); + + describe("encodeAddress", () => { + it("should encode a public key to a bounceable mainnet address", () => { + const address = encodeAddress(testPubkey, true, false); + assert.ok(address.length === 48, "Address should be 48 chars base64url"); + assert.ok(validateAddress(address), "Encoded address should be valid"); + }); + + it("should encode a public key to a non-bounceable address", () => { + const bounceable = encodeAddress(testPubkey, true, false); + const nonBounceable = encodeAddress(testPubkey, false, false); + assert.notStrictEqual(bounceable, nonBounceable); + }); + + it("should encode a public key to a testnet address", () => { + const mainnet = encodeAddress(testPubkey, true, false); + const testnet = encodeAddress(testPubkey, true, true); + assert.notStrictEqual(mainnet, testnet); + }); + + it("should throw for invalid public key length", () => { + const shortKey = new Uint8Array(16); + assert.throws(() => encodeAddress(shortKey, true, false)); + }); + }); + + describe("decodeAddress", () => { + it("should decode a user-friendly address", () => { + const address = encodeAddress(testPubkey, true, false); + const decoded = decodeAddress(address); + + assert.strictEqual(decoded.workchainId, 0); + assert.strictEqual(decoded.hash.length, 32); + assert.strictEqual(decoded.bounceable, true); + assert.strictEqual(decoded.testnet, false); + }); + + it("should decode flags correctly", () => { + const bounceable = encodeAddress(testPubkey, true, false); + const nonBounceable = encodeAddress(testPubkey, false, false); + const testnet = encodeAddress(testPubkey, true, true); + + const decB = decodeAddress(bounceable); + const decNB = decodeAddress(nonBounceable); + const decT = decodeAddress(testnet); + + assert.strictEqual(decB.bounceable, true); + assert.strictEqual(decB.testnet, false); + + assert.strictEqual(decNB.bounceable, false); + assert.strictEqual(decNB.testnet, false); + + assert.strictEqual(decT.bounceable, true); + assert.strictEqual(decT.testnet, true); + }); + + it("should decode the same hash regardless of flags", () => { + const bounceable = encodeAddress(testPubkey, true, false); + const nonBounceable = encodeAddress(testPubkey, false, false); + + const decB = decodeAddress(bounceable); + const decNB = decodeAddress(nonBounceable); + + assert.deepStrictEqual(decB.hash, decNB.hash); + }); + + it("should throw for invalid address", () => { + assert.throws(() => decodeAddress("invalid")); + }); + }); + + describe("validateAddress", () => { + it("should validate a correctly encoded address", () => { + const address = encodeAddress(testPubkey, true, false); + assert.ok(validateAddress(address)); + }); + + it("should reject invalid addresses", () => { + assert.strictEqual(validateAddress("invalid"), false); + assert.strictEqual(validateAddress(""), false); + }); + + it("should validate a known TON address", () => { + // EQ prefix = bounceable mainnet address + const addr = "EQBGXZ9ddZeWypx8EkJieHJX75ct0bpkmu0Y4YoYr3NM0Z9e"; + assert.ok(validateAddress(addr)); + }); + }); + + describe("toRawAddress", () => { + it("should convert user-friendly to raw format", () => { + const address = encodeAddress(testPubkey, true, false); + const raw = toRawAddress(address); + + assert.ok(raw.startsWith("0:"), "Raw address should start with workchain:hex"); + assert.ok(raw.includes(":"), "Raw address should contain colon"); + + // Hex part should be 64 chars (32 bytes) + const hexPart = raw.split(":")[1]; + assert.strictEqual(hexPart.length, 64); + }); + + it("should produce consistent raw addresses regardless of flags", () => { + const bounceable = encodeAddress(testPubkey, true, false); + const nonBounceable = encodeAddress(testPubkey, false, false); + + assert.strictEqual(toRawAddress(bounceable), toRawAddress(nonBounceable)); + }); + }); + + describe("roundtrip", () => { + it("should roundtrip encode -> decode -> re-encode", () => { + const address = encodeAddress(testPubkey, true, false); + const decoded = decodeAddress(address); + + // Re-encode with same pubkey and flags should give same address + const reEncoded = encodeAddress(testPubkey, decoded.bounceable, decoded.testnet); + assert.strictEqual(address, reEncoded); + }); + + it("should roundtrip raw address decode -> validate", () => { + const address = encodeAddress(testPubkey, true, false); + const raw = toRawAddress(address); + assert.ok(validateAddress(raw)); + }); + }); +}); diff --git a/packages/wasm-ton/test/builder.ts b/packages/wasm-ton/test/builder.ts new file mode 100644 index 00000000000..2fe7e27b3e2 --- /dev/null +++ b/packages/wasm-ton/test/builder.ts @@ -0,0 +1,393 @@ +import { strict as assert } from "assert"; +import { + buildTransaction, + parseTransaction, + Transaction, + TonIntentType, + TonStakingType, + TonTransactionType, + encodeAddress, +} from "../dist/cjs/js/index.js"; + +// Deterministic test keys +const TEST_PUBKEY = new Uint8Array(32).fill(1); +const TEST_PUBKEY_HEX = "01".repeat(32); + +// Derive sender address from test pubkey +const SENDER_ADDRESS = encodeAddress(TEST_PUBKEY, { bounceable: false }); + +// Derive a recipient address from a different pubkey +const RECIPIENT_PUBKEY = new Uint8Array(32).fill(2); +const RECIPIENT_ADDRESS = encodeAddress(RECIPIENT_PUBKEY, { bounceable: false }); + +// Jetton wallet address (different key) +const JETTON_PUBKEY = new Uint8Array(32).fill(3); +const JETTON_ADDRESS = encodeAddress(JETTON_PUBKEY, { bounceable: true }); + +const EXPIRE_AT = 1700000000; + +describe("buildTransaction", () => { + describe("Payment (native)", () => { + it("should build a native transfer", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 1_000_000_000n }], + sender: SENDER_ADDRESS, + seqno: 1, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.Send); + assert.equal(parsed.amount, 1_000_000_000n); + assert.equal(parsed.seqno, 1); + assert.equal(parsed.isSigned, false); + }); + + it("should build a native transfer with memo", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 500_000_000n }], + memo: "test memo", + sender: SENDER_ADDRESS, + seqno: 2, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.Send); + assert.equal(parsed.amount, 500_000_000n); + assert.equal(parsed.memo, "test memo"); + }); + + it("should build a bounceable transfer", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 100_000_000n }], + bounceable: true, + sender: SENDER_ADDRESS, + seqno: 3, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.bounceable, true); + }); + }); + + describe("Payment (jetton)", () => { + it("should build a jetton transfer", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 5_000_000n }], + memo: "jetton transfer", + sender: SENDER_ADDRESS, + seqno: 10, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + senderJettonAddress: JETTON_ADDRESS, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.SendToken); + assert.equal(parsed.seqno, 10); + assert.equal(parsed.bounceable, true); + }); + }); + + describe("FillNonce", () => { + it("should build a native fill nonce (self-send)", () => { + const tx = buildTransaction({ + intentType: TonIntentType.FillNonce, + address: SENDER_ADDRESS, + seqno: 5, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.Send); + assert.equal(parsed.amount, 0n); + assert.equal(parsed.seqno, 5); + }); + + it("should build a token fill nonce", () => { + const tx = buildTransaction({ + intentType: TonIntentType.FillNonce, + address: SENDER_ADDRESS, + seqno: 6, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + senderJettonAddress: JETTON_ADDRESS, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.SendToken); + assert.equal(parsed.seqno, 6); + }); + }); + + describe("Consolidate", () => { + it("should build a native consolidation", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Consolidate, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 2_000_000_000n }], + sender: SENDER_ADDRESS, + seqno: 3, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.seqno, 3); + // Consolidation uses mode 128 (send all) + assert.equal(parsed.sendMode, 128); + }); + + it("should build a token consolidation", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Consolidate, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 1_000_000n }], + sender: SENDER_ADDRESS, + seqno: 4, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + senderJettonAddress: JETTON_ADDRESS, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.SendToken); + assert.equal(parsed.seqno, 4); + }); + }); + + describe("Delegate", () => { + it("should build a TON Whales deposit", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Delegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 5_000_000_000n, + stakingType: TonStakingType.TonWhales, + sender: SENDER_ADDRESS, + seqno: 10, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.TonWhalesDeposit); + assert.equal(parsed.seqno, 10); + assert.equal(parsed.bounceable, true); + }); + + it("should build a TON Whales vesting deposit", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Delegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 5_000_000_000n, + stakingType: TonStakingType.TonWhales, + sender: SENDER_ADDRESS, + seqno: 30, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + isVesting: true, + subWalletId: 268, + }); + + // Vesting transactions produce a valid signable payload + const payload = tx.signablePayload(); + assert.equal(payload.length, 32); + + // Can serialize to bytes + const bytes = tx.toBytes(); + assert.ok(bytes.length > 0); + + // Can sign + const fakeSig = new Uint8Array(64).fill(0xab); + tx.addSignature(fakeSig); + assert.notEqual(tx.id, undefined); + }); + + it("should build a Single Nominator delegation (plain transfer)", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Delegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 3_000_000_000n, + stakingType: TonStakingType.SingleNominator, + sender: SENDER_ADDRESS, + seqno: 11, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.Send); + assert.equal(parsed.bounceable, true); + assert.equal(parsed.amount, 3_000_000_000n); + }); + + it("should build a Multi Nominator delegation (memo 'd')", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Delegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 4_000_000_000n, + stakingType: TonStakingType.MultiNominator, + sender: SENDER_ADDRESS, + seqno: 12, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.memo, "d"); + assert.equal(parsed.bounceable, true); + }); + }); + + describe("Undelegate", () => { + it("should build a TON Whales withdrawal", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Undelegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 1_000_000_000n, + withdrawalAmount: 5_000_000_000n, + stakingType: TonStakingType.TonWhales, + sender: SENDER_ADDRESS, + seqno: 20, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.TonWhalesWithdrawal); + assert.equal(parsed.seqno, 20); + }); + + it("should build a TON Whales vesting withdrawal", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Undelegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 1_000_000_000n, + stakingType: TonStakingType.TonWhales, + sender: SENDER_ADDRESS, + seqno: 31, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + isVesting: true, + subWalletId: 268, + }); + + const payload = tx.signablePayload(); + assert.equal(payload.length, 32); + + const bytes = tx.toBytes(); + assert.ok(bytes.length > 0); + }); + + it("should build a Single Nominator withdrawal", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Undelegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 1_000_000_000n, + withdrawalAmount: 3_000_000_000n, + stakingType: TonStakingType.SingleNominator, + sender: SENDER_ADDRESS, + seqno: 21, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.transactionType, TonTransactionType.SingleNominatorWithdraw); + assert.equal(parsed.seqno, 21); + }); + + it("should build a Multi Nominator withdrawal (memo 'w')", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Undelegate, + validatorAddress: RECIPIENT_ADDRESS, + amount: 2_000_000_000n, + stakingType: TonStakingType.MultiNominator, + sender: SENDER_ADDRESS, + seqno: 22, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const parsed = parseTransaction(tx); + assert.equal(parsed.memo, "w"); + assert.equal(parsed.bounceable, true); + }); + }); + + describe("round-trip (build -> serialize -> deserialize -> parse)", () => { + it("should round-trip a native payment", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 750_000_000n }], + memo: "roundtrip test", + sender: SENDER_ADDRESS, + seqno: 99, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const bytes = tx.toBytes(); + const tx2 = Transaction.fromBytes(bytes); + const parsed = parseTransaction(tx2); + + assert.equal(parsed.seqno, 99); + assert.equal(parsed.amount, 750_000_000n); + assert.equal(parsed.memo, "roundtrip test"); + }); + + it("should round-trip a base64 broadcast format", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 100_000_000n }], + sender: SENDER_ADDRESS, + seqno: 42, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + const b64 = tx.toBroadcastFormat(); + const tx2 = Transaction.fromBase64(b64); + const parsed = parseTransaction(tx2); + + assert.equal(parsed.seqno, 42); + assert.equal(parsed.amount, 100_000_000n); + }); + }); + + describe("signing flow", () => { + it("should be unsigned initially, allow signing, then have an ID", () => { + const tx = buildTransaction({ + intentType: TonIntentType.Payment, + recipients: [{ address: RECIPIENT_ADDRESS, amount: 1_000_000n }], + sender: SENDER_ADDRESS, + seqno: 1, + expireAt: EXPIRE_AT, + publicKey: TEST_PUBKEY_HEX, + }); + + // Unsigned: no ID + assert.equal(tx.id, undefined); + + // Get signable payload + const payload = tx.signablePayload(); + assert.equal(payload.length, 32); + + // Add a fake signature + const fakeSig = new Uint8Array(64).fill(0xab); + tx.addSignature(fakeSig); + + // Now has an ID + assert.notEqual(tx.id, undefined); + assert.equal(typeof tx.id, "string"); + }); + }); +}); diff --git a/packages/wasm-ton/test/parser.ts b/packages/wasm-ton/test/parser.ts new file mode 100644 index 00000000000..8107ef3a770 --- /dev/null +++ b/packages/wasm-ton/test/parser.ts @@ -0,0 +1,139 @@ +/** + * Parser tests: verify parseTransaction output matches BitGoJS expectations. + */ +import * as assert from "assert"; +import { Transaction } from "../js/transaction.js"; +import { parseTransaction, TonTransactionType } from "../js/parser.js"; + +describe("parseTransaction", () => { + // From BitGoJS sdk-coin-ton test/resources/ton.ts + const SIGNED_SEND_TX = + "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w=="; + + describe("simple send transaction", () => { + it("should parse transaction type as Send", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.transactionType, TonTransactionType.Send); + }); + + it("should parse sender address", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.ok(parsed.sender); + assert.strictEqual(typeof parsed.sender, "string"); + // Sender should be a base64url address + assert.ok(parsed.sender.length > 0); + }); + + it("should parse destination address", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.ok(parsed.destination); + assert.strictEqual(typeof parsed.destination, "string"); + }); + + it("should parse amount as bigint", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(typeof parsed.amount, "bigint"); + // 10000000 nanoTON = 0.01 TON + assert.strictEqual(parsed.amount, 10000000n); + }); + + it("should parse seqno", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(typeof parsed.seqno, "number"); + assert.strictEqual(parsed.seqno, 6); + }); + + it("should detect signed state", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.isSigned, true); + }); + + it("should have a transaction ID", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.ok(parsed.id); + assert.strictEqual(typeof parsed.id, "string"); + }); + + it("should parse expiration time as bigint", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(typeof parsed.expirationTime, "bigint"); + assert.ok(parsed.expirationTime > 0n); + }); + }); + + describe("bounceable send transaction", () => { + // Bounceable version of the same send tx + const BOUNCEABLE_SEND_TX = + "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmYgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAYubM0w=="; + + it("should parse bounceable destination", () => { + const tx = Transaction.fromBase64(BOUNCEABLE_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.bounceable, true); + }); + }); + + describe("Whales deposit transaction", () => { + const WHALES_DEPOSIT_TX = + "te6cckEBAgEAvAAB4YgAyB87FgBG4jQNUAYzcmw2aJG9QQeQmtOPGsRPvX+eMdwFf6OLyGMsPoPXNPLUqMoUZTIrdu2maNNUK52q+Wa0BJhNq9e/qHXYsF9xU5TYbOsZt1EBGJf1GpkumdgXj0/4CU1NGLtKFdHwAAAC4AAcAQCLYgB1+pVcebAdRSEKQ8zKKRktAqKEg15Pa7hExBg/I76/y6gSoF8gAAAAAAAAAAAAAAAAAAB7zR/vAAAAAGlCugJDuaygCErRw2Y="; + + it("should detect TonWhalesDeposit type", () => { + const tx = Transaction.fromBase64(WHALES_DEPOSIT_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.transactionType, TonTransactionType.TonWhalesDeposit); + }); + + it("should parse as bounceable", () => { + const tx = Transaction.fromBase64(WHALES_DEPOSIT_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.bounceable, true); + }); + }); + + describe("Whales withdrawal transaction", () => { + const WHALES_WITHDRAW_TX = + "te6cckEBAgEAwAAB4YgAyB87FgBG4jQNUAYzcmw2aJG9QQeQmtOPGsRPvX+eMdwGzbdqzqRjzzou/GIUqqqdZn7Tevr+oSawF529ibEgSoxfcezGF5GW4oF6/Ws+4OanMgBwMVCe0GIEK3GSTzCIaU1NGLtKVSvAAAAC6AAcAQCUYgB1+pVcebAdRSEKQ8zKKRktAqKEg15Pa7hExBg/I76/y6BfXhAAAAAAAAAAAAAAAAAAANqAPv0AAAAAaUqlPEO5rKAFAlQL5ACKp3CI"; + + it("should detect TonWhalesWithdrawal type", () => { + const tx = Transaction.fromBase64(WHALES_WITHDRAW_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.transactionType, TonTransactionType.TonWhalesWithdrawal); + }); + }); + + describe("Single nominator withdraw transaction", () => { + const SINGLE_NOMINATOR_TX = + "te6cckECGAEAA8MAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QACPQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr6A613oAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAHA0/PoUC5EIEyWuPg=="; + + it("should detect SingleNominatorWithdraw type", () => { + const tx = Transaction.fromBase64(SINGLE_NOMINATOR_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.transactionType, TonTransactionType.SingleNominatorWithdraw); + }); + }); + + describe("Token send transaction", () => { + const TOKEN_SEND_TX = + "te6cckECGgEABB0AAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAkAAAAAGpldHRvbiB0ZXN0aW5nwHtw7A=="; + + it("should detect SendToken type", () => { + const tx = Transaction.fromBase64(TOKEN_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(parsed.transactionType, TonTransactionType.SendToken); + }); + + it("should parse amount as bigint", () => { + const tx = Transaction.fromBase64(TOKEN_SEND_TX); + const parsed = parseTransaction(tx); + assert.strictEqual(typeof parsed.amount, "bigint"); + }); + }); +}); diff --git a/packages/wasm-ton/test/transaction.ts b/packages/wasm-ton/test/transaction.ts new file mode 100644 index 00000000000..47bd02d9f9c --- /dev/null +++ b/packages/wasm-ton/test/transaction.ts @@ -0,0 +1,131 @@ +/** + * Transaction tests: deserialization, signing, serialization, round-trip. + */ +import * as assert from "assert"; +import { Transaction } from "../js/transaction.js"; + +function base64ToBytes(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, "base64")); +} + +describe("Transaction", () => { + // From BitGoJS sdk-coin-ton test/resources/ton.ts + const SIGNED_SEND_TX = + "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w=="; + + // From BitGoJS: signedSendTransaction.signable + const EXPECTED_SIGNABLE_B64 = "k4XUmjB65j3klMXCXdh5Vs3bJZzo3NSfnXK8NIYFayI="; + + describe("fromBase64", () => { + it("should deserialize a signed send transaction", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + assert.ok(tx); + }); + + it("should throw on invalid base64", () => { + assert.throws(() => Transaction.fromBase64("not-valid-boc!!!")); + }); + }); + + describe("fromBytes", () => { + it("should deserialize from raw bytes", () => { + const bytes = base64ToBytes(SIGNED_SEND_TX); + const tx = Transaction.fromBytes(bytes); + assert.ok(tx); + }); + }); + + describe("fromHex", () => { + it("should deserialize from hex-encoded BOC", () => { + const bytes = base64ToBytes(SIGNED_SEND_TX); + const hex = Buffer.from(bytes).toString("hex"); + const tx = Transaction.fromHex(hex); + assert.ok(tx); + + // Should produce the same signable payload as fromBase64 + const txB64 = Transaction.fromBase64(SIGNED_SEND_TX); + assert.deepStrictEqual(tx.signablePayload(), txB64.signablePayload()); + }); + + it("should throw on invalid hex", () => { + assert.throws(() => Transaction.fromHex("not_valid_hex!!!")); + }); + }); + + describe("signablePayload", () => { + it("should return 32 bytes matching the BitGoJS fixture", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const payload = tx.signablePayload(); + assert.strictEqual(payload.length, 32); + + const expected = base64ToBytes(EXPECTED_SIGNABLE_B64); + assert.deepStrictEqual(payload, expected); + }); + }); + + describe("addSignature", () => { + it("should accept a 64-byte signature", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const sig = new Uint8Array(64).fill(0xab); + tx.addSignature(sig); + // After signing, tx should have an ID + assert.ok(tx.id); + }); + + it("should reject non-64-byte signatures", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + assert.throws(() => tx.addSignature(new Uint8Array(32))); + assert.throws(() => tx.addSignature(new Uint8Array(128))); + }); + }); + + describe("id", () => { + it("should return a string for signed transactions", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + assert.ok(tx.id); + assert.strictEqual(typeof tx.id, "string"); + }); + + it("should return undefined for unsigned transactions", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + tx.addSignature(new Uint8Array(64)); // all zeros + assert.strictEqual(tx.id, undefined); + }); + }); + + describe("toBytes / toBroadcastFormat", () => { + it("should serialize to bytes", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const bytes = tx.toBytes(); + assert.ok(bytes.length > 0); + }); + + it("should serialize to base64 broadcast format", () => { + const tx = Transaction.fromBase64(SIGNED_SEND_TX); + const broadcast = tx.toBroadcastFormat(); + assert.strictEqual(typeof broadcast, "string"); + // Should be valid base64 + const decoded = Buffer.from(broadcast, "base64"); + assert.ok(decoded.length > 0); + }); + }); + + describe("round-trip", () => { + it("should round-trip through bytes", () => { + const tx1 = Transaction.fromBase64(SIGNED_SEND_TX); + const bytes = tx1.toBytes(); + const tx2 = Transaction.fromBytes(bytes); + + // Both should produce the same signable payload + assert.deepStrictEqual(tx1.signablePayload(), tx2.signablePayload()); + }); + + it("should round-trip through base64 broadcast format", () => { + const tx1 = Transaction.fromBase64(SIGNED_SEND_TX); + const broadcast = tx1.toBroadcastFormat(); + const tx2 = Transaction.fromBase64(broadcast); + + assert.deepStrictEqual(tx1.signablePayload(), tx2.signablePayload()); + }); + }); +}); diff --git a/packages/wasm-ton/tsconfig.cjs.json b/packages/wasm-ton/tsconfig.cjs.json new file mode 100644 index 00000000000..390db86d99f --- /dev/null +++ b/packages/wasm-ton/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "rootDir": ".", + "outDir": "./dist/cjs" + }, + "exclude": ["node_modules", "./js/wasm/**/*", "test/**/*"] +} diff --git a/packages/wasm-ton/tsconfig.json b/packages/wasm-ton/tsconfig.json new file mode 100644 index 00000000000..499b65c4266 --- /dev/null +++ b/packages/wasm-ton/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowJs": true, + "skipLibCheck": true, + "declaration": true, + "composite": true, + "rootDir": ".", + "outDir": "./dist/esm", + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["./js/**/*.ts"], + "exclude": ["node_modules", "./js/wasm/**/*", "test/**/*"] +} diff --git a/packages/wasm-ton/tsconfig.test.json b/packages/wasm-ton/tsconfig.test.json new file mode 100644 index 00000000000..7ec46d32502 --- /dev/null +++ b/packages/wasm-ton/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "mocha"], + "noEmit": true + }, + "include": ["./js/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "./js/wasm/**/*"] +}