From 7eace49c474b307855fb2e5d00fee653f9f62109 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 20 May 2026 15:20:14 +0200 Subject: [PATCH 01/32] Add Wrapped TON (wTON) - init --- .../examples/jetton/onramp_mock.tolk | 5 +- contracts/contracts/lib/jetton/errors.tolk | 9 + .../contracts/lib/jetton/fees-management.tolk | 73 ++++++++ .../contracts/lib/jetton/jetton-utils.tolk | 24 +++ contracts/contracts/lib/jetton/messages.tolk | 37 ++-- contracts/contracts/lib/jetton/storage.tolk | 36 ++-- contracts/contracts/lib/jetton/utils.tolk | 49 ----- contracts/contracts/wton/JettonMinter.tolk | 168 ++++++++++++++++++ contracts/contracts/wton/JettonWallet.tolk | 167 +++++++++++++++++ contracts/contracts/wton/README.md | 3 + 10 files changed, 470 insertions(+), 101 deletions(-) create mode 100644 contracts/contracts/lib/jetton/errors.tolk create mode 100644 contracts/contracts/lib/jetton/fees-management.tolk create mode 100644 contracts/contracts/lib/jetton/jetton-utils.tolk delete mode 100644 contracts/contracts/lib/jetton/utils.tolk create mode 100644 contracts/contracts/wton/JettonMinter.tolk create mode 100644 contracts/contracts/wton/JettonWallet.tolk create mode 100644 contracts/contracts/wton/README.md diff --git a/contracts/contracts/examples/jetton/onramp_mock.tolk b/contracts/contracts/examples/jetton/onramp_mock.tolk index 88571daa5..22d337c39 100644 --- a/contracts/contracts/examples/jetton/onramp_mock.tolk +++ b/contracts/contracts/examples/jetton/onramp_mock.tolk @@ -2,7 +2,7 @@ import "@stdlib/common.tolk" import "../../lib/jetton/jetton_client.tolk" import "../../lib/jetton/messages.tolk" -import "../../lib/jetton/utils.tolk" +import "../../lib/jetton/jetton-utils" import "../../lib/utils.tolk" // OnrampMock contract in Tolk @@ -12,6 +12,9 @@ const FEE = 5 const INCORRECT_SENDER_ERROR = 100 const FORWARD_PAYLOAD_REQUIRED_ERROR = 101 +// Jetton wallet utilities for Tolk +const JETTON_TOPIC : int = 0x351 // for easier indexing + struct OnrampMock { JettonClient: JettonClient } diff --git a/contracts/contracts/lib/jetton/errors.tolk b/contracts/contracts/lib/jetton/errors.tolk new file mode 100644 index 000000000..9daaf9153 --- /dev/null +++ b/contracts/contracts/lib/jetton/errors.tolk @@ -0,0 +1,9 @@ + +const ERROR_INVALID_OP = 72 +const ERROR_WRONG_OP = 0xffff +const ERROR_NOT_OWNER = 73 +const ERROR_NOT_VALID_WALLET = 74 +const ERROR_WRONG_WORKCHAIN = 333 +const ERROR_BALANCE_ERROR = 47 +const ERROR_NOT_ENOUGH_GAS = 48 +const ERROR_INVALID_MESSAGE = 49 diff --git a/contracts/contracts/lib/jetton/fees-management.tolk b/contracts/contracts/lib/jetton/fees-management.tolk new file mode 100644 index 000000000..d33ff8062 --- /dev/null +++ b/contracts/contracts/lib/jetton/fees-management.tolk @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +// Imported from https://github.com/ton-blockchain/tolk-bench/blob/0f416ca611fbfa25e736973d01e5fb70af485468/contracts_Tolk/03_notcoin/messages.tolk +import "@stdlib/gas-payments" +import "errors" + +// we're working in basechain, but theoretically, a jetton might even work in masterchain +const MY_WORKCHAIN = BASECHAIN + +fun getPrecompiledGasConsumption(): int? + asm "GETPRECOMPILEDGAS" + +// Storage costs +// these constants are used to estimate storage fee (how much we should pay for storing a wallet contract) + +const STORAGE_SIZE_MaxWallet_bits = 1033 +const STORAGE_SIZE_MaxWallet_cells = 3 +const STORAGE_SIZE_InitStateWallet_bits = 931 +const STORAGE_SIZE_InitStateWallet_cells = 3 + +const MESSAGE_SIZE_BurnNotification_bits = 754 // body = 32+64+124+(3+8+256)+(3+8+256) +const MESSAGE_SIZE_BurnNotification_cells = 1 // body always in ref + +const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years + + +// Gas costs +// these constants are used to estimate gas fee (how much we should remain on balance for a swap to succeed); +// they must be absolutely equal to consumed gas; if not, tests fail; +// actual consumed gas (desired value of these constants) are printed to console after tests run + +const GAS_CONSUMPTION_JettonTransfer = 6153 +const GAS_CONSUMPTION_JettonReceive = 7253 +const GAS_CONSUMPTION_BurnRequest = 4368 +const GAS_CONSUMPTION_BurnNotification = 3855 + + +fun calculateJettonWalletMinStorageFee() { + return calculateStorageFee(MY_WORKCHAIN, MIN_STORAGE_DURATION, STORAGE_SIZE_MaxWallet_bits, STORAGE_SIZE_MaxWallet_cells); +} + +fun forwardInitStateOverhead() { + return calculateForwardFeeWithoutLumpPrice(MY_WORKCHAIN, STORAGE_SIZE_InitStateWallet_bits, STORAGE_SIZE_InitStateWallet_cells); +} + +fun checkAmountIsEnoughToTransfer(msgValue: int, forwardTonAmount: int, fwdFee: int) { + var fwdCount = forwardTonAmount != 0 ? 2 : 1; // second sending (forward) will be cheaper that first + + var jettonWalletGasConsumption = getPrecompiledGasConsumption(); + var sendTransferGasConsumption = (jettonWalletGasConsumption == null) ? GAS_CONSUMPTION_JettonTransfer : jettonWalletGasConsumption; + var receiveTransferGasConsumption = (jettonWalletGasConsumption == null) ? GAS_CONSUMPTION_JettonReceive : jettonWalletGasConsumption; + + assert (msgValue > + forwardTonAmount + + // 3 messages: wal1->wal2, wal2->owner, wal2->response + // but last one is optional (it is ok if it fails) + fwdCount * fwdFee + + forwardInitStateOverhead() + // additional fwd fees related to initstate in iternal_transfer + calculateGasFee(MY_WORKCHAIN, sendTransferGasConsumption) + + calculateGasFee(MY_WORKCHAIN, receiveTransferGasConsumption) + + calculateJettonWalletMinStorageFee() + ) throw ERROR_NOT_ENOUGH_GAS; +} + +fun checkAmountIsEnoughToBurn(msgValue: int) { + var jettonWalletGasConsumption = getPrecompiledGasConsumption(); + var sendBurnGasConsumption = (jettonWalletGasConsumption == null) ? GAS_CONSUMPTION_BurnRequest : jettonWalletGasConsumption; + + assert (msgValue > + calculateForwardFee(MY_WORKCHAIN, MESSAGE_SIZE_BurnNotification_bits, MESSAGE_SIZE_BurnNotification_cells) + + calculateGasFee(MY_WORKCHAIN, sendBurnGasConsumption) + + calculateGasFee(MY_WORKCHAIN, GAS_CONSUMPTION_BurnNotification) + ) throw ERROR_NOT_ENOUGH_GAS; +} diff --git a/contracts/contracts/lib/jetton/jetton-utils.tolk b/contracts/contracts/lib/jetton/jetton-utils.tolk new file mode 100644 index 000000000..0917dbbaf --- /dev/null +++ b/contracts/contracts/lib/jetton/jetton-utils.tolk @@ -0,0 +1,24 @@ +import "fees-management" +import "storage" + +fun calcDeployedJettonWallet(ownerAddress: address, minterAddress: address, jettonWalletCode: cell): AutoDeployAddress { + val emptyWalletStorage: WalletStorage = { + status: 0, + jettonBalance: 0, + ownerAddress, + minterAddress, + }; + + return { + workchain: MY_WORKCHAIN, + stateInit: { + code: jettonWalletCode, + data: emptyWalletStorage.toCell() + } + } +} + +fun calcAddressOfJettonWallet(ownerAddress: address, minterAddress: address, jettonWalletCode: cell) { + val jwDeployed = calcDeployedJettonWallet(ownerAddress, minterAddress, jettonWalletCode); + return jwDeployed.calculateAddress() +} diff --git a/contracts/contracts/lib/jetton/messages.tolk b/contracts/contracts/lib/jetton/messages.tolk index 72e6657bb..4f78471a5 100644 --- a/contracts/contracts/lib/jetton/messages.tolk +++ b/contracts/contracts/lib/jetton/messages.tolk @@ -4,71 +4,62 @@ import "storage" type ForwardPayloadRemainder = RemainingBitsAndRefs -// nolint:opcode struct (0x0f8a7ea5) AskToTransfer { queryId: uint64 jettonAmount: coins transferRecipient: address - sendExcessesTo: address + sendExcessesTo: address? customPayload: cell? forwardTonAmount: coins forwardPayload: ForwardPayloadRemainder } -// nolint:opcode struct (0x7362d09c) TransferNotificationForRecipient { queryId: uint64 jettonAmount: coins - transferInitiator: address + transferInitiator: address? forwardPayload: ForwardPayloadRemainder } -// nolint:opcode struct (0x178d4519) InternalTransferStep { queryId: uint64 jettonAmount: coins - transferInitiator: address - sendExcessesTo: address + transferInitiator: address? // is null when minting (not initiated by another wallet) + sendExcessesTo: address? forwardTonAmount: coins forwardPayload: ForwardPayloadRemainder } -// nolint:opcode struct (0xd53276db) ReturnExcessesBack { queryId: uint64 } -// nolint:opcode struct (0x595f07bc) AskToBurn { queryId: uint64 jettonAmount: coins - sendExcessesTo: address + sendExcessesTo: address? customPayload: cell? } -// nolint:opcode struct (0x7bdd97de) BurnNotificationForMinter { queryId: uint64 jettonAmount: coins burnInitiator: address - sendExcessesTo: address + sendExcessesTo: address? } -// nolint:opcode struct (0x2c76b973) RequestWalletAddress { queryId: uint64 ownerAddress: address includeOwnerAddress: bool } -// nolint:opcode struct (0xd1735400) ResponseWalletAddress { queryId: uint64 - jettonWalletAddress: address + jettonWalletAddress: address? ownerAddress: Cell
? } -// nolint:opcode struct (0x00000015) MintNewJettons { queryId: uint64 mintRecipient: address @@ -76,37 +67,33 @@ struct (0x00000015) MintNewJettons { internalTransferMsg: Cell } -// nolint:opcode struct (0x6501f354) ChangeMinterAdmin { queryId: uint64 newAdminAddress: address } -// nolint:opcode struct (0xfb88e119) ClaimMinterAdmin { queryId: uint64 } -// nolint:opcode struct (0x7431f221) DropMinterAdmin { queryId: uint64 } -// nolint:opcode struct (0x2508d66a) UpgradeMinterCode { queryId: uint64 newData: cell newCode: cell } -// nolint:opcode struct (0xcb862902) ChangeMinterMetadataUri { queryId: uint64 - newMetadataUri: SnakeString + newMetadataUri: RemainingBitsAndRefs // by standard, it's "inlined snake string" (but not in a ref, that's why not `string`) +} + +struct (0xd372158c) TopUpTons { } -// nolint:opcode -struct (0xd372158c) TopUpTons {} // "forward payload" is TL/B `(Either Cell ^Cell)`; // we want to test, that if ^Cell, no other data exists in a slice @@ -114,6 +101,6 @@ fun ForwardPayloadRemainder.checkIsCorrectTLBEither(self) { var mutableCopy = self; if (mutableCopy.loadMaybeRef() != null) { // throw "cell underflow" if there is data besides a ref - mutableCopy.assertEnd(); + mutableCopy.assertEnd() } } diff --git a/contracts/contracts/lib/jetton/storage.tolk b/contracts/contracts/lib/jetton/storage.tolk index c61b677ea..24b45853b 100644 --- a/contracts/contracts/lib/jetton/storage.tolk +++ b/contracts/contracts/lib/jetton/storage.tolk @@ -1,24 +1,5 @@ // SPDX-License-Identifier: MIT // Imported from https://github.com/ton-blockchain/tolk-bench/blob/0f416ca611fbfa25e736973d01e5fb70af485468/contracts_Tolk/03_notcoin/storage.tolk -// SnakeString describes a (potentially long) string inside a cell; -// short strings are stored as-is, like "my-picture.png"; -// long strings are nested refs, like "xxxx".ref("yyyy".ref("zzzz")) -type SnakeString = slice - -fun SnakeString.unpackFromSlice(mutate s: slice) { - // obviously, SnakeString can be only the last: it's just "the remainder"; - // for correctness, it's better to validate it has no more refs: - // assert (s.remainingRefsCount() <= 1) throw 5; - // but since here we're matching the original FunC implementation, leave no checks - val snakeRemainder = s; - s = createEmptySlice(); // no more left to read - return snakeRemainder; -} - -fun SnakeString.packToBuilder(self, mutate b: builder) { - b.storeSlice(self); -} - struct WalletStorage { status: uint4 jettonBalance: coins @@ -28,24 +9,27 @@ struct WalletStorage { struct MinterStorage { totalSupply: coins - adminAddress: address - nextAdminAddress: address + adminAddress: address? + nextAdminAddress: address? jettonWalletCode: cell - metadataUri: Cell + metadataUri: string } + + fun MinterStorage.load() { - return MinterStorage.fromCell(contract.getData()); + return MinterStorage.fromCell(contract.getData()) } fun MinterStorage.save(self) { - contract.setData(self.toCell()); + contract.setData(self.toCell()) } + fun WalletStorage.load() { - return WalletStorage.fromCell(contract.getData()); + return WalletStorage.fromCell(contract.getData()) } fun WalletStorage.save(self) { - contract.setData(self.toCell()); + contract.setData(self.toCell()) } diff --git a/contracts/contracts/lib/jetton/utils.tolk b/contracts/contracts/lib/jetton/utils.tolk deleted file mode 100644 index 36ca7f7da..000000000 --- a/contracts/contracts/lib/jetton/utils.tolk +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: MIT -import "@stdlib/common.tolk" - -// Jetton wallet utilities for Tolk -const JETTON_TOPIC : int = 0x351 // for easier indexing - -// Jetton Wallet state structure -struct JettonWalletData { - status: uint4 - balance: coins - ownerAddress: address - jettonMasterAddress: address -} - -// Calculate jetton wallet state init -fun calculateJettonWalletStateInit( - ownerAddress: address, - jettonMasterAddress: address, - jettonWalletCode: cell, -): cell { - return StateInit { - fixedPrefixLength: null, - special: null, - code: jettonWalletCode, - data: JettonWalletData { - status: 0, - balance: 0, - ownerAddress: ownerAddress, - jettonMasterAddress: jettonMasterAddress, - } - .toCell(), - library: null, - } - .toCell(); -} - -fun calculateUserJettonWalletAddress( - ownerAddress: address, - jettonMasterAddress: address, - jettonWalletCode: cell, -): address { - val stateInit = calculateJettonWalletStateInit( - ownerAddress, - jettonMasterAddress, - jettonWalletCode - ); - val addrBuilder = AutoDeployAddress { stateInit: stateInit, toShard: null }.buildAddress(); - return address.fromValidBuilder(addrBuilder); -} diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk new file mode 100644 index 000000000..1c0d1855c --- /dev/null +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -0,0 +1,168 @@ +import "@stdlib/strings" +import "../lib/jetton/errors" +import "../lib/jetton/jetton-utils" +import "../lib/jetton/fees-management" +import "../lib/jetton/storage" +import "../lib/jetton/messages" + +contract JettonMinter { + author: "Chainlink" + incomingMessages: AllowedMessageToMinter + storage: MinterStorage +} + +type AllowedMessageToMinter = + | MintNewJettons + | BurnNotificationForMinter + | RequestWalletAddress + | ChangeMinterAdmin + | ClaimMinterAdmin + | DropMinterAdmin + | ChangeMinterMetadataUri + | UpgradeMinterCode + | TopUpTons + +fun onBouncedMessage(in: InMessageBounced) { + in.bouncedBody.skipBouncedPrefix(); + // process only mint bounces; on other messages, an exception will be thrown, it's okay + val msg = lazy InternalTransferStep.fromSlice(in.bouncedBody); + + var storage = lazy MinterStorage.load(); + storage.totalSupply -= msg.jettonAmount; + storage.save(); +} + +fun onInternalMessage(in: InMessage) { + val msg = lazy AllowedMessageToMinter.fromSlice(in.body); + + match (msg) { + BurnNotificationForMinter => { + var storage = lazy MinterStorage.load(); + assert (in.senderAddress == calcAddressOfJettonWallet(msg.burnInitiator, contract.getAddress(), storage.jettonWalletCode)) throw ERROR_NOT_VALID_WALLET; + storage.totalSupply -= msg.jettonAmount; + storage.save(); + + if (msg.sendExcessesTo == null) { + return; + } + + val excessesMsg = createMessage({ + bounce: BounceMode.NoBounce, + dest: msg.sendExcessesTo, + value: 0, + body: ReturnExcessesBack { + queryId: msg.queryId + } + }); + excessesMsg.send(SEND_MODE_IGNORE_ERRORS | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); + } + + RequestWalletAddress => { + var ownerAddress: Cell
? = msg.includeOwnerAddress + ? msg.ownerAddress.toCell() + : null; + + var walletAddress: address? = null; + if (msg.ownerAddress.getWorkchain() == MY_WORKCHAIN) { + val storage = lazy MinterStorage.load(); + walletAddress = calcAddressOfJettonWallet(msg.ownerAddress, contract.getAddress(), storage.jettonWalletCode); + } + + val respondMsg = createMessage({ + bounce: BounceMode.NoBounce, + dest: in.senderAddress, + value: 0, + body: ResponseWalletAddress { + queryId: msg.queryId, + jettonWalletAddress: walletAddress, + ownerAddress: ownerAddress, + } + }); + respondMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + } + + MintNewJettons => { + var storage = lazy MinterStorage.load(); + assertSenderIsAdmin(in.senderAddress, storage.adminAddress); + assert (msg.mintRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; + + val internalTransferMsg = lazy msg.internalTransferMsg.load({ + throwIfOpcodeDoesNotMatch: ERROR_INVALID_OP + }); + var forwardTonAmount = internalTransferMsg.forwardTonAmount; + internalTransferMsg.forwardPayload.checkIsCorrectTLBEither(); + + // a little more than needed, it’s ok since it’s sent by the admin and excesses will return back + checkAmountIsEnoughToTransfer(msg.tonAmount, forwardTonAmount, in.originalForwardFee); + + storage.totalSupply += internalTransferMsg.jettonAmount; + storage.save(); + + reserveToncoinsOnBalance(ton("0.01"), RESERVE_MODE_EXACT_AMOUNT); // reserve for storage fees + + val deployMsg = createMessage({ + bounce: BounceMode.Only256BitsOfBody, + dest: calcDeployedJettonWallet(msg.mintRecipient, contract.getAddress(), storage.jettonWalletCode), + value: msg.tonAmount, + body: msg.internalTransferMsg, + }); + deployMsg.send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + } + + ChangeMinterMetadataUri => { + var storage = lazy MinterStorage.load(); + assert (storage.newMetadataUri != null) throw 75; // TODO: error code + // convert "inlined snake string" to a normal (ref) string, bypassing the type system + storage.metadataUri = msg.newMetadataUri.toCell() as unknown as string; + storage.save(); + } + + TopUpTons => { + // just accept tons + } + + else => throw 0xFFFF + } +} + + + +struct JettonDataReply { + totalSupply: int + mintable: bool + adminAddress: address? // left for compatibility with the standard (admin == null) + jettonContent: Cell + jettonWalletCode: cell +} + +struct (0x00) OnchainMetadataReply { + contentDict: map +} + +// --- Getters --- + +get fun typeAndVersion(): (slice, slice) { + return ("link.chain.ton.wton.JettonMinter", "0.0.1"); +} + +get fun get_jetton_data(): JettonDataReply { + val storage = lazy MinterStorage.load(); + var metadata: OnchainMetadataReply = { + contentDict: [] + }; + metadata.contentDict.set("uri".sha256(), storage.metadataUri.prefixWith00()); + metadata.contentDict.set("decimals".sha256(), "9".prefixWith00()); + + return { + totalSupply: storage.totalSupply, + mintable: true, + adminAddress: null, // wTON has no admin + jettonContent: metadata.toCell(), + jettonWalletCode: storage.jettonWalletCode, + } +} + +get fun get_wallet_address(ownerAddress: address): address { + val storage = lazy MinterStorage.load(); + return calcAddressOfJettonWallet(ownerAddress, contract.getAddress(), storage.jettonWalletCode); +} diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk new file mode 100644 index 000000000..80cefdefd --- /dev/null +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -0,0 +1,167 @@ +import "@stdlib/gas-payments" +import "../lib/jetton/errors" +import "../lib/jetton/jetton-utils" +import "../lib/jetton/fees-management" +import "../lib/jetton/storage" +import "../lib/jetton/messages" + +contract JettonWallet { + author: "Chainlink" + incomingMessages: AllowedMessageToWallet + storage: WalletStorage +} + +type AllowedMessageToWallet = + | AskToTransfer + | AskToBurn + | InternalTransferStep + | TopUpTons + +type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter + +fun onBouncedMessage(in: InMessageBounced) { + in.bouncedBody.skipBouncedPrefix(); + + val msg = lazy BounceOpToHandle.fromSlice(in.bouncedBody); + val restoreAmount = match (msg) { + InternalTransferStep => msg.jettonAmount, // safe to fetch jettonAmount, because + BurnNotificationForMinter => msg.jettonAmount, // it's in the beginning of a message + }; + + var storage = lazy WalletStorage.load(); + storage.jettonBalance += restoreAmount; + storage.save(); +} + +fun onInternalMessage(in: InMessage) { + val msg = lazy AllowedMessageToWallet.fromSlice(in.body); + + match (msg) { + InternalTransferStep => { + var storage = lazy WalletStorage.load(); + if (in.senderAddress != storage.minterAddress) { + assert (in.senderAddress == calcAddressOfJettonWallet(msg.transferInitiator!, storage.minterAddress, contract.getCode())) throw ERROR_NOT_VALID_WALLET; + } + storage.jettonBalance += msg.jettonAmount; + storage.save(); + + if (msg.forwardTonAmount != 0) { + val notifyOwnerMsg = createMessage({ + bounce: BounceMode.NoBounce, + dest: storage.ownerAddress, + value: msg.forwardTonAmount, + body: TransferNotificationForRecipient { + queryId: msg.queryId, + jettonAmount: msg.jettonAmount, + transferInitiator: msg.transferInitiator, + forwardPayload: msg.forwardPayload, + } + }); + notifyOwnerMsg.send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + } + + if (msg.sendExcessesTo != null) { + // TODO: need to account for msg.jettonAmount of TON + var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins + contract.getStorageDuePayment(); + reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); + + val excessesMsg = createMessage({ + bounce: BounceMode.NoBounce, + dest: msg.sendExcessesTo, + value: 0, + body: ReturnExcessesBack { + queryId: msg.queryId + } + }); + excessesMsg.send(SEND_MODE_CARRY_ALL_BALANCE | SEND_MODE_IGNORE_ERRORS); + } + } + + AskToTransfer => { + msg.forwardPayload.checkIsCorrectTLBEither(); + assert (msg.transferRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; + checkAmountIsEnoughToTransfer(in.valueCoins, msg.forwardTonAmount, in.originalForwardFee); + + var storage = lazy WalletStorage.load(); + assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; + assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; + storage.jettonBalance -= msg.jettonAmount; + storage.save(); + + val deployMsg = createMessage({ + bounce: BounceMode.Only256BitsOfBody, + dest: calcDeployedJettonWallet(msg.transferRecipient, storage.minterAddress, contract.getCode()), + value: 0, // TODO: send TON amount as value + body: InternalTransferStep { + queryId: msg.queryId, + jettonAmount: msg.jettonAmount, + transferInitiator: storage.ownerAddress, + sendExcessesTo: msg.sendExcessesTo, + forwardTonAmount: msg.forwardTonAmount, + forwardPayload: msg.forwardPayload, + } + }); + deployMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + } + + AskToBurn => { + checkAmountIsEnoughToBurn(in.valueCoins); + + var storage = lazy WalletStorage.load(); + assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; + assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; + storage.jettonBalance -= msg.jettonAmount; + storage.save(); + + val notifyMinterMsg = createMessage({ + bounce: BounceMode.Only256BitsOfBody, + dest: storage.minterAddress, + value: 0, + body: BurnNotificationForMinter { + queryId: msg.queryId, + jettonAmount: msg.jettonAmount, + burnInitiator: storage.ownerAddress, + sendExcessesTo: msg.sendExcessesTo, + } + }); + notifyMinterMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + } + + TopUpTons => { + // just accept tons + } + + else => throw 0xFFFF + } +} + + + +struct JettonWalletDataReply { + jettonBalance: coins + ownerAddress: address + minterAddress: address + jettonWalletCode: cell +} + +// --- Getters --- + +get fun typeAndVersion(): (slice, slice) { + return ("link.chain.ton.wton.JettonWallet", "0.0.1"); +} + +get fun get_wallet_data(): JettonWalletDataReply { + var storage = lazy WalletStorage.load(); + + return { + jettonBalance: storage.jettonBalance, + ownerAddress: storage.ownerAddress, + minterAddress: storage.minterAddress, + jettonWalletCode: contract.getCode(), + } +} + +get fun get_status(): int { + var storage = lazy WalletStorage.load(); + return storage.status; +} diff --git a/contracts/contracts/wton/README.md b/contracts/contracts/wton/README.md new file mode 100644 index 000000000..bf74df41c --- /dev/null +++ b/contracts/contracts/wton/README.md @@ -0,0 +1,3 @@ +# Wrapped TON + +An escrow protocol to make TON behave as Jetton in a new asset called wTON. From 1c8e98ec102ca1136be3e743ea4276daab4394d9 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 20 May 2026 15:47:52 +0200 Subject: [PATCH 02/32] Add wTON README.md + nolint:opcode --- contracts/contracts/lib/jetton/messages.tolk | 15 +++++++++++++++ contracts/contracts/wton/README.md | 20 +++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/lib/jetton/messages.tolk b/contracts/contracts/lib/jetton/messages.tolk index 4f78471a5..fa8bd6ce3 100644 --- a/contracts/contracts/lib/jetton/messages.tolk +++ b/contracts/contracts/lib/jetton/messages.tolk @@ -4,6 +4,7 @@ import "storage" type ForwardPayloadRemainder = RemainingBitsAndRefs +// nolint:opcode struct (0x0f8a7ea5) AskToTransfer { queryId: uint64 jettonAmount: coins @@ -14,6 +15,7 @@ struct (0x0f8a7ea5) AskToTransfer { forwardPayload: ForwardPayloadRemainder } +// nolint:opcode struct (0x7362d09c) TransferNotificationForRecipient { queryId: uint64 jettonAmount: coins @@ -21,6 +23,7 @@ struct (0x7362d09c) TransferNotificationForRecipient { forwardPayload: ForwardPayloadRemainder } +// nolint:opcode struct (0x178d4519) InternalTransferStep { queryId: uint64 jettonAmount: coins @@ -30,10 +33,12 @@ struct (0x178d4519) InternalTransferStep { forwardPayload: ForwardPayloadRemainder } +// nolint:opcode struct (0xd53276db) ReturnExcessesBack { queryId: uint64 } +// nolint:opcode struct (0x595f07bc) AskToBurn { queryId: uint64 jettonAmount: coins @@ -41,6 +46,7 @@ struct (0x595f07bc) AskToBurn { customPayload: cell? } +// nolint:opcode struct (0x7bdd97de) BurnNotificationForMinter { queryId: uint64 jettonAmount: coins @@ -48,18 +54,21 @@ struct (0x7bdd97de) BurnNotificationForMinter { sendExcessesTo: address? } +// nolint:opcode struct (0x2c76b973) RequestWalletAddress { queryId: uint64 ownerAddress: address includeOwnerAddress: bool } +// nolint:opcode struct (0xd1735400) ResponseWalletAddress { queryId: uint64 jettonWalletAddress: address? ownerAddress: Cell
? } +// nolint:opcode struct (0x00000015) MintNewJettons { queryId: uint64 mintRecipient: address @@ -67,30 +76,36 @@ struct (0x00000015) MintNewJettons { internalTransferMsg: Cell } +// nolint:opcode struct (0x6501f354) ChangeMinterAdmin { queryId: uint64 newAdminAddress: address } +// nolint:opcode struct (0xfb88e119) ClaimMinterAdmin { queryId: uint64 } +// nolint:opcode struct (0x7431f221) DropMinterAdmin { queryId: uint64 } +// nolint:opcode struct (0x2508d66a) UpgradeMinterCode { queryId: uint64 newData: cell newCode: cell } +// nolint:opcode struct (0xcb862902) ChangeMinterMetadataUri { queryId: uint64 newMetadataUri: RemainingBitsAndRefs // by standard, it's "inlined snake string" (but not in a ref, that's why not `string`) } +// nolint:opcode struct (0xd372158c) TopUpTons { } diff --git a/contracts/contracts/wton/README.md b/contracts/contracts/wton/README.md index bf74df41c..dd8520c0d 100644 --- a/contracts/contracts/wton/README.md +++ b/contracts/contracts/wton/README.md @@ -1,3 +1,21 @@ # Wrapped TON -An escrow protocol to make TON behave as Jetton in a new asset called wTON. +A token escrow protocol to make TON behave as Jetton in a new asset called wTON. + +**Features:** + +* Standard Jetton [TEP #74](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md) minimal deviation +* No admin: + * Mint (new) wTON by providing TON + * Burn wTON to withdraw TON +* Base Jetton Tolk implementation from at [57e1009](https://github.com/ton-blockchain/tolk-bench/commit/57e1009743bfc19748caa95d76180d9e9793e4c5) + +**Why this version?** + + + +> ## Notcoin contract +> +> This version is straightforward - it is a forked Stablecoin contract with removed governance functionality and added burn mechanism. Until recent times, it was the most suitable Jetton for basic on-chain coin use cases. + +Which is exactly what we need as a base for wTON (and CCTs), and the [ton-blockchain/tolk-bench](https://github.com/ton-blockchain/tolk-bench) is implemented in latest Tolk 1.4 and brings substantial gas improvements over using FunC originals. From ad06d92da1a996c076fb0d9cbe43c2fb2a8608c4 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 20 May 2026 17:08:11 +0200 Subject: [PATCH 03/32] Add TON handling on wTON transfer/burn --- contracts/contracts/lib/jetton/errors.tolk | 1 + .../contracts/lib/jetton/jetton-utils.tolk | 1 + contracts/contracts/wton/JettonMinter.tolk | 7 +++--- contracts/contracts/wton/JettonWallet.tolk | 22 ++++++++++++++----- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/contracts/contracts/lib/jetton/errors.tolk b/contracts/contracts/lib/jetton/errors.tolk index 9daaf9153..5701a4867 100644 --- a/contracts/contracts/lib/jetton/errors.tolk +++ b/contracts/contracts/lib/jetton/errors.tolk @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT const ERROR_INVALID_OP = 72 const ERROR_WRONG_OP = 0xffff diff --git a/contracts/contracts/lib/jetton/jetton-utils.tolk b/contracts/contracts/lib/jetton/jetton-utils.tolk index 0917dbbaf..62888cde8 100644 --- a/contracts/contracts/lib/jetton/jetton-utils.tolk +++ b/contracts/contracts/lib/jetton/jetton-utils.tolk @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT import "fees-management" import "storage" diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 1c0d1855c..bfe121f3a 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -15,13 +15,12 @@ type AllowedMessageToMinter = | MintNewJettons | BurnNotificationForMinter | RequestWalletAddress - | ChangeMinterAdmin - | ClaimMinterAdmin - | DropMinterAdmin | ChangeMinterMetadataUri | UpgradeMinterCode | TopUpTons +const ERROR_ALREADY_INITIALIZED = 75 + fun onBouncedMessage(in: InMessageBounced) { in.bouncedBody.skipBouncedPrefix(); // process only mint bounces; on other messages, an exception will be thrown, it's okay @@ -111,7 +110,7 @@ fun onInternalMessage(in: InMessage) { ChangeMinterMetadataUri => { var storage = lazy MinterStorage.load(); - assert (storage.newMetadataUri != null) throw 75; // TODO: error code + assert (storage.newMetadataUri != null) throw ERROR_ALREADY_INITIALIZED; // convert "inlined snake string" to a normal (ref) string, bypassing the type system storage.metadataUri = msg.newMetadataUri.toCell() as unknown as string; storage.save(); diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 80cefdefd..1cbb26840 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -19,6 +19,8 @@ type AllowedMessageToWallet = type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter +const ERROR_INVALID_BURN_DESTINATION = 76 + fun onBouncedMessage(in: InMessageBounced) { in.bouncedBody.skipBouncedPrefix(); @@ -61,8 +63,8 @@ fun onInternalMessage(in: InMessage) { } if (msg.sendExcessesTo != null) { - // TODO: need to account for msg.jettonAmount of TON - var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins + contract.getStorageDuePayment(); + // Notice: need to account for msg.jettonAmount of TON (we leave on balance) + var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins + msg.jettonAmount + contract.getStorageDuePayment(); reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); val excessesMsg = createMessage({ @@ -80,6 +82,7 @@ fun onInternalMessage(in: InMessage) { AskToTransfer => { msg.forwardPayload.checkIsCorrectTLBEither(); assert (msg.transferRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; + checkAmountIsEnoughToTransfer(in.valueCoins, msg.forwardTonAmount, in.originalForwardFee); var storage = lazy WalletStorage.load(); @@ -88,10 +91,14 @@ fun onInternalMessage(in: InMessage) { storage.jettonBalance -= msg.jettonAmount; storage.save(); + // Notice: need to account for msg.jettonAmount of TON (we send from balance - to transfer) + var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins - msg.jettonAmount + contract.getStorageDuePayment(); + reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); + val deployMsg = createMessage({ bounce: BounceMode.Only256BitsOfBody, dest: calcDeployedJettonWallet(msg.transferRecipient, storage.minterAddress, contract.getCode()), - value: 0, // TODO: send TON amount as value + value: 0, body: InternalTransferStep { queryId: msg.queryId, jettonAmount: msg.jettonAmount, @@ -101,7 +108,7 @@ fun onInternalMessage(in: InMessage) { forwardPayload: msg.forwardPayload, } }); - deployMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + deployMsg.send(SEND_MODE_CARRY_ALL_BALANCE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } AskToBurn => { @@ -110,9 +117,14 @@ fun onInternalMessage(in: InMessage) { var storage = lazy WalletStorage.load(); assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; + assert (msg.sendExcessesTo == null) throw ERROR_INVALID_BURN_DESTINATION; storage.jettonBalance -= msg.jettonAmount; storage.save(); + // Notice: need to account for msg.jettonAmount of TON (we send from balance - to burn/withdraw) + var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins - msg.jettonAmount + contract.getStorageDuePayment(); + reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); + val notifyMinterMsg = createMessage({ bounce: BounceMode.Only256BitsOfBody, dest: storage.minterAddress, @@ -124,7 +136,7 @@ fun onInternalMessage(in: InMessage) { sendExcessesTo: msg.sendExcessesTo, } }); - notifyMinterMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); + notifyMinterMsg.send(SEND_MODE_CARRY_ALL_BALANCE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } TopUpTons => { From c9268c8517b1a41fb9ff15a1c7209a8b625468a5 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 20 May 2026 17:11:01 +0200 Subject: [PATCH 04/32] Fix ChangeMinterMetadataUri --- contracts/contracts/wton/JettonMinter.tolk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index bfe121f3a..0fdd9176e 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -110,7 +110,7 @@ fun onInternalMessage(in: InMessage) { ChangeMinterMetadataUri => { var storage = lazy MinterStorage.load(); - assert (storage.newMetadataUri != null) throw ERROR_ALREADY_INITIALIZED; + assert (storage.metadataUri == null) throw ERROR_ALREADY_INITIALIZED; // convert "inlined snake string" to a normal (ref) string, bypassing the type system storage.metadataUri = msg.newMetadataUri.toCell() as unknown as string; storage.save(); From 0de29f1a1948ef4cdc1b09fb22e3e709b9cbdb50 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 20 May 2026 17:12:06 +0200 Subject: [PATCH 05/32] Remove UpgradeMinterCode from in msgs --- contracts/contracts/wton/JettonMinter.tolk | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 0fdd9176e..1141f3f14 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -16,7 +16,6 @@ type AllowedMessageToMinter = | BurnNotificationForMinter | RequestWalletAddress | ChangeMinterMetadataUri - | UpgradeMinterCode | TopUpTons const ERROR_ALREADY_INITIALIZED = 75 From 8e0ea9010a32806698414171dd85d715ad29d7d3 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 21 May 2026 10:14:03 +0200 Subject: [PATCH 06/32] Add MintNewJettons handling, audit --- contracts/contracts/wton/JettonMinter.tolk | 82 ++++++++++++++++++---- contracts/contracts/wton/JettonWallet.tolk | 45 ++++++++---- 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 1141f3f14..a7a8566d6 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -19,6 +19,42 @@ type AllowedMessageToMinter = | TopUpTons const ERROR_ALREADY_INITIALIZED = 75 +const ERROR_UNSUFFICIENT_AMOUNT = 76 +const ERROR_INVALID_EXCESSES_DESTINATION = 77 +const ERROR_TOP_UP_TOO_LARGE = 78 + +fun reserveModeExactFail() { + return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL; +} + +fun requiredMinterReserve() { + return ton("0.01") + contract.getStorageDuePayment(); +} + +fun minterTopUpCap() { + return requiredMinterReserve(); +} + +fun refundMintBounce(msg: InternalTransferStep) { + // AUDIT(WTON-7): mint reuses sendExcessesTo as the bounce refund destination, so a failed wallet deploy/credit returns TON. + reserveToncoinsOnBalance(requiredMinterReserve(), reserveModeExactFail()); + + val refundMsg = createMessage({ + // AUDIT(WTON-17): mint-bounce refund is a forced TON deposit to the caller-chosen refund address. + // If the destination throws, NoBounce keeps the TON there instead of looping another bounce. + bounce: BounceMode.NoBounce, + dest: msg.sendExcessesTo, + value: 0, + body: ReturnExcessesBack { + queryId: msg.queryId + } + }); + // AUDIT(WTON-18): IGNORE_ERRORS is kept only on the mint-bounce refund path because we are already in + // onBouncedMessage after rolling supply back. If the send action itself fails here, reverting this tx would + // resurrect supply without restoring the recipient wallet. The security-grade long-term fix is a pending-refund + // ledger/claim flow; until then this path is intentionally best-effort and never a final accounting transition. + refundMsg.send(SEND_MODE_IGNORE_ERRORS | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); +} fun onBouncedMessage(in: InMessageBounced) { in.bouncedBody.skipBouncedPrefix(); @@ -28,6 +64,8 @@ fun onBouncedMessage(in: InMessageBounced) { var storage = lazy MinterStorage.load(); storage.totalSupply -= msg.jettonAmount; storage.save(); + + refundMintBounce(msg); } fun onInternalMessage(in: InMessage) { @@ -37,14 +75,18 @@ fun onInternalMessage(in: InMessage) { BurnNotificationForMinter => { var storage = lazy MinterStorage.load(); assert (in.senderAddress == calcAddressOfJettonWallet(msg.burnInitiator, contract.getAddress(), storage.jettonWalletCode)) throw ERROR_NOT_VALID_WALLET; + // reject burns without a refund destination so the withdrawn TON never gets stranded here + assert (msg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; storage.totalSupply -= msg.jettonAmount; storage.save(); - if (msg.sendExcessesTo == null) { - return; - } + // keep only the minter rent reserve before withdrawing TON + reserveToncoinsOnBalance(requiredMinterReserve(), reserveModeExactFail()); val excessesMsg = createMessage({ + // AUDIT(WTON-19): burn payout sends native TON to the recipient as a non-bounceable withdrawal. + // If the recipient contract throws, NoBounce makes the TON stay there; the critical property is that + // sender-side action failures MUST NOT be ignored, so the burn rolls back through the wallet bounce path. bounce: BounceMode.NoBounce, dest: msg.sendExcessesTo, value: 0, @@ -52,7 +94,9 @@ fun onInternalMessage(in: InMessage) { queryId: msg.queryId } }); - excessesMsg.send(SEND_MODE_IGNORE_ERRORS | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); + // AUDIT(WTON-20): burn withdrawal is not "best effort". If this send action cannot be executed, + // the minter tx must fail so BurnNotificationForMinter bounces back to the wallet and restores wTON. + excessesMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } RequestWalletAddress => { @@ -81,7 +125,6 @@ fun onInternalMessage(in: InMessage) { MintNewJettons => { var storage = lazy MinterStorage.load(); - assertSenderIsAdmin(in.senderAddress, storage.adminAddress); assert (msg.mintRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; val internalTransferMsg = lazy msg.internalTransferMsg.load({ @@ -90,33 +133,42 @@ fun onInternalMessage(in: InMessage) { var forwardTonAmount = internalTransferMsg.forwardTonAmount; internalTransferMsg.forwardPayload.checkIsCorrectTLBEither(); - // a little more than needed, it’s ok since it’s sent by the admin and excesses will return back + val jettonAmount = internalTransferMsg.jettonAmount; + // AUDIT(WTON-10): mint must name the same excess/refund destination for both the happy path and a bounced wallet deployment. + assert (internalTransferMsg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; + // AUDIT(WTON-11): minting must not impersonate a peer wallet transfer initiator. + assert (internalTransferMsg.transferInitiator == null) throw ERROR_INVALID_OP; + // AUDIT(WTON-12): the caller must fund both the new hosted TON backing and the extra transfer budget. + assert (in.valueCoins >= jettonAmount + msg.tonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; + + // AUDIT(WTON-13): the extra mint budget must independently cover the receiver-side transfer/forward flow. checkAmountIsEnoughToTransfer(msg.tonAmount, forwardTonAmount, in.originalForwardFee); + // AUDIT(WTON-14): keep the minter rent reserve intact so minting cannot drain operational TON. + val requiredReserve = requiredMinterReserve(); + assert (contract.getOriginalBalance() >= requiredReserve + jettonAmount + msg.tonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; - storage.totalSupply += internalTransferMsg.jettonAmount; + storage.totalSupply += jettonAmount; storage.save(); - reserveToncoinsOnBalance(ton("0.01"), RESERVE_MODE_EXACT_AMOUNT); // reserve for storage fees + reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail()); val deployMsg = createMessage({ bounce: BounceMode.Only256BitsOfBody, dest: calcDeployedJettonWallet(msg.mintRecipient, contract.getAddress(), storage.jettonWalletCode), - value: msg.tonAmount, + value: jettonAmount + msg.tonAmount, body: msg.internalTransferMsg, }); deployMsg.send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } ChangeMinterMetadataUri => { - var storage = lazy MinterStorage.load(); - assert (storage.metadataUri == null) throw ERROR_ALREADY_INITIALIZED; - // convert "inlined snake string" to a normal (ref) string, bypassing the type system - storage.metadataUri = msg.newMetadataUri.toCell() as unknown as string; - storage.save(); + // wTON has no admin path, so metadata is immutable and this opcode throws + throw ERROR_ALREADY_INITIALIZED; } TopUpTons => { - // just accept tons + // AUDIT(WTON-15): cap rent top-ups so arbitrary TON cannot silently accumulate outside mint/burn accounting. + assert (in.valueCoins <= minterTopUpCap()) throw ERROR_TOP_UP_TOO_LARGE; } else => throw 0xFFFF diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 1cbb26840..1a41228b8 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -19,7 +19,21 @@ type AllowedMessageToWallet = type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter -const ERROR_INVALID_BURN_DESTINATION = 76 +const ERROR_UNSUFFICIENT_AMOUNT = 76 +const ERROR_INVALID_BURN_DESTINATION = 77 +const ERROR_TOP_UP_TOO_LARGE = 78 + +fun reserveModeExactFail() { + return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL; +} + +fun requiredWalletReserve(backedTonAmount: coins) { + return backedTonAmount + calculateJettonWalletMinStorageFee() + contract.getStorageDuePayment(); +} + +fun walletTopUpCap() { + return calculateJettonWalletMinStorageFee() + contract.getStorageDuePayment(); +} fun onBouncedMessage(in: InMessageBounced) { in.bouncedBody.skipBouncedPrefix(); @@ -44,7 +58,14 @@ fun onInternalMessage(in: InMessage) { if (in.senderAddress != storage.minterAddress) { assert (in.senderAddress == calcAddressOfJettonWallet(msg.transferInitiator!, storage.minterAddress, contract.getCode())) throw ERROR_NOT_VALID_WALLET; } - storage.jettonBalance += msg.jettonAmount; + val nextJettonBalance = storage.jettonBalance + msg.jettonAmount; + val requiredReserve = requiredWalletReserve(nextJettonBalance); + // AUDIT(WTON-1): require the incoming transfer to carry the full hosted TON backing before we credit wTON. + assert (contract.getOriginalBalance() >= requiredReserve + msg.forwardTonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; + // AUDIT(WTON-2): lock backing + storage reserve before any notification/excess send can spend value. + reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail()); + + storage.jettonBalance = nextJettonBalance; storage.save(); if (msg.forwardTonAmount != 0) { @@ -63,10 +84,6 @@ fun onInternalMessage(in: InMessage) { } if (msg.sendExcessesTo != null) { - // Notice: need to account for msg.jettonAmount of TON (we leave on balance) - var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins + msg.jettonAmount + contract.getStorageDuePayment(); - reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); - val excessesMsg = createMessage({ bounce: BounceMode.NoBounce, dest: msg.sendExcessesTo, @@ -91,9 +108,8 @@ fun onInternalMessage(in: InMessage) { storage.jettonBalance -= msg.jettonAmount; storage.save(); - // Notice: need to account for msg.jettonAmount of TON (we send from balance - to transfer) - var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins - msg.jettonAmount + contract.getStorageDuePayment(); - reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); + // AUDIT(WTON-3): preserve the remaining wTON backing plus storage reserve exactly, or abort the transfer. + reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail()); val deployMsg = createMessage({ bounce: BounceMode.Only256BitsOfBody, @@ -117,13 +133,13 @@ fun onInternalMessage(in: InMessage) { var storage = lazy WalletStorage.load(); assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; - assert (msg.sendExcessesTo == null) throw ERROR_INVALID_BURN_DESTINATION; + // AUDIT(WTON-4): burn must name a refund destination so the minter can return the withdrawn TON instead of trapping it. + assert (msg.sendExcessesTo != null) throw ERROR_INVALID_BURN_DESTINATION; storage.jettonBalance -= msg.jettonAmount; storage.save(); - // Notice: need to account for msg.jettonAmount of TON (we send from balance - to burn/withdraw) - var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins - msg.jettonAmount + contract.getStorageDuePayment(); - reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST); + // AUDIT(WTON-5): preserve the remaining backing exactly before sending the withdrawn TON to the minter. + reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail()); val notifyMinterMsg = createMessage({ bounce: BounceMode.Only256BitsOfBody, @@ -140,7 +156,8 @@ fun onInternalMessage(in: InMessage) { } TopUpTons => { - // just accept tons + // AUDIT(WTON-6): cap rent top-ups so arbitrary TON cannot be mixed into the hosted asset accounting. + assert (in.valueCoins <= walletTopUpCap()) throw ERROR_TOP_UP_TOO_LARGE; } else => throw 0xFFFF From d5e7e6fb6568d9b2e193d304c25f4011406bd494 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 21 May 2026 14:19:36 +0200 Subject: [PATCH 07/32] Make compile w/ tolk 1.2, add tests --- .../examples/jetton/onramp_mock.tolk | 8 +- .../contracts/examples/jetton/sender.tolk | 2 + .../examples/jetton/simple_receiver.tolk | 2 + .../contracts/lib/jetton/jetton-utils.tolk | 4 +- .../contracts/lib/jetton/jetton_client.tolk | 4 +- contracts/contracts/lib/jetton/messages.tolk | 3 +- contracts/contracts/lib/jetton/storage.tolk | 24 +- contracts/contracts/wton/JettonMinter.tolk | 51 +- contracts/contracts/wton/JettonWallet.tolk | 17 +- .../{lib/jetton => wton}/fees-management.tolk | 2 +- contracts/tests/Wton.spec.ts | 638 ++++++++++++++++++ contracts/wrappers/jetton/JettonMinter.ts | 12 +- contracts/wrappers/jetton/JettonWallet.ts | 2 +- .../wrappers/wton.JettonMinter.compile.ts | 7 + .../wrappers/wton.JettonWallet.compile.ts | 7 + 15 files changed, 747 insertions(+), 36 deletions(-) rename contracts/contracts/{lib/jetton => wton}/fees-management.tolk (99%) create mode 100644 contracts/tests/Wton.spec.ts create mode 100644 contracts/wrappers/wton.JettonMinter.compile.ts create mode 100644 contracts/wrappers/wton.JettonWallet.compile.ts diff --git a/contracts/contracts/examples/jetton/onramp_mock.tolk b/contracts/contracts/examples/jetton/onramp_mock.tolk index 22d337c39..a9bfa2971 100644 --- a/contracts/contracts/examples/jetton/onramp_mock.tolk +++ b/contracts/contracts/examples/jetton/onramp_mock.tolk @@ -1,8 +1,10 @@ // SPDX-License-Identifier: MIT +tolk 1.2 + import "@stdlib/common.tolk" import "../../lib/jetton/jetton_client.tolk" import "../../lib/jetton/messages.tolk" -import "../../lib/jetton/jetton-utils" +import "../../lib/jetton/jetton-utils.tolk" import "../../lib/utils.tolk" // OnrampMock contract in Tolk @@ -52,13 +54,13 @@ fun OnrampMock.handleJettonTransferNotification( // Handle the jetton transfer if (msg.jettonAmount < FEE) { - emit(JETTON_TOPIC, InsufficientFee { queryId: msg.queryId, sender: msg.transferInitiator }); + emit(JETTON_TOPIC, InsufficientFee { queryId: msg.queryId, sender: msg.transferInitiator! }); } else { emit( JETTON_TOPIC, AcceptedRequest { queryId: msg.queryId, - sender: msg.transferInitiator, + sender: msg.transferInitiator!, payload: forwardPayloadCell!, } ); diff --git a/contracts/contracts/examples/jetton/sender.tolk b/contracts/contracts/examples/jetton/sender.tolk index 6da086d70..8459d4012 100644 --- a/contracts/contracts/examples/jetton/sender.tolk +++ b/contracts/contracts/examples/jetton/sender.tolk @@ -1,4 +1,6 @@ // SPDX-License-Identifier: MIT +tolk 1.2 + import "@stdlib/common.tolk" import "../../lib/jetton/jetton_client.tolk" import "../../lib/jetton/messages.tolk" diff --git a/contracts/contracts/examples/jetton/simple_receiver.tolk b/contracts/contracts/examples/jetton/simple_receiver.tolk index 00dfd341e..791701d6a 100644 --- a/contracts/contracts/examples/jetton/simple_receiver.tolk +++ b/contracts/contracts/examples/jetton/simple_receiver.tolk @@ -1,4 +1,6 @@ // SPDX-License-Identifier: MIT +tolk 1.2 + import "@stdlib/common.tolk" import "../../lib/jetton/jetton_client.tolk" import "../../lib/jetton/messages.tolk" diff --git a/contracts/contracts/lib/jetton/jetton-utils.tolk b/contracts/contracts/lib/jetton/jetton-utils.tolk index 62888cde8..0d53648d1 100644 --- a/contracts/contracts/lib/jetton/jetton-utils.tolk +++ b/contracts/contracts/lib/jetton/jetton-utils.tolk @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import "fees-management" +import "@stdlib/gas-payments" import "storage" fun calcDeployedJettonWallet(ownerAddress: address, minterAddress: address, jettonWalletCode: cell): AutoDeployAddress { @@ -11,7 +11,7 @@ fun calcDeployedJettonWallet(ownerAddress: address, minterAddress: address, jett }; return { - workchain: MY_WORKCHAIN, + workchain: BASECHAIN, stateInit: { code: jettonWalletCode, data: emptyWalletStorage.toCell() diff --git a/contracts/contracts/lib/jetton/jetton_client.tolk b/contracts/contracts/lib/jetton/jetton_client.tolk index c9f20a487..06207434e 100644 --- a/contracts/contracts/lib/jetton/jetton_client.tolk +++ b/contracts/contracts/lib/jetton/jetton_client.tolk @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import "./utils.tolk" +import "jetton-utils" import "messages" struct JettonClient { @@ -9,7 +9,7 @@ struct JettonClient { @inline fun JettonClient.walletAddress(self): address { - return calculateUserJettonWalletAddress( + return calcAddressOfJettonWallet( contract.getAddress(), self.masterAddress, self.jettonWalletCode diff --git a/contracts/contracts/lib/jetton/messages.tolk b/contracts/contracts/lib/jetton/messages.tolk index fa8bd6ce3..3409f9ebb 100644 --- a/contracts/contracts/lib/jetton/messages.tolk +++ b/contracts/contracts/lib/jetton/messages.tolk @@ -102,7 +102,8 @@ struct (0x2508d66a) UpgradeMinterCode { // nolint:opcode struct (0xcb862902) ChangeMinterMetadataUri { queryId: uint64 - newMetadataUri: RemainingBitsAndRefs // by standard, it's "inlined snake string" (but not in a ref, that's why not `string`) + // TODO: update to 1.4 + newMetadataUri: SnakeString } // nolint:opcode diff --git a/contracts/contracts/lib/jetton/storage.tolk b/contracts/contracts/lib/jetton/storage.tolk index 24b45853b..ba8d9e625 100644 --- a/contracts/contracts/lib/jetton/storage.tolk +++ b/contracts/contracts/lib/jetton/storage.tolk @@ -1,5 +1,26 @@ // SPDX-License-Identifier: MIT // Imported from https://github.com/ton-blockchain/tolk-bench/blob/0f416ca611fbfa25e736973d01e5fb70af485468/contracts_Tolk/03_notcoin/storage.tolk + +// TODO: update to 1.4 +// SnakeString describes a (potentially long) string inside a cell; +// short strings are stored as-is, like "my-picture.png"; +// long strings are nested refs, like "xxxx".ref("yyyy".ref("zzzz")) +type SnakeString = slice + +fun SnakeString.unpackFromSlice(mutate s: slice) { + // obviously, SnakeString can be only the last: it's just "the remainder"; + // for correctness, it's better to validate it has no more refs: + // assert (s.remainingRefsCount() <= 1) throw 5; + // but since here we're matching the original FunC implementation, leave no checks + val snakeRemainder = s; + s = createEmptySlice(); // no more left to read + return snakeRemainder +} + +fun SnakeString.packToBuilder(self, mutate b: builder) { + b.storeSlice(self) +} + struct WalletStorage { status: uint4 jettonBalance: coins @@ -12,7 +33,8 @@ struct MinterStorage { adminAddress: address? nextAdminAddress: address? jettonWalletCode: cell - metadataUri: string + // TODO: update to 1.4 + metadataUri: Cell } diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index a7a8566d6..ddc8f7b31 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -1,15 +1,19 @@ -import "@stdlib/strings" +tolk 1.2 + +// TODO: update to 1.4 +// import "@stdlib/strings" import "../lib/jetton/errors" import "../lib/jetton/jetton-utils" -import "../lib/jetton/fees-management" import "../lib/jetton/storage" import "../lib/jetton/messages" +import "fees-management" -contract JettonMinter { - author: "Chainlink" - incomingMessages: AllowedMessageToMinter - storage: MinterStorage -} +// TODO: update to 1.4 +// contract JettonMinter { +// author: "Chainlink Labs" +// incomingMessages: AllowedMessageToMinter +// storage: MinterStorage +// } type AllowedMessageToMinter = | MintNewJettons @@ -43,7 +47,7 @@ fun refundMintBounce(msg: InternalTransferStep) { // AUDIT(WTON-17): mint-bounce refund is a forced TON deposit to the caller-chosen refund address. // If the destination throws, NoBounce keeps the TON there instead of looping another bounce. bounce: BounceMode.NoBounce, - dest: msg.sendExcessesTo, + dest: msg.sendExcessesTo!, value: 0, body: ReturnExcessesBack { queryId: msg.queryId @@ -57,9 +61,14 @@ fun refundMintBounce(msg: InternalTransferStep) { } fun onBouncedMessage(in: InMessageBounced) { - in.bouncedBody.skipBouncedPrefix(); - // process only mint bounces; on other messages, an exception will be thrown, it's okay - val msg = lazy InternalTransferStep.fromSlice(in.bouncedBody); + // AUDIT(WTON-21): refundMintBounce needs sendExcessesTo from the original mint payload, so the + // bounced body must preserve the full root cell rather than the old 256-bit truncation. + val rich = lazy RichBounceBody.fromSlice(in.bouncedBody); + var originalBody = rich.originalBody.beginParse(); + if (originalBody.preloadUint(32) != InternalTransferStep.getDeclaredPackPrefix()) { + return; + } + val msg = lazy InternalTransferStep.fromCell(rich.originalBody); var storage = lazy MinterStorage.load(); storage.totalSupply -= msg.jettonAmount; @@ -88,7 +97,7 @@ fun onInternalMessage(in: InMessage) { // If the recipient contract throws, NoBounce makes the TON stay there; the critical property is that // sender-side action failures MUST NOT be ignored, so the burn rolls back through the wallet bounce path. bounce: BounceMode.NoBounce, - dest: msg.sendExcessesTo, + dest: msg.sendExcessesTo!, value: 0, body: ReturnExcessesBack { queryId: msg.queryId @@ -153,7 +162,9 @@ fun onInternalMessage(in: InMessage) { reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail()); val deployMsg = createMessage({ - bounce: BounceMode.Only256BitsOfBody, + // AUDIT(WTON-22): mint-bounce refunds need the original InternalTransferStep root cell, not + // a truncated 256-bit prefix, otherwise sendExcessesTo/query data become unavailable on bounce. + bounce: BounceMode.RichBounceOnlyRootCell, dest: calcDeployedJettonWallet(msg.mintRecipient, contract.getAddress(), storage.jettonWalletCode), value: jettonAmount + msg.tonAmount, body: msg.internalTransferMsg, @@ -185,8 +196,13 @@ struct JettonDataReply { jettonWalletCode: cell } +// TODO: update to 1.4 struct (0x00) OnchainMetadataReply { - contentDict: map + contentDict: map> +} + +struct (0x00) SnakeDataReply { + string: SnakeString } // --- Getters --- @@ -195,13 +211,14 @@ get fun typeAndVersion(): (slice, slice) { return ("link.chain.ton.wton.JettonMinter", "0.0.1"); } +// TODO: update to 1.4 get fun get_jetton_data(): JettonDataReply { val storage = lazy MinterStorage.load(); var metadata: OnchainMetadataReply = { - contentDict: [] + contentDict: createEmptyMap() }; - metadata.contentDict.set("uri".sha256(), storage.metadataUri.prefixWith00()); - metadata.contentDict.set("decimals".sha256(), "9".prefixWith00()); + metadata.contentDict.set(stringSha256("uri"), SnakeDataReply{string: storage.metadataUri.load()}.toCell()); + metadata.contentDict.set(stringSha256("decimals"), SnakeDataReply{string: "9"}.toCell()); return { totalSupply: storage.totalSupply, diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 1a41228b8..32f125f98 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -1,15 +1,18 @@ +tolk 1.2 + import "@stdlib/gas-payments" import "../lib/jetton/errors" import "../lib/jetton/jetton-utils" -import "../lib/jetton/fees-management" import "../lib/jetton/storage" import "../lib/jetton/messages" - -contract JettonWallet { - author: "Chainlink" - incomingMessages: AllowedMessageToWallet - storage: WalletStorage -} +import "fees-management" + +// TODO: update to 1.4 +// contract JettonWallet { +// author: "Chainlink Labs" +// incomingMessages: AllowedMessageToWallet +// storage: WalletStorage +// } type AllowedMessageToWallet = | AskToTransfer diff --git a/contracts/contracts/lib/jetton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk similarity index 99% rename from contracts/contracts/lib/jetton/fees-management.tolk rename to contracts/contracts/wton/fees-management.tolk index d33ff8062..6cf2f2037 100644 --- a/contracts/contracts/lib/jetton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Imported from https://github.com/ton-blockchain/tolk-bench/blob/0f416ca611fbfa25e736973d01e5fb70af485468/contracts_Tolk/03_notcoin/messages.tolk import "@stdlib/gas-payments" -import "errors" +import "../lib/jetton/errors" // we're working in basechain, but theoretically, a jetton might even work in masterchain const MY_WORKCHAIN = BASECHAIN diff --git a/contracts/tests/Wton.spec.ts b/contracts/tests/Wton.spec.ts new file mode 100644 index 000000000..8a7763313 --- /dev/null +++ b/contracts/tests/Wton.spec.ts @@ -0,0 +1,638 @@ +import '@ton/test-utils' +import { compile } from '@ton/blueprint' +import { Address, beginCell, Cell, toNano } from '@ton/core' +import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' + +import { JettonMinter } from '../wrappers/jetton/JettonMinter' +import { JettonWallet, opcodes as walletOpcodes } from '../wrappers/jetton/JettonWallet' +import * as bouncer from '../wrappers/test/mock/Bouncer' + +const JETTON_DATA_URI = 'wton.test' +const WTON_TOP_UP_OPCODE = 0xd372158c +const WTON_MINT_OPCODE = 0x00000015 +const INTERNAL_TRANSFER_OPCODE = 0x178d4519 +const ERROR_INVALID_OP = 72 +const ERROR_NOT_OWNER = 73 +const ERROR_NOT_VALID_WALLET = 74 +const ERROR_INVALID_BURN_DESTINATION = 77 +const ERROR_TOP_UP_TOO_LARGE = 78 + +type MintOptions = { + minterContract?: SandboxContract + destination: Address + jettonAmount?: bigint + tonAmount?: bigint + forwardTonAmount?: bigint + responseDestination?: Address | null + transferInitiator?: Address | null + value?: bigint +} + +describe('wTON', () => { + let blockchain: Blockchain + + let minterCode: Cell + let walletCode: Cell + let bouncerCode: Cell + + let minter: SandboxContract + let deployer: SandboxContract + let alice: SandboxContract + let bob: SandboxContract + let recipient: SandboxContract + + let nextQueryId: bigint + + beforeAll(async () => { + minterCode = await compile('wton.JettonMinter') + walletCode = await compile('wton.JettonWallet') + bouncerCode = await compile('tests.mock.Bouncer') + }) + + async function deployMinter(customWalletCode: Cell = walletCode) { + const content = beginCell().storeStringTail(JETTON_DATA_URI).endCell() + const contract = blockchain.openContract( + JettonMinter.createFromConfig( + { + admin: deployer.address, + transferAdmin: null, + walletCode: customWalletCode, + jettonContent: content, + totalSupply: 0n, + }, + minterCode, + ), + ) + + const deployResult = await contract.sendTopUpTons(deployer.getSender(), toNano('0.01')) + expect(deployResult.transactions).toHaveTransaction({ + from: deployer.address, + to: contract.address, + deploy: true, + success: true, + }) + + return contract + } + + beforeEach(async () => { + blockchain = await Blockchain.create() + + deployer = await blockchain.treasury('deployer') + alice = await blockchain.treasury('alice') + bob = await blockchain.treasury('bob') + recipient = await blockchain.treasury('recipient') + + nextQueryId = 1n + minter = await deployMinter() + }) + + async function userWallet(owner: Address): Promise> { + return blockchain.openContract( + JettonWallet.createFromAddress(await minter.getWalletAddress(owner)), + ) + } + + async function walletBalance(owner: Address) { + const wallet = await userWallet(owner) + return (await wallet.getWalletData()).balance + } + + async function walletNativeBalance(owner: Address) { + const wallet = await userWallet(owner) + return (await blockchain.getContract(wallet.address)).balance + } + + async function contractBalance(address: Address) { + return (await blockchain.getContract(address)).balance + } + + async function expectBalanceIncreaseAtLeast(address: Address, balanceBefore: bigint, minimumDelta: bigint) { + const balanceAfter = await contractBalance(address) + expect(balanceAfter - balanceBefore).toBeGreaterThanOrEqual(minimumDelta) + } + + function internalTransactionTo(result: { transactions: Array }, address: Address) { + const tx = result.transactions.find((candidate) => { + return candidate.inMessage?.info.type === 'internal' && candidate.inMessage.info.dest.equals(address) + }) + + if (!tx) { + throw new Error(`Missing internal transaction to ${address.toString()}`) + } + + return tx + } + + function topUpBody() { + return beginCell().storeUint(WTON_TOP_UP_OPCODE, 32).endCell() + } + + function mintBody({ + destination, + queryId, + jettonAmount, + tonAmount, + responseDestination, + transferInitiator, + forwardTonAmount, + }: { + destination: Address + queryId: bigint + jettonAmount: bigint + tonAmount: bigint + responseDestination: Address | null + transferInitiator: Address | null + forwardTonAmount: bigint + }) { + const internalTransferMsg = beginCell() + .storeUint(INTERNAL_TRANSFER_OPCODE, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(transferInitiator) + .storeAddress(responseDestination) + .storeCoins(forwardTonAmount) + .storeBit(0) + .endCell() + + return beginCell() + .storeUint(WTON_MINT_OPCODE, 32) + .storeUint(queryId, 64) + .storeAddress(destination) + .storeCoins(tonAmount) + .storeRef(internalTransferMsg) + .endCell() + } + + async function sendMint({ + minterContract = minter, + destination, + jettonAmount = toNano('1'), + tonAmount = toNano('0.2'), + forwardTonAmount = 0n, + responseDestination = deployer.address, + transferInitiator = null, + value, + }: MintOptions) { + const queryId = nextQueryId++ + const body = mintBody({ + destination, + queryId, + jettonAmount, + tonAmount, + responseDestination, + transferInitiator, + forwardTonAmount, + }) + + const result = await deployer.send({ + to: minterContract.address, + value: value ?? jettonAmount + tonAmount + toNano('0.3'), + body, + }) + + return { queryId, result } + } + + async function mintTo(destination: Address, options: Omit = {}) { + const { result } = await sendMint({ destination, ...options }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: true, + }) + + return result + } + + function burnBody(queryId: bigint, jettonAmount: bigint, responseDestination: Address | null) { + return beginCell() + .storeUint(walletOpcodes.in.BURN, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(responseDestination) + .storeBit(0) + .endCell() + } + + function internalTransferBody({ + queryId, + jettonAmount, + transferInitiator, + responseDestination, + forwardTonAmount = 0n, + }: { + queryId: bigint + jettonAmount: bigint + transferInitiator: Address | null + responseDestination: Address | null + forwardTonAmount?: bigint + }) { + return beginCell() + .storeUint(INTERNAL_TRANSFER_OPCODE, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(transferInitiator) + .storeAddress(responseDestination) + .storeCoins(forwardTonAmount) + .storeBit(0) + .endCell() + } + + async function deployRejector() { + const rejector = blockchain.openContract(bouncer.ContractClient.createFromConfig(bouncerCode)) + await rejector.sendDeploy(deployer.getSender(), toNano('0.05')) + return rejector + } + + describe('basic e2e', () => { + it('deploys and exposes basic jetton data', async () => { + const data = await minter.getJettonData() + + expect(data.totalSupply).toEqual(0n) + expect(data.mintable).toBe(true) + expect(data.admin).toBeNull() + expect(data.jettonWalletCode.equals(walletCode)).toBe(true) + }) + + it('completes a mint-transfer-burn lifecycle', async () => { + const minted = toNano('2') + const transferred = toNano('0.75') + const burned = toNano('0.5') + const recipientBalanceBefore = await contractBalance(recipient.address) + + await mintTo(alice.address, { jettonAmount: minted }) + + const aliceWallet = await userWallet(alice.address) + const bobWallet = await userWallet(bob.address) + await aliceWallet.sendTransfer(alice.getSender(), { + value: toNano('0.5'), + message: { + queryId: Number(nextQueryId++), + jettonAmount: transferred, + destination: bob.address, + responseDestination: alice.address, + customPayload: null, + forwardTonAmount: 0n, + forwardPayload: null, + }, + }) + + await bobWallet.sendBurn(bob.getSender(), { + value: toNano('0.2'), + message: { + queryId: nextQueryId++, + jettonAmount: burned, + responseDestination: recipient.address, + customPayload: null, + }, + }) + + expect(await walletBalance(alice.address)).toEqual(minted - transferred) + expect(await walletBalance(bob.address)).toEqual(transferred - burned) + expect((await minter.getJettonData()).totalSupply).toEqual(minted - burned) + await expectBalanceIncreaseAtLeast(recipient.address, recipientBalanceBefore, burned) + }) + + it('rejects oversized top-ups on both minter and wallet', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + + const minterTopUp = await deployer.send({ + to: minter.address, + value: toNano('1'), + body: topUpBody(), + }) + expect(minterTopUp.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_TOP_UP_TOO_LARGE, + }) + + const walletTopUp = await alice.send({ + to: aliceWallet.address, + value: toNano('1'), + body: topUpBody(), + }) + expect(walletTopUp.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + exitCode: ERROR_TOP_UP_TOO_LARGE, + }) + }) + }) + + describe('minting', () => { + it('mints wTON into a backed wallet', async () => { + const mintAmount = toNano('1') + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const walletData = await aliceWallet.getWalletData() + const walletBalance = await walletNativeBalance(alice.address) + const minterData = await minter.getJettonData() + + expect(walletData.balance).toEqual(mintAmount) + expect(minterData.totalSupply).toEqual(mintAmount) + expect(walletBalance).toBeGreaterThanOrEqual(mintAmount) + }) + + it('rejects mint messages without a refund destination', async () => { + const mintAmount = toNano('1') + const { result } = await sendMint({ + destination: alice.address, + jettonAmount: mintAmount, + responseDestination: null, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_INVALID_BURN_DESTINATION, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + }) + + it('rejects mint messages that spoof a transfer initiator', async () => { + const { result } = await sendMint({ + destination: alice.address, + transferInitiator: alice.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_INVALID_OP, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + }) + + it('rolls supply back and refunds the caller when mint deployment bounces', async () => { + const brokenMinter = await deployMinter(bouncerCode) + const { result } = await sendMint({ + minterContract: brokenMinter, + destination: alice.address, + responseDestination: deployer.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: brokenMinter.address, + to: deployer.address, + success: true, + }) + expect((await brokenMinter.getJettonData()).totalSupply).toEqual(0n) + }) + }) + + describe('transferring', () => { + it('transfers wTON between wallets', async () => { + const mintAmount = toNano('2') + const transferAmount = toNano('0.75') + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const bobWallet = await userWallet(bob.address) + + const transferResult = await aliceWallet.sendTransfer(alice.getSender(), { + value: toNano('0.5'), + message: { + queryId: Number(nextQueryId++), + jettonAmount: transferAmount, + destination: bob.address, + responseDestination: alice.address, + customPayload: null, + forwardTonAmount: 0n, + forwardPayload: null, + }, + }) + + expect(transferResult.transactions).toHaveTransaction({ + from: aliceWallet.address, + to: bobWallet.address, + success: true, + }) + expect(await walletBalance(alice.address)).toEqual(mintAmount - transferAmount) + expect(await walletBalance(bob.address)).toEqual(transferAmount) + expect(await walletNativeBalance(bob.address)).toBeGreaterThanOrEqual(transferAmount) + }) + + it('forwards TON to the recipient owner when requested', async () => { + const transferAmount = toNano('0.4') + const forwardTonAmount = toNano('0.05') + await mintTo(alice.address, { jettonAmount: toNano('1.5') }) + + const aliceWallet = await userWallet(alice.address) + const bobBalanceBefore = await contractBalance(bob.address) + + const transferResult = await aliceWallet.sendTransfer(alice.getSender(), { + value: toNano('0.7'), + message: { + queryId: Number(nextQueryId++), + jettonAmount: transferAmount, + destination: bob.address, + responseDestination: alice.address, + customPayload: null, + forwardTonAmount, + forwardPayload: null, + }, + }) + + expect(transferResult.transactions).toHaveTransaction({ + from: aliceWallet.address, + success: true, + }) + expect(await walletBalance(bob.address)).toEqual(transferAmount) + expect(await walletNativeBalance(bob.address)).toBeGreaterThanOrEqual(transferAmount) + + const bobReceiveTx = internalTransactionTo(transferResult, bob.address) + const bobBalanceAfter = await contractBalance(bob.address) + expect(bobBalanceAfter - bobBalanceBefore).toEqual(forwardTonAmount - bobReceiveTx.totalFees.coins) + }) + + it('rejects transfers from non-owners', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + + const aliceWallet = await userWallet(alice.address) + const transferResult = await aliceWallet.sendTransfer(deployer.getSender(), { + value: toNano('0.5'), + message: { + queryId: Number(nextQueryId++), + jettonAmount: toNano('0.25'), + destination: bob.address, + responseDestination: deployer.address, + customPayload: null, + forwardTonAmount: 0n, + forwardPayload: null, + }, + }) + + expect(transferResult.transactions).toHaveTransaction({ + from: deployer.address, + to: aliceWallet.address, + success: false, + exitCode: ERROR_NOT_OWNER, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + }) + + it('rejects forged internal transfer senders', async () => { + const bobMint = toNano('0.5') + await mintTo(bob.address, { jettonAmount: bobMint }) + + const bobWallet = await userWallet(bob.address) + const forgedTransfer = internalTransferBody({ + queryId: nextQueryId++, + jettonAmount: toNano('0.1'), + transferInitiator: alice.address, + responseDestination: deployer.address, + }) + + const forgedResult = await deployer.send({ + to: bobWallet.address, + value: toNano('0.2'), + body: forgedTransfer, + }) + + expect(forgedResult.transactions).toHaveTransaction({ + from: deployer.address, + to: bobWallet.address, + success: false, + exitCode: ERROR_NOT_VALID_WALLET, + }) + expect(await walletBalance(bob.address)).toEqual(bobMint) + }) + }) + + describe('burning', () => { + it('rejects burns without a refund destination', async () => { + const mintAmount = toNano('1') + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const burnResult = await alice.send({ + to: aliceWallet.address, + value: toNano('0.2'), + body: burnBody(nextQueryId++, mintAmount, null), + }) + + expect(burnResult.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + exitCode: ERROR_INVALID_BURN_DESTINATION, + }) + expect(await walletBalance(alice.address)).toEqual(mintAmount) + expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) + }) + + it('rejects burns from non-owners', async () => { + const mintAmount = toNano('1') + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const burnResult = await aliceWallet.sendBurn(deployer.getSender(), { + value: toNano('0.2'), + message: { + queryId: nextQueryId++, + jettonAmount: mintAmount, + responseDestination: recipient.address, + customPayload: null, + }, + }) + + expect(burnResult.transactions).toHaveTransaction({ + from: deployer.address, + to: aliceWallet.address, + success: false, + exitCode: ERROR_NOT_OWNER, + }) + expect(await walletBalance(alice.address)).toEqual(mintAmount) + }) + + it('burns wTON and pays the nominated recipient', async () => { + const mintAmount = toNano('1') + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const recipientBalanceBefore = await contractBalance(recipient.address) + + const burnResult = await aliceWallet.sendBurn(alice.getSender(), { + value: toNano('0.2'), + message: { + queryId: nextQueryId++, + jettonAmount: mintAmount, + responseDestination: recipient.address, + customPayload: null, + }, + }) + + expect(burnResult.transactions).toHaveTransaction({ + from: aliceWallet.address, + to: minter.address, + success: true, + }) + expect(await walletBalance(alice.address)).toEqual(0n) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + await expectBalanceIncreaseAtLeast(recipient.address, recipientBalanceBefore, mintAmount) + }) + + it('keeps burn payout at a throwing destination because withdrawal is non-bounceable', async () => { + const mintAmount = toNano('1') + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const rejector = await deployRejector() + const rejectorBalanceBefore = await contractBalance(rejector.address) + + const burnResult = await aliceWallet.sendBurn(alice.getSender(), { + value: toNano('0.2'), + message: { + queryId: nextQueryId++, + jettonAmount: mintAmount, + responseDestination: rejector.address, + customPayload: null, + }, + }) + + expect(burnResult.transactions).toHaveTransaction({ + from: minter.address, + to: rejector.address, + success: false, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + expect(await walletBalance(alice.address)).toEqual(0n) + await expectBalanceIncreaseAtLeast(rejector.address, rejectorBalanceBefore, mintAmount) + }) + + it('rejects forged burn notifications sent directly to the minter', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + + const forgedBurn = beginCell() + .storeUint(walletOpcodes.in.BURN_NOTIFICATION, 32) + .storeUint(nextQueryId++, 64) + .storeCoins(toNano('0.5')) + .storeAddress(alice.address) + .storeAddress(recipient.address) + .endCell() + + const forgedResult = await deployer.send({ + to: minter.address, + value: toNano('0.1'), + body: forgedBurn, + }) + + expect(forgedResult.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_NOT_VALID_WALLET, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(toNano('1')) + }) + }) +}) diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index ed5dd87cd..da2f3e4b4 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -71,13 +71,15 @@ export const MinterOpcodes = { EXCESSES: JettonOpcodes.EXCESSES, } +const WTON_TOP_UP_OPCODE = 0xd372158c + export type MintMessage = { queryId: bigint destination: Address tonAmount: bigint jettonAmount: bigint from: Maybe
- responseDestination: Maybe
+ responseDestination: Address customPayload?: Cell | null forwardTonAmount?: bigint } @@ -116,6 +118,14 @@ export class JettonMinter implements Contract { }) } + async sendTopUpTons(provider: ContractProvider, via: Sender, value: bigint) { + await provider.internal(via, { + value, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: beginCell().storeUint(WTON_TOP_UP_OPCODE, 32).endCell(), + }) + } + static async code(): Promise { return await JettonMinterCode() } diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index 1a02cdf8f..1de69586a 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -75,7 +75,7 @@ export type AskToTransferWithFwdPayload = { export type BurnMessage = { queryId: bigint jettonAmount: bigint - responseDestination: Address | null + responseDestination: Address customPayload: Cell | null } diff --git a/contracts/wrappers/wton.JettonMinter.compile.ts b/contracts/wrappers/wton.JettonMinter.compile.ts new file mode 100644 index 000000000..7180a6022 --- /dev/null +++ b/contracts/wrappers/wton.JettonMinter.compile.ts @@ -0,0 +1,7 @@ +import { CompilerConfig } from '@ton/blueprint' + +export const compile: CompilerConfig = { + lang: 'tolk', + entrypoint: 'contracts/wton/JettonMinter.tolk', + withStackComments: true, +} \ No newline at end of file diff --git a/contracts/wrappers/wton.JettonWallet.compile.ts b/contracts/wrappers/wton.JettonWallet.compile.ts new file mode 100644 index 000000000..f0ea8b047 --- /dev/null +++ b/contracts/wrappers/wton.JettonWallet.compile.ts @@ -0,0 +1,7 @@ +import { CompilerConfig } from '@ton/blueprint' + +export const compile: CompilerConfig = { + lang: 'tolk', + entrypoint: 'contracts/wton/JettonWallet.tolk', + withStackComments: true, +} \ No newline at end of file From 54d6b9206278f9369c0a1aad7f58d75da8205873 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 21 May 2026 14:44:20 +0200 Subject: [PATCH 08/32] Lint fix --- contracts/contracts/lib/jetton/jetton-utils.tolk | 1 - contracts/contracts/wton/JettonMinter.tolk | 1 + contracts/contracts/wton/JettonWallet.tolk | 1 + contracts/contracts/wton/README.md | 10 +++++----- contracts/tests/Wton.spec.ts | 15 ++++++++++++--- contracts/wrappers/wton.JettonMinter.compile.ts | 2 +- contracts/wrappers/wton.JettonWallet.compile.ts | 2 +- 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/contracts/contracts/lib/jetton/jetton-utils.tolk b/contracts/contracts/lib/jetton/jetton-utils.tolk index 0d53648d1..21c2a3700 100644 --- a/contracts/contracts/lib/jetton/jetton-utils.tolk +++ b/contracts/contracts/lib/jetton/jetton-utils.tolk @@ -1,5 +1,4 @@ // SPDX-License-Identifier: MIT -import "@stdlib/gas-payments" import "storage" fun calcDeployedJettonWallet(ownerAddress: address, minterAddress: address, jettonWalletCode: cell): AutoDeployAddress { diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index ddc8f7b31..632953706 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT tolk 1.2 // TODO: update to 1.4 diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 32f125f98..5f771931e 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: MIT tolk 1.2 import "@stdlib/gas-payments" diff --git a/contracts/contracts/wton/README.md b/contracts/contracts/wton/README.md index dd8520c0d..4a457f022 100644 --- a/contracts/contracts/wton/README.md +++ b/contracts/contracts/wton/README.md @@ -4,11 +4,11 @@ A token escrow protocol to make TON behave as Jetton in a new asset called wTON. **Features:** -* Standard Jetton [TEP #74](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md) minimal deviation -* No admin: - * Mint (new) wTON by providing TON - * Burn wTON to withdraw TON -* Base Jetton Tolk implementation from at [57e1009](https://github.com/ton-blockchain/tolk-bench/commit/57e1009743bfc19748caa95d76180d9e9793e4c5) +- Standard Jetton [TEP #74](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md) minimal deviation +- No admin: + - Mint (new) wTON by providing TON + - Burn wTON to withdraw TON +- Base Jetton Tolk implementation from at [57e1009](https://github.com/ton-blockchain/tolk-bench/commit/57e1009743bfc19748caa95d76180d9e9793e4c5) **Why this version?** diff --git a/contracts/tests/Wton.spec.ts b/contracts/tests/Wton.spec.ts index 8a7763313..e9d4cdc7f 100644 --- a/contracts/tests/Wton.spec.ts +++ b/contracts/tests/Wton.spec.ts @@ -107,14 +107,21 @@ describe('wTON', () => { return (await blockchain.getContract(address)).balance } - async function expectBalanceIncreaseAtLeast(address: Address, balanceBefore: bigint, minimumDelta: bigint) { + async function expectBalanceIncreaseAtLeast( + address: Address, + balanceBefore: bigint, + minimumDelta: bigint, + ) { const balanceAfter = await contractBalance(address) expect(balanceAfter - balanceBefore).toBeGreaterThanOrEqual(minimumDelta) } function internalTransactionTo(result: { transactions: Array }, address: Address) { const tx = result.transactions.find((candidate) => { - return candidate.inMessage?.info.type === 'internal' && candidate.inMessage.info.dest.equals(address) + return ( + candidate.inMessage?.info.type === 'internal' && + candidate.inMessage.info.dest.equals(address) + ) }) if (!tx) { @@ -451,7 +458,9 @@ describe('wTON', () => { const bobReceiveTx = internalTransactionTo(transferResult, bob.address) const bobBalanceAfter = await contractBalance(bob.address) - expect(bobBalanceAfter - bobBalanceBefore).toEqual(forwardTonAmount - bobReceiveTx.totalFees.coins) + expect(bobBalanceAfter - bobBalanceBefore).toEqual( + forwardTonAmount - bobReceiveTx.totalFees.coins, + ) }) it('rejects transfers from non-owners', async () => { diff --git a/contracts/wrappers/wton.JettonMinter.compile.ts b/contracts/wrappers/wton.JettonMinter.compile.ts index 7180a6022..1fec5cb05 100644 --- a/contracts/wrappers/wton.JettonMinter.compile.ts +++ b/contracts/wrappers/wton.JettonMinter.compile.ts @@ -4,4 +4,4 @@ export const compile: CompilerConfig = { lang: 'tolk', entrypoint: 'contracts/wton/JettonMinter.tolk', withStackComments: true, -} \ No newline at end of file +} diff --git a/contracts/wrappers/wton.JettonWallet.compile.ts b/contracts/wrappers/wton.JettonWallet.compile.ts index f0ea8b047..cc63f5673 100644 --- a/contracts/wrappers/wton.JettonWallet.compile.ts +++ b/contracts/wrappers/wton.JettonWallet.compile.ts @@ -4,4 +4,4 @@ export const compile: CompilerConfig = { lang: 'tolk', entrypoint: 'contracts/wton/JettonWallet.tolk', withStackComments: true, -} \ No newline at end of file +} From 95237a10f459f461fa2e61c27295c8cadb2f5991 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 21 May 2026 15:24:06 +0200 Subject: [PATCH 09/32] mv test, add wton errors bindings --- contracts/contracts/wton/JettonWallet.tolk | 4 ++-- .../tests/{Wton.spec.ts => wton/wton.spec.ts} | 16 +++++++++------- contracts/wrappers/wton/errors.ts | 4 ++++ contracts/wrappers/wton/index.ts | 1 + 4 files changed, 16 insertions(+), 9 deletions(-) rename contracts/tests/{Wton.spec.ts => wton/wton.spec.ts} (97%) create mode 100644 contracts/wrappers/wton/errors.ts create mode 100644 contracts/wrappers/wton/index.ts diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 5f771931e..728db95d1 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -24,7 +24,7 @@ type AllowedMessageToWallet = type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter const ERROR_UNSUFFICIENT_AMOUNT = 76 -const ERROR_INVALID_BURN_DESTINATION = 77 +const ERROR_INVALID_EXCESSES_DESTINATION = 77 const ERROR_TOP_UP_TOO_LARGE = 78 fun reserveModeExactFail() { @@ -138,7 +138,7 @@ fun onInternalMessage(in: InMessage) { assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; // AUDIT(WTON-4): burn must name a refund destination so the minter can return the withdrawn TON instead of trapping it. - assert (msg.sendExcessesTo != null) throw ERROR_INVALID_BURN_DESTINATION; + assert (msg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; storage.jettonBalance -= msg.jettonAmount; storage.save(); diff --git a/contracts/tests/Wton.spec.ts b/contracts/tests/wton/wton.spec.ts similarity index 97% rename from contracts/tests/Wton.spec.ts rename to contracts/tests/wton/wton.spec.ts index e9d4cdc7f..4d630cbaa 100644 --- a/contracts/tests/Wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -3,9 +3,13 @@ import { compile } from '@ton/blueprint' import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' -import { JettonMinter } from '../wrappers/jetton/JettonMinter' -import { JettonWallet, opcodes as walletOpcodes } from '../wrappers/jetton/JettonWallet' -import * as bouncer from '../wrappers/test/mock/Bouncer' +import { JettonMinter } from '../../wrappers/jetton/JettonMinter' +import { JettonWallet, opcodes as walletOpcodes } from '../../wrappers/jetton/JettonWallet' +import { + ERROR_INVALID_EXCESSES_DESTINATION, + ERROR_TOP_UP_TOO_LARGE, +} from '../../wrappers/wton' +import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' const WTON_TOP_UP_OPCODE = 0xd372158c @@ -14,8 +18,6 @@ const INTERNAL_TRANSFER_OPCODE = 0x178d4519 const ERROR_INVALID_OP = 72 const ERROR_NOT_OWNER = 73 const ERROR_NOT_VALID_WALLET = 74 -const ERROR_INVALID_BURN_DESTINATION = 77 -const ERROR_TOP_UP_TOO_LARGE = 78 type MintOptions = { minterContract?: SandboxContract @@ -359,7 +361,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_INVALID_BURN_DESTINATION, + exitCode: ERROR_INVALID_EXCESSES_DESTINATION, }) expect((await minter.getJettonData()).totalSupply).toEqual(0n) }) @@ -533,7 +535,7 @@ describe('wTON', () => { from: alice.address, to: aliceWallet.address, success: false, - exitCode: ERROR_INVALID_BURN_DESTINATION, + exitCode: ERROR_INVALID_EXCESSES_DESTINATION, }) expect(await walletBalance(alice.address)).toEqual(mintAmount) expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) diff --git a/contracts/wrappers/wton/errors.ts b/contracts/wrappers/wton/errors.ts new file mode 100644 index 000000000..389c4e835 --- /dev/null +++ b/contracts/wrappers/wton/errors.ts @@ -0,0 +1,4 @@ +export const ERROR_ALREADY_INITIALIZED = 75 +export const ERROR_UNSUFFICIENT_AMOUNT = 76 +export const ERROR_INVALID_EXCESSES_DESTINATION = 77 +export const ERROR_TOP_UP_TOO_LARGE = 78 diff --git a/contracts/wrappers/wton/index.ts b/contracts/wrappers/wton/index.ts new file mode 100644 index 000000000..183e8bd09 --- /dev/null +++ b/contracts/wrappers/wton/index.ts @@ -0,0 +1 @@ +export * from './errors' From bf7054a2badc84cf3dc95566af4b9298894509c3 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 21 May 2026 15:37:04 +0200 Subject: [PATCH 10/32] Fix lint add /contracts/.envrc --- contracts/.envrc | 2 ++ contracts/tests/wton/wton.spec.ts | 5 +---- 2 files changed, 3 insertions(+), 4 deletions(-) create mode 100644 contracts/.envrc diff --git a/contracts/.envrc b/contracts/.envrc new file mode 100644 index 000000000..0319c8988 --- /dev/null +++ b/contracts/.envrc @@ -0,0 +1,2 @@ +watch_file shell.nix +use flake .#contracts || use nix diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 4d630cbaa..294f02a32 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -5,10 +5,7 @@ import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' import { JettonMinter } from '../../wrappers/jetton/JettonMinter' import { JettonWallet, opcodes as walletOpcodes } from '../../wrappers/jetton/JettonWallet' -import { - ERROR_INVALID_EXCESSES_DESTINATION, - ERROR_TOP_UP_TOO_LARGE, -} from '../../wrappers/wton' +import { ERROR_INVALID_EXCESSES_DESTINATION, ERROR_TOP_UP_TOO_LARGE } from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' From 494093fbd6d43188b7a30ae95f178f986e42a563 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 21 May 2026 15:42:17 +0200 Subject: [PATCH 11/32] Cleanup TOP_UP opcode --- contracts/tests/wton/wton.spec.ts | 5 ++--- contracts/wrappers/examples/jetton/types.ts | 2 +- contracts/wrappers/jetton/JettonMinter.ts | 6 ++---- contracts/wrappers/jetton/JettonWallet.ts | 1 + 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 294f02a32..e52da1ea1 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -3,13 +3,12 @@ import { compile } from '@ton/blueprint' import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' -import { JettonMinter } from '../../wrappers/jetton/JettonMinter' +import { JettonMinter, MinterOpcodes } from '../../wrappers/jetton/JettonMinter' import { JettonWallet, opcodes as walletOpcodes } from '../../wrappers/jetton/JettonWallet' import { ERROR_INVALID_EXCESSES_DESTINATION, ERROR_TOP_UP_TOO_LARGE } from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' -const WTON_TOP_UP_OPCODE = 0xd372158c const WTON_MINT_OPCODE = 0x00000015 const INTERNAL_TRANSFER_OPCODE = 0x178d4519 const ERROR_INVALID_OP = 72 @@ -131,7 +130,7 @@ describe('wTON', () => { } function topUpBody() { - return beginCell().storeUint(WTON_TOP_UP_OPCODE, 32).endCell() + return beginCell().storeUint(MinterOpcodes.TOP_UP, 32).endCell() } function mintBody({ diff --git a/contracts/wrappers/examples/jetton/types.ts b/contracts/wrappers/examples/jetton/types.ts index 801048861..1bfc389c4 100644 --- a/contracts/wrappers/examples/jetton/types.ts +++ b/contracts/wrappers/examples/jetton/types.ts @@ -46,7 +46,7 @@ export const JettonOpcodes = { DROP_ADMIN: 0x7431f221, CHANGE_METADATA_URL: 0xcb862902, UPGRADE: 0x2508d66a, - // TOP_UP: 0x8, + TOP_UP: 0xd372158c, } export const ErrorCodes = { diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index da2f3e4b4..fdf1ef6c7 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -66,13 +66,11 @@ export const MinterOpcodes = { DROP_ADMIN: JettonOpcodes.DROP_ADMIN, CHANGE_METADATA_URL: JettonOpcodes.CHANGE_METADATA_URL, UPGRADE: JettonOpcodes.UPGRADE, - // TOP_UP: JettonOpcodes.TOP_UP, + TOP_UP: JettonOpcodes.TOP_UP, INTERNAL_TRANSFER: JettonOpcodes.INTERNAL_TRANSFER, EXCESSES: JettonOpcodes.EXCESSES, } -const WTON_TOP_UP_OPCODE = 0xd372158c - export type MintMessage = { queryId: bigint destination: Address @@ -122,7 +120,7 @@ export class JettonMinter implements Contract { await provider.internal(via, { value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell().storeUint(WTON_TOP_UP_OPCODE, 32).endCell(), + body: beginCell().storeUint(MinterOpcodes.TOP_UP, 32).endCell(), }) } diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index 1de69586a..bb9bc723e 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -43,6 +43,7 @@ export const opcodes = { in: { TRANSFER: JettonOpcodes.TRANSFER, TRANSFER_NOTIFICATION: JettonOpcodes.TRANSFER_NOTIFICATION, + TOP_UP: JettonOpcodes.TOP_UP, INTERNAL_TRANSFER: JettonOpcodes.INTERNAL_TRANSFER, EXCESSES: JettonOpcodes.EXCESSES, BURN: JettonOpcodes.BURN, From ade013352e701b214c8832aeecaed394684144ac Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 22 May 2026 10:38:23 +0200 Subject: [PATCH 12/32] Polish and add tests --- contracts/contracts/wton/JettonMinter.tolk | 46 +-- contracts/contracts/wton/JettonWallet.tolk | 27 +- contracts/tests/wton/wton.spec.ts | 433 +++++++++++++++++++-- contracts/wrappers/jetton/JettonWallet.ts | 8 + contracts/wrappers/wton/errors.ts | 1 - 5 files changed, 430 insertions(+), 85 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 632953706..8ebf4356b 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -26,7 +26,6 @@ type AllowedMessageToMinter = const ERROR_ALREADY_INITIALIZED = 75 const ERROR_UNSUFFICIENT_AMOUNT = 76 const ERROR_INVALID_EXCESSES_DESTINATION = 77 -const ERROR_TOP_UP_TOO_LARGE = 78 fun reserveModeExactFail() { return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL; @@ -36,17 +35,14 @@ fun requiredMinterReserve() { return ton("0.01") + contract.getStorageDuePayment(); } -fun minterTopUpCap() { - return requiredMinterReserve(); -} - +/// Refund the mint on bounce, reuses sendExcessesTo as the bounce refund destination, so a failed wallet deploy/credit returns TON. fun refundMintBounce(msg: InternalTransferStep) { - // AUDIT(WTON-7): mint reuses sendExcessesTo as the bounce refund destination, so a failed wallet deploy/credit returns TON. - reserveToncoinsOnBalance(requiredMinterReserve(), reserveModeExactFail()); + // We're already in the mint recovery path after supply rollback, so keep the minter reserve + // if possible but never fail the bounce handler itself. + reserveToncoinsOnBalance(requiredMinterReserve(), RESERVE_MODE_AT_MOST); val refundMsg = createMessage({ - // AUDIT(WTON-17): mint-bounce refund is a forced TON deposit to the caller-chosen refund address. - // If the destination throws, NoBounce keeps the TON there instead of looping another bounce. + // The mint-bounce refund is a forced TON deposit to the caller-chosen refund address. bounce: BounceMode.NoBounce, dest: msg.sendExcessesTo!, value: 0, @@ -54,21 +50,15 @@ fun refundMintBounce(msg: InternalTransferStep) { queryId: msg.queryId } }); - // AUDIT(WTON-18): IGNORE_ERRORS is kept only on the mint-bounce refund path because we are already in - // onBouncedMessage after rolling supply back. If the send action itself fails here, reverting this tx would - // resurrect supply without restoring the recipient wallet. The security-grade long-term fix is a pending-refund - // ledger/claim flow; until then this path is intentionally best-effort and never a final accounting transition. + // IGNORE_ERRORS is used in the mint-bounce refund path because we are already in + // onBouncedMessage after rolling supply back, so we make a forced deposit refundMsg.send(SEND_MODE_IGNORE_ERRORS | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE); } fun onBouncedMessage(in: InMessageBounced) { - // AUDIT(WTON-21): refundMintBounce needs sendExcessesTo from the original mint payload, so the + // We require sendExcessesTo for refundMintBounce from the original mint payload, so the // bounced body must preserve the full root cell rather than the old 256-bit truncation. val rich = lazy RichBounceBody.fromSlice(in.bouncedBody); - var originalBody = rich.originalBody.beginParse(); - if (originalBody.preloadUint(32) != InternalTransferStep.getDeclaredPackPrefix()) { - return; - } val msg = lazy InternalTransferStep.fromCell(rich.originalBody); var storage = lazy MinterStorage.load(); @@ -84,13 +74,14 @@ fun onInternalMessage(in: InMessage) { match (msg) { BurnNotificationForMinter => { var storage = lazy MinterStorage.load(); - assert (in.senderAddress == calcAddressOfJettonWallet(msg.burnInitiator, contract.getAddress(), storage.jettonWalletCode)) throw ERROR_NOT_VALID_WALLET; - // reject burns without a refund destination so the withdrawn TON never gets stranded here + val calcAddress = calcAddressOfJettonWallet(msg.burnInitiator, contract.getAddress(), storage.jettonWalletCode); + assert (in.senderAddress == calcAddress) throw ERROR_NOT_VALID_WALLET; + // Reject burns without a refund destination so the withdrawn TON never gets stranded here. assert (msg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; storage.totalSupply -= msg.jettonAmount; storage.save(); - // keep only the minter rent reserve before withdrawing TON + // Keep only the minter rent reserve before withdrawing TON. reserveToncoinsOnBalance(requiredMinterReserve(), reserveModeExactFail()); val excessesMsg = createMessage({ @@ -104,8 +95,8 @@ fun onInternalMessage(in: InMessage) { queryId: msg.queryId } }); - // AUDIT(WTON-20): burn withdrawal is not "best effort". If this send action cannot be executed, - // the minter tx must fail so BurnNotificationForMinter bounces back to the wallet and restores wTON. + // Burn withdrawal is not "best effort". If this send action cannot be executed, the minter tx must fail + // so BurnNotificationForMinter bounces back to the wallet and restores wTON. excessesMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } @@ -144,11 +135,11 @@ fun onInternalMessage(in: InMessage) { internalTransferMsg.forwardPayload.checkIsCorrectTLBEither(); val jettonAmount = internalTransferMsg.jettonAmount; - // AUDIT(WTON-10): mint must name the same excess/refund destination for both the happy path and a bounced wallet deployment. + // Mint must name the same excess/refund destination for both the happy path and a bounced wallet deployment. assert (internalTransferMsg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; - // AUDIT(WTON-11): minting must not impersonate a peer wallet transfer initiator. + // Minting must not impersonate a peer wallet transfer initiator. assert (internalTransferMsg.transferInitiator == null) throw ERROR_INVALID_OP; - // AUDIT(WTON-12): the caller must fund both the new hosted TON backing and the extra transfer budget. + // The caller must fund both the new hosted TON backing and the extra transfer budget. assert (in.valueCoins >= jettonAmount + msg.tonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; // AUDIT(WTON-13): the extra mint budget must independently cover the receiver-side transfer/forward flow. @@ -179,8 +170,7 @@ fun onInternalMessage(in: InMessage) { } TopUpTons => { - // AUDIT(WTON-15): cap rent top-ups so arbitrary TON cannot silently accumulate outside mint/burn accounting. - assert (in.valueCoins <= minterTopUpCap()) throw ERROR_TOP_UP_TOO_LARGE; + // Accept TONs } else => throw 0xFFFF diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 728db95d1..10a41fd55 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -25,7 +25,6 @@ type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter const ERROR_UNSUFFICIENT_AMOUNT = 76 const ERROR_INVALID_EXCESSES_DESTINATION = 77 -const ERROR_TOP_UP_TOO_LARGE = 78 fun reserveModeExactFail() { return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL; @@ -35,10 +34,6 @@ fun requiredWalletReserve(backedTonAmount: coins) { return backedTonAmount + calculateJettonWalletMinStorageFee() + contract.getStorageDuePayment(); } -fun walletTopUpCap() { - return calculateJettonWalletMinStorageFee() + contract.getStorageDuePayment(); -} - fun onBouncedMessage(in: InMessageBounced) { in.bouncedBody.skipBouncedPrefix(); @@ -60,16 +55,17 @@ fun onInternalMessage(in: InMessage) { InternalTransferStep => { var storage = lazy WalletStorage.load(); if (in.senderAddress != storage.minterAddress) { - assert (in.senderAddress == calcAddressOfJettonWallet(msg.transferInitiator!, storage.minterAddress, contract.getCode())) throw ERROR_NOT_VALID_WALLET; + val calcAddress = calcAddressOfJettonWallet(msg.transferInitiator!, storage.minterAddress, contract.getCode()); + assert (in.senderAddress == calcAddress) throw ERROR_NOT_VALID_WALLET; } - val nextJettonBalance = storage.jettonBalance + msg.jettonAmount; - val requiredReserve = requiredWalletReserve(nextJettonBalance); - // AUDIT(WTON-1): require the incoming transfer to carry the full hosted TON backing before we credit wTON. + val jettonBalanceNext = storage.jettonBalance + msg.jettonAmount; + val requiredReserve = requiredWalletReserve(jettonBalanceNext); + // Require the incoming transfer to carry the full hosted TON backing before we credit wTON. assert (contract.getOriginalBalance() >= requiredReserve + msg.forwardTonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; - // AUDIT(WTON-2): lock backing + storage reserve before any notification/excess send can spend value. + // Lock backing + storage reserve before any notification/excess send can spend value. reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail()); - storage.jettonBalance = nextJettonBalance; + storage.jettonBalance = jettonBalanceNext; storage.save(); if (msg.forwardTonAmount != 0) { @@ -112,7 +108,7 @@ fun onInternalMessage(in: InMessage) { storage.jettonBalance -= msg.jettonAmount; storage.save(); - // AUDIT(WTON-3): preserve the remaining wTON backing plus storage reserve exactly, or abort the transfer. + // Preserve the remaining wTON backing plus storage reserve exactly, or abort the transfer. reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail()); val deployMsg = createMessage({ @@ -137,12 +133,12 @@ fun onInternalMessage(in: InMessage) { var storage = lazy WalletStorage.load(); assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; - // AUDIT(WTON-4): burn must name a refund destination so the minter can return the withdrawn TON instead of trapping it. + // Burn must name a refund destination so the minter can return the withdrawn TON instead of trapping it assert (msg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; storage.jettonBalance -= msg.jettonAmount; storage.save(); - // AUDIT(WTON-5): preserve the remaining backing exactly before sending the withdrawn TON to the minter. + // Preserve the remaining backing exactly before sending the withdrawn TON to the minter. reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail()); val notifyMinterMsg = createMessage({ @@ -160,8 +156,7 @@ fun onInternalMessage(in: InMessage) { } TopUpTons => { - // AUDIT(WTON-6): cap rent top-ups so arbitrary TON cannot be mixed into the hosted asset accounting. - assert (in.valueCoins <= walletTopUpCap()) throw ERROR_TOP_UP_TOO_LARGE; + // Accept TONs } else => throw 0xFFFF diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index e52da1ea1..3ce01b47e 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -5,15 +5,18 @@ import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' import { JettonMinter, MinterOpcodes } from '../../wrappers/jetton/JettonMinter' import { JettonWallet, opcodes as walletOpcodes } from '../../wrappers/jetton/JettonWallet' -import { ERROR_INVALID_EXCESSES_DESTINATION, ERROR_TOP_UP_TOO_LARGE } from '../../wrappers/wton' +import { ERROR_INVALID_EXCESSES_DESTINATION } from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' const WTON_MINT_OPCODE = 0x00000015 const INTERNAL_TRANSFER_OPCODE = 0x178d4519 +const ERROR_BALANCE_ERROR = 47 +const ERROR_NOT_ENOUGH_GAS = 48 const ERROR_INVALID_OP = 72 const ERROR_NOT_OWNER = 73 const ERROR_NOT_VALID_WALLET = 74 +const ERROR_UNSUFFICIENT_AMOUNT = 76 type MintOptions = { minterContract?: SandboxContract @@ -62,8 +65,8 @@ describe('wTON', () => { ), ) - const deployResult = await contract.sendTopUpTons(deployer.getSender(), toNano('0.01')) - expect(deployResult.transactions).toHaveTransaction({ + const res = await contract.sendTopUpTons(deployer.getSender(), toNano('0.01')) + expect(res.transactions).toHaveTransaction({ from: deployer.address, to: contract.address, deploy: true, @@ -86,9 +89,8 @@ describe('wTON', () => { }) async function userWallet(owner: Address): Promise> { - return blockchain.openContract( - JettonWallet.createFromAddress(await minter.getWalletAddress(owner)), - ) + const walletAddr = await minter.getWalletAddress(owner) + return blockchain.openContract(JettonWallet.createFromAddress(walletAddr)) } async function walletBalance(owner: Address) { @@ -98,20 +100,28 @@ describe('wTON', () => { async function walletNativeBalance(owner: Address) { const wallet = await userWallet(owner) - return (await blockchain.getContract(wallet.address)).balance + return contractBalance(wallet.address) + } + + async function totalSupply() { + return (await minter.getJettonData()).totalSupply + } + + async function sumWalletBalances(owners: Address[]) { + let total = 0n + for (const owner of owners) { + total += await walletBalance(owner) + } + return total } async function contractBalance(address: Address) { return (await blockchain.getContract(address)).balance } - async function expectBalanceIncreaseAtLeast( - address: Address, - balanceBefore: bigint, - minimumDelta: bigint, - ) { - const balanceAfter = await contractBalance(address) - expect(balanceAfter - balanceBefore).toBeGreaterThanOrEqual(minimumDelta) + async function expectBalanceIncreaseAtLeast(address: Address, before: bigint, minDelta: bigint) { + const after = await contractBalance(address) + expect(after - before).toBeGreaterThanOrEqual(minDelta) } function internalTransactionTo(result: { transactions: Array }, address: Address) { @@ -129,10 +139,6 @@ describe('wTON', () => { return tx } - function topUpBody() { - return beginCell().storeUint(MinterOpcodes.TOP_UP, 32).endCell() - } - function mintBody({ destination, queryId, @@ -221,6 +227,31 @@ describe('wTON', () => { .endCell() } + function transferBody({ + queryId, + jettonAmount, + destination, + responseDestination, + forwardTonAmount = 0n, + }: { + queryId: bigint + jettonAmount: bigint + destination: Address + responseDestination: Address | null + forwardTonAmount?: bigint + }) { + return beginCell() + .storeUint(walletOpcodes.in.TRANSFER, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(destination) + .storeAddress(responseDestination) + .storeBit(0) + .storeCoins(forwardTonAmount) + .storeBit(0) + .endCell() + } + function internalTransferBody({ queryId, jettonAmount, @@ -251,6 +282,60 @@ describe('wTON', () => { return rejector } + async function transferFrom( + owner: SandboxContract, + { + jettonAmount, + destination, + responseDestination = owner.address, + value = toNano('0.5'), + forwardTonAmount = 0n, + }: { + jettonAmount: bigint + destination: Address + responseDestination?: Address | null + value?: bigint + forwardTonAmount?: bigint + }, + ) { + const wallet = await userWallet(owner.address) + const result = await owner.send({ + to: wallet.address, + value, + body: transferBody({ + queryId: nextQueryId++, + jettonAmount, + destination, + responseDestination, + forwardTonAmount, + }), + }) + + return { wallet, result } + } + + async function burnFrom( + owner: SandboxContract, + { + jettonAmount, + responseDestination, + value = toNano('0.2'), + }: { + jettonAmount: bigint + responseDestination: Address | null + value?: bigint + }, + ) { + const wallet = await userWallet(owner.address) + const result = await owner.send({ + to: wallet.address, + value, + body: burnBody(nextQueryId++, jettonAmount, responseDestination), + }) + + return { wallet, result } + } + describe('basic e2e', () => { it('deploys and exposes basic jetton data', async () => { const data = await minter.getJettonData() @@ -300,33 +385,51 @@ describe('wTON', () => { await expectBalanceIncreaseAtLeast(recipient.address, recipientBalanceBefore, burned) }) - it('rejects oversized top-ups on both minter and wallet', async () => { + it('accepts direct top-ups on both minter and wallet', async () => { await mintTo(alice.address, { jettonAmount: toNano('1') }) const aliceWallet = await userWallet(alice.address) + const minterBalanceBefore = await contractBalance(minter.address) + const walletBalanceBefore = await contractBalance(aliceWallet.address) - const minterTopUp = await deployer.send({ - to: minter.address, - value: toNano('1'), - body: topUpBody(), - }) + const minterTopUp = await minter.sendTopUpTons(deployer.getSender(), toNano('1')) expect(minterTopUp.transactions).toHaveTransaction({ from: deployer.address, to: minter.address, - success: false, - exitCode: ERROR_TOP_UP_TOO_LARGE, + success: true, }) - const walletTopUp = await alice.send({ - to: aliceWallet.address, - value: toNano('1'), - body: topUpBody(), - }) + const walletTopUp = await aliceWallet.sendTopUpTons(alice.getSender(), toNano('1')) expect(walletTopUp.transactions).toHaveTransaction({ from: alice.address, to: aliceWallet.address, - success: false, - exitCode: ERROR_TOP_UP_TOO_LARGE, + success: true, }) + + expect(await contractBalance(minter.address)).toBeGreaterThan(minterBalanceBefore) + expect(await contractBalance(aliceWallet.address)).toBeGreaterThan(walletBalanceBefore) + }) + + it('keeps wallet addresses stable before and after first deployment', async () => { + const predictedAliceWallet = await minter.getWalletAddress(alice.address) + const predictedBobWallet = await minter.getWalletAddress(bob.address) + + await mintTo(alice.address, { jettonAmount: toNano('1') }) + await mintTo(bob.address, { jettonAmount: toNano('0.5') }) + + expect((await userWallet(alice.address)).address.equals(predictedAliceWallet)).toBe(true) + expect((await userWallet(bob.address)).address.equals(predictedBobWallet)).toBe(true) + }) + + it('keeps total supply equal to the sum of live wallet balances after mixed operations', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1.2') }) + await mintTo(bob.address, { jettonAmount: toNano('0.8') }) + + await burnFrom(alice, { + jettonAmount: toNano('0.3'), + responseDestination: recipient.address, + }) + + expect(await totalSupply()).toEqual(await sumWalletBalances([alice.address, bob.address])) }) }) @@ -378,19 +481,128 @@ describe('wTON', () => { }) it('rolls supply back and refunds the caller when mint deployment bounces', async () => { - const brokenMinter = await deployMinter(bouncerCode) + const rejector = await deployRejector() + const mintAmount = toNano('1') + await sendMint({ + destination: rejector.address, + jettonAmount: mintAmount, + responseDestination: rejector.address, // refund + }) + + const rejectorWallet = await userWallet(rejector.address) + const c = await blockchain.getContract(rejectorWallet.address) + c.balance = 0n // Put wallet in debt to trigger the mint bounce + + const { result } = await sendMint({ + destination: rejector.address, + jettonAmount: mintAmount, + responseDestination: rejector.address, // refund + }) + + // mint transfer notification bounce + expect(result.transactions).toHaveTransaction({ + from: minter.address, + to: rejectorWallet.address, + success: false, + }) + + // mint-bounce flow + expect(result.transactions).toHaveTransaction({ + from: minter.address, + to: rejector.address, + success: false, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) // first mint + + const mintRefundBalance = await contractBalance(rejector.address) + expect(mintRefundBalance).toBeGreaterThanOrEqual(mintAmount) // second mint refunded + }) + + it('accumulates repeated mints into the same wallet', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1.25') }) + await mintTo(alice.address, { jettonAmount: toNano('0.75') }) + + expect(await walletBalance(alice.address)).toEqual(toNano('2')) + expect(await totalSupply()).toEqual(toNano('2')) + }) + + it('can mint with forwarded TON to the recipient owner', async () => { + const mintAmount = toNano('1') + const forwardTonAmount = toNano('0.05') + const bobBalanceBefore = await contractBalance(bob.address) + + const mintResult = await mintTo(bob.address, { + jettonAmount: mintAmount, + tonAmount: toNano('0.4'), + forwardTonAmount, + }) + + expect(await walletBalance(bob.address)).toEqual(mintAmount) + const bobReceiveTx = internalTransactionTo(mintResult, bob.address) + const bobBalanceAfter = await contractBalance(bob.address) + const delta = bobBalanceAfter - bobBalanceBefore + expect(delta).toEqual(forwardTonAmount - bobReceiveTx.totalFees.coins) + }) + + it('rejects underfunded mint principal', async () => { + const jettonAmount = toNano('1') + const tonAmount = toNano('0.2') const { result } = await sendMint({ - minterContract: brokenMinter, destination: alice.address, - responseDestination: deployer.address, + jettonAmount, + tonAmount, + value: jettonAmount + tonAmount - 1n, }) expect(result.transactions).toHaveTransaction({ - from: brokenMinter.address, - to: deployer.address, - success: true, + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_UNSUFFICIENT_AMOUNT, + }) + expect(await totalSupply()).toEqual(0n) + }) + + it('rejects underfunded mint transfer budget when forwarding TON', async () => { + const { result } = await sendMint({ + destination: alice.address, + jettonAmount: toNano('1'), + tonAmount: 1n, + forwardTonAmount: toNano('0.05'), + value: toNano('1.1'), + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_NOT_ENOUGH_GAS, + }) + expect(await totalSupply()).toEqual(0n) + }) + + it('rejects malformed internal transfer payloads', async () => { + const body = beginCell() + .storeUint(WTON_MINT_OPCODE, 32) + .storeUint(nextQueryId++, 64) + .storeAddress(alice.address) + .storeCoins(toNano('0.2')) + .storeRef(beginCell().storeUint(0x12345678, 32).endCell()) + .endCell() + + const result = await deployer.send({ + to: minter.address, + value: toNano('1.5'), + body, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_INVALID_OP, }) - expect((await brokenMinter.getJettonData()).totalSupply).toEqual(0n) + expect(await totalSupply()).toEqual(0n) }) }) @@ -513,6 +725,77 @@ describe('wTON', () => { }) expect(await walletBalance(bob.address)).toEqual(bobMint) }) + + it('supports transfers without a response destination', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + + const { result } = await transferFrom(alice, { + jettonAmount: toNano('0.25'), + destination: bob.address, + responseDestination: null, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + success: true, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('0.75')) + expect(await walletBalance(bob.address)).toEqual(toNano('0.25')) + }) + + it('rejects transfers that exceed wallet balance', async () => { + await mintTo(alice.address, { jettonAmount: toNano('0.2') }) + + const { result } = await transferFrom(alice, { + jettonAmount: toNano('0.25'), + destination: bob.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: (await userWallet(alice.address)).address, + success: false, + exitCode: ERROR_BALANCE_ERROR, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('0.2')) + expect(await totalSupply()).toEqual(toNano('0.2')) + }) + + it('rejects underfunded transfer value before moving balance', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + + const { result } = await transferFrom(alice, { + jettonAmount: toNano('0.25'), + destination: bob.address, + value: 1n, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + expect(await totalSupply()).toEqual(toNano('1')) + }) + + it('preserves total supply across chained transfers', async () => { + await mintTo(alice.address, { jettonAmount: toNano('2.5') }) + + await transferFrom(alice, { + jettonAmount: toNano('1'), + destination: bob.address, + }) + await transferFrom(bob, { + jettonAmount: toNano('0.4'), + destination: recipient.address, + }) + + expect(await totalSupply()).toEqual( + await sumWalletBalances([alice.address, bob.address, recipient.address]), + ) + }) }) describe('burning', () => { @@ -641,5 +924,75 @@ describe('wTON', () => { }) expect((await minter.getJettonData()).totalSupply).toEqual(toNano('1')) }) + + it('supports partial burns and keeps the remainder spendable', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1.5') }) + + await burnFrom(alice, { + jettonAmount: toNano('0.4'), + responseDestination: recipient.address, + }) + await transferFrom(alice, { + jettonAmount: toNano('0.3'), + destination: bob.address, + }) + + expect(await walletBalance(alice.address)).toEqual(toNano('0.8')) + expect(await walletBalance(bob.address)).toEqual(toNano('0.3')) + expect(await totalSupply()).toEqual(toNano('1.1')) + }) + + it('rejects burns that exceed wallet balance', async () => { + await mintTo(alice.address, { jettonAmount: toNano('0.4') }) + + const { result } = await burnFrom(alice, { + jettonAmount: toNano('0.5'), + responseDestination: recipient.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: (await userWallet(alice.address)).address, + success: false, + exitCode: ERROR_BALANCE_ERROR, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('0.4')) + expect(await totalSupply()).toEqual(toNano('0.4')) + }) + + it('rejects underfunded burn value before moving balance', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + + const { result } = await burnFrom(alice, { + jettonAmount: toNano('0.25'), + responseDestination: recipient.address, + value: 1n, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + expect(await totalSupply()).toEqual(toNano('1')) + }) + + it('keeps total supply equal to the sum of balances after sequential burns', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1.5') }) + await mintTo(bob.address, { jettonAmount: toNano('0.7') }) + + await burnFrom(alice, { + jettonAmount: toNano('0.4'), + responseDestination: recipient.address, + }) + await burnFrom(bob, { + jettonAmount: toNano('0.2'), + responseDestination: recipient.address, + }) + + expect(await totalSupply()).toEqual(await sumWalletBalances([alice.address, bob.address])) + }) }) }) diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index bb9bc723e..0c1f7eab5 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -118,6 +118,14 @@ export class JettonWallet implements Contract { }) } + async sendTopUpTons(provider: ContractProvider, via: Sender, value: bigint) { + await provider.internal(via, { + value, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: beginCell().storeUint(opcodes.in.TOP_UP, 32).endCell(), + }) + } + async sendTransfer( provider: ContractProvider, via: Sender, diff --git a/contracts/wrappers/wton/errors.ts b/contracts/wrappers/wton/errors.ts index 389c4e835..0a9052a1e 100644 --- a/contracts/wrappers/wton/errors.ts +++ b/contracts/wrappers/wton/errors.ts @@ -1,4 +1,3 @@ export const ERROR_ALREADY_INITIALIZED = 75 export const ERROR_UNSUFFICIENT_AMOUNT = 76 export const ERROR_INVALID_EXCESSES_DESTINATION = 77 -export const ERROR_TOP_UP_TOO_LARGE = 78 From f2a24a937676e743c8867cff8313f6cc5317840f Mon Sep 17 00:00:00 2001 From: Kristijan Date: Mon, 25 May 2026 13:55:05 +0200 Subject: [PATCH 13/32] Add gas-report calibration --- contracts/contracts/wton/fees-management.tolk | 19 +- contracts/package.json | 1 + contracts/tests/gas-report/wton/wton.spec.ts | 256 ++++++++++++++++++ contracts/wton-gas-report.config.ts | 24 ++ contracts/wton-gas-report.json | 224 +++++++++++++++ 5 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 contracts/tests/gas-report/wton/wton.spec.ts create mode 100644 contracts/wton-gas-report.config.ts create mode 100644 contracts/wton-gas-report.json diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index 6cf2f2037..b8d52ce14 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -25,13 +25,18 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years // Gas costs // these constants are used to estimate gas fee (how much we should remain on balance for a swap to succeed); -// they must be absolutely equal to consumed gas; if not, tests fail; -// actual consumed gas (desired value of these constants) are printed to console after tests run - -const GAS_CONSUMPTION_JettonTransfer = 6153 -const GAS_CONSUMPTION_JettonReceive = 7253 -const GAS_CONSUMPTION_BurnRequest = 4368 -const GAS_CONSUMPTION_BurnNotification = 3855 +// they must stay exactly equal to the calibrated measured gas values for the covered live paths; +// if those measured values drift, tests fail. +// actual consumed gas is measured by tests/gas-report/wton/Wton.spec.ts via yarn wton-gas-report +// GAS_CONSUMPTION_JettonTransfer is calibrated against the max sender-side path that reuses +// checkAmountIsEnoughToTransfer (wallet transfer sender vs mint sender candidate). +// GAS_CONSUMPTION_JettonReceive is calibrated against the max live receive branch with both +// recipient notification and excess handling enabled. + +const GAS_CONSUMPTION_JettonTransfer = 6948 +const GAS_CONSUMPTION_JettonReceive = 7295 +const GAS_CONSUMPTION_BurnRequest = 5291 +const GAS_CONSUMPTION_BurnNotification = 4462 fun calculateJettonWalletMinStorageFee() { diff --git a/contracts/package.json b/contracts/package.json index b376ef821..feb2a2507 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -27,6 +27,7 @@ "fmt-typescript:check": "prettier --check .", "fmt-typescript": "prettier --write .", "ccip-gas-report": "blueprint test --gas-report -- --config ccip-gas-report.config.ts", + "wton-gas-report": "blueprint test --gas-report -- --config wton-gas-report.config.ts", "get-key-pair": "ts-node scripts/getKeyPair.ts" }, "dependencies": { diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts new file mode 100644 index 000000000..8deed45a0 --- /dev/null +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -0,0 +1,256 @@ +import '@ton/test-utils' +import * as fs from 'fs' +import * as path from 'path' + +import { compile } from '@ton/blueprint' +import { Address, beginCell, Cell, toNano } from '@ton/core' +import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox' + +import { JettonMinter } from '../../../wrappers/jetton/JettonMinter' +import { JettonWallet } from '../../../wrappers/jetton/JettonWallet' + +const JETTON_DATA_URI = 'wton.gas' +const WTON_MINT_OPCODE = 0x00000015 +const INTERNAL_TRANSFER_OPCODE = 0x178d4519 + +type ConfiguredGasConstants = { + GAS_CONSUMPTION_JettonTransfer: number + GAS_CONSUMPTION_JettonReceive: number + GAS_CONSUMPTION_BurnRequest: number + GAS_CONSUMPTION_BurnNotification: number +} + +function readConfiguredGasConstants(): ConfiguredGasConstants { + const feesFile = path.join(__dirname, '../../../contracts/wton/fees-management.tolk') + const source = fs.readFileSync(feesFile, 'utf8') + + const readConstant = (name: keyof ConfiguredGasConstants) => { + const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`)) + if (!match) { + throw new Error(`Missing gas constant ${name} in fees-management.tolk`) + } + return Number(match[1]) + } + + return { + GAS_CONSUMPTION_JettonTransfer: readConstant('GAS_CONSUMPTION_JettonTransfer'), + GAS_CONSUMPTION_JettonReceive: readConstant('GAS_CONSUMPTION_JettonReceive'), + GAS_CONSUMPTION_BurnRequest: readConstant('GAS_CONSUMPTION_BurnRequest'), + GAS_CONSUMPTION_BurnNotification: readConstant('GAS_CONSUMPTION_BurnNotification'), + } +} + +function mintBody({ + destination, + queryId, + jettonAmount, + tonAmount, + responseDestination, + forwardTonAmount, + forwardPayload, +}: { + destination: Address + queryId: bigint + jettonAmount: bigint + tonAmount: bigint + responseDestination: Address + forwardTonAmount: bigint + forwardPayload: Cell | null +}) { + const internalTransferMsg = beginCell() + .storeUint(INTERNAL_TRANSFER_OPCODE, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(null) + .storeAddress(responseDestination) + .storeCoins(forwardTonAmount) + + if (forwardPayload) { + internalTransferMsg.storeBit(1).storeRef(forwardPayload) + } else { + internalTransferMsg.storeBit(0) + } + + return beginCell() + .storeUint(WTON_MINT_OPCODE, 32) + .storeUint(queryId, 64) + .storeAddress(destination) + .storeCoins(tonAmount) + .storeRef(internalTransferMsg.endCell()) + .endCell() +} + +function vmGasUsed(tx: any) { + if (tx.description.type !== 'generic' || tx.description.computePhase.type !== 'vm') { + throw new Error('Expected a VM transaction') + } + + return tx.description.computePhase.gasUsed +} + +function internalTxTo(result: { transactions: Array }, destination: Address) { + const tx = result.transactions.find((candidate) => { + return ( + candidate.inMessage?.info.type === 'internal' && + candidate.inMessage.info.dest.equals(destination) + ) + }) + + if (!tx) { + throw new Error(`Missing internal transaction to ${destination.toString()}`) + } + + return tx +} + +describe('wTON gas calibration', () => { + let blockchain: Blockchain + let minterCode: Cell + let walletCode: Cell + + let minter: SandboxContract + let deployer: SandboxContract + let alice: SandboxContract + let bob: SandboxContract + let recipient: SandboxContract + + let nextQueryId: bigint + + beforeAll(async () => { + minterCode = await compile('wton.JettonMinter') + walletCode = await compile('wton.JettonWallet') + }) + + beforeEach(async () => { + blockchain = await Blockchain.create() + deployer = await blockchain.treasury('deployer') + alice = await blockchain.treasury('alice') + bob = await blockchain.treasury('bob') + recipient = await blockchain.treasury('recipient') + nextQueryId = 1n + + const content = beginCell().storeStringTail(JETTON_DATA_URI).endCell() + minter = blockchain.openContract( + JettonMinter.createFromConfig( + { + admin: deployer.address, + transferAdmin: null, + walletCode, + jettonContent: content, + totalSupply: 0n, + }, + minterCode, + ), + ) + + await minter.sendTopUpTons(deployer.getSender(), toNano('0.01')) + }) + + async function userWallet(owner: Address) { + return blockchain.openContract( + JettonWallet.createFromAddress(await minter.getWalletAddress(owner)), + ) + } + + async function mintTo( + destination: Address, + jettonAmount: bigint, + { + tonAmount = toNano('0.2'), + forwardTonAmount = 0n, + forwardPayload = null, + }: { + tonAmount?: bigint + forwardTonAmount?: bigint + forwardPayload?: Cell | null + } = {}, + ) { + const queryId = nextQueryId++ + const body = mintBody({ + destination, + queryId, + jettonAmount, + tonAmount, + responseDestination: deployer.address, + forwardTonAmount, + forwardPayload, + }) + + return await deployer.send({ + to: minter.address, + value: jettonAmount + tonAmount + toNano('0.5'), + body, + }) + } + + it('keeps fee-management gas constants aligned with measured wallet and minter execution', async () => { + const configured = readConfiguredGasConstants() + // Exercise the highest live receive branch: notify recipient owner and still send excesses. + const transferForwardPayload = beginCell().storeStringTail('wton.gas.forward').endCell() + const transferCustomPayload = beginCell().storeStringTail('wton.gas.custom').endCell() + const burnCustomPayload = beginCell().storeStringTail('wton.gas.burn').endCell() + const mintForwardPayload = beginCell().storeStringTail('wton.gas.mint-forward').endCell() + + const mintResult = await mintTo(alice.address, toNano('1.5'), { + tonAmount: toNano('0.3'), + forwardTonAmount: toNano('0.05'), + forwardPayload: mintForwardPayload, + }) + const aliceWallet = await userWallet(alice.address) + const bobWallet = await userWallet(bob.address) + + const transferResult = await aliceWallet.sendTransfer(alice.getSender(), { + value: toNano('0.8'), + message: { + queryId: Number(nextQueryId++), + jettonAmount: toNano('0.7'), + destination: bob.address, + responseDestination: alice.address, + customPayload: transferCustomPayload, + forwardTonAmount: toNano('0.05'), + forwardPayload: transferForwardPayload, + }, + }) + + const burnResult = await bobWallet.sendBurn(bob.getSender(), { + value: toNano('0.2'), + message: { + queryId: nextQueryId++, + jettonAmount: toNano('0.3'), + responseDestination: recipient.address, + customPayload: burnCustomPayload, + }, + }) + + const mintMinterGas = vmGasUsed(internalTxTo(mintResult, minter.address)) + const mintReceiveGas = vmGasUsed(internalTxTo(mintResult, aliceWallet.address)) + const transferSendGas = vmGasUsed(internalTxTo(transferResult, aliceWallet.address)) + const transferReceiveGas = vmGasUsed(internalTxTo(transferResult, bobWallet.address)) + const burnRequestGas = vmGasUsed(internalTxTo(burnResult, bobWallet.address)) + const burnNotificationGas = vmGasUsed(internalTxTo(burnResult, minter.address)) + const maxSendTransferGas = Number(transferSendGas > mintMinterGas ? transferSendGas : mintMinterGas) + const maxReceiveTransferGas = Number( + transferReceiveGas > mintReceiveGas ? transferReceiveGas : mintReceiveGas, + ) + + console.table([ + { operation: 'mint minter (worst candidate)', gasUsed: mintMinterGas }, + { operation: 'mint receive (candidate)', gasUsed: mintReceiveGas }, + { operation: 'transfer sender wallet (worst candidate)', gasUsed: transferSendGas }, + { operation: 'transfer receiver wallet (worst candidate)', gasUsed: transferReceiveGas }, + { operation: 'burn sender wallet', gasUsed: burnRequestGas }, + { operation: 'burn minter notification', gasUsed: burnNotificationGas }, + ]) + + printTransactionFees(mintResult.transactions) + printTransactionFees(transferResult.transactions) + printTransactionFees(burnResult.transactions) + + expect({ + GAS_CONSUMPTION_JettonTransfer: maxSendTransferGas, + GAS_CONSUMPTION_JettonReceive: maxReceiveTransferGas, + GAS_CONSUMPTION_BurnRequest: Number(burnRequestGas), + GAS_CONSUMPTION_BurnNotification: Number(burnNotificationGas), + }).toEqual(configured) + }) + }) \ No newline at end of file diff --git a/contracts/wton-gas-report.config.ts b/contracts/wton-gas-report.config.ts new file mode 100644 index 000000000..2f20286b5 --- /dev/null +++ b/contracts/wton-gas-report.config.ts @@ -0,0 +1,24 @@ +import type { Config } from 'jest' + +const config: Config = { + preset: 'ts-jest', + testEnvironment: '@ton/sandbox/jest-environment', + testMatch: ['**/tests/gas-report/wton/**/*.spec.ts'], + modulePathIgnorePatterns: ['/node_modules/', '/dist/', '/vendor/'], + testTimeout: 120000, + reporters: [ + 'default', + [ + '@ton/sandbox/jest-reporter', + { + snapshotDir: '.snapshot', + contractDatabase: 'contract.abi.json', + reportName: 'wton-gas-report', + depthCompare: 2, + removeRawResult: true, + }, + ], + ], +} + +export default config \ No newline at end of file diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json new file mode 100644 index 000000000..a663ffc7b --- /dev/null +++ b/contracts/wton-gas-report.json @@ -0,0 +1,224 @@ +[ + { + "label": "current", + "createdAt": "2026-05-25T11:43:23.024Z", + "result": { + "JettonMinter": { + "sendTopUpTons": { + "gasUsed": { + "kind": "init", + "value": "1937" + }, + "cells": { + "kind": "init", + "value": "29" + }, + "bits": { + "kind": "init", + "value": "12911" + } + }, + "0xd372158c": { + "gasUsed": { + "kind": "init", + "value": "771" + }, + "cells": { + "kind": "init", + "value": "29" + }, + "bits": { + "kind": "init", + "value": "12911" + } + } + }, + "TreasuryContract": { + "send": { + "gasUsed": { + "kind": "init", + "value": "1937" + }, + "cells": { + "kind": "init", + "value": "5" + }, + "bits": { + "kind": "init", + "value": "714" + } + }, + "0x15": { + "gasUsed": { + "kind": "init", + "value": "6724" + }, + "cells": { + "kind": "init", + "value": "5" + }, + "bits": { + "kind": "init", + "value": "714" + } + }, + "0x178d4519": { + "gasUsed": { + "kind": "init", + "value": "6337" + }, + "cells": { + "kind": "init", + "value": "5" + }, + "bits": { + "kind": "init", + "value": "714" + } + }, + "0x7362d09c": { + "gasUsed": { + "kind": "init", + "value": "309" + }, + "cells": { + "kind": "init", + "value": "5" + }, + "bits": { + "kind": "init", + "value": "714" + } + }, + "0xd53276db": { + "gasUsed": { + "kind": "init", + "value": "309" + }, + "cells": { + "kind": "init", + "value": "5" + }, + "bits": { + "kind": "init", + "value": "714" + } + } + }, + "JettonWallet": { + "sendTransfer": { + "gasUsed": { + "kind": "init", + "value": "1937" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "0xf8a7ea5": { + "gasUsed": { + "kind": "init", + "value": "6948" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "0x178d4519": { + "gasUsed": { + "kind": "init", + "value": "7295" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "0x7362d09c": { + "gasUsed": { + "kind": "init", + "value": "309" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "0xd53276db": { + "gasUsed": { + "kind": "init", + "value": "309" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "sendBurn": { + "gasUsed": { + "kind": "init", + "value": "1937" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "0x595f07bc": { + "gasUsed": { + "kind": "init", + "value": "5291" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + }, + "0x7bdd97de": { + "gasUsed": { + "kind": "init", + "value": "4462" + }, + "cells": { + "kind": "init", + "value": "15" + }, + "bits": { + "kind": "init", + "value": "6725" + } + } + } + } + } +] From b1258dffbe23306a6cbdd63e7ff3487b5ca81931 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Mon, 25 May 2026 14:40:11 +0200 Subject: [PATCH 14/32] Add missing test cases, adjust burn-budgeting --- contracts/contracts/wton/fees-management.tolk | 8 +- contracts/tests/gas-report/wton/wton.spec.ts | 222 ++++++++++++++++-- contracts/tests/wton/wton.spec.ts | 155 ++++++++++++ contracts/wton-gas-report.config.ts | 2 +- contracts/wton-gas-report.json | 24 +- 5 files changed, 382 insertions(+), 29 deletions(-) diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index b8d52ce14..7416317bd 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -19,6 +19,8 @@ const STORAGE_SIZE_InitStateWallet_cells = 3 const MESSAGE_SIZE_BurnNotification_bits = 754 // body = 32+64+124+(3+8+256)+(3+8+256) const MESSAGE_SIZE_BurnNotification_cells = 1 // body always in ref +const MESSAGE_SIZE_ReturnExcesses_bits = 96 // body = 32+64 +const MESSAGE_SIZE_ReturnExcesses_cells = 1 // body fits in a single cell const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years @@ -35,7 +37,7 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years const GAS_CONSUMPTION_JettonTransfer = 6948 const GAS_CONSUMPTION_JettonReceive = 7295 -const GAS_CONSUMPTION_BurnRequest = 5291 +const GAS_CONSUMPTION_BurnRequest = 5397 const GAS_CONSUMPTION_BurnNotification = 4462 @@ -72,6 +74,10 @@ fun checkAmountIsEnoughToBurn(msgValue: int) { assert (msgValue > calculateForwardFee(MY_WORKCHAIN, MESSAGE_SIZE_BurnNotification_bits, MESSAGE_SIZE_BurnNotification_cells) + + // The burn sender must also budget the minter's final ReturnExcessesBack payout send. + // Without this extra forward fee, a tiny burn can succeed while sender-side payout costs + // shave the delivered principal before the payout even reaches the recipient. + calculateForwardFee(MY_WORKCHAIN, MESSAGE_SIZE_ReturnExcesses_bits, MESSAGE_SIZE_ReturnExcesses_cells) + calculateGasFee(MY_WORKCHAIN, sendBurnGasConsumption) + calculateGasFee(MY_WORKCHAIN, GAS_CONSUMPTION_BurnNotification) ) throw ERROR_NOT_ENOUGH_GAS; diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index 8deed45a0..8a197cf6d 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -12,6 +12,9 @@ import { JettonWallet } from '../../../wrappers/jetton/JettonWallet' const JETTON_DATA_URI = 'wton.gas' const WTON_MINT_OPCODE = 0x00000015 const INTERNAL_TRANSFER_OPCODE = 0x178d4519 +const TRANSFER_NOTIFICATION_OPCODE = 0x7362d09c +const BURN_NOTIFICATION_OPCODE = 0x7bdd97de +const RETURN_EXCESSES_OPCODE = 0xd53276db type ConfiguredGasConstants = { GAS_CONSUMPTION_JettonTransfer: number @@ -20,23 +23,66 @@ type ConfiguredGasConstants = { GAS_CONSUMPTION_BurnNotification: number } -function readConfiguredGasConstants(): ConfiguredGasConstants { +type ConfiguredShapeConstants = { + MESSAGE_SIZE_BurnNotification_bits: number + MESSAGE_SIZE_BurnNotification_cells: number + MESSAGE_SIZE_ReturnExcesses_bits: number + MESSAGE_SIZE_ReturnExcesses_cells: number +} + +function readFeesManagementConstant(source: string, name: string) { + const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`)) + if (!match) { + throw new Error(`Missing constant ${name} in fees-management.tolk`) + } + return Number(match[1]) +} + +function readFeesManagementSource() { const feesFile = path.join(__dirname, '../../../contracts/wton/fees-management.tolk') - const source = fs.readFileSync(feesFile, 'utf8') - - const readConstant = (name: keyof ConfiguredGasConstants) => { - const match = source.match(new RegExp(`const\\s+${name}\\s*=\\s*(\\d+)`)) - if (!match) { - throw new Error(`Missing gas constant ${name} in fees-management.tolk`) - } - return Number(match[1]) + return fs.readFileSync(feesFile, 'utf8') +} + +function readConfiguredGasConstants(): ConfiguredGasConstants { + const source = readFeesManagementSource() + + return { + GAS_CONSUMPTION_JettonTransfer: readFeesManagementConstant( + source, + 'GAS_CONSUMPTION_JettonTransfer', + ), + GAS_CONSUMPTION_JettonReceive: readFeesManagementConstant( + source, + 'GAS_CONSUMPTION_JettonReceive', + ), + GAS_CONSUMPTION_BurnRequest: readFeesManagementConstant(source, 'GAS_CONSUMPTION_BurnRequest'), + GAS_CONSUMPTION_BurnNotification: readFeesManagementConstant( + source, + 'GAS_CONSUMPTION_BurnNotification', + ), } +} + +function readConfiguredShapeConstants(): ConfiguredShapeConstants { + const source = readFeesManagementSource() return { - GAS_CONSUMPTION_JettonTransfer: readConstant('GAS_CONSUMPTION_JettonTransfer'), - GAS_CONSUMPTION_JettonReceive: readConstant('GAS_CONSUMPTION_JettonReceive'), - GAS_CONSUMPTION_BurnRequest: readConstant('GAS_CONSUMPTION_BurnRequest'), - GAS_CONSUMPTION_BurnNotification: readConstant('GAS_CONSUMPTION_BurnNotification'), + MESSAGE_SIZE_BurnNotification_bits: readFeesManagementConstant( + source, + 'MESSAGE_SIZE_BurnNotification_bits', + ), + MESSAGE_SIZE_BurnNotification_cells: readFeesManagementConstant( + source, + 'MESSAGE_SIZE_BurnNotification_cells', + ), + MESSAGE_SIZE_ReturnExcesses_bits: readFeesManagementConstant( + source, + 'MESSAGE_SIZE_ReturnExcesses_bits', + ), + MESSAGE_SIZE_ReturnExcesses_cells: readFeesManagementConstant( + source, + 'MESSAGE_SIZE_ReturnExcesses_cells', + ), } } @@ -80,6 +126,91 @@ function mintBody({ .endCell() } +function internalTransferBody({ + queryId, + jettonAmount, + transferInitiator, + responseDestination, + forwardTonAmount, + forwardPayload, +}: { + queryId: bigint + jettonAmount: bigint + transferInitiator: Address + responseDestination: Address + forwardTonAmount: bigint + forwardPayload: Cell +}) { + return beginCell() + .storeUint(INTERNAL_TRANSFER_OPCODE, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(transferInitiator) + .storeAddress(responseDestination) + .storeCoins(forwardTonAmount) + .storeBit(1) + .storeRef(forwardPayload) + .endCell() +} + +function transferNotificationBody({ + queryId, + jettonAmount, + transferInitiator, + forwardPayload, +}: { + queryId: bigint + jettonAmount: bigint + transferInitiator: Address + forwardPayload: Cell +}) { + return beginCell() + .storeUint(TRANSFER_NOTIFICATION_OPCODE, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(transferInitiator) + .storeBit(1) + .storeRef(forwardPayload) + .endCell() +} + +function burnNotificationBody({ + queryId, + jettonAmount, + burnInitiator, + responseDestination, +}: { + queryId: bigint + jettonAmount: bigint + burnInitiator: Address + responseDestination: Address +}) { + return beginCell() + .storeUint(BURN_NOTIFICATION_OPCODE, 32) + .storeUint(queryId, 64) + .storeCoins(jettonAmount) + .storeAddress(burnInitiator) + .storeAddress(responseDestination) + .endCell() +} + +function returnExcessesBody(queryId: bigint) { + return beginCell().storeUint(RETURN_EXCESSES_OPCODE, 32).storeUint(queryId, 64).endCell() +} + +function cellStats(cell: Cell): { bits: number; cells: number } { + return cell.refs.reduce( + (stats, ref) => { + const nested = cellStats(ref) + return { + bits: stats.bits + nested.bits, + cells: stats.cells + nested.cells, + } + }, + { bits: cell.bits.length, cells: 1 }, + ) +} + function vmGasUsed(tx: any) { if (tx.description.type !== 'generic' || tx.description.computePhase.type !== 'vm') { throw new Error('Expected a VM transaction') @@ -228,7 +359,9 @@ describe('wTON gas calibration', () => { const transferReceiveGas = vmGasUsed(internalTxTo(transferResult, bobWallet.address)) const burnRequestGas = vmGasUsed(internalTxTo(burnResult, bobWallet.address)) const burnNotificationGas = vmGasUsed(internalTxTo(burnResult, minter.address)) - const maxSendTransferGas = Number(transferSendGas > mintMinterGas ? transferSendGas : mintMinterGas) + const maxSendTransferGas = Number( + transferSendGas > mintMinterGas ? transferSendGas : mintMinterGas, + ) const maxReceiveTransferGas = Number( transferReceiveGas > mintReceiveGas ? transferReceiveGas : mintReceiveGas, ) @@ -253,4 +386,63 @@ describe('wTON gas calibration', () => { GAS_CONSUMPTION_BurnNotification: Number(burnNotificationGas), }).toEqual(configured) }) - }) \ No newline at end of file + + it('keeps fee-shape constants aligned with live transfer and burn message bodies', () => { + const configured = readConfiguredShapeConstants() + const forwardPayload = beginCell().storeStringTail('wton.gas.shape').endCell() + const maxCoins = (1n << 120n) - 1n + + const transferBodyStats = cellStats( + internalTransferBody({ + queryId: 1n, + jettonAmount: toNano('0.7'), + transferInitiator: alice.address, + responseDestination: deployer.address, + forwardTonAmount: toNano('0.05'), + forwardPayload, + }), + ) + const notificationBodyStats = cellStats( + transferNotificationBody({ + queryId: 1n, + jettonAmount: toNano('0.7'), + transferInitiator: alice.address, + forwardPayload, + }), + ) + const burnNotificationLiveStats = cellStats( + burnNotificationBody({ + queryId: 1n, + jettonAmount: toNano('0.3'), + burnInitiator: bob.address, + responseDestination: recipient.address, + }), + ) + const burnNotificationWorstCaseStats = cellStats( + burnNotificationBody({ + queryId: 1n, + jettonAmount: maxCoins, + burnInitiator: bob.address, + responseDestination: recipient.address, + }), + ) + const returnExcessesStats = cellStats(returnExcessesBody(1n)) + + expect(burnNotificationWorstCaseStats).toEqual({ + bits: configured.MESSAGE_SIZE_BurnNotification_bits, + cells: configured.MESSAGE_SIZE_BurnNotification_cells, + }) + expect(returnExcessesStats).toEqual({ + bits: configured.MESSAGE_SIZE_ReturnExcesses_bits, + cells: configured.MESSAGE_SIZE_ReturnExcesses_cells, + }) + expect(burnNotificationLiveStats.bits).toBeLessThanOrEqual( + configured.MESSAGE_SIZE_BurnNotification_bits, + ) + expect(burnNotificationLiveStats.cells).toBeLessThanOrEqual( + configured.MESSAGE_SIZE_BurnNotification_cells, + ) + expect(notificationBodyStats.bits).toBeLessThan(transferBodyStats.bits) + expect(notificationBodyStats.cells).toBeLessThanOrEqual(transferBodyStats.cells) + }) +}) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 3ce01b47e..52a795f2b 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -11,7 +11,10 @@ import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' const WTON_MINT_OPCODE = 0x00000015 const INTERNAL_TRANSFER_OPCODE = 0x178d4519 +const REQUEST_WALLET_ADDRESS_OPCODE = 0x2c76b973 +const RESPONSE_WALLET_ADDRESS_OPCODE = 0xd1735400 const ERROR_BALANCE_ERROR = 47 +const ERROR_ALREADY_INITIALIZED = 75 const ERROR_NOT_ENOUGH_GAS = 48 const ERROR_INVALID_OP = 72 const ERROR_NOT_OWNER = 73 @@ -139,6 +142,17 @@ describe('wTON', () => { return tx } + function internalMessageBodyTo(result: { transactions: Array }, address: Address) { + const tx = internalTransactionTo(result, address) + const body = tx.inMessage?.body + + if (!body) { + throw new Error(`Missing internal message body to ${address.toString()}`) + } + + return body + } + function mintBody({ destination, queryId, @@ -420,6 +434,38 @@ describe('wTON', () => { expect((await userWallet(bob.address)).address.equals(predictedBobWallet)).toBe(true) }) + it('responds to wallet-address requests and can include the owner address', async () => { + const queryId = nextQueryId++ + const result = await deployer.send({ + to: minter.address, + value: toNano('0.05'), + body: beginCell() + .storeUint(REQUEST_WALLET_ADDRESS_OPCODE, 32) + .storeUint(queryId, 64) + .storeAddress(alice.address) + .storeBit(1) + .endCell(), + }) + + expect(result.transactions).toHaveTransaction({ + from: minter.address, + to: deployer.address, + success: true, + }) + + const body = internalMessageBodyTo(result, deployer.address).beginParse() + expect(body.loadUint(32)).toEqual(RESPONSE_WALLET_ADDRESS_OPCODE) + expect(body.loadUintBig(64)).toEqual(queryId) + + const walletAddress = body.loadMaybeAddress() + expect(walletAddress?.equals(await minter.getWalletAddress(alice.address))).toBe(true) + + expect(body.loadBit()).toBe(true) + expect(body.loadRef().beginParse().loadAddress().equals(alice.address)).toBe(true) + expect(body.remainingBits).toEqual(0) + expect(body.remainingRefs).toEqual(0) + }) + it('keeps total supply equal to the sum of live wallet balances after mixed operations', async () => { await mintTo(alice.address, { jettonAmount: toNano('1.2') }) await mintTo(bob.address, { jettonAmount: toNano('0.8') }) @@ -604,6 +650,26 @@ describe('wTON', () => { }) expect(await totalSupply()).toEqual(0n) }) + + it('rejects metadata changes because wTON metadata is immutable', async () => { + const dataBefore = await minter.getJettonData() + const result = await minter.sendChangeContent(deployer.getSender(), { + message: { + queryId: nextQueryId++, + content: beginCell().storeStringTail('wton.changed').endCell(), + }, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_ALREADY_INITIALIZED, + }) + expect((await minter.getJettonData()).jettonContent.equals(dataBefore.jettonContent)).toBe( + true, + ) + }) }) describe('transferring', () => { @@ -780,6 +846,35 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(toNano('1')) }) + it('restores the sender balance when the destination wallet receive path bounces', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1.2') }) + await mintTo(bob.address, { jettonAmount: toNano('1') }) + + const aliceWallet = await userWallet(alice.address) + const bobWallet = await userWallet(bob.address) + const aliceBalanceBefore = await walletBalance(alice.address) + const bobBalanceBefore = await walletBalance(bob.address) + const supplyBefore = await totalSupply() + + const contract = await blockchain.getContract(bobWallet.address) + contract.balance = 0n + + const { result } = await transferFrom(alice, { + jettonAmount: toNano('0.3'), + destination: bob.address, + value: toNano('0.5'), + }) + + expect(result.transactions).toHaveTransaction({ + from: aliceWallet.address, + to: bobWallet.address, + success: false, + }) + expect(await walletBalance(alice.address)).toEqual(aliceBalanceBefore) + expect(await walletBalance(bob.address)).toEqual(bobBalanceBefore) + expect(await totalSupply()).toEqual(supplyBefore) + }) + it('preserves total supply across chained transfers', async () => { await mintTo(alice.address, { jettonAmount: toNano('2.5') }) @@ -979,6 +1074,29 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(toNano('1')) }) + it.skip('restores wallet balance when burn notification bounces at the minter', async () => { + const minted = toNano('1') + await mintTo(alice.address, { jettonAmount: minted }) + + const aliceWallet = await userWallet(alice.address) + const minterContract = await blockchain.getContract(minter.address) + minterContract.balance = 0n + + const { result } = await burnFrom(alice, { + jettonAmount: 1n, + responseDestination: recipient.address, + value: toNano('0.005'), + }) + + expect(result.transactions).toHaveTransaction({ + from: aliceWallet.address, + to: minter.address, + success: false, + }) + expect(await walletBalance(alice.address)).toEqual(minted) + expect(await totalSupply()).toEqual(minted) + }) + it('keeps total supply equal to the sum of balances after sequential burns', async () => { await mintTo(alice.address, { jettonAmount: toNano('1.5') }) await mintTo(bob.address, { jettonAmount: toNano('0.7') }) @@ -994,5 +1112,42 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(await sumWalletBalances([alice.address, bob.address])) }) + + it('rejects fee-boundary burns unless the payout reaching the recipient still covers the full burned principal', async () => { + const burnAmount = 1n + await mintTo(alice.address, { jettonAmount: toNano('1') }) + + const snapshot = blockchain.snapshot() + const candidateValues = ['0.0045', '0.0047', '0.005', '0.006'] + + for (const value of candidateValues) { + await blockchain.loadFrom(snapshot) + + const recipientBalanceBefore = await contractBalance(recipient.address) + const { result } = await burnFrom(alice, { + jettonAmount: burnAmount, + responseDestination: recipient.address, + value: toNano(value), + }) + + const reachedMinter = result.transactions.some( + (tx) => + tx.inMessage?.info.type === 'internal' && tx.inMessage.info.dest.equals(minter.address), + ) + + if (!reachedMinter) { + continue + } + + const recipientTx = internalTransactionTo(result, recipient.address) + expect(recipientTx.inMessage.info.type).toEqual('internal') + expect(recipientTx.inMessage.info.value.coins).toBeGreaterThanOrEqual(burnAmount) + return + } + + throw new Error( + 'Expected at least one fee-boundary burn candidate to reach the post-check path', + ) + }) }) }) diff --git a/contracts/wton-gas-report.config.ts b/contracts/wton-gas-report.config.ts index 2f20286b5..280282171 100644 --- a/contracts/wton-gas-report.config.ts +++ b/contracts/wton-gas-report.config.ts @@ -21,4 +21,4 @@ const config: Config = { ], } -export default config \ No newline at end of file +export default config diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index a663ffc7b..98495358b 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-25T11:43:23.024Z", + "createdAt": "2026-05-25T12:26:52.412Z", "result": { "JettonMinter": { "sendTopUpTons": { @@ -15,7 +15,7 @@ }, "bits": { "kind": "init", - "value": "12911" + "value": "12967" } }, "0xd372158c": { @@ -29,7 +29,7 @@ }, "bits": { "kind": "init", - "value": "12911" + "value": "12967" } } }, @@ -117,7 +117,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "0xf8a7ea5": { @@ -131,7 +131,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "0x178d4519": { @@ -145,7 +145,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "0x7362d09c": { @@ -159,7 +159,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "0xd53276db": { @@ -173,7 +173,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "sendBurn": { @@ -187,13 +187,13 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "0x595f07bc": { "gasUsed": { "kind": "init", - "value": "5291" + "value": "5397" }, "cells": { "kind": "init", @@ -201,7 +201,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } }, "0x7bdd97de": { @@ -215,7 +215,7 @@ }, "bits": { "kind": "init", - "value": "6725" + "value": "6781" } } } From 84e10d0cbff246c5f35af70b0cb1909fa8dc4bbb Mon Sep 17 00:00:00 2001 From: Kristijan Date: Mon, 25 May 2026 15:08:56 +0200 Subject: [PATCH 15/32] Unskip a test - burn notification bounces --- contracts/tests/wton/wton.spec.ts | 13 +++++++++++-- contracts/wton-gas-report.json | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 52a795f2b..4e979b0f3 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -1074,7 +1074,7 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(toNano('1')) }) - it.skip('restores wallet balance when burn notification bounces at the minter', async () => { + it('restores wallet balance when burn notification bounces at the minter', async () => { const minted = toNano('1') await mintTo(alice.address, { jettonAmount: minted }) @@ -1085,7 +1085,7 @@ describe('wTON', () => { const { result } = await burnFrom(alice, { jettonAmount: 1n, responseDestination: recipient.address, - value: toNano('0.005'), + value: toNano('0.01'), }) expect(result.transactions).toHaveTransaction({ @@ -1093,6 +1093,15 @@ describe('wTON', () => { to: minter.address, success: false, }) + + const bounceTx = result.transactions.find( + (tx: any) => + tx.inMessage?.info.type === 'internal' && + tx.inMessage.info.src?.equals(minter.address) && + tx.inMessage.info.dest.equals(aliceWallet.address), + ) + + expect(bounceTx).toBeDefined() expect(await walletBalance(alice.address)).toEqual(minted) expect(await totalSupply()).toEqual(minted) }) diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index 98495358b..470518496 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-25T12:26:52.412Z", + "createdAt": "2026-05-25T13:02:01.874Z", "result": { "JettonMinter": { "sendTopUpTons": { From 29a568b1f64c0a0c367b3f128bb19da9e63be3f1 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Mon, 25 May 2026 15:57:08 +0200 Subject: [PATCH 16/32] Add test - refunds bounced mint --- contracts/tests/wton/wton.spec.ts | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 4e979b0f3..b331c3ef7 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -564,6 +564,46 @@ describe('wTON', () => { expect(mintRefundBalance).toBeGreaterThanOrEqual(mintAmount) // second mint refunded }) + it('refunds bounced mint dispatches even for dust principal near the transfer-budget floor', async () => { + const rejector = await deployRejector() + const setupMintAmount = toNano('1') + + await sendMint({ + destination: rejector.address, + jettonAmount: setupMintAmount, + responseDestination: rejector.address, + }) + + const rejectorWallet = await userWallet(rejector.address) + const rejectorWalletContract = await blockchain.getContract(rejectorWallet.address) + rejectorWalletContract.balance = 0n + + const dustMintAmount = toNano('0.000001') + const dustTonAmount = toNano('0.0131') // slightly above the 0.013 TONS transfer budget floor to trigger the mint but cause a bounce on the internal transfer + const refundBalanceBefore = await contractBalance(rejector.address) + + const { result } = await sendMint({ + destination: rejector.address, + jettonAmount: dustMintAmount, + tonAmount: dustTonAmount, + responseDestination: rejector.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: minter.address, + to: rejectorWallet.address, + success: false, + }) + expect(result.transactions).toHaveTransaction({ + from: minter.address, + to: rejector.address, + success: false, + }) + expect(await walletBalance(rejector.address)).toEqual(setupMintAmount) + expect((await minter.getJettonData()).totalSupply).toEqual(setupMintAmount) + expect(await contractBalance(rejector.address)).toBeGreaterThan(refundBalanceBefore) + }) + it('accumulates repeated mints into the same wallet', async () => { await mintTo(alice.address, { jettonAmount: toNano('1.25') }) await mintTo(alice.address, { jettonAmount: toNano('0.75') }) From 449378da75a873fe7c85e89f5ed7af9316fabbae Mon Sep 17 00:00:00 2001 From: Kristijan Date: Mon, 25 May 2026 17:36:47 +0200 Subject: [PATCH 17/32] Add checkAmountIsEnoughToMint on mint --- contracts/contracts/wton/JettonMinter.tolk | 5 ++-- contracts/contracts/wton/fees-management.tolk | 13 ++++++++++- contracts/tests/wton/wton.spec.ts | 23 ++++++++++++++++++- contracts/wton-gas-report.json | 2 +- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 8ebf4356b..b46f7d90e 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -139,8 +139,9 @@ fun onInternalMessage(in: InMessage) { assert (internalTransferMsg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; // Minting must not impersonate a peer wallet transfer initiator. assert (internalTransferMsg.transferInitiator == null) throw ERROR_INVALID_OP; - // The caller must fund both the new hosted TON backing and the extra transfer budget. - assert (in.valueCoins >= jettonAmount + msg.tonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; + // The caller must fund the hosted TON backing, the extra transfer budget, and the + // minter's own outbound deploy/forward fee rather than borrowing it from minter surplus. + checkAmountIsEnoughToMint(in.valueCoins, jettonAmount, msg.tonAmount, in.originalForwardFee); // AUDIT(WTON-13): the extra mint budget must independently cover the receiver-side transfer/forward flow. checkAmountIsEnoughToTransfer(msg.tonAmount, forwardTonAmount, in.originalForwardFee); diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index 7416317bd..d133a1312 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -61,13 +61,24 @@ fun checkAmountIsEnoughToTransfer(msgValue: int, forwardTonAmount: int, fwdFee: // 3 messages: wal1->wal2, wal2->owner, wal2->response // but last one is optional (it is ok if it fails) fwdCount * fwdFee + - forwardInitStateOverhead() + // additional fwd fees related to initstate in iternal_transfer + forwardInitStateOverhead() + // additional fwd fees related to initstate in internal_transfer calculateGasFee(MY_WORKCHAIN, sendTransferGasConsumption) + calculateGasFee(MY_WORKCHAIN, receiveTransferGasConsumption) + calculateJettonWalletMinStorageFee() ) throw ERROR_NOT_ENOUGH_GAS; } +fun checkAmountIsEnoughToMint(msgValue: int, jettonAmount: int, tonAmount: int, fwdFee: int) { + // Mint sends InternalTransferStep with PAY_FEES_SEPARATELY, so the caller must fund + // both the hosted TON/backing value and the minter's own outbound auto-deploy fee. + assert (msgValue > + jettonAmount + + tonAmount + + fwdFee + + forwardInitStateOverhead() // additional fwd fees related to initstate in internal_transfer + ) throw ERROR_NOT_ENOUGH_GAS; +} + fun checkAmountIsEnoughToBurn(msgValue: int) { var jettonWalletGasConsumption = getPrecompiledGasConsumption(); var sendBurnGasConsumption = (jettonWalletGasConsumption == null) ? GAS_CONSUMPTION_BurnRequest : jettonWalletGasConsumption; diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index b331c3ef7..17dfb5604 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -644,7 +644,28 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_UNSUFFICIENT_AMOUNT, + exitCode: ERROR_NOT_ENOUGH_GAS, + }) + expect(await totalSupply()).toEqual(0n) + }) + + it('rejects mint calls that leave no room for the minter dispatch fee', async () => { + const jettonAmount = toNano('1') + const tonAmount = toNano('0.2') + await minter.sendTopUpTons(deployer.getSender(), toNano('0.01')) + + const { result } = await sendMint({ + destination: alice.address, + jettonAmount, + tonAmount, + value: jettonAmount + tonAmount, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_NOT_ENOUGH_GAS, }) expect(await totalSupply()).toEqual(0n) }) diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index 470518496..17080d6c5 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-25T13:02:01.874Z", + "createdAt": "2026-05-25T14:15:19.824Z", "result": { "JettonMinter": { "sendTopUpTons": { From c1c2283b24d3b36b3661bd85037e9cb3901e9c82 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Tue, 26 May 2026 12:15:48 +0200 Subject: [PATCH 18/32] Polish Jetton TS bindings - first pass --- contracts/contracts/wton/JettonMinter.tolk | 3 +- contracts/contracts/wton/fees-management.tolk | 2 +- contracts/tests/gas-report/wton/wton.spec.ts | 94 ++------- contracts/tests/wton/wton.spec.ts | 197 +++++++----------- contracts/wrappers/jetton/JettonMinter.ts | 58 ++++-- contracts/wrappers/jetton/JettonWallet.ts | 74 +++++-- 6 files changed, 185 insertions(+), 243 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index b46f7d90e..8b59f5883 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -35,7 +35,8 @@ fun requiredMinterReserve() { return ton("0.01") + contract.getStorageDuePayment(); } -/// Refund the mint on bounce, reuses sendExcessesTo as the bounce refund destination, so a failed wallet deploy/credit returns TON. +/// Refund the mint on bounce, reuses sendExcessesTo as the bounce refund destination, +/// so a failed wallet deploy/credit returns TON. fun refundMintBounce(msg: InternalTransferStep) { // We're already in the mint recovery path after supply rollback, so keep the minter reserve // if possible but never fail the bounce handler itself. diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index d133a1312..91a7b37b9 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -35,7 +35,7 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years // GAS_CONSUMPTION_JettonReceive is calibrated against the max live receive branch with both // recipient notification and excess handling enabled. -const GAS_CONSUMPTION_JettonTransfer = 6948 +const GAS_CONSUMPTION_JettonTransfer = 7028 const GAS_CONSUMPTION_JettonReceive = 7295 const GAS_CONSUMPTION_BurnRequest = 5397 const GAS_CONSUMPTION_BurnNotification = 4462 diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index 8a197cf6d..986550e9a 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -6,12 +6,11 @@ import { compile } from '@ton/blueprint' import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox' -import { JettonMinter } from '../../../wrappers/jetton/JettonMinter' -import { JettonWallet } from '../../../wrappers/jetton/JettonWallet' +import { JettonMinter, mintBody } from '../../../wrappers/jetton/JettonMinter' +import { internalTransferBody, JettonWallet } from '../../../wrappers/jetton/JettonWallet' const JETTON_DATA_URI = 'wton.gas' const WTON_MINT_OPCODE = 0x00000015 -const INTERNAL_TRANSFER_OPCODE = 0x178d4519 const TRANSFER_NOTIFICATION_OPCODE = 0x7362d09c const BURN_NOTIFICATION_OPCODE = 0x7bdd97de const RETURN_EXCESSES_OPCODE = 0xd53276db @@ -86,73 +85,6 @@ function readConfiguredShapeConstants(): ConfiguredShapeConstants { } } -function mintBody({ - destination, - queryId, - jettonAmount, - tonAmount, - responseDestination, - forwardTonAmount, - forwardPayload, -}: { - destination: Address - queryId: bigint - jettonAmount: bigint - tonAmount: bigint - responseDestination: Address - forwardTonAmount: bigint - forwardPayload: Cell | null -}) { - const internalTransferMsg = beginCell() - .storeUint(INTERNAL_TRANSFER_OPCODE, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(null) - .storeAddress(responseDestination) - .storeCoins(forwardTonAmount) - - if (forwardPayload) { - internalTransferMsg.storeBit(1).storeRef(forwardPayload) - } else { - internalTransferMsg.storeBit(0) - } - - return beginCell() - .storeUint(WTON_MINT_OPCODE, 32) - .storeUint(queryId, 64) - .storeAddress(destination) - .storeCoins(tonAmount) - .storeRef(internalTransferMsg.endCell()) - .endCell() -} - -function internalTransferBody({ - queryId, - jettonAmount, - transferInitiator, - responseDestination, - forwardTonAmount, - forwardPayload, -}: { - queryId: bigint - jettonAmount: bigint - transferInitiator: Address - responseDestination: Address - forwardTonAmount: bigint - forwardPayload: Cell -}) { - return beginCell() - .storeUint(INTERNAL_TRANSFER_OPCODE, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(transferInitiator) - .storeAddress(responseDestination) - .storeCoins(forwardTonAmount) - .storeBit(1) - .storeRef(forwardPayload) - .endCell() -} - function transferNotificationBody({ queryId, jettonAmount, @@ -297,15 +229,19 @@ describe('wTON gas calibration', () => { } = {}, ) { const queryId = nextQueryId++ - const body = mintBody({ - destination, - queryId, - jettonAmount, - tonAmount, - responseDestination: deployer.address, - forwardTonAmount, - forwardPayload, - }) + const body = mintBody( + { + queryId, + destination, + tonAmount, + jettonAmount, + from: null, + responseDestination: deployer.address, + forwardTonAmount, + customPayload: forwardPayload, + }, + { mintOpcode: WTON_MINT_OPCODE }, + ) return await deployer.send({ to: minter.address, diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 17dfb5604..dcf7002c9 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -3,14 +3,17 @@ import { compile } from '@ton/blueprint' import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' -import { JettonMinter, MinterOpcodes } from '../../wrappers/jetton/JettonMinter' -import { JettonWallet, opcodes as walletOpcodes } from '../../wrappers/jetton/JettonWallet' +import { JettonMinter } from '../../wrappers/jetton/JettonMinter' +import { + JettonWallet, + internalTransferBody, + opcodes as walletOpcodes, +} from '../../wrappers/jetton/JettonWallet' import { ERROR_INVALID_EXCESSES_DESTINATION } from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' const WTON_MINT_OPCODE = 0x00000015 -const INTERNAL_TRANSFER_OPCODE = 0x178d4519 const REQUEST_WALLET_ADDRESS_OPCODE = 0x2c76b973 const RESPONSE_WALLET_ADDRESS_OPCODE = 0xd1735400 const ERROR_BALANCE_ERROR = 47 @@ -153,40 +156,13 @@ describe('wTON', () => { return body } - function mintBody({ - destination, - queryId, - jettonAmount, - tonAmount, - responseDestination, - transferInitiator, - forwardTonAmount, - }: { - destination: Address - queryId: bigint - jettonAmount: bigint - tonAmount: bigint - responseDestination: Address | null - transferInitiator: Address | null - forwardTonAmount: bigint - }) { - const internalTransferMsg = beginCell() - .storeUint(INTERNAL_TRANSFER_OPCODE, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(transferInitiator) - .storeAddress(responseDestination) - .storeCoins(forwardTonAmount) - .storeBit(0) - .endCell() - - return beginCell() - .storeUint(WTON_MINT_OPCODE, 32) - .storeUint(queryId, 64) - .storeAddress(destination) - .storeCoins(tonAmount) - .storeRef(internalTransferMsg) - .endCell() + function hasInternalTransactionTo(result: { transactions: Array }, address: Address) { + return result.transactions.some((candidate) => { + return ( + candidate.inMessage?.info.type === 'internal' && + candidate.inMessage.info.dest.equals(address) + ) + }) } async function sendMint({ @@ -200,20 +176,19 @@ describe('wTON', () => { value, }: MintOptions) { const queryId = nextQueryId++ - const body = mintBody({ - destination, - queryId, - jettonAmount, - tonAmount, - responseDestination, - transferInitiator, - forwardTonAmount, - }) - - const result = await deployer.send({ - to: minterContract.address, + const result = await minterContract.sendMint(deployer.getSender(), { value: value ?? jettonAmount + tonAmount + toNano('0.3'), - body, + mintOpcode: WTON_MINT_OPCODE, + message: { + queryId, + destination, + tonAmount, + jettonAmount, + from: transferInitiator, + responseDestination, + forwardTonAmount, + customPayload: null, + }, }) return { queryId, result } @@ -231,65 +206,6 @@ describe('wTON', () => { return result } - function burnBody(queryId: bigint, jettonAmount: bigint, responseDestination: Address | null) { - return beginCell() - .storeUint(walletOpcodes.in.BURN, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(responseDestination) - .storeBit(0) - .endCell() - } - - function transferBody({ - queryId, - jettonAmount, - destination, - responseDestination, - forwardTonAmount = 0n, - }: { - queryId: bigint - jettonAmount: bigint - destination: Address - responseDestination: Address | null - forwardTonAmount?: bigint - }) { - return beginCell() - .storeUint(walletOpcodes.in.TRANSFER, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(destination) - .storeAddress(responseDestination) - .storeBit(0) - .storeCoins(forwardTonAmount) - .storeBit(0) - .endCell() - } - - function internalTransferBody({ - queryId, - jettonAmount, - transferInitiator, - responseDestination, - forwardTonAmount = 0n, - }: { - queryId: bigint - jettonAmount: bigint - transferInitiator: Address | null - responseDestination: Address | null - forwardTonAmount?: bigint - }) { - return beginCell() - .storeUint(INTERNAL_TRANSFER_OPCODE, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(transferInitiator) - .storeAddress(responseDestination) - .storeCoins(forwardTonAmount) - .storeBit(0) - .endCell() - } - async function deployRejector() { const rejector = blockchain.openContract(bouncer.ContractClient.createFromConfig(bouncerCode)) await rejector.sendDeploy(deployer.getSender(), toNano('0.05')) @@ -313,16 +229,17 @@ describe('wTON', () => { }, ) { const wallet = await userWallet(owner.address) - const result = await owner.send({ - to: wallet.address, + const result = await wallet.sendTransfer(owner.getSender(), { value, - body: transferBody({ - queryId: nextQueryId++, + message: { + queryId: Number(nextQueryId++), jettonAmount, destination, responseDestination, + customPayload: null, forwardTonAmount, - }), + forwardPayload: null, + }, }) return { wallet, result } @@ -341,10 +258,14 @@ describe('wTON', () => { }, ) { const wallet = await userWallet(owner.address) - const result = await owner.send({ - to: wallet.address, + const result = await wallet.sendBurn(owner.getSender(), { value, - body: burnBody(nextQueryId++, jettonAmount, responseDestination), + message: { + queryId: nextQueryId++, + jettonAmount, + responseDestination, + customPayload: null, + }, }) return { wallet, result } @@ -579,7 +500,36 @@ describe('wTON', () => { rejectorWalletContract.balance = 0n const dustMintAmount = toNano('0.000001') - const dustTonAmount = toNano('0.0131') // slightly above the 0.013 TONS transfer budget floor to trigger the mint but cause a bounce on the internal transfer + + async function dispatchesAt(tonAmount: bigint) { + const { result } = await sendMint({ + destination: rejector.address, + jettonAmount: dustMintAmount, + tonAmount, + responseDestination: rejector.address, + }) + + return hasInternalTransactionTo(result, rejectorWallet.address) + } + + // Finding fix: derive the first dispatching dust amount from the live fee model + // instead of pinning the regression to a stale magic boundary literal. + let lowerBound = 1n + let upperBound = toNano('0.05') + + expect(await dispatchesAt(upperBound)).toBe(true) + + while (lowerBound + 1n < upperBound) { + const candidate = lowerBound + (upperBound - lowerBound) / 2n + if (await dispatchesAt(candidate)) { + upperBound = candidate + } else { + lowerBound = candidate + } + } + + const dustTonAmount = upperBound + rejectorWalletContract.balance = 0n const refundBalanceBefore = await contractBalance(rejector.address) const { result } = await sendMint({ @@ -836,6 +786,7 @@ describe('wTON', () => { jettonAmount: toNano('0.1'), transferInitiator: alice.address, responseDestination: deployer.address, + forwardPayload: null, }) const forgedResult = await deployer.send({ @@ -960,10 +911,14 @@ describe('wTON', () => { await mintTo(alice.address, { jettonAmount: mintAmount }) const aliceWallet = await userWallet(alice.address) - const burnResult = await alice.send({ - to: aliceWallet.address, + const burnResult = await aliceWallet.sendBurn(alice.getSender(), { value: toNano('0.2'), - body: burnBody(nextQueryId++, mintAmount, null), + message: { + queryId: nextQueryId++, + jettonAmount: mintAmount, + responseDestination: null, + customPayload: null, + }, }) expect(burnResult.transactions).toHaveTransaction({ diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index fdf1ef6c7..6aae50289 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -77,11 +77,44 @@ export type MintMessage = { tonAmount: bigint jettonAmount: bigint from: Maybe
- responseDestination: Address + responseDestination: Maybe
customPayload?: Cell | null forwardTonAmount?: bigint } +export function mintInternalTransferBody(message: MintMessage): Cell { + const mintMsg = beginCell() + .storeUint(MinterOpcodes.INTERNAL_TRANSFER, 32) + .storeUint(message.queryId, 64) + .storeCoins(message.jettonAmount) + .storeAddress(message.from) + .storeAddress(message.responseDestination) + .storeCoins(message.forwardTonAmount ?? 0n) + + if (message.customPayload) { + mintMsg.storeBit(1).storeRef(message.customPayload) + } else { + mintMsg.storeBit(0) + } + + return mintMsg.endCell() +} + +export function mintBody( + message: MintMessage, + opts: { + mintOpcode?: number + } = {}, +): Cell { + return beginCell() + .storeUint(opts.mintOpcode ?? MinterOpcodes.MINT, 32) + .storeUint(message.queryId, 64) + .storeAddress(message.destination) + .storeCoins(message.tonAmount) + .storeRef(mintInternalTransferBody(message)) + .endCell() +} + export type ChangeAdminMessage = { queryId: bigint newAdmin: Address @@ -134,29 +167,10 @@ export class JettonMinter implements Contract { opts: { value: bigint message: MintMessage + mintOpcode?: number }, ) { - const mintMsg = beginCell() - .storeUint(MinterOpcodes.INTERNAL_TRANSFER, 32) - .storeUint(opts.message.queryId, 64) - .storeCoins(opts.message.jettonAmount) - .storeAddress(opts.message.from) - .storeAddress(opts.message.responseDestination) - .storeCoins(opts.message.forwardTonAmount ?? 0n) - - if (opts.message.customPayload) { - mintMsg.storeBit(1).storeRef(opts.message.customPayload) - } else { - mintMsg.storeBit(0) - } - - const body = beginCell() - .storeUint(MinterOpcodes.MINT, 32) - .storeUint(opts.message.queryId, 64) - .storeAddress(opts.message.destination) - .storeCoins(opts.message.tonAmount) - .storeRef(mintMsg.endCell()) - .endCell() + const body = mintBody(opts.message, { mintOpcode: opts.mintOpcode }) await provider.internal(via, { value: opts.value, diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index 0c1f7eab5..2c7197c9f 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -57,7 +57,7 @@ export type AskToTransfer = { queryId: number jettonAmount: bigint destination: Address - responseDestination: Address + responseDestination: Address | null customPayload: Cell | null forwardTonAmount: bigint forwardPayload: Cell | Slice | null @@ -67,7 +67,7 @@ export type AskToTransferWithFwdPayload = { queryId: number jettonAmount: bigint destination: Address - responseDestination: Address + responseDestination: Address | null customPayload: Cell | null forwardTonAmount: bigint forwardPayload: T @@ -76,10 +76,19 @@ export type AskToTransferWithFwdPayload = { export type BurnMessage = { queryId: bigint jettonAmount: bigint - responseDestination: Address + responseDestination: Address | null customPayload: Cell | null } +export type InternalTransferMessage = { + queryId: bigint + jettonAmount: bigint + transferInitiator: Address | null + responseDestination: Address | null + forwardTonAmount?: bigint + forwardPayload?: Cell | Slice | null +} + export type TransferNotificationForRecipient = { queryId: number jettonAmount: bigint @@ -134,12 +143,10 @@ export class JettonWallet implements Contract { message: AskToTransfer }, ) { - const body = builder.messages.in.askToTransfer.encode(opts.message) - await provider.internal(via, { value: opts.value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: body.endCell(), + body: transferBody(opts.message), }) } @@ -151,22 +158,10 @@ export class JettonWallet implements Contract { message: BurnMessage }, ) { - const body = beginCell() - .storeUint(opcodes.in.BURN, 32) - .storeUint(opts.message.queryId, 64) - .storeCoins(opts.message.jettonAmount) - .storeAddress(opts.message.responseDestination) - - if (opts.message.customPayload) { - body.storeBit(1).storeRef(opts.message.customPayload) - } else { - body.storeBit(0) - } - await provider.internal(via, { value: opts.value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: body.endCell(), + body: burnBody(opts.message), }) } @@ -345,3 +340,44 @@ export const builder = { })(), }, } + +export function transferBody(message: AskToTransfer): Cell { + return builder.messages.in.askToTransfer.encode(message).endCell() +} + +export function burnBody(message: BurnMessage): Cell { + const body = beginCell() + .storeUint(opcodes.in.BURN, 32) + .storeUint(message.queryId, 64) + .storeCoins(message.jettonAmount) + .storeAddress(message.responseDestination) + + if (message.customPayload) { + body.storeBit(1).storeRef(message.customPayload) + } else { + body.storeBit(0) + } + + return body.endCell() +} + +export function internalTransferBody(message: InternalTransferMessage): Cell { + const body = beginCell() + .storeUint(opcodes.in.INTERNAL_TRANSFER, 32) + .storeUint(message.queryId, 64) + .storeCoins(message.jettonAmount) + .storeAddress(message.transferInitiator) + .storeAddress(message.responseDestination) + .storeCoins(message.forwardTonAmount ?? 0n) + + const forwardPayload = message.forwardPayload ?? null + const byRef = forwardPayload instanceof Cell + body.storeBit(byRef) + if (byRef) { + body.storeRef(forwardPayload) + } else if (forwardPayload) { + body.storeSlice(forwardPayload) + } + + return body.endCell() +} From 079a20e7b41bba5104b2f50e12db710548078392 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Tue, 26 May 2026 13:00:49 +0200 Subject: [PATCH 19/32] Add workchain guard to burn flow --- .../contracts/lib/jetton/jetton-utils.tolk | 2 +- contracts/contracts/lib/jetton/messages.tolk | 2 +- contracts/contracts/lib/jetton/storage.tolk | 12 ++++----- contracts/contracts/wton/JettonMinter.tolk | 14 +++++----- contracts/contracts/wton/JettonWallet.tolk | 1 + contracts/contracts/wton/fees-management.tolk | 4 +-- contracts/tests/wton/wton.spec.ts | 27 +++++++++++++++++++ 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/contracts/contracts/lib/jetton/jetton-utils.tolk b/contracts/contracts/lib/jetton/jetton-utils.tolk index 21c2a3700..8d39b0419 100644 --- a/contracts/contracts/lib/jetton/jetton-utils.tolk +++ b/contracts/contracts/lib/jetton/jetton-utils.tolk @@ -20,5 +20,5 @@ fun calcDeployedJettonWallet(ownerAddress: address, minterAddress: address, jett fun calcAddressOfJettonWallet(ownerAddress: address, minterAddress: address, jettonWalletCode: cell) { val jwDeployed = calcDeployedJettonWallet(ownerAddress, minterAddress, jettonWalletCode); - return jwDeployed.calculateAddress() + return jwDeployed.calculateAddress(); } diff --git a/contracts/contracts/lib/jetton/messages.tolk b/contracts/contracts/lib/jetton/messages.tolk index 3409f9ebb..ed2f3ff19 100644 --- a/contracts/contracts/lib/jetton/messages.tolk +++ b/contracts/contracts/lib/jetton/messages.tolk @@ -117,6 +117,6 @@ fun ForwardPayloadRemainder.checkIsCorrectTLBEither(self) { var mutableCopy = self; if (mutableCopy.loadMaybeRef() != null) { // throw "cell underflow" if there is data besides a ref - mutableCopy.assertEnd() + mutableCopy.assertEnd(); } } diff --git a/contracts/contracts/lib/jetton/storage.tolk b/contracts/contracts/lib/jetton/storage.tolk index ba8d9e625..d6005fe32 100644 --- a/contracts/contracts/lib/jetton/storage.tolk +++ b/contracts/contracts/lib/jetton/storage.tolk @@ -14,11 +14,11 @@ fun SnakeString.unpackFromSlice(mutate s: slice) { // but since here we're matching the original FunC implementation, leave no checks val snakeRemainder = s; s = createEmptySlice(); // no more left to read - return snakeRemainder + return snakeRemainder; } fun SnakeString.packToBuilder(self, mutate b: builder) { - b.storeSlice(self) + b.storeSlice(self); } struct WalletStorage { @@ -40,18 +40,18 @@ struct MinterStorage { fun MinterStorage.load() { - return MinterStorage.fromCell(contract.getData()) + return MinterStorage.fromCell(contract.getData()); } fun MinterStorage.save(self) { - contract.setData(self.toCell()) + contract.setData(self.toCell()); } fun WalletStorage.load() { - return WalletStorage.fromCell(contract.getData()) + return WalletStorage.fromCell(contract.getData()); } fun WalletStorage.save(self) { - contract.setData(self.toCell()) + contract.setData(self.toCell()); } diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 8b59f5883..43ef0c652 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -79,6 +79,7 @@ fun onInternalMessage(in: InMessage) { assert (in.senderAddress == calcAddress) throw ERROR_NOT_VALID_WALLET; // Reject burns without a refund destination so the withdrawn TON never gets stranded here. assert (msg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; + assert (msg.sendExcessesTo!.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; storage.totalSupply -= msg.jettonAmount; storage.save(); @@ -86,9 +87,7 @@ fun onInternalMessage(in: InMessage) { reserveToncoinsOnBalance(requiredMinterReserve(), reserveModeExactFail()); val excessesMsg = createMessage({ - // AUDIT(WTON-19): burn payout sends native TON to the recipient as a non-bounceable withdrawal. - // If the recipient contract throws, NoBounce makes the TON stay there; the critical property is that - // sender-side action failures MUST NOT be ignored, so the burn rolls back through the wallet bounce path. + // Burn payout sends native TON to the recipient as a non-bounceable withdrawal (forced deposit). bounce: BounceMode.NoBounce, dest: msg.sendExcessesTo!, value: 0, @@ -96,8 +95,7 @@ fun onInternalMessage(in: InMessage) { queryId: msg.queryId } }); - // Burn withdrawal is not "best effort". If this send action cannot be executed, the minter tx must fail - // so BurnNotificationForMinter bounces back to the wallet and restores wTON. + // If the send action cannot be executed, the tx must fail and bounce back to the wallet to restore wTON. excessesMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } @@ -144,9 +142,9 @@ fun onInternalMessage(in: InMessage) { // minter's own outbound deploy/forward fee rather than borrowing it from minter surplus. checkAmountIsEnoughToMint(in.valueCoins, jettonAmount, msg.tonAmount, in.originalForwardFee); - // AUDIT(WTON-13): the extra mint budget must independently cover the receiver-side transfer/forward flow. + // The extra mint budget must independently cover the receiver-side transfer/forward flow. checkAmountIsEnoughToTransfer(msg.tonAmount, forwardTonAmount, in.originalForwardFee); - // AUDIT(WTON-14): keep the minter rent reserve intact so minting cannot drain operational TON. + // Keep the minter rent reserve intact so minting cannot drain operational TON. val requiredReserve = requiredMinterReserve(); assert (contract.getOriginalBalance() >= requiredReserve + jettonAmount + msg.tonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; @@ -156,7 +154,7 @@ fun onInternalMessage(in: InMessage) { reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail()); val deployMsg = createMessage({ - // AUDIT(WTON-22): mint-bounce refunds need the original InternalTransferStep root cell, not + // The mint-bounce refund flow need the original InternalTransferStep root cell, not // a truncated 256-bit prefix, otherwise sendExcessesTo/query data become unavailable on bounce. bounce: BounceMode.RichBounceOnlyRootCell, dest: calcDeployedJettonWallet(msg.mintRecipient, contract.getAddress(), storage.jettonWalletCode), diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index 10a41fd55..b6d05bfcb 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -135,6 +135,7 @@ fun onInternalMessage(in: InMessage) { assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR; // Burn must name a refund destination so the minter can return the withdrawn TON instead of trapping it assert (msg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; + assert (msg.sendExcessesTo!.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; storage.jettonBalance -= msg.jettonAmount; storage.save(); diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index 91a7b37b9..72f69f9fa 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -37,8 +37,8 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years const GAS_CONSUMPTION_JettonTransfer = 7028 const GAS_CONSUMPTION_JettonReceive = 7295 -const GAS_CONSUMPTION_BurnRequest = 5397 -const GAS_CONSUMPTION_BurnNotification = 4462 +const GAS_CONSUMPTION_BurnRequest = 5613 +const GAS_CONSUMPTION_BurnNotification = 4558 fun calculateJettonWalletMinStorageFee() { diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index dcf7002c9..d02ff524a 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -22,6 +22,7 @@ const ERROR_NOT_ENOUGH_GAS = 48 const ERROR_INVALID_OP = 72 const ERROR_NOT_OWNER = 73 const ERROR_NOT_VALID_WALLET = 74 +const ERROR_WRONG_WORKCHAIN = 333 const ERROR_UNSUFFICIENT_AMOUNT = 76 type MintOptions = { @@ -931,6 +932,32 @@ describe('wTON', () => { expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) }) + it('rejects burns to non-basechain payout destinations', async () => { + const mintAmount = toNano('1') + const masterchainRecipient = Address.parse(`-1:${'0'.repeat(64)}`) + await mintTo(alice.address, { jettonAmount: mintAmount }) + + const aliceWallet = await userWallet(alice.address) + const burnResult = await aliceWallet.sendBurn(alice.getSender(), { + value: toNano('0.2'), + message: { + queryId: nextQueryId++, + jettonAmount: mintAmount, + responseDestination: masterchainRecipient, + customPayload: null, + }, + }) + + expect(burnResult.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + exitCode: ERROR_WRONG_WORKCHAIN, + }) + expect(await walletBalance(alice.address)).toEqual(mintAmount) + expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) + }) + it('rejects burns from non-owners', async () => { const mintAmount = toNano('1') await mintTo(alice.address, { jettonAmount: mintAmount }) From 0142a19480580f7a793c9d33508498aab8256959 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Tue, 26 May 2026 13:30:48 +0200 Subject: [PATCH 20/32] Polish Jetton TS bindings - consts/payloads --- contracts/tests/gas-report/wton/wton.spec.ts | 62 +++------------ contracts/tests/wton/wton.spec.ts | 75 +++++++++---------- .../wrappers/examples/jetton/JettonSender.ts | 2 +- contracts/wrappers/examples/jetton/types.ts | 23 ------ contracts/wrappers/jetton/JettonMinter.ts | 21 +++++- contracts/wrappers/jetton/JettonWallet.ts | 31 +++++++- contracts/wrappers/jetton/constants.ts | 33 ++++++++ contracts/wrappers/wton/constants.ts | 5 ++ contracts/wrappers/wton/index.ts | 1 + 9 files changed, 132 insertions(+), 121 deletions(-) create mode 100644 contracts/wrappers/jetton/constants.ts create mode 100644 contracts/wrappers/wton/constants.ts diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index 986550e9a..4b7cc1262 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -7,13 +7,16 @@ import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox' import { JettonMinter, mintBody } from '../../../wrappers/jetton/JettonMinter' -import { internalTransferBody, JettonWallet } from '../../../wrappers/jetton/JettonWallet' +import { + burnNotificationBody, + internalTransferBody, + JettonWallet, + returnExcessesBody, + transferNotificationBody, +} from '../../../wrappers/jetton/JettonWallet' +import { WTON_MINT_OPCODE } from '../../../wrappers/wton' const JETTON_DATA_URI = 'wton.gas' -const WTON_MINT_OPCODE = 0x00000015 -const TRANSFER_NOTIFICATION_OPCODE = 0x7362d09c -const BURN_NOTIFICATION_OPCODE = 0x7bdd97de -const RETURN_EXCESSES_OPCODE = 0xd53276db type ConfiguredGasConstants = { GAS_CONSUMPTION_JettonTransfer: number @@ -85,51 +88,6 @@ function readConfiguredShapeConstants(): ConfiguredShapeConstants { } } -function transferNotificationBody({ - queryId, - jettonAmount, - transferInitiator, - forwardPayload, -}: { - queryId: bigint - jettonAmount: bigint - transferInitiator: Address - forwardPayload: Cell -}) { - return beginCell() - .storeUint(TRANSFER_NOTIFICATION_OPCODE, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(transferInitiator) - .storeBit(1) - .storeRef(forwardPayload) - .endCell() -} - -function burnNotificationBody({ - queryId, - jettonAmount, - burnInitiator, - responseDestination, -}: { - queryId: bigint - jettonAmount: bigint - burnInitiator: Address - responseDestination: Address -}) { - return beginCell() - .storeUint(BURN_NOTIFICATION_OPCODE, 32) - .storeUint(queryId, 64) - .storeCoins(jettonAmount) - .storeAddress(burnInitiator) - .storeAddress(responseDestination) - .endCell() -} - -function returnExcessesBody(queryId: bigint) { - return beginCell().storeUint(RETURN_EXCESSES_OPCODE, 32).storeUint(queryId, 64).endCell() -} - function cellStats(cell: Cell): { bits: number; cells: number } { return cell.refs.reduce( (stats, ref) => { @@ -340,9 +298,9 @@ describe('wTON gas calibration', () => { ) const notificationBodyStats = cellStats( transferNotificationBody({ - queryId: 1n, + queryId: 1, jettonAmount: toNano('0.7'), - transferInitiator: alice.address, + senderAddress: alice.address, forwardPayload, }), ) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index d02ff524a..fc5b39266 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -3,27 +3,26 @@ import { compile } from '@ton/blueprint' import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' -import { JettonMinter } from '../../wrappers/jetton/JettonMinter' import { + JettonMinter, + MinterOpcodes, + walletAddressRequestBody, +} from '../../wrappers/jetton/JettonMinter' +import { JettonErrorCodes } from '../../wrappers/jetton/constants' +import { + burnNotificationBody, JettonWallet, internalTransferBody, opcodes as walletOpcodes, } from '../../wrappers/jetton/JettonWallet' -import { ERROR_INVALID_EXCESSES_DESTINATION } from '../../wrappers/wton' +import { + ERROR_ALREADY_INITIALIZED, + ERROR_INVALID_EXCESSES_DESTINATION, + WTON_MINT_OPCODE, +} from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' -const WTON_MINT_OPCODE = 0x00000015 -const REQUEST_WALLET_ADDRESS_OPCODE = 0x2c76b973 -const RESPONSE_WALLET_ADDRESS_OPCODE = 0xd1735400 -const ERROR_BALANCE_ERROR = 47 -const ERROR_ALREADY_INITIALIZED = 75 -const ERROR_NOT_ENOUGH_GAS = 48 -const ERROR_INVALID_OP = 72 -const ERROR_NOT_OWNER = 73 -const ERROR_NOT_VALID_WALLET = 74 -const ERROR_WRONG_WORKCHAIN = 333 -const ERROR_UNSUFFICIENT_AMOUNT = 76 type MintOptions = { minterContract?: SandboxContract @@ -361,12 +360,11 @@ describe('wTON', () => { const result = await deployer.send({ to: minter.address, value: toNano('0.05'), - body: beginCell() - .storeUint(REQUEST_WALLET_ADDRESS_OPCODE, 32) - .storeUint(queryId, 64) - .storeAddress(alice.address) - .storeBit(1) - .endCell(), + body: walletAddressRequestBody({ + queryId, + ownerAddress: alice.address, + includeOwnerAddress: true, + }), }) expect(result.transactions).toHaveTransaction({ @@ -376,7 +374,7 @@ describe('wTON', () => { }) const body = internalMessageBodyTo(result, deployer.address).beginParse() - expect(body.loadUint(32)).toEqual(RESPONSE_WALLET_ADDRESS_OPCODE) + expect(body.loadUint(32)).toEqual(MinterOpcodes.TAKE_WALLET_ADDRESS) expect(body.loadUintBig(64)).toEqual(queryId) const walletAddress = body.loadMaybeAddress() @@ -443,7 +441,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_INVALID_OP, + exitCode: JettonErrorCodes.INVALID_OP, }) expect((await minter.getJettonData()).totalSupply).toEqual(0n) }) @@ -595,7 +593,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_NOT_ENOUGH_GAS, + exitCode: JettonErrorCodes.NOT_ENOUGH_GAS, }) expect(await totalSupply()).toEqual(0n) }) @@ -616,7 +614,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_NOT_ENOUGH_GAS, + exitCode: JettonErrorCodes.NOT_ENOUGH_GAS, }) expect(await totalSupply()).toEqual(0n) }) @@ -634,7 +632,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_NOT_ENOUGH_GAS, + exitCode: JettonErrorCodes.NOT_ENOUGH_GAS, }) expect(await totalSupply()).toEqual(0n) }) @@ -658,7 +656,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_INVALID_OP, + exitCode: JettonErrorCodes.INVALID_OP, }) expect(await totalSupply()).toEqual(0n) }) @@ -772,7 +770,7 @@ describe('wTON', () => { from: deployer.address, to: aliceWallet.address, success: false, - exitCode: ERROR_NOT_OWNER, + exitCode: JettonErrorCodes.NOT_OWNER, }) expect(await walletBalance(alice.address)).toEqual(toNano('1')) }) @@ -800,7 +798,7 @@ describe('wTON', () => { from: deployer.address, to: bobWallet.address, success: false, - exitCode: ERROR_NOT_VALID_WALLET, + exitCode: JettonErrorCodes.NOT_VALID_WALLET, }) expect(await walletBalance(bob.address)).toEqual(bobMint) }) @@ -834,7 +832,7 @@ describe('wTON', () => { from: alice.address, to: (await userWallet(alice.address)).address, success: false, - exitCode: ERROR_BALANCE_ERROR, + exitCode: JettonErrorCodes.BALANCE_ERROR, }) expect(await walletBalance(alice.address)).toEqual(toNano('0.2')) expect(await totalSupply()).toEqual(toNano('0.2')) @@ -952,7 +950,7 @@ describe('wTON', () => { from: alice.address, to: aliceWallet.address, success: false, - exitCode: ERROR_WRONG_WORKCHAIN, + exitCode: JettonErrorCodes.WRONG_WORKCHAIN, }) expect(await walletBalance(alice.address)).toEqual(mintAmount) expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) @@ -977,7 +975,7 @@ describe('wTON', () => { from: deployer.address, to: aliceWallet.address, success: false, - exitCode: ERROR_NOT_OWNER, + exitCode: JettonErrorCodes.NOT_OWNER, }) expect(await walletBalance(alice.address)).toEqual(mintAmount) }) @@ -1040,13 +1038,12 @@ describe('wTON', () => { it('rejects forged burn notifications sent directly to the minter', async () => { await mintTo(alice.address, { jettonAmount: toNano('1') }) - const forgedBurn = beginCell() - .storeUint(walletOpcodes.in.BURN_NOTIFICATION, 32) - .storeUint(nextQueryId++, 64) - .storeCoins(toNano('0.5')) - .storeAddress(alice.address) - .storeAddress(recipient.address) - .endCell() + const forgedBurn = burnNotificationBody({ + queryId: nextQueryId++, + jettonAmount: toNano('0.5'), + burnInitiator: alice.address, + responseDestination: recipient.address, + }) const forgedResult = await deployer.send({ to: minter.address, @@ -1058,7 +1055,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_NOT_VALID_WALLET, + exitCode: JettonErrorCodes.NOT_VALID_WALLET, }) expect((await minter.getJettonData()).totalSupply).toEqual(toNano('1')) }) @@ -1092,7 +1089,7 @@ describe('wTON', () => { from: alice.address, to: (await userWallet(alice.address)).address, success: false, - exitCode: ERROR_BALANCE_ERROR, + exitCode: JettonErrorCodes.BALANCE_ERROR, }) expect(await walletBalance(alice.address)).toEqual(toNano('0.4')) expect(await totalSupply()).toEqual(toNano('0.4')) diff --git a/contracts/wrappers/examples/jetton/JettonSender.ts b/contracts/wrappers/examples/jetton/JettonSender.ts index c313839b0..7f112c5db 100644 --- a/contracts/wrappers/examples/jetton/JettonSender.ts +++ b/contracts/wrappers/examples/jetton/JettonSender.ts @@ -8,7 +8,7 @@ import { Sender, SendMode, } from '@ton/core' -import { JettonClientConfig, builder, JettonOpcodes } from './types' +import { JettonClientConfig, builder } from './types' import { crc32 } from 'zlib' import { loadContractCode } from '../../codeLoader' diff --git a/contracts/wrappers/examples/jetton/types.ts b/contracts/wrappers/examples/jetton/types.ts index 1bfc389c4..4addada38 100644 --- a/contracts/wrappers/examples/jetton/types.ts +++ b/contracts/wrappers/examples/jetton/types.ts @@ -26,29 +26,6 @@ export const builder = { })(), } -export const JettonOpcodes = { - // Jetton Wallet opcodes - TRANSFER: 0x0f8a7ea5, - TRANSFER_NOTIFICATION: 0x7362d09c, - INTERNAL_TRANSFER: 0x178d4519, - EXCESSES: 0xd53276db, - BURN: 0x595f07bc, - BURN_NOTIFICATION: 0x7bdd97de, - WITHDRAW_TONS: 0x107c49ef, - WITHDRAW_JETTONS: 0x10, - - // Jetton Minter opcodes - MINT: 0x642b7d07, - // PROVIDE_WALLET_ADDRESS: 0x2c76b973, - // TAKE_WALLET_ADDRESS: 0xd1735400, - CHANGE_ADMIN: 0x6501f354, - CLAIM_ADMIN: 0xfb88e119, - DROP_ADMIN: 0x7431f221, - CHANGE_METADATA_URL: 0xcb862902, - UPGRADE: 0x2508d66a, - TOP_UP: 0xd372158c, -} - export const ErrorCodes = { INCORRECT_SENDER: 100, FORWARD_PAYLOAD_REQUIRED: 101, diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index 6aae50289..c9eddcb4e 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -10,7 +10,7 @@ import { SendMode, toNano, } from '@ton/core' -import { JettonOpcodes } from '../examples/jetton/types' +import { JettonOpcodes } from './constants' import { JettonMinterCode } from './JettonCode' import { Maybe } from '@ton/core/dist/utils/maybe' @@ -59,8 +59,8 @@ export function parseJettonMinterData(data: Cell) { export const MinterOpcodes = { MINT: JettonOpcodes.MINT, BURN_NOTIFICATION: JettonOpcodes.BURN_NOTIFICATION, - // PROVIDE_WALLET_ADDRESS: JettonOpcodes.PROVIDE_WALLET_ADDRESS, - // TAKE_WALLET_ADDRESS: JettonOpcodes.TAKE_WALLET_ADDRESS, + PROVIDE_WALLET_ADDRESS: JettonOpcodes.PROVIDE_WALLET_ADDRESS, + TAKE_WALLET_ADDRESS: JettonOpcodes.TAKE_WALLET_ADDRESS, CHANGE_ADMIN: JettonOpcodes.CHANGE_ADMIN, CLAIM_ADMIN: JettonOpcodes.CLAIM_ADMIN, DROP_ADMIN: JettonOpcodes.DROP_ADMIN, @@ -82,6 +82,12 @@ export type MintMessage = { forwardTonAmount?: bigint } +export type WalletAddressRequestMessage = { + queryId: bigint + ownerAddress: Address + includeOwnerAddress: boolean +} + export function mintInternalTransferBody(message: MintMessage): Cell { const mintMsg = beginCell() .storeUint(MinterOpcodes.INTERNAL_TRANSFER, 32) @@ -115,6 +121,15 @@ export function mintBody( .endCell() } +export function walletAddressRequestBody(message: WalletAddressRequestMessage): Cell { + return beginCell() + .storeUint(MinterOpcodes.PROVIDE_WALLET_ADDRESS, 32) + .storeUint(message.queryId, 64) + .storeAddress(message.ownerAddress) + .storeBit(message.includeOwnerAddress) + .endCell() +} + export type ChangeAdminMessage = { queryId: bigint newAdmin: Address diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index 2c7197c9f..25a24bf4c 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -10,7 +10,7 @@ import { SendMode, Slice, } from '@ton/core' -import { JettonOpcodes } from '../examples/jetton/types' +import { JettonOpcodes } from './constants' import { CellCodec } from '../utils' export type JettonWalletConfig = { @@ -90,19 +90,26 @@ export type InternalTransferMessage = { } export type TransferNotificationForRecipient = { - queryId: number + queryId: number | bigint jettonAmount: bigint senderAddress: Address forwardPayload: Cell | null } export type TransferNotificationWithFwdPayload = { - queryId: number + queryId: number | bigint jettonAmount: bigint senderAddress: Address forwardPayload: T } +export type BurnNotificationMessage = { + queryId: bigint + jettonAmount: bigint + burnInitiator: Address + responseDestination: Address | null +} + export class JettonWallet implements Contract { constructor( readonly address: Address, @@ -345,6 +352,10 @@ export function transferBody(message: AskToTransfer): Cell { return builder.messages.in.askToTransfer.encode(message).endCell() } +export function transferNotificationBody(message: TransferNotificationForRecipient): Cell { + return builder.messages.out.transferNotificationForRecipient.encode(message).endCell() +} + export function burnBody(message: BurnMessage): Cell { const body = beginCell() .storeUint(opcodes.in.BURN, 32) @@ -361,6 +372,20 @@ export function burnBody(message: BurnMessage): Cell { return body.endCell() } +export function burnNotificationBody(message: BurnNotificationMessage): Cell { + return beginCell() + .storeUint(opcodes.in.BURN_NOTIFICATION, 32) + .storeUint(message.queryId, 64) + .storeCoins(message.jettonAmount) + .storeAddress(message.burnInitiator) + .storeAddress(message.responseDestination) + .endCell() +} + +export function returnExcessesBody(queryId: bigint): Cell { + return beginCell().storeUint(opcodes.in.EXCESSES, 32).storeUint(queryId, 64).endCell() +} + export function internalTransferBody(message: InternalTransferMessage): Cell { const body = beginCell() .storeUint(opcodes.in.INTERNAL_TRANSFER, 32) diff --git a/contracts/wrappers/jetton/constants.ts b/contracts/wrappers/jetton/constants.ts new file mode 100644 index 000000000..b185424f3 --- /dev/null +++ b/contracts/wrappers/jetton/constants.ts @@ -0,0 +1,33 @@ +export const JettonOpcodes = { + // Jetton Wallet opcodes + TRANSFER: 0x0f8a7ea5, + TRANSFER_NOTIFICATION: 0x7362d09c, + INTERNAL_TRANSFER: 0x178d4519, + EXCESSES: 0xd53276db, + BURN: 0x595f07bc, + BURN_NOTIFICATION: 0x7bdd97de, + WITHDRAW_TONS: 0x107c49ef, + WITHDRAW_JETTONS: 0x10, + + // Jetton Minter opcodes + MINT: 0x642b7d07, + PROVIDE_WALLET_ADDRESS: 0x2c76b973, + TAKE_WALLET_ADDRESS: 0xd1735400, + CHANGE_ADMIN: 0x6501f354, + CLAIM_ADMIN: 0xfb88e119, + DROP_ADMIN: 0x7431f221, + CHANGE_METADATA_URL: 0xcb862902, + UPGRADE: 0x2508d66a, + TOP_UP: 0xd372158c, +} + +export const JettonErrorCodes = { + BALANCE_ERROR: 47, + NOT_ENOUGH_GAS: 48, + INVALID_MESSAGE: 49, + INVALID_OP: 72, + NOT_OWNER: 73, + NOT_VALID_WALLET: 74, + WRONG_WORKCHAIN: 333, + WRONG_OP: 0xffff, +} as const diff --git a/contracts/wrappers/wton/constants.ts b/contracts/wrappers/wton/constants.ts new file mode 100644 index 000000000..33b3c2685 --- /dev/null +++ b/contracts/wrappers/wton/constants.ts @@ -0,0 +1,5 @@ +export const WtonOpcodes = { + MINT: 0x00000015, +} as const + +export const WTON_MINT_OPCODE = WtonOpcodes.MINT diff --git a/contracts/wrappers/wton/index.ts b/contracts/wrappers/wton/index.ts index 183e8bd09..207cfb490 100644 --- a/contracts/wrappers/wton/index.ts +++ b/contracts/wrappers/wton/index.ts @@ -1 +1,2 @@ export * from './errors' +export * from './constants' From 5acd17b0ef19eb283ea8e94ed708f1346f14b63c Mon Sep 17 00:00:00 2001 From: Kristijan Date: Tue, 26 May 2026 14:01:37 +0200 Subject: [PATCH 21/32] Add 'internalTransferMsg.sendExcessesTo.getWorkchain()' check and a few tests --- contracts/contracts/wton/JettonMinter.tolk | 1 + contracts/contracts/wton/fees-management.tolk | 2 +- contracts/tests/wton/wton.spec.ts | 152 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 43ef0c652..6e2cf9c73 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -136,6 +136,7 @@ fun onInternalMessage(in: InMessage) { val jettonAmount = internalTransferMsg.jettonAmount; // Mint must name the same excess/refund destination for both the happy path and a bounced wallet deployment. assert (internalTransferMsg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; + assert (internalTransferMsg.sendExcessesTo!.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; // Minting must not impersonate a peer wallet transfer initiator. assert (internalTransferMsg.transferInitiator == null) throw ERROR_INVALID_OP; // The caller must fund the hosted TON backing, the extra transfer budget, and the diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index 72f69f9fa..593939c1f 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -35,7 +35,7 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years // GAS_CONSUMPTION_JettonReceive is calibrated against the max live receive branch with both // recipient notification and excess handling enabled. -const GAS_CONSUMPTION_JettonTransfer = 7028 +const GAS_CONSUMPTION_JettonTransfer = 7124 const GAS_CONSUMPTION_JettonReceive = 7295 const GAS_CONSUMPTION_BurnRequest = 5613 const GAS_CONSUMPTION_BurnNotification = 4558 diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index fc5b39266..49813b8f2 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -23,6 +23,7 @@ import { import * as bouncer from '../../wrappers/test/mock/Bouncer' const JETTON_DATA_URI = 'wton.test' +const MASTERCHAIN_ZERO_ADDRESS = Address.parse(`-1:${'0'.repeat(64)}`) type MintOptions = { minterContract?: SandboxContract @@ -386,6 +387,34 @@ describe('wTON', () => { expect(body.remainingRefs).toEqual(0) }) + it('returns a null wallet address for non-basechain owners while preserving the echoed owner', async () => { + const queryId = nextQueryId++ + const result = await deployer.send({ + to: minter.address, + value: toNano('0.05'), + body: walletAddressRequestBody({ + queryId, + ownerAddress: MASTERCHAIN_ZERO_ADDRESS, + includeOwnerAddress: true, + }), + }) + + expect(result.transactions).toHaveTransaction({ + from: minter.address, + to: deployer.address, + success: true, + }) + + const body = internalMessageBodyTo(result, deployer.address).beginParse() + expect(body.loadUint(32)).toEqual(MinterOpcodes.TAKE_WALLET_ADDRESS) + expect(body.loadUintBig(64)).toEqual(queryId) + expect(body.loadMaybeAddress()).toBeNull() + expect(body.loadBit()).toBe(true) + expect(body.loadRef().beginParse().loadAddress().equals(MASTERCHAIN_ZERO_ADDRESS)).toBe(true) + expect(body.remainingBits).toEqual(0) + expect(body.remainingRefs).toEqual(0) + }) + it('keeps total supply equal to the sum of live wallet balances after mixed operations', async () => { await mintTo(alice.address, { jettonAmount: toNano('1.2') }) await mintTo(bob.address, { jettonAmount: toNano('0.8') }) @@ -431,6 +460,35 @@ describe('wTON', () => { expect((await minter.getJettonData()).totalSupply).toEqual(0n) }) + it('rejects mint messages to non-basechain recipients', async () => { + const { result } = await sendMint({ + destination: MASTERCHAIN_ZERO_ADDRESS, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: JettonErrorCodes.WRONG_WORKCHAIN, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + }) + + it('rejects mint messages with non-basechain refund destinations', async () => { + const { result } = await sendMint({ + destination: alice.address, + responseDestination: MASTERCHAIN_ZERO_ADDRESS, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: JettonErrorCodes.WRONG_WORKCHAIN, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + }) + it('rejects mint messages that spoof a transfer initiator', async () => { const { result } = await sendMint({ destination: alice.address, @@ -749,6 +807,33 @@ describe('wTON', () => { ) }) + it('rejects transfers to non-basechain recipients', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + + const aliceWallet = await userWallet(alice.address) + const transferResult = await aliceWallet.sendTransfer(alice.getSender(), { + value: toNano('0.5'), + message: { + queryId: Number(nextQueryId++), + jettonAmount: toNano('0.25'), + destination: MASTERCHAIN_ZERO_ADDRESS, + responseDestination: alice.address, + customPayload: null, + forwardTonAmount: 0n, + forwardPayload: null, + }, + }) + + expect(transferResult.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + exitCode: JettonErrorCodes.WRONG_WORKCHAIN, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + expect(await totalSupply()).toEqual(toNano('1')) + }) + it('rejects transfers from non-owners', async () => { await mintTo(alice.address, { jettonAmount: toNano('1') }) @@ -803,6 +888,33 @@ describe('wTON', () => { expect(await walletBalance(bob.address)).toEqual(bobMint) }) + it('rejects malformed forged internal transfers with a null initiator', async () => { + const bobMint = toNano('0.5') + await mintTo(bob.address, { jettonAmount: bobMint }) + + const bobWallet = await userWallet(bob.address) + const forgedTransfer = internalTransferBody({ + queryId: nextQueryId++, + jettonAmount: toNano('0.1'), + transferInitiator: null, + responseDestination: deployer.address, + forwardPayload: null, + }) + + const forgedResult = await deployer.send({ + to: bobWallet.address, + value: toNano('0.2'), + body: forgedTransfer, + }) + + expect(forgedResult.transactions).toHaveTransaction({ + from: deployer.address, + to: bobWallet.address, + success: false, + }) + expect(await walletBalance(bob.address)).toEqual(bobMint) + }) + it('supports transfers without a response destination', async () => { await mintTo(alice.address, { jettonAmount: toNano('1') }) @@ -857,6 +969,26 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(toNano('1')) }) + it('still rejects underfunded transfers after wallet top-ups', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + await aliceWallet.sendTopUpTons(deployer.getSender(), toNano('5')) + + const { result } = await transferFrom(alice, { + jettonAmount: toNano('0.25'), + destination: bob.address, + value: 1n, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + expect(await totalSupply()).toEqual(toNano('1')) + }) + it('restores the sender balance when the destination wallet receive path bounces', async () => { await mintTo(alice.address, { jettonAmount: toNano('1.2') }) await mintTo(bob.address, { jettonAmount: toNano('1') }) @@ -1114,6 +1246,26 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(toNano('1')) }) + it('still rejects underfunded burns after wallet top-ups', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + await aliceWallet.sendTopUpTons(deployer.getSender(), toNano('5')) + + const { result } = await burnFrom(alice, { + jettonAmount: toNano('0.25'), + responseDestination: recipient.address, + value: 1n, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: false, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + expect(await totalSupply()).toEqual(toNano('1')) + }) + it('restores wallet balance when burn notification bounces at the minter', async () => { const minted = toNano('1') await mintTo(alice.address, { jettonAmount: minted }) From 8e6f43fdc98a41aba8699d2bc87f73c10692c17a Mon Sep 17 00:00:00 2001 From: Kristijan Date: Tue, 26 May 2026 17:01:22 +0200 Subject: [PATCH 22/32] Add high-level info to README --- .../examples/jetton/onramp_mock.tolk | 1 - contracts/contracts/wton/README.md | 49 +++++++++++++++++++ contracts/tests/wton/wton.spec.ts | 7 +-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/contracts/contracts/examples/jetton/onramp_mock.tolk b/contracts/contracts/examples/jetton/onramp_mock.tolk index a9bfa2971..a2575115c 100644 --- a/contracts/contracts/examples/jetton/onramp_mock.tolk +++ b/contracts/contracts/examples/jetton/onramp_mock.tolk @@ -4,7 +4,6 @@ tolk 1.2 import "@stdlib/common.tolk" import "../../lib/jetton/jetton_client.tolk" import "../../lib/jetton/messages.tolk" -import "../../lib/jetton/jetton-utils.tolk" import "../../lib/utils.tolk" // OnrampMock contract in Tolk diff --git a/contracts/contracts/wton/README.md b/contracts/contracts/wton/README.md index 4a457f022..52da84b44 100644 --- a/contracts/contracts/wton/README.md +++ b/contracts/contracts/wton/README.md @@ -19,3 +19,52 @@ A token escrow protocol to make TON behave as Jetton in a new asset called wTON. > This version is straightforward - it is a forked Stablecoin contract with removed governance functionality and added burn mechanism. Until recent times, it was the most suitable Jetton for basic on-chain coin use cases. Which is exactly what we need as a base for wTON (and CCTs), and the [ton-blockchain/tolk-bench](https://github.com/ton-blockchain/tolk-bench) is implemented in latest Tolk 1.4 and brings substantial gas improvements over using FunC originals. + +## Design + +wTON is a fully backed Jetton wrapper around TON: + +- Minting funds the recipient wallet with the TON backing and issues the same amount of wTON there. +- Burning destroys wTON in the wallet and routes the withdrawn TON back to the chosen payout destination via the minter. +- Transfers move both the wTON balance and its TON backing between wTON wallets. +- Transfers stay Jetton-compatible, so ordinary Jetton tooling can interact with wTON wallets. + +The implementation keeps the protocol surface intentionally small: + +- `JettonMinter.tolk` tracks total supply, serves wallet-address requests, dispatches mint funding into wallets, and settles burn withdrawals. +- `JettonWallet.tolk` holds user balances, escrows the per-wallet TON backing, enforces owner-only transfer and burn requests, and processes incoming internal transfers. +- `fees-management.tolk` contains the storage, forward-fee, and gas constants that the runtime checks use to reject underfunded mint, transfer, and burn messages before balances move. + +The main behavior differences from a generic Jetton are deliberate: + +- wTON has no admin controls after deployment. +- Workflows are restricted to `MY_WORKCHAIN` so fee budgeting and refund paths stay deterministic. +- Mint bounce refunds are best-effort: supply is restored first, and any refund send is attempted with `IGNORE_ERRORS` rather than treated as protocol-critical. + +### Gas Reporter And Fee Constants + +The fee guards in `fees-management.tolk` must stay aligned with the measured live paths covered by [tests/gas-report/wton/Wton.spec.ts](../../tests/gas-report/wton/Wton.spec.ts). + +From [contracts/package.json](../../package.json), run the dedicated reporter from the `contracts` workspace: + +```sh +cd /Users/krebernisak/Developer/main/chainlink-ton/contracts +yarn wton-gas-report +``` + +This suite measures the worst covered execution branches and compares them against the constants in [contracts/contracts/wton/fees-management.tolk](./fees-management.tolk): + +- `GAS_CONSUMPTION_JettonTransfer` +- `GAS_CONSUMPTION_JettonReceive` +- `GAS_CONSUMPTION_BurnRequest` +- `GAS_CONSUMPTION_BurnNotification` +- `MESSAGE_SIZE_BurnNotification_*` +- `MESSAGE_SIZE_ReturnExcesses_*` + +When the reporter fails after a contract-path change: + +1. Re-run `yarn wton-gas-report` and read the measured values printed by the failing test. +2. Update the matching constants in [contracts/contracts/wton/fees-management.tolk](./fees-management.tolk). +3. Re-run `yarn wton-gas-report` until the measured values and configured constants match exactly. + +Only update these constants after an intentional logic change on a covered path. If the numbers drift unexpectedly, treat that as a behavior change to review first, not just a docs-only constant refresh. diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 49813b8f2..bd78ca708 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -504,7 +504,7 @@ describe('wTON', () => { expect((await minter.getJettonData()).totalSupply).toEqual(0n) }) - it('rolls supply back and refunds the caller when mint deployment bounces', async () => { + it('rolls supply back and best-effort refunds the caller when mint deployment bounces', async () => { const rejector = await deployRejector() const mintAmount = toNano('1') await sendMint({ @@ -516,6 +516,7 @@ describe('wTON', () => { const rejectorWallet = await userWallet(rejector.address) const c = await blockchain.getContract(rejectorWallet.address) c.balance = 0n // Put wallet in debt to trigger the mint bounce + const rejectorBalanceBefore = await contractBalance(rejector.address) const { result } = await sendMint({ destination: rejector.address, @@ -539,7 +540,7 @@ describe('wTON', () => { expect((await minter.getJettonData()).totalSupply).toEqual(mintAmount) // first mint const mintRefundBalance = await contractBalance(rejector.address) - expect(mintRefundBalance).toBeGreaterThanOrEqual(mintAmount) // second mint refunded + expect(mintRefundBalance).toBeGreaterThan(rejectorBalanceBefore) // best-effort refund still deposits on a throwing destination }) it('refunds bounced mint dispatches even for dust principal near the transfer-budget floor', async () => { @@ -883,7 +884,6 @@ describe('wTON', () => { from: deployer.address, to: bobWallet.address, success: false, - exitCode: JettonErrorCodes.NOT_VALID_WALLET, }) expect(await walletBalance(bob.address)).toEqual(bobMint) }) @@ -911,6 +911,7 @@ describe('wTON', () => { from: deployer.address, to: bobWallet.address, success: false, + exitCode: JettonErrorCodes.NOT_VALID_WALLET, }) expect(await walletBalance(bob.address)).toEqual(bobMint) }) From 38feac35651d9e6c39ff3ea6d44eec1f43a859b8 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 27 May 2026 14:25:10 +0200 Subject: [PATCH 23/32] Fix test --- contracts/tests/wton/wton.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index bd78ca708..060163f4e 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -884,6 +884,7 @@ describe('wTON', () => { from: deployer.address, to: bobWallet.address, success: false, + exitCode: JettonErrorCodes.NOT_VALID_WALLET, }) expect(await walletBalance(bob.address)).toEqual(bobMint) }) @@ -911,7 +912,6 @@ describe('wTON', () => { from: deployer.address, to: bobWallet.address, success: false, - exitCode: JettonErrorCodes.NOT_VALID_WALLET, }) expect(await walletBalance(bob.address)).toEqual(bobMint) }) From 8c2e2b26262f5c6418bd80052aa76a1bf8284b94 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 27 May 2026 15:32:11 +0200 Subject: [PATCH 24/32] Restructure and polish minter/wallet bindings --- contracts/tests/gas-report/wton/wton.spec.ts | 81 ++-- contracts/tests/wton/wton.spec.ts | 75 ++-- contracts/wrappers/examples/jetton/index.ts | 30 +- contracts/wrappers/jetton/JettonMinter.ts | 400 ++++++++++++++----- contracts/wrappers/jetton/JettonWallet.ts | 311 ++++++++------ integration-tests/jetton/jetton_test.go | 160 ++++---- pkg/bindings/jetton/common.go | 9 +- pkg/bindings/jetton/minter/minter.go | 80 ++-- pkg/bindings/jetton/wallet/wallet.go | 82 ++-- pkg/ton/codec/decoder_test.go | 48 +-- pkg/ton/wrappers/contract.go | 2 +- 11 files changed, 807 insertions(+), 471 deletions(-) diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index 4b7cc1262..cde5d5f43 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -6,13 +6,10 @@ import { compile } from '@ton/blueprint' import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox' -import { JettonMinter, mintBody } from '../../../wrappers/jetton/JettonMinter' +import { JettonMinter, builder as minterBuilder } from '../../../wrappers/jetton/JettonMinter' import { - burnNotificationBody, - internalTransferBody, JettonWallet, - returnExcessesBody, - transferNotificationBody, + builder as walletBuilder, } from '../../../wrappers/jetton/JettonWallet' import { WTON_MINT_OPCODE } from '../../../wrappers/wton' @@ -187,8 +184,9 @@ describe('wTON gas calibration', () => { } = {}, ) { const queryId = nextQueryId++ - const body = mintBody( - { + const body = minterBuilder.messages.in + .mintNewJettons({ opcode: WTON_MINT_OPCODE }) + .encode({ queryId, destination, tonAmount, @@ -197,9 +195,8 @@ describe('wTON gas calibration', () => { responseDestination: deployer.address, forwardTonAmount, customPayload: forwardPayload, - }, - { mintOpcode: WTON_MINT_OPCODE }, - ) + }) + .asCell() return await deployer.send({ to: minter.address, @@ -287,40 +284,50 @@ describe('wTON gas calibration', () => { const maxCoins = (1n << 120n) - 1n const transferBodyStats = cellStats( - internalTransferBody({ - queryId: 1n, - jettonAmount: toNano('0.7'), - transferInitiator: alice.address, - responseDestination: deployer.address, - forwardTonAmount: toNano('0.05'), - forwardPayload, - }), + walletBuilder.messages.out.internalTransferStep + .encode({ + queryId: 1n, + jettonAmount: toNano('0.7'), + transferInitiator: alice.address, + responseDestination: deployer.address, + forwardTonAmount: toNano('0.05'), + forwardPayload, + }) + .asCell(), ) const notificationBodyStats = cellStats( - transferNotificationBody({ - queryId: 1, - jettonAmount: toNano('0.7'), - senderAddress: alice.address, - forwardPayload, - }), + walletBuilder.messages.out.transferNotificationForRecipient + .encode({ + queryId: 1, + jettonAmount: toNano('0.7'), + senderAddress: alice.address, + forwardPayload, + }) + .asCell(), ) const burnNotificationLiveStats = cellStats( - burnNotificationBody({ - queryId: 1n, - jettonAmount: toNano('0.3'), - burnInitiator: bob.address, - responseDestination: recipient.address, - }), + walletBuilder.messages.out.burnNotificationForMinter + .encode({ + queryId: 1n, + jettonAmount: toNano('0.3'), + burnInitiator: bob.address, + responseDestination: recipient.address, + }) + .asCell(), ) const burnNotificationWorstCaseStats = cellStats( - burnNotificationBody({ - queryId: 1n, - jettonAmount: maxCoins, - burnInitiator: bob.address, - responseDestination: recipient.address, - }), + walletBuilder.messages.out.burnNotificationForMinter + .encode({ + queryId: 1n, + jettonAmount: maxCoins, + burnInitiator: bob.address, + responseDestination: recipient.address, + }) + .asCell(), + ) + const returnExcessesStats = cellStats( + walletBuilder.messages.out.returnExcessesBack.encode({ queryId: 1n }).asCell(), ) - const returnExcessesStats = cellStats(returnExcessesBody(1n)) expect(burnNotificationWorstCaseStats).toEqual({ bits: configured.MESSAGE_SIZE_BurnNotification_bits, diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 060163f4e..bc57121e7 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -6,13 +6,12 @@ import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox' import { JettonMinter, MinterOpcodes, - walletAddressRequestBody, + builder as minterBuilder, } from '../../wrappers/jetton/JettonMinter' import { JettonErrorCodes } from '../../wrappers/jetton/constants' import { - burnNotificationBody, JettonWallet, - internalTransferBody, + builder as walletBuilder, opcodes as walletOpcodes, } from '../../wrappers/jetton/JettonWallet' import { @@ -361,11 +360,13 @@ describe('wTON', () => { const result = await deployer.send({ to: minter.address, value: toNano('0.05'), - body: walletAddressRequestBody({ - queryId, - ownerAddress: alice.address, - includeOwnerAddress: true, - }), + body: minterBuilder.messages.in.requestWalletAddress + .encode({ + queryId, + ownerAddress: alice.address, + includeOwnerAddress: true, + }) + .asCell(), }) expect(result.transactions).toHaveTransaction({ @@ -392,11 +393,13 @@ describe('wTON', () => { const result = await deployer.send({ to: minter.address, value: toNano('0.05'), - body: walletAddressRequestBody({ - queryId, - ownerAddress: MASTERCHAIN_ZERO_ADDRESS, - includeOwnerAddress: true, - }), + body: minterBuilder.messages.in.requestWalletAddress + .encode({ + queryId, + ownerAddress: MASTERCHAIN_ZERO_ADDRESS, + includeOwnerAddress: true, + }) + .asCell(), }) expect(result.transactions).toHaveTransaction({ @@ -866,13 +869,15 @@ describe('wTON', () => { await mintTo(bob.address, { jettonAmount: bobMint }) const bobWallet = await userWallet(bob.address) - const forgedTransfer = internalTransferBody({ - queryId: nextQueryId++, - jettonAmount: toNano('0.1'), - transferInitiator: alice.address, - responseDestination: deployer.address, - forwardPayload: null, - }) + const forgedTransfer = walletBuilder.messages.out.internalTransferStep + .encode({ + queryId: nextQueryId++, + jettonAmount: toNano('0.1'), + transferInitiator: alice.address, + responseDestination: deployer.address, + forwardPayload: null, + }) + .asCell() const forgedResult = await deployer.send({ to: bobWallet.address, @@ -894,13 +899,15 @@ describe('wTON', () => { await mintTo(bob.address, { jettonAmount: bobMint }) const bobWallet = await userWallet(bob.address) - const forgedTransfer = internalTransferBody({ - queryId: nextQueryId++, - jettonAmount: toNano('0.1'), - transferInitiator: null, - responseDestination: deployer.address, - forwardPayload: null, - }) + const forgedTransfer = walletBuilder.messages.out.internalTransferStep + .encode({ + queryId: nextQueryId++, + jettonAmount: toNano('0.1'), + transferInitiator: null, + responseDestination: deployer.address, + forwardPayload: null, + }) + .asCell() const forgedResult = await deployer.send({ to: bobWallet.address, @@ -1171,12 +1178,14 @@ describe('wTON', () => { it('rejects forged burn notifications sent directly to the minter', async () => { await mintTo(alice.address, { jettonAmount: toNano('1') }) - const forgedBurn = burnNotificationBody({ - queryId: nextQueryId++, - jettonAmount: toNano('0.5'), - burnInitiator: alice.address, - responseDestination: recipient.address, - }) + const forgedBurn = walletBuilder.messages.out.burnNotificationForMinter + .encode({ + queryId: nextQueryId++, + jettonAmount: toNano('0.5'), + burnInitiator: alice.address, + responseDestination: recipient.address, + }) + .asCell() const forgedResult = await deployer.send({ to: minter.address, diff --git a/contracts/wrappers/examples/jetton/index.ts b/contracts/wrappers/examples/jetton/index.ts index fc248db3f..f602d4986 100644 --- a/contracts/wrappers/examples/jetton/index.ts +++ b/contracts/wrappers/examples/jetton/index.ts @@ -5,25 +5,35 @@ export * from './types' export { JettonMinter, MinterOpcodes, + builder as jettonMinterBuilder, type JettonMinterConfig, + type JettonMinterData, type JettonMinterContent, - type MintMessage, - type ChangeAdminMessage, - type ChangeContentMessage, - jettonMinterConfigToCell, - jettonContentToCell, - parseJettonMinterData, + type MintNewJettons, + type InternalTransferStep as MinterInternalTransferStep, + type RequestWalletAddress, + type ChangeMinterAdmin, + type ChangeMinterMetadataUri, + type ClaimMinterAdmin, + type DropMinterAdmin, + type UpgradeMinterCode, + type TopUpTons as MinterTopUpTons, } from '../../jetton/JettonMinter' // Jetton Wallet export { JettonWallet, + builder as jettonWalletBuilder, opcodes as WalletOpcodes, type JettonWalletConfig, - type AskToTransfer as TransferMessage, - type BurnMessage, - jettonWalletConfigToCell, - parseJettonWalletData, + type JettonWalletData, + type AskToTransfer, + type AskToBurn, + type InternalTransferStep, + type TransferNotificationForRecipient, + type BurnNotificationForMinter, + type ReturnExcessesBack, + type TopUpTons as WalletTopUpTons, } from '../../jetton/JettonWallet' // Jetton Sender diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index c9eddcb4e..6ed7a3659 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -1,23 +1,33 @@ import { - address, Address, beginCell, + Builder, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode, + Slice, toNano, } from '@ton/core' import { JettonOpcodes } from './constants' import { JettonMinterCode } from './JettonCode' import { Maybe } from '@ton/core/dist/utils/maybe' +import { CellCodec } from '../utils' export type JettonMinterContent = { uri: string } +export type JettonMinterData = { + totalSupply: bigint + admin: Address | null + walletCode: Cell + jettonContent: Cell + transferAdmin: Maybe
+} + export type JettonMinterConfig = { totalSupply: bigint admin: Address @@ -26,36 +36,6 @@ export type JettonMinterConfig = { transferAdmin: Maybe
} -export function jettonContentToCell(content: JettonMinterContent): Cell { - return beginCell().storeStringRefTail(content.uri).endCell() -} - -export function jettonMinterConfigToCell(config: JettonMinterConfig): Cell { - const content = - config.jettonContent instanceof Cell - ? config.jettonContent - : jettonContentToCell(config.jettonContent) - - return beginCell() - .storeCoins(config.totalSupply) - .storeAddress(config.admin) - .storeAddress(config.transferAdmin) - .storeRef(config.walletCode) - .storeRef(content) - .endCell() -} - -export function parseJettonMinterData(data: Cell) { - const sc = data.beginParse() - return { - supply: sc.loadCoins(), - admin: sc.loadMaybeAddress(), - transferAdmin: sc.loadMaybeAddress(), - walletCode: sc.loadRef(), - jettonContent: sc.loadRef(), - } -} - export const MinterOpcodes = { MINT: JettonOpcodes.MINT, BURN_NOTIFICATION: JettonOpcodes.BURN_NOTIFICATION, @@ -71,7 +51,7 @@ export const MinterOpcodes = { EXCESSES: JettonOpcodes.EXCESSES, } -export type MintMessage = { +export type MintNewJettons = { queryId: bigint destination: Address tonAmount: bigint @@ -82,62 +62,281 @@ export type MintMessage = { forwardTonAmount?: bigint } -export type WalletAddressRequestMessage = { +export type InternalTransferStep = { + queryId: bigint + jettonAmount: bigint + from: Maybe
+ responseDestination: Maybe
+ customPayload?: Cell | null + forwardTonAmount?: bigint +} + +export type RequestWalletAddress = { queryId: bigint ownerAddress: Address includeOwnerAddress: boolean } -export function mintInternalTransferBody(message: MintMessage): Cell { - const mintMsg = beginCell() - .storeUint(MinterOpcodes.INTERNAL_TRANSFER, 32) - .storeUint(message.queryId, 64) - .storeCoins(message.jettonAmount) - .storeAddress(message.from) - .storeAddress(message.responseDestination) - .storeCoins(message.forwardTonAmount ?? 0n) - - if (message.customPayload) { - mintMsg.storeBit(1).storeRef(message.customPayload) - } else { - mintMsg.storeBit(0) - } - - return mintMsg.endCell() +export type ChangeMinterAdmin = { + queryId: bigint + newAdmin: Address } -export function mintBody( - message: MintMessage, - opts: { - mintOpcode?: number - } = {}, -): Cell { - return beginCell() - .storeUint(opts.mintOpcode ?? MinterOpcodes.MINT, 32) - .storeUint(message.queryId, 64) - .storeAddress(message.destination) - .storeCoins(message.tonAmount) - .storeRef(mintInternalTransferBody(message)) - .endCell() +export type ChangeMinterMetadataUri = { + queryId: bigint + content: Cell | JettonMinterContent } -export function walletAddressRequestBody(message: WalletAddressRequestMessage): Cell { - return beginCell() - .storeUint(MinterOpcodes.PROVIDE_WALLET_ADDRESS, 32) - .storeUint(message.queryId, 64) - .storeAddress(message.ownerAddress) - .storeBit(message.includeOwnerAddress) - .endCell() +export type ClaimMinterAdmin = { + queryId: bigint } -export type ChangeAdminMessage = { +export type DropMinterAdmin = { queryId: bigint - newAdmin: Address } -export type ChangeContentMessage = { +export type UpgradeMinterCode = { queryId: bigint - content: Cell | JettonMinterContent + newData: Cell + newCode: Cell +} + +export type TopUpTons = Record + +function contentToCell(content: Cell | JettonMinterContent): Cell { + return content instanceof Cell ? content : builder.data.content.encode(content).asCell() +} + +function toContractData(config: JettonMinterConfig): JettonMinterData { + return { + totalSupply: config.totalSupply, + admin: config.admin, + transferAdmin: config.transferAdmin, + walletCode: config.walletCode, + jettonContent: contentToCell(config.jettonContent), + } +} + +function toInternalTransferStep(message: MintNewJettons): InternalTransferStep { + return { + queryId: message.queryId, + jettonAmount: message.jettonAmount, + from: message.from, + responseDestination: message.responseDestination, + customPayload: message.customPayload, + forwardTonAmount: message.forwardTonAmount, + } +} + +export const builder = { + data: { + content: ((): CellCodec => { + return { + encode: (data: JettonMinterContent): Builder => { + return beginCell().storeStringRefTail(data.uri) + }, + load: (src: Slice): JettonMinterContent => { + return { uri: src.loadStringRefTail() } + }, + } + })(), + contractData: ((): CellCodec => { + return { + encode: (data: JettonMinterData): Builder => { + return beginCell() + .storeCoins(data.totalSupply) + .storeAddress(data.admin) + .storeAddress(data.transferAdmin) + .storeRef(data.walletCode) + .storeRef(data.jettonContent) + }, + load: (src: Slice): JettonMinterData => { + return { + totalSupply: src.loadCoins(), + admin: src.loadMaybeAddress(), + transferAdmin: src.loadMaybeAddress(), + walletCode: src.loadRef(), + jettonContent: src.loadRef(), + } + }, + } + })(), + }, + messages: { + in: { + mintNewJettons: (opts: { opcode?: number } = {}): CellCodec => { + return { + encode: (data: MintNewJettons): Builder => { + return beginCell() + .storeUint(opts.opcode ?? MinterOpcodes.MINT, 32) + .storeUint(data.queryId, 64) + .storeAddress(data.destination) + .storeCoins(data.tonAmount) + .storeRef(builder.messages.out.internalTransferStep.encode(toInternalTransferStep(data))) + }, + load: (src: Slice): MintNewJettons => { + src.skip(32) + const queryId = src.loadUintBig(64) + const destination = src.loadAddress() + const tonAmount = src.loadCoins() + const internalTransfer = builder.messages.out.internalTransferStep.load(src.loadRef().beginParse()) + + return { + queryId, + destination, + tonAmount, + jettonAmount: internalTransfer.jettonAmount, + from: internalTransfer.from, + responseDestination: internalTransfer.responseDestination, + customPayload: internalTransfer.customPayload, + forwardTonAmount: internalTransfer.forwardTonAmount, + } + }, + } + }, + requestWalletAddress: ((): CellCodec => { + return { + encode: (data: RequestWalletAddress): Builder => { + return beginCell() + .storeUint(MinterOpcodes.PROVIDE_WALLET_ADDRESS, 32) + .storeUint(data.queryId, 64) + .storeAddress(data.ownerAddress) + .storeBit(data.includeOwnerAddress) + }, + load: (src: Slice): RequestWalletAddress => { + src.skip(32) + return { + queryId: src.loadUintBig(64), + ownerAddress: src.loadAddress(), + includeOwnerAddress: src.loadBit(), + } + }, + } + })(), + changeMinterAdmin: ((): CellCodec => { + return { + encode: (data: ChangeMinterAdmin): Builder => { + return beginCell() + .storeUint(MinterOpcodes.CHANGE_ADMIN, 32) + .storeUint(data.queryId, 64) + .storeAddress(data.newAdmin) + }, + load: (src: Slice): ChangeMinterAdmin => { + src.skip(32) + return { + queryId: src.loadUintBig(64), + newAdmin: src.loadAddress(), + } + }, + } + })(), + claimMinterAdmin: ((): CellCodec => { + return { + encode: (data: ClaimMinterAdmin): Builder => { + return beginCell() + .storeUint(MinterOpcodes.CLAIM_ADMIN, 32) + .storeUint(data.queryId, 64) + }, + load: (src: Slice): ClaimMinterAdmin => { + src.skip(32) + return { queryId: src.loadUintBig(64) } + }, + } + })(), + dropMinterAdmin: ((): CellCodec => { + return { + encode: (data: DropMinterAdmin): Builder => { + return beginCell() + .storeUint(MinterOpcodes.DROP_ADMIN, 32) + .storeUint(data.queryId, 64) + }, + load: (src: Slice): DropMinterAdmin => { + src.skip(32) + return { queryId: src.loadUintBig(64) } + }, + } + })(), + changeMinterMetadataUri: ((): CellCodec => { + return { + encode: (data: ChangeMinterMetadataUri): Builder => { + const content = + data.content instanceof Cell + ? data.content.beginParse().loadStringTail() + : data.content.uri + + return beginCell() + .storeUint(MinterOpcodes.CHANGE_METADATA_URL, 32) + .storeUint(data.queryId, 64) + .storeStringTail(content) + }, + load: (src: Slice): ChangeMinterMetadataUri => { + src.skip(32) + return { + queryId: src.loadUintBig(64), + content: { uri: src.loadStringTail() }, + } + }, + } + })(), + upgradeMinterCode: ((): CellCodec => { + return { + encode: (data: UpgradeMinterCode): Builder => { + return beginCell() + .storeUint(MinterOpcodes.UPGRADE, 32) + .storeUint(data.queryId, 64) + .storeRef(data.newData) + .storeRef(data.newCode) + }, + load: (src: Slice): UpgradeMinterCode => { + src.skip(32) + return { + queryId: src.loadUintBig(64), + newData: src.loadRef(), + newCode: src.loadRef(), + } + }, + } + })(), + topUpTons: ((): CellCodec => { + return { + encode: (): Builder => { + return beginCell().storeUint(MinterOpcodes.TOP_UP, 32) + }, + load: (src: Slice): TopUpTons => { + src.skip(32) + return {} + }, + } + })(), + }, + out: { + internalTransferStep: ((): CellCodec => { + return { + encode: (data: InternalTransferStep): Builder => { + return beginCell() + .storeUint(MinterOpcodes.INTERNAL_TRANSFER, 32) + .storeUint(data.queryId, 64) + .storeCoins(data.jettonAmount) + .storeAddress(data.from) + .storeAddress(data.responseDestination) + .storeCoins(data.forwardTonAmount ?? 0n) + .storeMaybeRef(data.customPayload ?? null) + }, + load: (src: Slice): InternalTransferStep => { + src.skip(32) + return { + queryId: src.loadUintBig(64), + jettonAmount: src.loadCoins(), + from: src.loadMaybeAddress(), + responseDestination: src.loadMaybeAddress(), + forwardTonAmount: src.loadCoins(), + customPayload: src.loadMaybeRef(), + } + }, + } + })(), + }, + }, } export class JettonMinter implements Contract { @@ -151,7 +350,7 @@ export class JettonMinter implements Contract { } static createFromConfig(config: JettonMinterConfig, code: Cell, workchain = 0) { - const data = jettonMinterConfigToCell(config) + const data = builder.data.contractData.encode(toContractData(config)).asCell() const init = { code, data } return new JettonMinter(contractAddress(workchain, init), init) } @@ -168,7 +367,7 @@ export class JettonMinter implements Contract { await provider.internal(via, { value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell().storeUint(MinterOpcodes.TOP_UP, 32).endCell(), + body: builder.messages.in.topUpTons.encode({}).asCell(), }) } @@ -181,11 +380,14 @@ export class JettonMinter implements Contract { via: Sender, opts: { value: bigint - message: MintMessage + message: MintNewJettons mintOpcode?: number }, ) { - const body = mintBody(opts.message, { mintOpcode: opts.mintOpcode }) + const body = builder.messages.in + .mintNewJettons({ opcode: opts.mintOpcode }) + .encode(opts.message) + .asCell() await provider.internal(via, { value: opts.value, @@ -220,17 +422,13 @@ export class JettonMinter implements Contract { via: Sender, opts: { value?: bigint - message: ChangeAdminMessage + message: ChangeMinterAdmin }, ) { await provider.internal(via, { value: opts.value ?? toNano('0.1'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell() - .storeUint(MinterOpcodes.CHANGE_ADMIN, 32) - .storeUint(opts.message.queryId, 64) - .storeAddress(opts.message.newAdmin) - .endCell(), + body: builder.messages.in.changeMinterAdmin.encode(opts.message).asCell(), }) } @@ -245,10 +443,9 @@ export class JettonMinter implements Contract { await provider.internal(via, { value: opts.value ?? toNano('0.1'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell() - .storeUint(MinterOpcodes.CLAIM_ADMIN, 32) - .storeUint(opts.queryId ?? 0n, 64) - .endCell(), + body: builder.messages.in.claimMinterAdmin + .encode({ queryId: opts.queryId ?? 0n }) + .asCell(), }) } @@ -263,10 +460,9 @@ export class JettonMinter implements Contract { await provider.internal(via, { value: opts.value ?? toNano('0.05'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell() - .storeUint(MinterOpcodes.DROP_ADMIN, 32) - .storeUint(opts.queryId ?? 0n, 64) - .endCell(), + body: builder.messages.in.dropMinterAdmin + .encode({ queryId: opts.queryId ?? 0n }) + .asCell(), }) } @@ -275,22 +471,13 @@ export class JettonMinter implements Contract { via: Sender, opts: { value?: bigint - message: ChangeContentMessage + message: ChangeMinterMetadataUri }, ) { - const contentString = - opts.message.content instanceof Cell - ? opts.message.content.beginParse().loadStringTail() - : opts.message.content.uri - await provider.internal(via, { value: opts.value ?? toNano('0.1'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell() - .storeUint(MinterOpcodes.CHANGE_METADATA_URL, 32) - .storeUint(opts.message.queryId, 64) - .storeStringTail(contentString) - .endCell(), + body: builder.messages.in.changeMinterMetadataUri.encode(opts.message).asCell(), }) } @@ -307,12 +494,9 @@ export class JettonMinter implements Contract { await provider.internal(via, { value: opts.value ?? toNano('0.1'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell() - .storeUint(MinterOpcodes.UPGRADE, 32) - .storeUint(opts.queryId ?? 0n, 64) - .storeRef(opts.newData) - .storeRef(opts.newCode) - .endCell(), + body: builder.messages.in.upgradeMinterCode + .encode({ queryId: opts.queryId ?? 0n, newData: opts.newData, newCode: opts.newCode }) + .asCell(), }) } diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index 25a24bf4c..d5e14f175 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -20,23 +20,11 @@ export type JettonWalletConfig = { status?: number } -export function jettonWalletConfigToCell(config: JettonWalletConfig): Cell { - return beginCell() - .storeUint(config.status ?? 0, 4) // status - .storeCoins(config.balance ?? 0n) // jetton balance - .storeAddress(config.ownerAddress) - .storeAddress(config.jettonMasterAddress) - .endCell() -} - -export function parseJettonWalletData(data: Cell) { - const sc = data.beginParse() - return { - status: sc.loadUint(4), - balance: sc.loadCoins(), - ownerAddress: sc.loadAddress(), - jettonMasterAddress: sc.loadAddress(), - } +export type JettonWalletData = { + ownerAddress: Address + jettonMasterAddress: Address + balance: bigint + status: number } export const opcodes = { @@ -73,14 +61,14 @@ export type AskToTransferWithFwdPayload = { forwardPayload: T } -export type BurnMessage = { +export type AskToBurn = { queryId: bigint jettonAmount: bigint responseDestination: Address | null customPayload: Cell | null } -export type InternalTransferMessage = { +export type InternalTransferStep = { queryId: bigint jettonAmount: bigint transferInitiator: Address | null @@ -103,13 +91,44 @@ export type TransferNotificationWithFwdPayload = { forwardPayload: T } -export type BurnNotificationMessage = { +export type BurnNotificationForMinter = { queryId: bigint jettonAmount: bigint burnInitiator: Address responseDestination: Address | null } +export type ReturnExcessesBack = { + queryId: bigint +} + +export type TopUpTons = Record + +export type WithdrawTonsMessage = { + queryId: bigint +} + +function toContractData(config: JettonWalletConfig): JettonWalletData { + return { + ownerAddress: config.ownerAddress, + jettonMasterAddress: config.jettonMasterAddress, + balance: config.balance ?? 0n, + status: config.status ?? 0, + } +} + +function loadForwardPayload(src: Slice): Cell | Slice | null { + const byRef = src.loadBit() + if (byRef) { + return src.loadRef() + } + return src.remainingBits > 0 || src.remainingRefs > 0 ? src : null +} + +function toForwardPayloadSlice(payload: Cell | Slice): Slice { + return payload instanceof Cell ? payload.beginParse() : payload +} + export class JettonWallet implements Contract { constructor( readonly address: Address, @@ -121,7 +140,7 @@ export class JettonWallet implements Contract { } static createFromConfig(config: JettonWalletConfig, code: Cell, workchain = 0) { - const data = jettonWalletConfigToCell(config) + const data = builder.data.contractData.encode(toContractData(config)).asCell() const init = { code, data } return new JettonWallet(contractAddress(workchain, init), init) } @@ -138,7 +157,7 @@ export class JettonWallet implements Contract { await provider.internal(via, { value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell().storeUint(opcodes.in.TOP_UP, 32).endCell(), + body: builder.messages.in.topUpTons.encode({}).asCell(), }) } @@ -153,7 +172,7 @@ export class JettonWallet implements Contract { await provider.internal(via, { value: opts.value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: transferBody(opts.message), + body: builder.messages.in.askToTransfer.encode(opts.message).asCell(), }) } @@ -162,13 +181,13 @@ export class JettonWallet implements Contract { via: Sender, opts: { value: bigint - message: BurnMessage + message: AskToBurn }, ) { await provider.internal(via, { value: opts.value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: burnBody(opts.message), + body: builder.messages.in.askToBurn.encode(opts.message).asCell(), }) } @@ -176,10 +195,7 @@ export class JettonWallet implements Contract { await provider.internal(via, { value, sendMode: SendMode.PAY_GAS_SEPARATELY, - body: beginCell() - .storeUint(opcodes.in.WITHDRAW_TONS, 32) - .storeUint(0, 64) // query_id - .endCell(), + body: builder.messages.in.withdrawTons.encode({ queryId: 0n }).asCell(), }) } @@ -205,6 +221,27 @@ export class JettonWallet implements Contract { } export const builder = { + data: { + contractData: ((): CellCodec => { + return { + encode: (data: JettonWalletData): Builder => { + return beginCell() + .storeUint(data.status, 4) + .storeCoins(data.balance) + .storeAddress(data.ownerAddress) + .storeAddress(data.jettonMasterAddress) + }, + load: (src: Slice): JettonWalletData => { + return { + status: src.loadUint(4), + balance: src.loadCoins(), + ownerAddress: src.loadAddress(), + jettonMasterAddress: src.loadAddress(), + } + }, + } + })(), + }, messages: { in: (() => { const askToTransfer: CellCodec = { @@ -233,22 +270,16 @@ export const builder = { if (op !== opcodes.in.TRANSFER) { throw new Error(`Invalid opcode, expected ${opcodes.in.TRANSFER}, got ${op}`) } - const askToTransfer = { + const parsed = { queryId: src.loadUint(64), jettonAmount: src.loadCoins(), destination: src.loadAddress(), responseDestination: src.loadAddress(), customPayload: src.loadMaybeRef(), forwardTonAmount: src.loadCoins(), - forwardPayload: null as Cell | Slice | null, + forwardPayload: loadForwardPayload(src), } - const byRef = src.loadBit() - if (byRef) { - askToTransfer.forwardPayload = src.loadRef() - } else if (src.remainingBits > 0) { - askToTransfer.forwardPayload = src - } - return askToTransfer + return parsed }, } const askToTransferWithFwdPayload = ( @@ -267,15 +298,7 @@ export const builder = { if (!transferRequest.forwardPayload) { throw new Error('forwardPayload is null') } - let payload = payloadCodec.load( - ((forwardPayload: Cell | Slice): Slice => { - if (forwardPayload instanceof Cell) { - return forwardPayload.beginParse() - } else { - return forwardPayload - } - })(transferRequest.forwardPayload), - ) + let payload = payloadCodec.load(toForwardPayloadSlice(transferRequest.forwardPayload)) return { ...transferRequest, forwardPayload: payload, @@ -283,9 +306,60 @@ export const builder = { }, } } + const askToBurn: CellCodec = { + encode: function (data: AskToBurn): Builder { + return beginCell() + .storeUint(opcodes.in.BURN, 32) + .storeUint(data.queryId, 64) + .storeCoins(data.jettonAmount) + .storeAddress(data.responseDestination) + .storeMaybeRef(data.customPayload) + }, + load: function (src: Slice): AskToBurn { + const op = src.loadUint(32) + if (op !== opcodes.in.BURN) { + throw new Error(`Invalid opcode, expected ${opcodes.in.BURN}, got ${op}`) + } + return { + queryId: src.loadUintBig(64), + jettonAmount: src.loadCoins(), + responseDestination: src.loadMaybeAddress(), + customPayload: src.loadMaybeRef(), + } + }, + } + const topUpTons: CellCodec = { + encode: function (): Builder { + return beginCell().storeUint(opcodes.in.TOP_UP, 32) + }, + load: function (src: Slice): TopUpTons { + const op = src.loadUint(32) + if (op !== opcodes.in.TOP_UP) { + throw new Error(`Invalid opcode, expected ${opcodes.in.TOP_UP}, got ${op}`) + } + return {} + }, + } + const withdrawTons: CellCodec = { + encode: function (data: WithdrawTonsMessage): Builder { + return beginCell() + .storeUint(opcodes.in.WITHDRAW_TONS, 32) + .storeUint(data.queryId, 64) + }, + load: function (src: Slice): WithdrawTonsMessage { + const op = src.loadUint(32) + if (op !== opcodes.in.WITHDRAW_TONS) { + throw new Error(`Invalid opcode, expected ${opcodes.in.WITHDRAW_TONS}, got ${op}`) + } + return { queryId: src.loadUintBig(64) } + }, + } return { askToTransfer, askToTransferWithFwdPayload, + askToBurn, + topUpTons, + withdrawTons, } })(), out: (() => { @@ -324,15 +398,7 @@ export const builder = { if (!tn.forwardPayload) { throw new Error('forwardPayload is null') } - let payload = payloadCodec.load( - ((forwardPayload: Cell | Slice): Slice => { - if (forwardPayload instanceof Cell) { - return forwardPayload.beginParse() - } else { - return forwardPayload - } - })(tn.forwardPayload), - ) + let payload = payloadCodec.load(toForwardPayloadSlice(tn.forwardPayload)) return { ...tn, forwardPayload: payload, @@ -340,69 +406,86 @@ export const builder = { }, } } + const burnNotificationForMinter: CellCodec = { + encode: function (data: BurnNotificationForMinter): Builder { + return beginCell() + .storeUint(opcodes.in.BURN_NOTIFICATION, 32) + .storeUint(data.queryId, 64) + .storeCoins(data.jettonAmount) + .storeAddress(data.burnInitiator) + .storeAddress(data.responseDestination) + }, + load: function (src: Slice): BurnNotificationForMinter { + const op = src.loadUint(32) + if (op !== opcodes.in.BURN_NOTIFICATION) { + throw new Error( + `Invalid opcode, expected ${opcodes.in.BURN_NOTIFICATION}, got ${op}`, + ) + } + return { + queryId: src.loadUintBig(64), + jettonAmount: src.loadCoins(), + burnInitiator: src.loadAddress(), + responseDestination: src.loadMaybeAddress(), + } + }, + } + const returnExcessesBack: CellCodec = { + encode: function (data: ReturnExcessesBack): Builder { + return beginCell().storeUint(opcodes.in.EXCESSES, 32).storeUint(data.queryId, 64) + }, + load: function (src: Slice): ReturnExcessesBack { + const op = src.loadUint(32) + if (op !== opcodes.in.EXCESSES) { + throw new Error(`Invalid opcode, expected ${opcodes.in.EXCESSES}, got ${op}`) + } + return { queryId: src.loadUintBig(64) } + }, + } + const internalTransferStep: CellCodec = { + encode: function (data: InternalTransferStep): Builder { + const body = beginCell() + .storeUint(opcodes.in.INTERNAL_TRANSFER, 32) + .storeUint(data.queryId, 64) + .storeCoins(data.jettonAmount) + .storeAddress(data.transferInitiator) + .storeAddress(data.responseDestination) + .storeCoins(data.forwardTonAmount ?? 0n) + + const forwardPayload = data.forwardPayload ?? null + const byRef = forwardPayload instanceof Cell + body.storeBit(byRef) + if (byRef) { + body.storeRef(forwardPayload) + } else if (forwardPayload) { + body.storeSlice(forwardPayload) + } + + return body + }, + load: function (src: Slice): InternalTransferStep { + const op = src.loadUint(32) + if (op !== opcodes.in.INTERNAL_TRANSFER) { + throw new Error(`Invalid opcode, expected ${opcodes.in.INTERNAL_TRANSFER}, got ${op}`) + } + return { + queryId: src.loadUintBig(64), + jettonAmount: src.loadCoins(), + transferInitiator: src.loadMaybeAddress(), + responseDestination: src.loadMaybeAddress(), + forwardTonAmount: src.loadCoins(), + forwardPayload: loadForwardPayload(src), + } + }, + } return { transferNotificationForRecipient, transferNotificationWithFwdPayload, + burnNotificationForMinter, + returnExcessesBack, + internalTransferStep, } })(), }, } -export function transferBody(message: AskToTransfer): Cell { - return builder.messages.in.askToTransfer.encode(message).endCell() -} - -export function transferNotificationBody(message: TransferNotificationForRecipient): Cell { - return builder.messages.out.transferNotificationForRecipient.encode(message).endCell() -} - -export function burnBody(message: BurnMessage): Cell { - const body = beginCell() - .storeUint(opcodes.in.BURN, 32) - .storeUint(message.queryId, 64) - .storeCoins(message.jettonAmount) - .storeAddress(message.responseDestination) - - if (message.customPayload) { - body.storeBit(1).storeRef(message.customPayload) - } else { - body.storeBit(0) - } - - return body.endCell() -} - -export function burnNotificationBody(message: BurnNotificationMessage): Cell { - return beginCell() - .storeUint(opcodes.in.BURN_NOTIFICATION, 32) - .storeUint(message.queryId, 64) - .storeCoins(message.jettonAmount) - .storeAddress(message.burnInitiator) - .storeAddress(message.responseDestination) - .endCell() -} - -export function returnExcessesBody(queryId: bigint): Cell { - return beginCell().storeUint(opcodes.in.EXCESSES, 32).storeUint(queryId, 64).endCell() -} - -export function internalTransferBody(message: InternalTransferMessage): Cell { - const body = beginCell() - .storeUint(opcodes.in.INTERNAL_TRANSFER, 32) - .storeUint(message.queryId, 64) - .storeCoins(message.jettonAmount) - .storeAddress(message.transferInitiator) - .storeAddress(message.responseDestination) - .storeCoins(message.forwardTonAmount ?? 0n) - - const forwardPayload = message.forwardPayload ?? null - const byRef = forwardPayload instanceof Cell - body.storeBit(byRef) - if (byRef) { - body.storeRef(forwardPayload) - } else if (forwardPayload) { - body.storeSlice(forwardPayload) - } - - return body.endCell() -} diff --git a/integration-tests/jetton/jetton_test.go b/integration-tests/jetton/jetton_test.go index e9770f3c8..7b619e180 100644 --- a/integration-tests/jetton/jetton_test.go +++ b/integration-tests/jetton/jetton_test.go @@ -118,17 +118,17 @@ func TestJettonAll(t *testing.T) { t.Logf("Minting jettons to sender contract\n") queryID := rand.Uint64() - sendMintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintMessage{ - QueryID: queryID, - Destination: Sender.Contract.Address, - TonAmount: tlb.MustFromTON("0.05"), - MasterMsg: wallet.InternalTransferMessage{ - QueryID: queryID, - Amount: jettonMintingAmount, - From: setup.deployer.Wallet.WalletAddress(), - ResponseAddress: setup.deployer.Wallet.WalletAddress(), - ForwardTonAmount: tlb.ZeroCoins, - ForwardPayload: nil, + sendMintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintNewJettons{ + QueryID: queryID, + MintRecipient: Sender.Contract.Address, + TonAmount: tlb.MustFromTON("0.05"), + InternalTransferMsg: wallet.InternalTransferStep{ + QueryID: queryID, + JettonAmount: jettonMintingAmount, + TransferInitiator: setup.deployer.Wallet.WalletAddress(), + SendExcessesTo: setup.deployer.Wallet.WalletAddress(), + ForwardTonAmount: tlb.ZeroCoins, + ForwardPayload: nil, }, }, tlb.MustFromTON("0.05")) require.NoError(t, err, "failed to mint jettons") @@ -194,17 +194,17 @@ func TestJettonAll(t *testing.T) { t.Logf("OnrampMock contract deployed at %s\n", onrampMock.Contract.Address.String()) queryID := rand.Uint64() - _, err = setup.common.jettonMinter.CallWaitRecursively(minter.MintMessage{ - QueryID: queryID, - Destination: setup.sender.Contract.Address, - TonAmount: tlb.MustFromTON("0.05"), - MasterMsg: wallet.InternalTransferMessage{ - QueryID: queryID, - Amount: tlb.MustFromTON("1"), - From: setup.common.deployer.Wallet.WalletAddress(), - ResponseAddress: setup.common.deployer.Wallet.WalletAddress(), - ForwardTonAmount: tlb.ZeroCoins, - ForwardPayload: nil, + _, err = setup.common.jettonMinter.CallWaitRecursively(minter.MintNewJettons{ + QueryID: queryID, + MintRecipient: setup.sender.Contract.Address, + TonAmount: tlb.MustFromTON("0.05"), + InternalTransferMsg: wallet.InternalTransferStep{ + QueryID: queryID, + JettonAmount: tlb.MustFromTON("1"), + TransferInitiator: setup.common.deployer.Wallet.WalletAddress(), + SendExcessesTo: setup.common.deployer.Wallet.WalletAddress(), + ForwardTonAmount: tlb.ZeroCoins, + ForwardPayload: nil, }, }, tlb.MustFromTON("0.05")) require.NoError(t, err, "failed to mint additional jettons for onramp tests") @@ -238,17 +238,17 @@ func TestJettonAll(t *testing.T) { t.Logf("SimpleJettonReceiver contract deployed at %s\n", simpleJettonReceiver.Contract.Address.String()) queryID := rand.Uint64() - _, err = setup.common.jettonMinter.CallWaitRecursively(minter.MintMessage{ - QueryID: queryID, - Destination: setup.sender.Contract.Address, - TonAmount: tlb.MustFromTON("0.05"), - MasterMsg: wallet.InternalTransferMessage{ - QueryID: queryID, - Amount: tlb.MustFromTON("1"), - From: setup.common.deployer.Wallet.WalletAddress(), - ResponseAddress: setup.common.deployer.Wallet.WalletAddress(), - ForwardTonAmount: tlb.ZeroCoins, - ForwardPayload: nil, + _, err = setup.common.jettonMinter.CallWaitRecursively(minter.MintNewJettons{ + QueryID: queryID, + MintRecipient: setup.sender.Contract.Address, + TonAmount: tlb.MustFromTON("0.05"), + InternalTransferMsg: wallet.InternalTransferStep{ + QueryID: queryID, + JettonAmount: tlb.MustFromTON("1"), + TransferInitiator: setup.common.deployer.Wallet.WalletAddress(), + SendExcessesTo: setup.common.deployer.Wallet.WalletAddress(), + ForwardTonAmount: tlb.ZeroCoins, + ForwardPayload: nil, }, }, tlb.MustFromTON("0.05")) require.NoError(t, err, "failed to mint additional jettons for receiver tests") @@ -270,17 +270,17 @@ func TestJettonAll(t *testing.T) { t.Logf("Minting jettons to sender contract\n") queryID := rand.Uint64() - sendMintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintMessage{ - QueryID: queryID, - Destination: setup.deployer.Wallet.WalletAddress(), - TonAmount: tlb.MustFromTON("0.05"), - MasterMsg: wallet.InternalTransferMessage{ - QueryID: queryID, - Amount: jettonMintingAmount, - From: setup.deployer.Wallet.WalletAddress(), - ResponseAddress: setup.deployer.Wallet.WalletAddress(), - ForwardTonAmount: tlb.ZeroCoins, - ForwardPayload: nil, + sendMintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintNewJettons{ + QueryID: queryID, + MintRecipient: setup.deployer.Wallet.WalletAddress(), + TonAmount: tlb.MustFromTON("0.05"), + InternalTransferMsg: wallet.InternalTransferStep{ + QueryID: queryID, + JettonAmount: jettonMintingAmount, + TransferInitiator: setup.deployer.Wallet.WalletAddress(), + SendExcessesTo: setup.deployer.Wallet.WalletAddress(), + ForwardTonAmount: tlb.ZeroCoins, + ForwardPayload: nil, }, }, tlb.MustFromTON("0.05")) require.NoError(t, err, "failed to mint jettons") @@ -357,9 +357,9 @@ func TestJettonAll(t *testing.T) { t.Logf("Testing change content\n") const newContentURI = "new_content_uri" newContent := createStringCell(t, newContentURI) - changeContentMsg, err := setup.jettonMinter.CallWaitRecursively(minter.ChangeContentMessage{ - QueryID: rand.Uint64(), - Content: newContent, + changeContentMsg, err := setup.jettonMinter.CallWaitRecursively(minter.ChangeMinterMetadataURI{ + QueryID: rand.Uint64(), + NewMetadataURI: newContent, }, tlb.MustFromTON("0.1")) require.NoError(t, err, "failed to change content") debugger := debug.NewDebuggerTreeTrace(map[string]debug.TypeAndVersion{ @@ -397,17 +397,17 @@ func TestJettonAll(t *testing.T) { jettonAmount := tlb.MustFromTON("0.5") queryID := rand.Uint64() - mintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintMessage{ - QueryID: queryID, - Destination: recipient, - TonAmount: tlb.MustFromTON("0.05"), - MasterMsg: wallet.InternalTransferMessage{ - QueryID: queryID, - Amount: jettonAmount, - From: setup.deployer.Wallet.WalletAddress(), - ResponseAddress: setup.deployer.Wallet.WalletAddress(), - ForwardTonAmount: tlb.ZeroCoins, - ForwardPayload: nil, + mintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintNewJettons{ + QueryID: queryID, + MintRecipient: recipient, + TonAmount: tlb.MustFromTON("0.05"), + InternalTransferMsg: wallet.InternalTransferStep{ + QueryID: queryID, + JettonAmount: jettonAmount, + TransferInitiator: setup.deployer.Wallet.WalletAddress(), + SendExcessesTo: setup.deployer.Wallet.WalletAddress(), + ForwardTonAmount: tlb.ZeroCoins, + ForwardPayload: nil, }, }, tlb.MustFromTON("0.5")) require.NoError(t, err, "failed to mint jettons") @@ -430,9 +430,9 @@ func TestJettonAll(t *testing.T) { t.Parallel() setup := setUpCommon(t) t.Logf("Testing change admin\n") - changeAdminMsg, err := setup.jettonMinter.CallWaitRecursively(minter.ChangeAdminMessage{ - QueryID: rand.Uint64(), - NewAdmin: setup.receiver.Wallet.Address(), + changeAdminMsg, err := setup.jettonMinter.CallWaitRecursively(minter.ChangeMinterAdmin{ + QueryID: rand.Uint64(), + NewAdminAddress: setup.receiver.Wallet.Address(), }, tlb.MustFromTON("0.1")) require.NoError(t, err, "failed to change admin") debugger := debug.NewDebuggerTreeTrace(map[string]debug.TypeAndVersion{ @@ -447,7 +447,7 @@ func TestJettonAll(t *testing.T) { Client: &setup.receiver, } require.NoError(t, err, "failed to open jetton minter as new admin") - claimAdminMsg, err := jettonMinterAsReceiver.CallWaitRecursively(minter.ClaimAdminMessage{QueryID: rand.Uint64()}, tlb.MustFromTON("0.1")) + claimAdminMsg, err := jettonMinterAsReceiver.CallWaitRecursively(minter.ClaimMinterAdmin{QueryID: rand.Uint64()}, tlb.MustFromTON("0.1")) require.NoError(t, err, "failed to claim admin") debugger2 := debug.NewDebuggerTreeTrace(map[string]debug.TypeAndVersion{ setup.receiver.Wallet.Address().String(): {Type: "NewAdmin", Version: *semver.MustParse("0.0.1")}, @@ -467,7 +467,7 @@ func TestJettonAll(t *testing.T) { t.Parallel() setup := setUpCommon(t) t.Logf("Testing drop admin\n") - dropAdminMsg, err := setup.jettonMinter.CallWaitRecursively(minter.DropAdminMessage{ + dropAdminMsg, err := setup.jettonMinter.CallWaitRecursively(minter.DropMinterAdmin{ QueryID: rand.Uint64(), }, tlb.MustFromTON("0.1")) require.NoError(t, err, "failed to drop admin") @@ -488,17 +488,17 @@ func TestJettonAll(t *testing.T) { require.Empty(t, msgToMinter.OutgoingInternalReceivedMessages, "Msg to minter should have no outgoing messages") queryID := rand.Uint64() - mintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintMessage{ - QueryID: queryID, - Destination: setup.receiver.Wallet.Address(), - TonAmount: tlb.MustFromTON("0.05"), - MasterMsg: wallet.InternalTransferMessage{ - QueryID: queryID, - Amount: jettonMintingAmount, - From: setup.deployer.Wallet.WalletAddress(), - ResponseAddress: setup.deployer.Wallet.WalletAddress(), - ForwardTonAmount: tlb.ZeroCoins, - ForwardPayload: nil, + mintMsg, err := setup.jettonMinter.CallWaitRecursively(minter.MintNewJettons{ + QueryID: queryID, + MintRecipient: setup.receiver.Wallet.Address(), + TonAmount: tlb.MustFromTON("0.05"), + InternalTransferMsg: wallet.InternalTransferStep{ + QueryID: queryID, + JettonAmount: jettonMintingAmount, + TransferInitiator: setup.deployer.Wallet.WalletAddress(), + SendExcessesTo: setup.deployer.Wallet.WalletAddress(), + ForwardTonAmount: tlb.ZeroCoins, + ForwardPayload: nil, }, }, tlb.MustFromTON("0.05")) require.NoError(t, err, "failed to mint jettons after admin drop") @@ -555,7 +555,7 @@ func TestJettonAll(t *testing.T) { require.NoError(t, err, "failed to deploy JettonWallet contract") jettonWalletInitCell, err := wallet.NewWalletProvider(setup.common.jettonMinter.Address).GetWalletInitCell(setup.common.receiver.Wallet.Address()) require.NoError(t, err, "failed to get JettonWallet init cell") - msg, err := tlb.ToCell(jetton_common.TopUpMessage{QueryID: rand.Uint64()}) + msg, err := tlb.ToCell(jetton_common.TopUpTons{}) require.NoError(t, err, "failed to create top-up message") receiverJettonWallet, deployMsg, err := wrappers.Deploy(t.Context(), &setup.common.receiver, jettonWalletCode, jettonWalletInitCell, tlb.MustFromTON("0.1"), msg) require.NoError(t, err, "failed to deploy JettonWallet contract") @@ -804,7 +804,7 @@ func TestJettonAll(t *testing.T) { setup := setupJettonWallet(t) t.Logf("Deploying JettonWallet contract\n") - msg, err := tlb.ToCell(jetton_common.TopUpMessage{QueryID: rand.Uint64()}) + msg, err := tlb.ToCell(jetton_common.TopUpTons{}) require.NoError(t, err, "failed to create top-up message") jettonWalletCode, err := wallet.Code() require.NoError(t, err, "failed to deploy JettonWallet contract") @@ -869,7 +869,7 @@ func TestJettonAll(t *testing.T) { require.NoError(t, err, "failed to deploy JettonWallet contract") jettonWalletInitCell, err := wallet.NewWalletProvider(setup.common.jettonMinter.Address).GetWalletInitCell(setup.common.deployer.Wallet.Address()) require.NoError(t, err, "failed to get JettonWallet init cell") - msg, err := tlb.ToCell(jetton_common.TopUpMessage{QueryID: rand.Uint64()}) + msg, err := tlb.ToCell(jetton_common.TopUpTons{}) require.NoError(t, err, "failed to create top-up message") deployerJettonWallet, deployMsg, err := wrappers.Deploy(t.Context(), &setup.common.deployer, jettonWalletCode, jettonWalletInitCell, tlb.MustFromTON("0.1"), msg) require.NoError(t, err, "failed to deploy JettonWallet contract") @@ -929,7 +929,7 @@ func TestJettonAll(t *testing.T) { require.NoError(t, err, "failed to deploy JettonWallet contract") jettonWalletInitCell, err := wallet.NewWalletProvider(setup.common.jettonMinter.Address).GetWalletInitCell(setup.common.deployer.Wallet.Address()) require.NoError(t, err, "failed to get JettonWallet init cell") - msg, err := tlb.ToCell(jetton_common.TopUpMessage{QueryID: rand.Uint64()}) + msg, err := tlb.ToCell(jetton_common.TopUpTons{}) require.NoError(t, err, "failed to create top-up message") jettonWallet, deployMsg, err := wrappers.Deploy(t.Context(), &setup.common.deployer, jettonWalletCode, jettonWalletInitCell, tlb.MustFromTON("0.1"), msg) require.NoError(t, err, "failed to deploy JettonWallet contract") @@ -978,9 +978,7 @@ func TestJettonAll(t *testing.T) { func DeployMinter(t *testing.T, deployer *tracetracking.SignedAPIClient, initData minter.InitData) *wrappers.Contract { minterCode, err := minter.Code() require.NoError(t, err, "failed to load JettonMinter code") - topUpMsg, err := tlb.ToCell(jetton_common.TopUpMessage{ - QueryID: rand.Uint64(), - }) + topUpMsg, err := tlb.ToCell(jetton_common.TopUpTons{}) require.NoError(t, err, "failed to create TopUp message") minterInitCell, err := tlb.ToCell(initData) require.NoError(t, err, "failed to create JettonMinter init data cell") diff --git a/pkg/bindings/jetton/common.go b/pkg/bindings/jetton/common.go index 742df3703..6e44923d4 100644 --- a/pkg/bindings/jetton/common.go +++ b/pkg/bindings/jetton/common.go @@ -33,12 +33,11 @@ const ( ErrorWrongWorkchain ExitCode = 333 ) -// For funding the contract with TON -type TopUpMessage struct { - _ tlb.Magic `tlb:"#d372158c" json:"-"` //nolint:revive // (opcode) should stay uninitialized - QueryID uint64 `tlb:"## 64"` +// For funding the contract with TON. +type TopUpTons struct { + _ tlb.Magic `tlb:"#d372158c" json:"-"` //nolint:revive // (opcode) should stay uninitialized } var TLBs = tvm.MustNewTLBMap([]any{ - TopUpMessage{}, + TopUpTons{}, }) diff --git a/pkg/bindings/jetton/minter/minter.go b/pkg/bindings/jetton/minter/minter.go index cdcd1f15d..0472db2d0 100644 --- a/pkg/bindings/jetton/minter/minter.go +++ b/pkg/bindings/jetton/minter/minter.go @@ -35,46 +35,62 @@ func Code() (*cell.Cell, error) { // JettonMinter opcodes const ( - OpcodeMinterMint = 0x642b7d07 - OpcodeMinterChangeAdmin = 0x6501f354 - OpcodeMinterClaimAdmin = 0xfb88e119 - OpcodeMinterDropAdmin = 0x7431f221 - OpcodeMinterBurnNotification = 0x7bdd97de - OpcodeMinterChangeMetadataURL = 0xcb862902 - OpcodeWalletBurnNotification = 0x7bdd97de + OpcodeMintNewJettons = 0x642b7d07 + OpcodeRequestWalletAddress = 0x2c76b973 + OpcodeResponseWalletAddress = 0xd1735400 + OpcodeChangeMinterAdmin = 0x6501f354 + OpcodeClaimMinterAdmin = 0xfb88e119 + OpcodeDropMinterAdmin = 0x7431f221 + OpcodeBurnNotificationForMinter = 0x7bdd97de + OpcodeChangeMinterMetadataURI = 0xcb862902 + OpcodeUpgradeMinterCode = 0x2508d66a ) -type MintMessage struct { - _ tlb.Magic `tlb:"#642b7d07" json:"-"` //nolint:revive // (opcode) should stay uninitialized - QueryID uint64 `tlb:"## 64"` - Destination *address.Address `tlb:"addr"` - TonAmount tlb.Coins `tlb:"."` - MasterMsg wallet.InternalTransferMessage `tlb:"^"` +type MintNewJettons struct { + _ tlb.Magic `tlb:"#642b7d07" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + MintRecipient *address.Address `tlb:"addr"` + TonAmount tlb.Coins `tlb:"."` + InternalTransferMsg wallet.InternalTransferStep `tlb:"^"` } -type ChangeAdminMessage struct { - _ tlb.Magic `tlb:"#6501f354" json:"-"` //nolint:revive // (opcode) should stay uninitialized - QueryID uint64 `tlb:"## 64"` - NewAdmin *address.Address `tlb:"addr"` +type RequestWalletAddress struct { + _ tlb.Magic `tlb:"#2c76b973" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + OwnerAddress *address.Address `tlb:"addr"` + IncludeOwnerAddress bool `tlb:"bool"` } -type ClaimAdminMessage struct { +type ResponseWalletAddress struct { + _ tlb.Magic `tlb:"#d1735400" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + JettonWalletAddress *address.Address `tlb:"addr"` + OwnerAddress *cell.Cell `tlb:"maybe ^"` +} + +type ChangeMinterAdmin struct { + _ tlb.Magic `tlb:"#6501f354" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + NewAdminAddress *address.Address `tlb:"addr"` +} + +type ClaimMinterAdmin struct { _ tlb.Magic `tlb:"#fb88e119" json:"-"` //nolint:revive // (opcode) should stay uninitialized QueryID uint64 `tlb:"## 64"` } -type DropAdminMessage struct { +type DropMinterAdmin struct { _ tlb.Magic `tlb:"#7431f221" json:"-"` //nolint:revive // (opcode) should stay uninitialized QueryID uint64 `tlb:"## 64"` } -type ChangeContentMessage struct { - _ tlb.Magic `tlb:"#cb862902" json:"-"` //nolint:revive // (opcode) should stay uninitialized - QueryID uint64 `tlb:"## 64"` - Content *cell.Cell `tlb:"^"` +type ChangeMinterMetadataURI struct { + _ tlb.Magic `tlb:"#cb862902" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + NewMetadataURI *cell.Cell `tlb:"^"` } -type UpgradeMessage struct { +type UpgradeMinterCode struct { _ tlb.Magic `tlb:"#2508d66a" json:"-"` //nolint:revive // (opcode) should stay uninitialized QueryID uint64 `tlb:"## 64"` NewData *cell.Cell `tlb:"^"` @@ -82,10 +98,14 @@ type UpgradeMessage struct { } var TLBs = tvm.MustNewTLBMap([]any{ - MintMessage{}, - ChangeAdminMessage{}, - ClaimAdminMessage{}, - DropAdminMessage{}, - ChangeContentMessage{}, - UpgradeMessage{}, + MintNewJettons{}, + RequestWalletAddress{}, + ResponseWalletAddress{}, + ChangeMinterAdmin{}, + ClaimMinterAdmin{}, + DropMinterAdmin{}, + ChangeMinterMetadataURI{}, + UpgradeMinterCode{}, + jetton.TopUpTons{}, + wallet.BurnNotificationForMinter{}, }).MustWithStorageType(InitData{}) diff --git a/pkg/bindings/jetton/wallet/wallet.go b/pkg/bindings/jetton/wallet/wallet.go index 5ed9165c9..f2c235668 100644 --- a/pkg/bindings/jetton/wallet/wallet.go +++ b/pkg/bindings/jetton/wallet/wallet.go @@ -15,11 +15,12 @@ import ( // JettonWallet opcodes const ( - OpcodeWalletTransfer = 0x0f8a7ea5 - OpcodeWalletTransferNotification = 0x7362d09c - OpcodeWalletInternalTransfer = 0x178d4519 - OpcodeWalletExcesses = 0xd53276db - OpcodeWalletBurn = 0x595f07bc + OpcodeAskToTransfer = 0x0f8a7ea5 + OpcodeTransferNotificationForRecipient = 0x7362d09c + OpcodeInternalTransferStep = 0x178d4519 + OpcodeReturnExcessesBack = 0xd53276db + OpcodeAskToBurn = 0x595f07bc + OpcodeBurnNotificationForMinter = 0x7bdd97de ) //go:generate go run golang.org/x/tools/cmd/stringer@v0.38.0 -type=ExitCode @@ -42,38 +43,63 @@ const ( ) type AskToTransfer struct { - _ tlb.Magic `tlb:"#0f8a7ea5" json:"-"` //nolint:revive // (opcode) should stay uninitialized - QueryID uint64 `tlb:"## 64"` - Amount tlb.Coins `tlb:"."` - Destination *address.Address `tlb:"addr"` - ResponseDestination *address.Address `tlb:"addr"` - CustomPayload *cell.Cell `tlb:"either . ^"` - ForwardTonAmount tlb.Coins `tlb:"."` - ForwardPayload *cell.Cell `tlb:"either . ^"` + _ tlb.Magic `tlb:"#0f8a7ea5" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + JettonAmount tlb.Coins `tlb:"."` + TransferRecipient *address.Address `tlb:"addr"` + SendExcessesTo *address.Address `tlb:"addr"` + CustomPayload *cell.Cell `tlb:"either . ^"` + ForwardTonAmount tlb.Coins `tlb:"."` + ForwardPayload *cell.Cell `tlb:"either . ^"` } -type InternalTransferMessage struct { - _ tlb.Magic `tlb:"#178d4519" json:"-"` //nolint:revive // (opcode) should stay uninitialized - QueryID uint64 `tlb:"## 64"` - Amount tlb.Coins `tlb:"."` - From *address.Address `tlb:"addr"` - ResponseAddress *address.Address `tlb:"addr"` - ForwardTonAmount tlb.Coins `tlb:"."` - ForwardPayload *cell.Cell `tlb:"either . ^"` +type AskToBurn struct { + _ tlb.Magic `tlb:"#595f07bc" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + JettonAmount tlb.Coins `tlb:"."` + SendExcessesTo *address.Address `tlb:"addr"` + CustomPayload *cell.Cell `tlb:"maybe ^"` +} + +type InternalTransferStep struct { + _ tlb.Magic `tlb:"#178d4519" json:"-"` //nolint:revive // (opcode) should stay uninitialized + QueryID uint64 `tlb:"## 64"` + JettonAmount tlb.Coins `tlb:"."` + TransferInitiator *address.Address `tlb:"addr"` + SendExcessesTo *address.Address `tlb:"addr"` + ForwardTonAmount tlb.Coins `tlb:"."` + ForwardPayload *cell.Cell `tlb:"either . ^"` } -type TransferNotification struct { - _ tlb.Magic `tlb:"#7362d09c" json:"-"` //nolint:revive // Ignore opcode tag +type TransferNotificationForRecipient struct { + _ tlb.Magic `tlb:"#7362d09c" json:"-"` //nolint:revive // Ignore opcode tag + QueryID uint64 `tlb:"## 64"` + JettonAmount tlb.Coins `tlb:"."` + TransferInitiator *address.Address `tlb:"addr"` + ForwardPayload *cell.Cell `tlb:"maybe ^"` +} + +type BurnNotificationForMinter struct { + _ tlb.Magic `tlb:"#7bdd97de" json:"-"` //nolint:revive // Ignore opcode tag QueryID uint64 `tlb:"## 64"` - Amount tlb.Coins `tlb:"^"` - Sender *address.Address `tlb:"addr"` - ForwardPayload *cell.Cell `tlb:"maybe ^"` + JettonAmount tlb.Coins `tlb:"."` + BurnInitiator *address.Address `tlb:"addr"` + SendExcessesTo *address.Address `tlb:"addr"` +} + +type ReturnExcessesBack struct { + _ tlb.Magic `tlb:"#d53276db" json:"-"` //nolint:revive // Ignore opcode tag + QueryID uint64 `tlb:"## 64"` } var TLBs = tvm.MustNewTLBMap([]any{ AskToTransfer{}, - InternalTransferMessage{}, - TransferNotification{}, + AskToBurn{}, + InternalTransferStep{}, + TransferNotificationForRecipient{}, + BurnNotificationForMinter{}, + ReturnExcessesBack{}, + jetton.TopUpTons{}, }).MustWithStorageType(InitData{}) var WalletContractPath = path.Join(jetton.PathToContracts, "JettonWallet.compiled.json") diff --git a/pkg/ton/codec/decoder_test.go b/pkg/ton/codec/decoder_test.go index 442bdacdf..ca1c6e6a3 100644 --- a/pkg/ton/codec/decoder_test.go +++ b/pkg/ton/codec/decoder_test.go @@ -84,9 +84,9 @@ var testMCMSExecuteCell = mustToCell(mcms.Execute{ Target: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8"), Value: tlb.MustFromTON("1.5"), Data: mustToCell(wallet.AskToTransfer{ - QueryID: 0, - Amount: tlb.MustFromTON("0.02"), - Destination: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8"), + QueryID: 0, + JettonAmount: tlb.MustFromTON("0.02"), + TransferRecipient: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8"), // CustomPayload: tvm.EmptyCell, // default for *cell.Cell ForwardPayload: mustToCell(Foo{Any: mustToCell(Baz{Val: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8")})}), ForwardTonAmount: tlb.MustFromTON("0.01"), @@ -199,26 +199,26 @@ func TestDecodeJSONMapFromCell(t *testing.T) { { name: "Decode Jetton AskToTransfer with Foo in ForwardPayload", cell: mustToCell(wallet.AskToTransfer{ - QueryID: 0, - Amount: tlb.MustFromTON("0.02"), - Destination: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8"), + QueryID: 0, + JettonAmount: tlb.MustFromTON("0.02"), + TransferRecipient: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8"), // CustomPayload: tvm.EmptyCell, // default for *cell.Cell ForwardPayload: mustToCell(Foo{Any: mustToCell(Baz{Val: address.MustParseAddr("EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8")})}), ForwardTonAmount: tlb.MustFromTON("0.01"), }), wantType: "AskToTransfer", wantMap: map[string]any{ - "QueryID": float64(0), - "Amount": "20000000", - "Destination": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", - "CustomPayload": "te6cckEBAgEAMwABDzmJaAAAAAAMAQBLAAAAA4AAbW63Q2k6USavDXT1yIHGz6nqGKQk7fyzwdLldq2YG1B7fNdk", + "QueryID": float64(0), + "JettonAmount": "20000000", + "TransferRecipient": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", + "CustomPayload": "te6cckEBAgEAMwABDzmJaAAAAAAMAQBLAAAAA4AAbW63Q2k6USavDXT1yIHGz6nqGKQk7fyzwdLldq2YG1B7fNdk", "ForwardPayload": map[string]any{ "Any": map[string]any{ "Val": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", }, }, - "ForwardTonAmount": "10000000", - "ResponseDestination": "NONE", + "ForwardTonAmount": "10000000", + "SendExcessesTo": "NONE", }, expectErr: false, }, @@ -255,17 +255,17 @@ func TestDecodeJSONMapFromCell(t *testing.T) { "Target": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", "Value": "1500000000", "Data": map[string]any{ - "QueryID": float64(0), - "Amount": "20000000", - "Destination": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", - "CustomPayload": "te6cckEBAgEAMwABDzmJaAAAAAAMAQBLAAAAA4AAbW63Q2k6USavDXT1yIHGz6nqGKQk7fyzwdLldq2YG1B7fNdk", + "QueryID": float64(0), + "JettonAmount": "20000000", + "TransferRecipient": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", + "CustomPayload": "te6cckEBAgEAMwABDzmJaAAAAAAMAQBLAAAAA4AAbW63Q2k6USavDXT1yIHGz6nqGKQk7fyzwdLldq2YG1B7fNdk", "ForwardPayload": map[string]any{ "Any": map[string]any{ "Val": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", }, }, - "ForwardTonAmount": "10000000", - "ResponseDestination": "NONE", + "ForwardTonAmount": "10000000", + "SendExcessesTo": "NONE", }, }, }, @@ -357,17 +357,17 @@ func TestDecodeJSONMapFromCellIteratively(t *testing.T) { "Target": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", "Value": "1500000000", "Data": map[string]any{ - "QueryID": float64(0), - "Amount": "20000000", - "Destination": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", - "CustomPayload": "te6cckEBAgEAMwABDzmJaAAAAAAMAQBLAAAAA4AAbW63Q2k6USavDXT1yIHGz6nqGKQk7fyzwdLldq2YG1B7fNdk", + "QueryID": float64(0), + "JettonAmount": "20000000", + "TransferRecipient": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", + "CustomPayload": "te6cckEBAgEAMwABDzmJaAAAAAAMAQBLAAAAA4AAbW63Q2k6USavDXT1yIHGz6nqGKQk7fyzwdLldq2YG1B7fNdk", "ForwardPayload": map[string]any{ "Any": map[string]any{ "Val": "EQADa3W6G0nSiTV4a6euRA42fU9QxSEnb-WeDpcrtWzA2jM8", }, }, - "ForwardTonAmount": "10000000", - "ResponseDestination": "NONE", + "ForwardTonAmount": "10000000", + "SendExcessesTo": "NONE", }, }, }, diff --git a/pkg/ton/wrappers/contract.go b/pkg/ton/wrappers/contract.go index c9cbbc6df..93386a747 100644 --- a/pkg/ton/wrappers/contract.go +++ b/pkg/ton/wrappers/contract.go @@ -204,7 +204,7 @@ func (c tolkCompiledContract) codeCell() (*cell.Cell, error) { // // ``` // -// msg, err := tlb.ToCell(jetton_wrappers.TopUpMessage{QueryID: rand.Uint64()}) +// msg, err := tlb.ToCell(jetton_wrappers.TopUpTons{}) // // require.NoError(t, err, "failed to create top-up message") // From a01efad912fdd8ffd2d79f5a417156808abf5d89 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Wed, 27 May 2026 15:42:25 +0200 Subject: [PATCH 25/32] Remove unused errors, fmt --- contracts/contracts/lib/jetton/errors.tolk | 2 -- contracts/tests/gas-report/wton/wton.spec.ts | 5 +--- contracts/wrappers/jetton/JettonMinter.ts | 24 ++++++++------------ contracts/wrappers/jetton/JettonWallet.ts | 9 ++------ 4 files changed, 13 insertions(+), 27 deletions(-) diff --git a/contracts/contracts/lib/jetton/errors.tolk b/contracts/contracts/lib/jetton/errors.tolk index 5701a4867..377949632 100644 --- a/contracts/contracts/lib/jetton/errors.tolk +++ b/contracts/contracts/lib/jetton/errors.tolk @@ -1,10 +1,8 @@ // SPDX-License-Identifier: MIT const ERROR_INVALID_OP = 72 -const ERROR_WRONG_OP = 0xffff const ERROR_NOT_OWNER = 73 const ERROR_NOT_VALID_WALLET = 74 const ERROR_WRONG_WORKCHAIN = 333 const ERROR_BALANCE_ERROR = 47 const ERROR_NOT_ENOUGH_GAS = 48 -const ERROR_INVALID_MESSAGE = 49 diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index cde5d5f43..eb4d4cce7 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -7,10 +7,7 @@ import { Address, beginCell, Cell, toNano } from '@ton/core' import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox' import { JettonMinter, builder as minterBuilder } from '../../../wrappers/jetton/JettonMinter' -import { - JettonWallet, - builder as walletBuilder, -} from '../../../wrappers/jetton/JettonWallet' +import { JettonWallet, builder as walletBuilder } from '../../../wrappers/jetton/JettonWallet' import { WTON_MINT_OPCODE } from '../../../wrappers/wton' const JETTON_DATA_URI = 'wton.gas' diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index 6ed7a3659..bdc7a49be 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -172,14 +172,18 @@ export const builder = { .storeUint(data.queryId, 64) .storeAddress(data.destination) .storeCoins(data.tonAmount) - .storeRef(builder.messages.out.internalTransferStep.encode(toInternalTransferStep(data))) + .storeRef( + builder.messages.out.internalTransferStep.encode(toInternalTransferStep(data)), + ) }, load: (src: Slice): MintNewJettons => { src.skip(32) const queryId = src.loadUintBig(64) const destination = src.loadAddress() const tonAmount = src.loadCoins() - const internalTransfer = builder.messages.out.internalTransferStep.load(src.loadRef().beginParse()) + const internalTransfer = builder.messages.out.internalTransferStep.load( + src.loadRef().beginParse(), + ) return { queryId, @@ -233,9 +237,7 @@ export const builder = { claimMinterAdmin: ((): CellCodec => { return { encode: (data: ClaimMinterAdmin): Builder => { - return beginCell() - .storeUint(MinterOpcodes.CLAIM_ADMIN, 32) - .storeUint(data.queryId, 64) + return beginCell().storeUint(MinterOpcodes.CLAIM_ADMIN, 32).storeUint(data.queryId, 64) }, load: (src: Slice): ClaimMinterAdmin => { src.skip(32) @@ -246,9 +248,7 @@ export const builder = { dropMinterAdmin: ((): CellCodec => { return { encode: (data: DropMinterAdmin): Builder => { - return beginCell() - .storeUint(MinterOpcodes.DROP_ADMIN, 32) - .storeUint(data.queryId, 64) + return beginCell().storeUint(MinterOpcodes.DROP_ADMIN, 32).storeUint(data.queryId, 64) }, load: (src: Slice): DropMinterAdmin => { src.skip(32) @@ -443,9 +443,7 @@ export class JettonMinter implements Contract { await provider.internal(via, { value: opts.value ?? toNano('0.1'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: builder.messages.in.claimMinterAdmin - .encode({ queryId: opts.queryId ?? 0n }) - .asCell(), + body: builder.messages.in.claimMinterAdmin.encode({ queryId: opts.queryId ?? 0n }).asCell(), }) } @@ -460,9 +458,7 @@ export class JettonMinter implements Contract { await provider.internal(via, { value: opts.value ?? toNano('0.05'), sendMode: SendMode.PAY_GAS_SEPARATELY, - body: builder.messages.in.dropMinterAdmin - .encode({ queryId: opts.queryId ?? 0n }) - .asCell(), + body: builder.messages.in.dropMinterAdmin.encode({ queryId: opts.queryId ?? 0n }).asCell(), }) } diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index d5e14f175..3702308cf 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -342,9 +342,7 @@ export const builder = { } const withdrawTons: CellCodec = { encode: function (data: WithdrawTonsMessage): Builder { - return beginCell() - .storeUint(opcodes.in.WITHDRAW_TONS, 32) - .storeUint(data.queryId, 64) + return beginCell().storeUint(opcodes.in.WITHDRAW_TONS, 32).storeUint(data.queryId, 64) }, load: function (src: Slice): WithdrawTonsMessage { const op = src.loadUint(32) @@ -418,9 +416,7 @@ export const builder = { load: function (src: Slice): BurnNotificationForMinter { const op = src.loadUint(32) if (op !== opcodes.in.BURN_NOTIFICATION) { - throw new Error( - `Invalid opcode, expected ${opcodes.in.BURN_NOTIFICATION}, got ${op}`, - ) + throw new Error(`Invalid opcode, expected ${opcodes.in.BURN_NOTIFICATION}, got ${op}`) } return { queryId: src.loadUintBig(64), @@ -488,4 +484,3 @@ export const builder = { })(), }, } - From b7aa74c5708a57c01e12f91ce636cae32cd7c421 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 28 May 2026 13:37:17 +0200 Subject: [PATCH 26/32] Avoid minting to minter addr --- contracts/contracts/wton/JettonMinter.tolk | 3 ++ contracts/contracts/wton/fees-management.tolk | 2 +- contracts/tests/wton/wton.spec.ts | 30 ++++++++++++ contracts/wrappers/wton/errors.ts | 1 + contracts/wton-gas-report.json | 48 +++++++++---------- 5 files changed, 59 insertions(+), 25 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 6e2cf9c73..63a7f3170 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -26,6 +26,7 @@ type AllowedMessageToMinter = const ERROR_ALREADY_INITIALIZED = 75 const ERROR_UNSUFFICIENT_AMOUNT = 76 const ERROR_INVALID_EXCESSES_DESTINATION = 77 +const ERROR_INVALID_RECIPIENT = 78 fun reserveModeExactFail() { return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL; @@ -126,6 +127,7 @@ fun onInternalMessage(in: InMessage) { MintNewJettons => { var storage = lazy MinterStorage.load(); assert (msg.mintRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; + assert (msg.mintRecipient != contract.getAddress()) throw ERROR_INVALID_RECIPIENT; val internalTransferMsg = lazy msg.internalTransferMsg.load({ throwIfOpcodeDoesNotMatch: ERROR_INVALID_OP @@ -137,6 +139,7 @@ fun onInternalMessage(in: InMessage) { // Mint must name the same excess/refund destination for both the happy path and a bounced wallet deployment. assert (internalTransferMsg.sendExcessesTo != null) throw ERROR_INVALID_EXCESSES_DESTINATION; assert (internalTransferMsg.sendExcessesTo!.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; + assert (internalTransferMsg.sendExcessesTo! != contract.getAddress()) throw ERROR_INVALID_RECIPIENT; // Minting must not impersonate a peer wallet transfer initiator. assert (internalTransferMsg.transferInitiator == null) throw ERROR_INVALID_OP; // The caller must fund the hosted TON backing, the extra transfer budget, and the diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index 593939c1f..2448b424a 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -35,7 +35,7 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years // GAS_CONSUMPTION_JettonReceive is calibrated against the max live receive branch with both // recipient notification and excess handling enabled. -const GAS_CONSUMPTION_JettonTransfer = 7124 +const GAS_CONSUMPTION_JettonTransfer = 7332 const GAS_CONSUMPTION_JettonReceive = 7295 const GAS_CONSUMPTION_BurnRequest = 5613 const GAS_CONSUMPTION_BurnNotification = 4558 diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index bc57121e7..1e8fc14a3 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -17,6 +17,7 @@ import { import { ERROR_ALREADY_INITIALIZED, ERROR_INVALID_EXCESSES_DESTINATION, + ERROR_INVALID_RECIPIENT, WTON_MINT_OPCODE, } from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' @@ -492,6 +493,35 @@ describe('wTON', () => { expect((await minter.getJettonData()).totalSupply).toEqual(0n) }) + it('rejects mint messages whose recipient is the minter itself', async () => { + const { result } = await sendMint({ + destination: minter.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_INVALID_RECIPIENT, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + }) + + it('rejects mint messages whose refund destination is the minter itself', async () => { + const { result } = await sendMint({ + destination: alice.address, + responseDestination: minter.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: minter.address, + success: false, + exitCode: ERROR_INVALID_RECIPIENT, + }) + expect((await minter.getJettonData()).totalSupply).toEqual(0n) + }) + it('rejects mint messages that spoof a transfer initiator', async () => { const { result } = await sendMint({ destination: alice.address, diff --git a/contracts/wrappers/wton/errors.ts b/contracts/wrappers/wton/errors.ts index 0a9052a1e..17edfaa6f 100644 --- a/contracts/wrappers/wton/errors.ts +++ b/contracts/wrappers/wton/errors.ts @@ -1,3 +1,4 @@ export const ERROR_ALREADY_INITIALIZED = 75 export const ERROR_UNSUFFICIENT_AMOUNT = 76 export const ERROR_INVALID_EXCESSES_DESTINATION = 77 +export const ERROR_INVALID_RECIPIENT = 78 diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index 17080d6c5..7bcb62ff2 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-25T14:15:19.824Z", + "createdAt": "2026-05-28T11:04:53.562Z", "result": { "JettonMinter": { "sendTopUpTons": { @@ -11,11 +11,11 @@ }, "cells": { "kind": "init", - "value": "29" + "value": "31" }, "bits": { "kind": "init", - "value": "12967" + "value": "13375" } }, "0xd372158c": { @@ -25,11 +25,11 @@ }, "cells": { "kind": "init", - "value": "29" + "value": "31" }, "bits": { "kind": "init", - "value": "12967" + "value": "13375" } } }, @@ -51,7 +51,7 @@ "0x15": { "gasUsed": { "kind": "init", - "value": "6724" + "value": "7332" }, "cells": { "kind": "init", @@ -113,11 +113,11 @@ }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "0xf8a7ea5": { @@ -127,11 +127,11 @@ }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "0x178d4519": { @@ -141,11 +141,11 @@ }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "0x7362d09c": { @@ -155,11 +155,11 @@ }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "0xd53276db": { @@ -169,11 +169,11 @@ }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "sendBurn": { @@ -183,39 +183,39 @@ }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "0x595f07bc": { "gasUsed": { "kind": "init", - "value": "5397" + "value": "5613" }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } }, "0x7bdd97de": { "gasUsed": { "kind": "init", - "value": "4462" + "value": "4558" }, "cells": { "kind": "init", - "value": "15" + "value": "16" }, "bits": { "kind": "init", - "value": "6781" + "value": "6845" } } } From 7951ea491d6a47580997d7318ea4de51865b2956 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 28 May 2026 13:49:58 +0200 Subject: [PATCH 27/32] rm admin requirement from bindings/tests - wTON has no admin --- contracts/tests/gas-report/wton/wton.spec.ts | 2 +- contracts/tests/wton/wton.spec.ts | 17 ++++++++++++++++- contracts/wrappers/jetton/JettonMinter.ts | 4 ++-- contracts/wton-gas-report.json | 6 +++--- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index eb4d4cce7..404475daf 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -148,7 +148,7 @@ describe('wTON gas calibration', () => { minter = blockchain.openContract( JettonMinter.createFromConfig( { - admin: deployer.address, + admin: null, transferAdmin: null, walletCode, jettonContent: content, diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 1e8fc14a3..09fdb8584 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -62,7 +62,8 @@ describe('wTON', () => { const contract = blockchain.openContract( JettonMinter.createFromConfig( { - admin: deployer.address, + // wTON has no admin runtime path; deploy storage matches the get_jetton_data null admin. + admin: null, transferAdmin: null, walletCode: customWalletCode, jettonContent: content, @@ -282,6 +283,20 @@ describe('wTON', () => { expect(data.jettonWalletCode.equals(walletCode)).toBe(true) }) + it('deploys with admin and transferAdmin set to null in raw storage', async () => { + const contract = await blockchain.getContract(minter.address) + const accountState = contract.accountState + expect(accountState?.type).toEqual('active') + if (accountState?.type !== 'active') { + throw new Error('Minter account is not active') + } + const dataCell = accountState.state.data + expect(dataCell).toBeDefined() + const storage = minterBuilder.data.contractData.load(dataCell!.beginParse()) + expect(storage.admin).toBeNull() + expect(storage.transferAdmin).toBeNull() + }) + it('completes a mint-transfer-burn lifecycle', async () => { const minted = toNano('2') const transferred = toNano('0.75') diff --git a/contracts/wrappers/jetton/JettonMinter.ts b/contracts/wrappers/jetton/JettonMinter.ts index bdc7a49be..a9cb3d995 100644 --- a/contracts/wrappers/jetton/JettonMinter.ts +++ b/contracts/wrappers/jetton/JettonMinter.ts @@ -30,7 +30,7 @@ export type JettonMinterData = { export type JettonMinterConfig = { totalSupply: bigint - admin: Address + admin: Maybe
walletCode: Cell jettonContent: Cell | JettonMinterContent transferAdmin: Maybe
@@ -110,7 +110,7 @@ function contentToCell(content: Cell | JettonMinterContent): Cell { function toContractData(config: JettonMinterConfig): JettonMinterData { return { totalSupply: config.totalSupply, - admin: config.admin, + admin: config.admin ?? null, transferAdmin: config.transferAdmin, walletCode: config.walletCode, jettonContent: contentToCell(config.jettonContent), diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index 7bcb62ff2..ca7160676 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-28T11:04:53.562Z", + "createdAt": "2026-05-28T11:45:06.923Z", "result": { "JettonMinter": { "sendTopUpTons": { @@ -15,7 +15,7 @@ }, "bits": { "kind": "init", - "value": "13375" + "value": "13110" } }, "0xd372158c": { @@ -29,7 +29,7 @@ }, "bits": { "kind": "init", - "value": "13375" + "value": "13110" } } }, From dd85e208a15f7a3a90107829e039a6a4f939bd36 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 28 May 2026 14:00:16 +0200 Subject: [PATCH 28/32] Remove ChangeMinterMetadataUri and custom error - throw 0xffff --- contracts/contracts/wton/JettonMinter.tolk | 7 ------- contracts/tests/wton/wton.spec.ts | 5 ++--- contracts/wrappers/wton/errors.ts | 1 - contracts/wton-gas-report.json | 8 ++++---- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index 63a7f3170..c79dd1d3c 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -20,10 +20,8 @@ type AllowedMessageToMinter = | MintNewJettons | BurnNotificationForMinter | RequestWalletAddress - | ChangeMinterMetadataUri | TopUpTons -const ERROR_ALREADY_INITIALIZED = 75 const ERROR_UNSUFFICIENT_AMOUNT = 76 const ERROR_INVALID_EXCESSES_DESTINATION = 77 const ERROR_INVALID_RECIPIENT = 78 @@ -168,11 +166,6 @@ fun onInternalMessage(in: InMessage) { deployMsg.send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL); } - ChangeMinterMetadataUri => { - // wTON has no admin path, so metadata is immutable and this opcode throws - throw ERROR_ALREADY_INITIALIZED; - } - TopUpTons => { // Accept TONs } diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 09fdb8584..9834aa58c 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -15,7 +15,6 @@ import { opcodes as walletOpcodes, } from '../../wrappers/jetton/JettonWallet' import { - ERROR_ALREADY_INITIALIZED, ERROR_INVALID_EXCESSES_DESTINATION, ERROR_INVALID_RECIPIENT, WTON_MINT_OPCODE, @@ -768,7 +767,7 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(0n) }) - it('rejects metadata changes because wTON metadata is immutable', async () => { + it('rejects metadata changes because wTON has no admin opcode surface', async () => { const dataBefore = await minter.getJettonData() const result = await minter.sendChangeContent(deployer.getSender(), { message: { @@ -781,7 +780,7 @@ describe('wTON', () => { from: deployer.address, to: minter.address, success: false, - exitCode: ERROR_ALREADY_INITIALIZED, + exitCode: JettonErrorCodes.WRONG_OP, }) expect((await minter.getJettonData()).jettonContent.equals(dataBefore.jettonContent)).toBe( true, diff --git a/contracts/wrappers/wton/errors.ts b/contracts/wrappers/wton/errors.ts index 17edfaa6f..16de7d5a3 100644 --- a/contracts/wrappers/wton/errors.ts +++ b/contracts/wrappers/wton/errors.ts @@ -1,4 +1,3 @@ -export const ERROR_ALREADY_INITIALIZED = 75 export const ERROR_UNSUFFICIENT_AMOUNT = 76 export const ERROR_INVALID_EXCESSES_DESTINATION = 77 export const ERROR_INVALID_RECIPIENT = 78 diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index ca7160676..83bc5dcf0 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-28T11:45:06.923Z", + "createdAt": "2026-05-28T11:57:12.931Z", "result": { "JettonMinter": { "sendTopUpTons": { @@ -15,13 +15,13 @@ }, "bits": { "kind": "init", - "value": "13110" + "value": "13014" } }, "0xd372158c": { "gasUsed": { "kind": "init", - "value": "771" + "value": "594" }, "cells": { "kind": "init", @@ -29,7 +29,7 @@ }, "bits": { "kind": "init", - "value": "13110" + "value": "13014" } } }, From 156c67e649082f2f04a44c121f0ecf91d47926aa Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 28 May 2026 14:19:37 +0200 Subject: [PATCH 29/32] Add burn BOUNCE_ON_ACTION_FAIL test --- contracts/tests/wton/wton.spec.ts | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 9834aa58c..072828448 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -1328,6 +1328,8 @@ describe('wTON', () => { const minterContract = await blockchain.getContract(minter.address) minterContract.balance = 0n + // Tiny inbound: post-msg balance (~0.005 TON) is below the minter rent reserve (~0.01 TON), + // so the action-phase failure that triggers the bounce is RAWRESERVE, not the send action. const { result } = await burnFrom(alice, { jettonAmount: 1n, responseDestination: recipient.address, @@ -1352,6 +1354,51 @@ describe('wTON', () => { expect(await totalSupply()).toEqual(minted) }) + it('restores wallet balance when the minter payout fails at the send action specifically', async () => { + const minted = toNano('2') + await mintTo(alice.address, { jettonAmount: minted }) + + const aliceWallet = await userWallet(alice.address) + const minterContract = await blockchain.getContract(minter.address) + const recipientBalanceBefore = await contractBalance(recipient.address) + // Drop the minter balance to zero so the contract has no own funds, but burn the full + // backing so the wallet's CARRY_ALL_BALANCE notification arrives with ~2 TON of inbound. + // Post-msg balance now far exceeds requiredMinterReserve, so RAWRESERVE succeeds. + // The subsequent SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE wants to forward the entire + // inbound — which would push the contract balance below the just-set reserve floor — so + // BOUNCE_ON_ACTION_FAIL triggers on the *send* action rather than RAWRESERVE. + minterContract.balance = 0n + + const { result } = await burnFrom(alice, { + jettonAmount: minted, + responseDestination: recipient.address, + value: toNano('0.2'), + }) + + // Compute phase on the minter succeeded; the bounce comes from BOUNCE_ON_ACTION_FAIL. + expect(result.transactions).toHaveTransaction({ + from: aliceWallet.address, + to: minter.address, + success: false, + }) + + const bounceTx = result.transactions.find( + (tx: any) => + tx.inMessage?.info.type === 'internal' && + tx.inMessage.info.src?.equals(minter.address) && + tx.inMessage.info.dest.equals(aliceWallet.address), + ) + expect(bounceTx).toBeDefined() + + // No payout reached the nominated recipient because the send action never executed. + expect(await contractBalance(recipient.address)).toEqual(recipientBalanceBefore) + + // The wallet's onBouncedMessage restored the burned principal, and the minter's compute-phase + // totalSupply decrement was reverted along with the failed transaction. + expect(await walletBalance(alice.address)).toEqual(minted) + expect(await totalSupply()).toEqual(minted) + }) + it('keeps total supply equal to the sum of balances after sequential burns', async () => { await mintTo(alice.address, { jettonAmount: toNano('1.5') }) await mintTo(bob.address, { jettonAmount: toNano('0.7') }) From 54aae5d6868b5bd1912424c3745d47361665bc82 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 28 May 2026 15:08:27 +0200 Subject: [PATCH 30/32] Add in.originalForwardFee test - payload size check --- contracts/tests/gas-report/wton/wton.spec.ts | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/contracts/tests/gas-report/wton/wton.spec.ts b/contracts/tests/gas-report/wton/wton.spec.ts index 404475daf..0b5d651f4 100644 --- a/contracts/tests/gas-report/wton/wton.spec.ts +++ b/contracts/tests/gas-report/wton/wton.spec.ts @@ -342,5 +342,54 @@ describe('wTON gas calibration', () => { ) expect(notificationBodyStats.bits).toBeLessThan(transferBodyStats.bits) expect(notificationBodyStats.cells).toBeLessThanOrEqual(transferBodyStats.cells) + + // checkAmountIsEnoughToTransfer and checkAmountIsEnoughToMint both reuse in.originalForwardFee + // (the fwd-fee for the *incoming* message) as the budget for the *outgoing* message. That is + // only safe while every outgoing body remains <= its incoming counterpart in both bits and + // cells. These assertions lock that invariant: a future change that grows InternalTransferStep + // beyond AskToTransfer (transfer flow) or beyond MintNewJettons (mint flow) will break the + // budget silently in gas terms, so we catch it here at the shape level. + const askToTransferBodyStats = cellStats( + walletBuilder.messages.in.askToTransfer + .encode({ + queryId: 1, + jettonAmount: toNano('0.7'), + // SMALLEST realistic incoming: customPayload is null (one bit, no ref). Any real call + // is at least this big. + customPayload: null, + destination: alice.address, + responseDestination: deployer.address, + forwardTonAmount: toNano('0.05'), + forwardPayload, + }) + .asCell(), + ) + expect(transferBodyStats.bits).toBeLessThan(askToTransferBodyStats.bits) + expect(transferBodyStats.cells).toBeLessThanOrEqual(askToTransferBodyStats.cells) + + const mintNewJettonsBodyStats = cellStats( + minterBuilder.messages.in + .mintNewJettons({ opcode: WTON_MINT_OPCODE }) + .encode({ + queryId: 1n, + destination: alice.address, + tonAmount: toNano('0.3'), + jettonAmount: toNano('0.7'), + // The wTON minter enforces transferInitiator == null on mint, so the actual outgoing + // InternalTransferStep is smaller than transferBodyStats. We use transferBodyStats as + // a strict upper bound for the outgoing — if that bound fits inside the incoming, + // the real outgoing fits too. + from: null, + responseDestination: deployer.address, + // The minter wrapper's `customPayload` field maps to the inner InternalTransferStep's + // forwardPayload position in the wire layout. Reuse the same forwardPayload here so + // the comparison is apples-to-apples with transferBodyStats above. + customPayload: forwardPayload, + forwardTonAmount: toNano('0.05'), + }) + .asCell(), + ) + expect(transferBodyStats.bits).toBeLessThan(mintNewJettonsBodyStats.bits) + expect(transferBodyStats.cells).toBeLessThan(mintNewJettonsBodyStats.cells) }) }) From 4508733bcbf5ba2a04ff280b0a2b55d8bc68f028 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 29 May 2026 12:29:02 +0200 Subject: [PATCH 31/32] Add wallet.AskToWithdrawExcess, add wallet.InternalTransferStep surplus reserve --- contracts/contracts/wton/JettonWallet.tolk | 44 ++++- contracts/contracts/wton/fees-management.tolk | 2 +- contracts/tests/wton/wton.spec.ts | 175 ++++++++++++++++++ contracts/wrappers/jetton/JettonWallet.ts | 45 +++++ contracts/wrappers/wton/constants.ts | 2 + contracts/wton-gas-report.json | 2 +- 6 files changed, 265 insertions(+), 5 deletions(-) diff --git a/contracts/contracts/wton/JettonWallet.tolk b/contracts/contracts/wton/JettonWallet.tolk index b6d05bfcb..5d62826db 100644 --- a/contracts/contracts/wton/JettonWallet.tolk +++ b/contracts/contracts/wton/JettonWallet.tolk @@ -15,11 +15,18 @@ import "fees-management" // storage: WalletStorage // } +// Lets the wallet owner withdraw any TON surplus sitting above the strict `jettonBalance + storage_fee` +struct (0xf2ff646f) AskToWithdrawExcess { + queryId: uint64 + sendExcessesTo: address +} + type AllowedMessageToWallet = | AskToTransfer | AskToBurn | InternalTransferStep | TopUpTons + | AskToWithdrawExcess type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter @@ -60,10 +67,22 @@ fun onInternalMessage(in: InMessage) { } val jettonBalanceNext = storage.jettonBalance + msg.jettonAmount; val requiredReserve = requiredWalletReserve(jettonBalanceNext); - // Require the incoming transfer to carry the full hosted TON backing before we credit wTON. + // Hard reserve check: the inbound transfer must fund the full post-credit backing floor + // before we credit wTON or allow any forward / excess action to run. assert (contract.getOriginalBalance() >= requiredReserve + msg.forwardTonAmount) throw ERROR_UNSUFFICIENT_AMOUNT; - // Lock backing + storage reserve before any notification/excess send can spend value. - reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail()); + // Soft reserve check: keep TON that was already on the wallet before this message from + // being reclassified as outbound excess. This surplus is best-effort under RESERVE_MODE_AT_MOST. + // `requiredWalletReserve(x)` is linear in `x` within this transaction, so subtracting the + // credited jetton amount gives the pre-credit reserve without recomputing the helper. + val requiredReserveBeforeCredit = requiredReserve - msg.jettonAmount; + val balanceBeforeMessage = contract.getOriginalBalance() - in.valueCoins; + val preservedSurplus = balanceBeforeMessage > requiredReserveBeforeCredit + ? balanceBeforeMessage - requiredReserveBeforeCredit + : 0 as coins; + reserveToncoinsOnBalance( + requiredReserve + preservedSurplus, + RESERVE_MODE_AT_MOST | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL + ); storage.jettonBalance = jettonBalanceNext; storage.save(); @@ -160,6 +179,25 @@ fun onInternalMessage(in: InMessage) { // Accept TONs } + AskToWithdrawExcess => { + var storage = lazy WalletStorage.load(); + assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER; + assert (msg.sendExcessesTo.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN; + // Preserve the strict TON backing for the current jetton balance; anything above + // is owner-recoverable surplus (top-ups, bounced-transfer leftovers, mint overfunding). + reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail()); + + val excessMsg = createMessage({ + bounce: BounceMode.NoBounce, + dest: msg.sendExcessesTo, + value: 0, + body: ReturnExcessesBack { + queryId: msg.queryId + } + }); + excessMsg.send(SEND_MODE_CARRY_ALL_BALANCE | SEND_MODE_IGNORE_ERRORS); + } + else => throw 0xFFFF } } diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk index 2448b424a..c99eaf856 100644 --- a/contracts/contracts/wton/fees-management.tolk +++ b/contracts/contracts/wton/fees-management.tolk @@ -36,7 +36,7 @@ const MIN_STORAGE_DURATION = 5 * 365 * 24 * 3600 // 5 years // recipient notification and excess handling enabled. const GAS_CONSUMPTION_JettonTransfer = 7332 -const GAS_CONSUMPTION_JettonReceive = 7295 +const GAS_CONSUMPTION_JettonReceive = 7592 const GAS_CONSUMPTION_BurnRequest = 5613 const GAS_CONSUMPTION_BurnNotification = 4558 diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index 072828448..fd7d240a0 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -18,6 +18,7 @@ import { ERROR_INVALID_EXCESSES_DESTINATION, ERROR_INVALID_RECIPIENT, WTON_MINT_OPCODE, + WTON_WITHDRAW_EXCESS_OPCODE, } from '../../wrappers/wton' import * as bouncer from '../../wrappers/test/mock/Bouncer' @@ -146,6 +147,28 @@ describe('wTON', () => { return tx } + function internalTransactionFromTo( + result: { transactions: Array }, + source: Address, + destination: Address, + ) { + const tx = result.transactions.find((candidate) => { + return ( + candidate.inMessage?.info.type === 'internal' && + candidate.inMessage.info.src?.equals(source) && + candidate.inMessage.info.dest.equals(destination) + ) + }) + + if (!tx) { + throw new Error( + `Missing internal transaction from ${source.toString()} to ${destination.toString()}`, + ) + } + + return tx + } + function internalMessageBodyTo(result: { transactions: Array }, address: Address) { const tx = internalTransactionTo(result, address) const body = tx.inMessage?.body @@ -272,6 +295,29 @@ describe('wTON', () => { return { wallet, result } } + async function withdrawExcessFrom( + owner: SandboxContract, + { + sendExcessesTo, + value = toNano('0.05'), + }: { + sendExcessesTo: Address + value?: bigint + }, + ) { + const wallet = await userWallet(owner.address) + const result = await wallet.sendWithdrawExcess(owner.getSender(), { + value, + opcode: WTON_WITHDRAW_EXCESS_OPCODE, + message: { + queryId: nextQueryId++, + sendExcessesTo, + }, + }) + + return { wallet, result } + } + describe('basic e2e', () => { it('deploys and exposes basic jetton data', async () => { const data = await minter.getJettonData() @@ -1089,6 +1135,59 @@ describe('wTON', () => { }) describe('burning', () => { + it('owner can withdraw wallet surplus to a chosen basechain address', async () => { + const minted = toNano('1') + await mintTo(alice.address, { jettonAmount: minted }) + + const aliceWallet = await userWallet(alice.address) + await aliceWallet.sendTopUpTons(alice.getSender(), toNano('5')) + + const walletJettonsBefore = await walletBalance(alice.address) + const walletNativeBefore = await walletNativeBalance(alice.address) + const recipientBefore = await contractBalance(recipient.address) + + const { result } = await withdrawExcessFrom(alice, { + sendExcessesTo: recipient.address, + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: true, + }) + + expect(await walletBalance(alice.address)).toEqual(walletJettonsBefore) + expect(await totalSupply()).toEqual(walletJettonsBefore) + expect((await contractBalance(recipient.address)) - recipientBefore).toBeGreaterThan( + toNano('4'), + ) + expect(await walletNativeBalance(alice.address)).toBeLessThan(walletNativeBefore) + expect(await walletNativeBalance(alice.address)).toBeGreaterThanOrEqual(minted) + }) + + it('rejects excess withdrawals from non-owners', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + + const result = await aliceWallet.sendWithdrawExcess(deployer.getSender(), { + value: toNano('0.05'), + opcode: WTON_WITHDRAW_EXCESS_OPCODE, + message: { + queryId: nextQueryId++, + sendExcessesTo: recipient.address, + }, + }) + + expect(result.transactions).toHaveTransaction({ + from: deployer.address, + to: aliceWallet.address, + success: false, + exitCode: JettonErrorCodes.NOT_OWNER, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1')) + expect(await totalSupply()).toEqual(toNano('1')) + }) + it('rejects burns without a refund destination', async () => { const mintAmount = toNano('1') await mintTo(alice.address, { jettonAmount: mintAmount }) @@ -1452,4 +1551,80 @@ describe('wTON', () => { ) }) }) + + describe('excess preservation', () => { + it('keeps pre-existing wallet surplus when a third party mints dust with attacker sendExcessesTo', async () => { + await mintTo(alice.address, { jettonAmount: toNano('1') }) + + const aliceWallet = await userWallet(alice.address) + await aliceWallet.sendTopUpTons(alice.getSender(), toNano('5')) + + const walletNativeBefore = await walletNativeBalance(alice.address) + const result = await minter.sendMint(bob.getSender(), { + value: 1n + toNano('0.2') + toNano('0.3'), + mintOpcode: WTON_MINT_OPCODE, + message: { + queryId: nextQueryId++, + destination: alice.address, + tonAmount: toNano('0.2'), + jettonAmount: 1n, + from: null, + responseDestination: bob.address, + forwardTonAmount: 0n, + customPayload: null, + }, + }) + + const excessTx = internalTransactionFromTo(result, aliceWallet.address, bob.address) + + expect(excessTx.inMessage.info.type).toEqual('internal') + expect(excessTx.inMessage.info.value.coins).toBeLessThan(toNano('1')) + expect(await walletBalance(alice.address)).toEqual(toNano('1') + 1n) + expect(await totalSupply()).toEqual(toNano('1') + 1n) + expect(await walletNativeBalance(alice.address)).toBeGreaterThan(walletNativeBefore) + }) + + it('preserves bounced-transfer surplus across a later third-party inbound transfer', async () => { + const minted = toNano('1.2') + await mintTo(alice.address, { jettonAmount: minted }) + await mintTo(bob.address, { jettonAmount: toNano('1') }) + + const aliceWallet = await userWallet(alice.address) + const bobWallet = await userWallet(bob.address) + const bobWalletContract = await blockchain.getContract(bobWallet.address) + bobWalletContract.balance = 0n + + await transferFrom(alice, { + jettonAmount: toNano('0.3'), + destination: bob.address, + value: toNano('0.5'), + }) + + expect(await walletBalance(alice.address)).toEqual(minted) + const walletNativeAfterBounce = await walletNativeBalance(alice.address) + + const result = await minter.sendMint(recipient.getSender(), { + value: 1n + toNano('0.2') + toNano('0.3'), + mintOpcode: WTON_MINT_OPCODE, + message: { + queryId: nextQueryId++, + destination: alice.address, + tonAmount: toNano('0.2'), + jettonAmount: 1n, + from: null, + responseDestination: recipient.address, + forwardTonAmount: 0n, + customPayload: null, + }, + }) + + const excessTx = internalTransactionFromTo(result, aliceWallet.address, recipient.address) + + expect(excessTx.inMessage.info.type).toEqual('internal') + expect(excessTx.inMessage.info.value.coins).toBeLessThan(toNano('1')) + expect(await walletBalance(alice.address)).toEqual(minted + 1n) + expect(await totalSupply()).toEqual(minted + toNano('1') + 1n) + expect(await walletNativeBalance(alice.address)).toBeGreaterThan(walletNativeAfterBounce) + }) + }) }) diff --git a/contracts/wrappers/jetton/JettonWallet.ts b/contracts/wrappers/jetton/JettonWallet.ts index 3702308cf..37a5fbe31 100644 --- a/contracts/wrappers/jetton/JettonWallet.ts +++ b/contracts/wrappers/jetton/JettonWallet.ts @@ -108,6 +108,13 @@ export type WithdrawTonsMessage = { queryId: bigint } +// wTON-specific extension: lets the wallet owner withdraw any TON surplus +// sitting above the strict `jettonBalance + storage_fee` +export type AskToWithdrawExcess = { + queryId: bigint + sendExcessesTo: Address +} + function toContractData(config: JettonWalletConfig): JettonWalletData { return { ownerAddress: config.ownerAddress, @@ -199,6 +206,25 @@ export class JettonWallet implements Contract { }) } + async sendWithdrawExcess( + provider: ContractProvider, + via: Sender, + opts: { + value: bigint + opcode: number + message: AskToWithdrawExcess + }, + ) { + await provider.internal(via, { + value: opts.value, + sendMode: SendMode.PAY_GAS_SEPARATELY, + body: builder.messages.in + .askToWithdrawExcess({ opcode: opts.opcode }) + .encode(opts.message) + .asCell(), + }) + } + async getWalletData(provider: ContractProvider) { const { stack } = await provider.get('get_wallet_data', []) return { @@ -352,12 +378,31 @@ export const builder = { return { queryId: src.loadUintBig(64) } }, } + const askToWithdrawExcess = (opts: { opcode: number }): CellCodec => ({ + encode: function (data: AskToWithdrawExcess): Builder { + return beginCell() + .storeUint(opts.opcode, 32) + .storeUint(data.queryId, 64) + .storeAddress(data.sendExcessesTo) + }, + load: function (src: Slice): AskToWithdrawExcess { + const op = src.loadUint(32) + if (op !== opts.opcode) { + throw new Error(`Invalid opcode, expected ${opts.opcode}, got ${op}`) + } + return { + queryId: src.loadUintBig(64), + sendExcessesTo: src.loadAddress(), + } + }, + }) return { askToTransfer, askToTransferWithFwdPayload, askToBurn, topUpTons, withdrawTons, + askToWithdrawExcess, } })(), out: (() => { diff --git a/contracts/wrappers/wton/constants.ts b/contracts/wrappers/wton/constants.ts index 33b3c2685..c952bba5e 100644 --- a/contracts/wrappers/wton/constants.ts +++ b/contracts/wrappers/wton/constants.ts @@ -1,5 +1,7 @@ export const WtonOpcodes = { MINT: 0x00000015, + WITHDRAW_EXCESS: 0xf2ff646f, } as const export const WTON_MINT_OPCODE = WtonOpcodes.MINT +export const WTON_WITHDRAW_EXCESS_OPCODE = WtonOpcodes.WITHDRAW_EXCESS diff --git a/contracts/wton-gas-report.json b/contracts/wton-gas-report.json index 83bc5dcf0..d7ba71ac7 100644 --- a/contracts/wton-gas-report.json +++ b/contracts/wton-gas-report.json @@ -1,7 +1,7 @@ [ { "label": "current", - "createdAt": "2026-05-28T11:57:12.931Z", + "createdAt": "2026-05-28T12:29:13.830Z", "result": { "JettonMinter": { "sendTopUpTons": { From 5b28f2d2b5e2db04cf7ed5dff2dc1849c916b59f Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 29 May 2026 13:42:23 +0200 Subject: [PATCH 32/32] Add extra coverage --- contracts/contracts/wton/JettonMinter.tolk | 4 +- contracts/tests/wton/wton.spec.ts | 317 +++++++++++++++++++-- 2 files changed, 288 insertions(+), 33 deletions(-) diff --git a/contracts/contracts/wton/JettonMinter.tolk b/contracts/contracts/wton/JettonMinter.tolk index c79dd1d3c..7ae2ef9d4 100644 --- a/contracts/contracts/wton/JettonMinter.tolk +++ b/contracts/contracts/wton/JettonMinter.tolk @@ -44,7 +44,7 @@ fun refundMintBounce(msg: InternalTransferStep) { val refundMsg = createMessage({ // The mint-bounce refund is a forced TON deposit to the caller-chosen refund address. bounce: BounceMode.NoBounce, - dest: msg.sendExcessesTo!, + dest: msg.sendExcessesTo!, // safe to unwrap as it is required and validated in the minting path value: 0, body: ReturnExcessesBack { queryId: msg.queryId @@ -57,7 +57,7 @@ fun refundMintBounce(msg: InternalTransferStep) { fun onBouncedMessage(in: InMessageBounced) { // We require sendExcessesTo for refundMintBounce from the original mint payload, so the - // bounced body must preserve the full root cell rather than the old 256-bit truncation. + // bounced body must preserve the original root cell rather than the old 256-bit truncation. val rich = lazy RichBounceBody.fromSlice(in.bouncedBody); val msg = lazy InternalTransferStep.fromCell(rich.originalBody); diff --git a/contracts/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts index fd7d240a0..a29aeeba5 100644 --- a/contracts/tests/wton/wton.spec.ts +++ b/contracts/tests/wton/wton.spec.ts @@ -12,7 +12,6 @@ import { JettonErrorCodes } from '../../wrappers/jetton/constants' import { JettonWallet, builder as walletBuilder, - opcodes as walletOpcodes, } from '../../wrappers/jetton/JettonWallet' import { ERROR_INVALID_EXCESSES_DESTINATION, @@ -26,6 +25,7 @@ const JETTON_DATA_URI = 'wton.test' const MASTERCHAIN_ZERO_ADDRESS = Address.parse(`-1:${'0'.repeat(64)}`) type MintOptions = { + sender?: SandboxContract minterContract?: SandboxContract destination: Address jettonAmount?: bigint @@ -33,6 +33,7 @@ type MintOptions = { forwardTonAmount?: bigint responseDestination?: Address | null transferInitiator?: Address | null + customPayload?: Cell | null value?: bigint } @@ -190,6 +191,7 @@ describe('wTON', () => { } async function sendMint({ + sender = deployer, minterContract = minter, destination, jettonAmount = toNano('1'), @@ -197,10 +199,11 @@ describe('wTON', () => { forwardTonAmount = 0n, responseDestination = deployer.address, transferInitiator = null, + customPayload = null, value, }: MintOptions) { const queryId = nextQueryId++ - const result = await minterContract.sendMint(deployer.getSender(), { + const result = await minterContract.sendMint(sender.getSender(), { value: value ?? jettonAmount + tonAmount + toNano('0.3'), mintOpcode: WTON_MINT_OPCODE, message: { @@ -211,7 +214,7 @@ describe('wTON', () => { from: transferInitiator, responseDestination, forwardTonAmount, - customPayload: null, + customPayload, }, }) @@ -219,10 +222,11 @@ describe('wTON', () => { } async function mintTo(destination: Address, options: Omit = {}) { - const { result } = await sendMint({ destination, ...options }) + const sender = options.sender ?? deployer + const { result } = await sendMint({ destination, ...options, sender }) expect(result.transactions).toHaveTransaction({ - from: deployer.address, + from: sender.address, to: minter.address, success: true, }) @@ -607,8 +611,8 @@ describe('wTON', () => { }) const rejectorWallet = await userWallet(rejector.address) - const c = await blockchain.getContract(rejectorWallet.address) - c.balance = 0n // Put wallet in debt to trigger the mint bounce + const rejectorWalletContract = await blockchain.getContract(rejectorWallet.address) + rejectorWalletContract.balance = 0n // Put wallet in debt to trigger the mint bounce const rejectorBalanceBefore = await contractBalance(rejector.address) const { result } = await sendMint({ @@ -1560,19 +1564,13 @@ describe('wTON', () => { await aliceWallet.sendTopUpTons(alice.getSender(), toNano('5')) const walletNativeBefore = await walletNativeBalance(alice.address) - const result = await minter.sendMint(bob.getSender(), { + const { result } = await sendMint({ + sender: bob, + destination: alice.address, + jettonAmount: 1n, + tonAmount: toNano('0.2'), + responseDestination: bob.address, value: 1n + toNano('0.2') + toNano('0.3'), - mintOpcode: WTON_MINT_OPCODE, - message: { - queryId: nextQueryId++, - destination: alice.address, - tonAmount: toNano('0.2'), - jettonAmount: 1n, - from: null, - responseDestination: bob.address, - forwardTonAmount: 0n, - customPayload: null, - }, }) const excessTx = internalTransactionFromTo(result, aliceWallet.address, bob.address) @@ -1603,19 +1601,13 @@ describe('wTON', () => { expect(await walletBalance(alice.address)).toEqual(minted) const walletNativeAfterBounce = await walletNativeBalance(alice.address) - const result = await minter.sendMint(recipient.getSender(), { + const { result } = await sendMint({ + sender: recipient, + destination: alice.address, + jettonAmount: 1n, + tonAmount: toNano('0.2'), + responseDestination: recipient.address, value: 1n + toNano('0.2') + toNano('0.3'), - mintOpcode: WTON_MINT_OPCODE, - message: { - queryId: nextQueryId++, - destination: alice.address, - tonAmount: toNano('0.2'), - jettonAmount: 1n, - from: null, - responseDestination: recipient.address, - forwardTonAmount: 0n, - customPayload: null, - }, }) const excessTx = internalTransactionFromTo(result, aliceWallet.address, recipient.address) @@ -1627,4 +1619,267 @@ describe('wTON', () => { expect(await walletNativeBalance(alice.address)).toBeGreaterThan(walletNativeAfterBounce) }) }) + + describe('extra coverage', () => { + // This is a tiny deterministic PRNG, not property-test randomness: the fixed seeds keep + // the sequence reproducible so a failing step can be replayed exactly. + function createDeterministicFuzzer(seed: number) { + let state = seed >>> 0 + + const nextUint32 = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state + } + + return { + pick(values: readonly T[]) { + return values[nextUint32() % values.length] + }, + } + } + + // Bias the spend candidates toward boundary-ish values and full-balance spends while keeping + // every sampled operation valid for the current wallet state. + function pickSpendAmount( + pick: (values: readonly T[]) => T, + maxAmount: bigint, + operation: 'transfer' | 'burn', + ) { + const candidates = [ + 1n, + maxAmount, + maxAmount / 2n, + maxAmount / 3n, + maxAmount > 1n ? maxAmount - 1n : maxAmount, + toNano('0.01'), + toNano('0.05'), + operation === 'transfer' ? toNano('0.2') : toNano('0.15'), + operation === 'transfer' ? toNano('0.45') : toNano('0.3'), + ].filter((amount) => amount > 0n && amount <= maxAmount) + + return pick(Array.from(new Set(candidates))) + } + + // Exercise only valid mint / transfer / burn sequences and assert the core accounting + // invariants after every step. The goal is broad state-space coverage without introducing + // random invalid-input failures that belong in dedicated negative tests. + async function runDeterministicInvariantSequence(seed: number, steps: number) { + const owners = [alice, bob, recipient] + const ownerAddresses = owners.map((owner) => owner.address) + const { pick } = createDeterministicFuzzer(seed) + const mintJettonOptions = [ + 1n, + 2n, + 7n, + toNano('0.03'), + toNano('0.11'), + toNano('0.2'), + toNano('0.45'), + toNano('0.9'), + ] + const tonBudgetOptions = [ + toNano('0.2'), + toNano('0.23'), + toNano('0.27'), + toNano('0.31'), + toNano('0.37'), + toNano('0.5'), + ] + const forwardTonOptions = [ + 0n, + toNano('0.005'), + toNano('0.01'), + toNano('0.02'), + toNano('0.03'), + ] + const mintMarginOptions = [ + toNano('0.35'), + toNano('0.4'), + toNano('0.45'), + toNano('0.55'), + toNano('0.7'), + ] + const transferValueOptions = [ + toNano('0.55'), + toNano('0.6'), + toNano('0.7'), + toNano('0.85'), + toNano('1'), + ] + const burnValueOptions = [ + toNano('0.2'), + toNano('0.23'), + toNano('0.27'), + toNano('0.3'), + toNano('0.35'), + ] + + for (const owner of owners) { + const jettonAmount = pick(mintJettonOptions) + const tonAmount = pick(tonBudgetOptions) + await mintTo(owner.address, { + jettonAmount, + tonAmount, + responseDestination: pick(ownerAddresses), + forwardTonAmount: pick(forwardTonOptions), + value: jettonAmount + tonAmount + pick(mintMarginOptions), + }) + } + + await assertCoreInvariants(ownerAddresses) + + for (let step = 0; step < steps; step++) { + const operation = pick(['mint', 'transfer', 'burn'] as const) + + if (operation === 'mint') { + const jettonAmount = pick(mintJettonOptions) + const tonAmount = pick(tonBudgetOptions) + await mintTo(pick(ownerAddresses), { + jettonAmount, + tonAmount, + forwardTonAmount: pick(forwardTonOptions), + responseDestination: pick(ownerAddresses), + value: jettonAmount + tonAmount + pick(mintMarginOptions), + }) + } else { + const spenders = [] as Array<{ + owner: SandboxContract + balance: bigint + }> + + for (const owner of owners) { + const balance = await walletBalance(owner.address) + if (balance > 0n) { + spenders.push({ owner, balance }) + } + } + + const senderState = spenders.length > 0 ? pick(spenders) : null + if (!senderState) { + continue + } + + const sender = senderState.owner + + if (operation === 'transfer') { + const receiverOptions = owners.filter((owner) => !owner.address.equals(sender.address)) + const { wallet, result } = await transferFrom(sender, { + jettonAmount: pickSpendAmount(pick, senderState.balance, 'transfer'), + destination: pick(receiverOptions).address, + responseDestination: pick([...ownerAddresses, null] as const), + value: pick(transferValueOptions), + forwardTonAmount: pick(forwardTonOptions), + }) + + expect(result.transactions).toHaveTransaction({ + from: sender.address, + to: wallet.address, + success: true, + }) + } else { + const { wallet, result } = await burnFrom(sender, { + jettonAmount: pickSpendAmount(pick, senderState.balance, 'burn'), + responseDestination: pick(ownerAddresses), + value: pick(burnValueOptions), + }) + + expect(result.transactions).toHaveTransaction({ + from: sender.address, + to: wallet.address, + success: true, + }) + } + } + + await assertCoreInvariants(ownerAddresses) + } + } + + // For wTON solvency we care about two invariants: supply matches wallet balances, and the + // minter plus all wallet backings still cover that supply with the minter reserve on top. + async function assertCoreInvariants(owners: Address[]) { + const supply = await totalSupply() + expect(supply).toEqual(await sumWalletBalances(owners)) + + let hostedTon = 0n + for (const owner of owners) { + hostedTon += await walletNativeBalance(owner) + } + + const balance = await contractBalance(minter.address) + expect(balance + hostedTon).toBeGreaterThanOrEqual(supply + toNano('0.01')) + } + + it('keeps core supply and backing invariants across deterministic fuzz sequences', async () => { + const snapshot = blockchain.snapshot() + + // Reset to the pristine deployed state before each seed so every sequence stays independent. + for (const seed of [0x1badc0de, 0x0ddc0ffe, 0xdecafbad]) { + await blockchain.loadFrom(snapshot) + nextQueryId = 1n + await runDeterministicInvariantSequence(seed, 24) + } + }) + + it('keeps supply whole when bounced mint bodies carry ref-heavy trailing payloads', async () => { + const bounceMinter = await deployMinter(bouncerCode) + const snapshot = blockchain.snapshot() + const payloads = [ + beginCell().storeStringRefTail('bounce.ref-tail').endCell(), + beginCell() + .storeRef( + beginCell().storeRef(beginCell().storeStringTail('deep-ref').endCell()).endCell(), + ) + .endCell(), + ] + + for (const payload of payloads) { + await blockchain.loadFrom(snapshot) + + const { result } = await sendMint({ + minterContract: bounceMinter, + destination: alice.address, + jettonAmount: 1n, + tonAmount: toNano('0.2'), + responseDestination: recipient.address, + customPayload: payload, + value: 1n + toNano('0.2') + toNano('0.35'), + }) + + // The wrapper's customPayload lands in the inner InternalTransferStep tail, so this is a + // focused tripwire for the RichBounceOnlyRootCell assumption used by the mint bounce path. + expect((await bounceMinter.getJettonData()).totalSupply).toEqual(0n) + + const refundTx = internalTransactionFromTo(result, bounceMinter.address, recipient.address) + expect(refundTx.inMessage.info.type).toEqual('internal') + expect(refundTx.inMessage.info.value.coins).toBeGreaterThan(0n) + } + }) + + it('keeps a wallet live across the modeled five-year storage horizon', async () => { + const fiveYears = 5 * 365 * 24 * 3600 + const startTime = blockchain.now ?? Math.floor(Date.now() / 1000) + blockchain.now = startTime + + await mintTo(alice.address, { jettonAmount: toNano('1') }) + const aliceWallet = await userWallet(alice.address) + + blockchain.now = startTime + fiveYears - 60 + + const { result } = await transferFrom(alice, { + jettonAmount: 1n, + destination: bob.address, + value: toNano('0.5'), + }) + + expect(result.transactions).toHaveTransaction({ + from: alice.address, + to: aliceWallet.address, + success: true, + }) + expect(await walletBalance(alice.address)).toEqual(toNano('1') - 1n) + expect(await walletBalance(bob.address)).toEqual(1n) + await assertCoreInvariants([alice.address, bob.address]) + }) + }) })