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/contracts/examples/jetton/onramp_mock.tolk b/contracts/contracts/examples/jetton/onramp_mock.tolk
index 88571daa5..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/utils.tolk"
+import "../../lib/jetton/jetton-utils.tolk"
import "../../lib/utils.tolk"
// OnrampMock contract in Tolk
@@ -12,6 +14,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
}
@@ -49,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/errors.tolk b/contracts/contracts/lib/jetton/errors.tolk
new file mode 100644
index 000000000..5701a4867
--- /dev/null
+++ b/contracts/contracts/lib/jetton/errors.tolk
@@ -0,0 +1,10 @@
+// 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/contracts/lib/jetton/jetton-utils.tolk b/contracts/contracts/lib/jetton/jetton-utils.tolk
new file mode 100644
index 000000000..21c2a3700
--- /dev/null
+++ b/contracts/contracts/lib/jetton/jetton-utils.tolk
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: MIT
+import "storage"
+
+fun calcDeployedJettonWallet(ownerAddress: address, minterAddress: address, jettonWalletCode: cell): AutoDeployAddress {
+ val emptyWalletStorage: WalletStorage = {
+ status: 0,
+ jettonBalance: 0,
+ ownerAddress,
+ minterAddress,
+ };
+
+ return {
+ workchain: BASECHAIN,
+ 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/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 72e6657bb..3409f9ebb 100644
--- a/contracts/contracts/lib/jetton/messages.tolk
+++ b/contracts/contracts/lib/jetton/messages.tolk
@@ -9,7 +9,7 @@ struct (0x0f8a7ea5) AskToTransfer {
queryId: uint64
jettonAmount: coins
transferRecipient: address
- sendExcessesTo: address
+ sendExcessesTo: address?
customPayload: cell?
forwardTonAmount: coins
forwardPayload: ForwardPayloadRemainder
@@ -19,7 +19,7 @@ struct (0x0f8a7ea5) AskToTransfer {
struct (0x7362d09c) TransferNotificationForRecipient {
queryId: uint64
jettonAmount: coins
- transferInitiator: address
+ transferInitiator: address?
forwardPayload: ForwardPayloadRemainder
}
@@ -27,8 +27,8 @@ struct (0x7362d09c) TransferNotificationForRecipient {
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
}
@@ -42,7 +42,7 @@ struct (0xd53276db) ReturnExcessesBack {
struct (0x595f07bc) AskToBurn {
queryId: uint64
jettonAmount: coins
- sendExcessesTo: address
+ sendExcessesTo: address?
customPayload: cell?
}
@@ -51,7 +51,7 @@ struct (0x7bdd97de) BurnNotificationForMinter {
queryId: uint64
jettonAmount: coins
burnInitiator: address
- sendExcessesTo: address
+ sendExcessesTo: address?
}
// nolint:opcode
@@ -64,7 +64,7 @@ struct (0x2c76b973) RequestWalletAddress {
// nolint:opcode
struct (0xd1735400) ResponseWalletAddress {
queryId: uint64
- jettonWalletAddress: address
+ jettonWalletAddress: address?
ownerAddress: Cell
?
}
@@ -102,11 +102,14 @@ struct (0x2508d66a) UpgradeMinterCode {
// nolint:opcode
struct (0xcb862902) ChangeMinterMetadataUri {
queryId: uint64
+ // TODO: update to 1.4
newMetadataUri: SnakeString
}
// nolint:opcode
-struct (0xd372158c) TopUpTons {}
+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 +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 c61b677ea..ba8d9e625 100644
--- a/contracts/contracts/lib/jetton/storage.tolk
+++ b/contracts/contracts/lib/jetton/storage.tolk
@@ -1,5 +1,7 @@
// 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"))
@@ -11,12 +13,12 @@ fun SnakeString.unpackFromSlice(mutate s: slice) {
// 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;
+ s = createEmptySlice(); // no more left to read
+ return snakeRemainder
}
fun SnakeString.packToBuilder(self, mutate b: builder) {
- b.storeSlice(self);
+ b.storeSlice(self)
}
struct WalletStorage {
@@ -28,24 +30,28 @@ struct WalletStorage {
struct MinterStorage {
totalSupply: coins
- adminAddress: address
- nextAdminAddress: address
+ adminAddress: address?
+ nextAdminAddress: address?
jettonWalletCode: cell
+ // TODO: update to 1.4
metadataUri: Cell
}
+
+
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..8ebf4356b
--- /dev/null
+++ b/contracts/contracts/wton/JettonMinter.tolk
@@ -0,0 +1,226 @@
+// SPDX-License-Identifier: MIT
+tolk 1.2
+
+// TODO: update to 1.4
+// import "@stdlib/strings"
+import "../lib/jetton/errors"
+import "../lib/jetton/jetton-utils"
+import "../lib/jetton/storage"
+import "../lib/jetton/messages"
+import "fees-management"
+
+// TODO: update to 1.4
+// contract JettonMinter {
+// author: "Chainlink Labs"
+// incomingMessages: AllowedMessageToMinter
+// storage: MinterStorage
+// }
+
+type AllowedMessageToMinter =
+ | MintNewJettons
+ | BurnNotificationForMinter
+ | RequestWalletAddress
+ | ChangeMinterMetadataUri
+ | TopUpTons
+
+const ERROR_ALREADY_INITIALIZED = 75
+const ERROR_UNSUFFICIENT_AMOUNT = 76
+const ERROR_INVALID_EXCESSES_DESTINATION = 77
+
+fun reserveModeExactFail() {
+ return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL;
+}
+
+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.
+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.
+ reserveToncoinsOnBalance(requiredMinterReserve(), RESERVE_MODE_AT_MOST);
+
+ val refundMsg = createMessage({
+ // The mint-bounce refund is a forced TON deposit to the caller-chosen refund address.
+ bounce: BounceMode.NoBounce,
+ dest: msg.sendExcessesTo!,
+ value: 0,
+ body: ReturnExcessesBack {
+ queryId: msg.queryId
+ }
+ });
+ // 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) {
+ // 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);
+ val msg = lazy InternalTransferStep.fromCell(rich.originalBody);
+
+ var storage = lazy MinterStorage.load();
+ storage.totalSupply -= msg.jettonAmount;
+ storage.save();
+
+ refundMintBounce(msg);
+}
+
+fun onInternalMessage(in: InMessage) {
+ val msg = lazy AllowedMessageToMinter.fromSlice(in.body);
+
+ match (msg) {
+ BurnNotificationForMinter => {
+ var storage = lazy MinterStorage.load();
+ 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.
+ 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,
+ body: ReturnExcessesBack {
+ 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.
+ excessesMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ 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();
+ 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();
+
+ 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;
+ // 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;
+
+ // 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 += jettonAmount;
+ storage.save();
+
+ reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail());
+
+ val deployMsg = createMessage({
+ // 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,
+ });
+ 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
+ }
+
+ else => throw 0xFFFF
+ }
+}
+
+
+
+struct JettonDataReply {
+ totalSupply: int
+ mintable: bool
+ adminAddress: address? // left for compatibility with the standard (admin == null)
+ jettonContent: Cell
+ jettonWalletCode: cell
+}
+
+// TODO: update to 1.4
+struct (0x00) OnchainMetadataReply {
+ contentDict: map>
+}
+
+struct (0x00) SnakeDataReply {
+ string: SnakeString
+}
+
+// --- Getters ---
+
+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: createEmptyMap()
+ };
+ metadata.contentDict.set(stringSha256("uri"), SnakeDataReply{string: storage.metadataUri.load()}.toCell());
+ metadata.contentDict.set(stringSha256("decimals"), SnakeDataReply{string: "9"}.toCell());
+
+ 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..10a41fd55
--- /dev/null
+++ b/contracts/contracts/wton/JettonWallet.tolk
@@ -0,0 +1,195 @@
+// SPDX-License-Identifier: MIT
+tolk 1.2
+
+import "@stdlib/gas-payments"
+import "../lib/jetton/errors"
+import "../lib/jetton/jetton-utils"
+import "../lib/jetton/storage"
+import "../lib/jetton/messages"
+import "fees-management"
+
+// TODO: update to 1.4
+// contract JettonWallet {
+// author: "Chainlink Labs"
+// incomingMessages: AllowedMessageToWallet
+// storage: WalletStorage
+// }
+
+type AllowedMessageToWallet =
+ | AskToTransfer
+ | AskToBurn
+ | InternalTransferStep
+ | TopUpTons
+
+type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter
+
+const ERROR_UNSUFFICIENT_AMOUNT = 76
+const ERROR_INVALID_EXCESSES_DESTINATION = 77
+
+fun reserveModeExactFail() {
+ return RESERVE_MODE_EXACT_AMOUNT | RESERVE_MODE_BOUNCE_ON_ACTION_FAIL;
+}
+
+fun requiredWalletReserve(backedTonAmount: coins) {
+ return backedTonAmount + calculateJettonWalletMinStorageFee() + contract.getStorageDuePayment();
+}
+
+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) {
+ val calcAddress = calcAddressOfJettonWallet(msg.transferInitiator!, storage.minterAddress, contract.getCode());
+ assert (in.senderAddress == calcAddress) throw ERROR_NOT_VALID_WALLET;
+ }
+ 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;
+ // Lock backing + storage reserve before any notification/excess send can spend value.
+ reserveToncoinsOnBalance(requiredReserve, reserveModeExactFail());
+
+ storage.jettonBalance = jettonBalanceNext;
+ 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) {
+ 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();
+
+ // Preserve the remaining wTON backing plus storage reserve exactly, or abort the transfer.
+ reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail());
+
+ val deployMsg = createMessage({
+ bounce: BounceMode.Only256BitsOfBody,
+ dest: calcDeployedJettonWallet(msg.transferRecipient, storage.minterAddress, contract.getCode()),
+ value: 0,
+ 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_BALANCE | 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;
+ // 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();
+
+ // Preserve the remaining backing exactly before sending the withdrawn TON to the minter.
+ reserveToncoinsOnBalance(requiredWalletReserve(storage.jettonBalance), reserveModeExactFail());
+
+ 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_BALANCE | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ TopUpTons => {
+ // 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..4a457f022
--- /dev/null
+++ b/contracts/contracts/wton/README.md
@@ -0,0 +1,21 @@
+# Wrapped TON
+
+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.
diff --git a/contracts/contracts/wton/fees-management.tolk b/contracts/contracts/wton/fees-management.tolk
new file mode 100644
index 000000000..6cf2f2037
--- /dev/null
+++ b/contracts/contracts/wton/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 "../lib/jetton/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/tests/wton/wton.spec.ts b/contracts/tests/wton/wton.spec.ts
new file mode 100644
index 000000000..3ce01b47e
--- /dev/null
+++ b/contracts/tests/wton/wton.spec.ts
@@ -0,0 +1,998 @@
+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, MinterOpcodes } from '../../wrappers/jetton/JettonMinter'
+import { JettonWallet, 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 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
+ 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 res = await contract.sendTopUpTons(deployer.getSender(), toNano('0.01'))
+ expect(res.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> {
+ const walletAddr = await minter.getWalletAddress(owner)
+ return blockchain.openContract(JettonWallet.createFromAddress(walletAddr))
+ }
+
+ 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 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, before: bigint, minDelta: bigint) {
+ const after = await contractBalance(address)
+ expect(after - before).toBeGreaterThanOrEqual(minDelta)
+ }
+
+ 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 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 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'))
+ 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()
+
+ 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('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 minter.sendTopUpTons(deployer.getSender(), toNano('1'))
+ expect(minterTopUp.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: minter.address,
+ success: true,
+ })
+
+ const walletTopUp = await aliceWallet.sendTopUpTons(alice.getSender(), toNano('1'))
+ expect(walletTopUp.transactions).toHaveTransaction({
+ from: alice.address,
+ to: aliceWallet.address,
+ 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]))
+ })
+ })
+
+ 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_EXCESSES_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 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({
+ destination: alice.address,
+ jettonAmount,
+ tonAmount,
+ value: jettonAmount + tonAmount - 1n,
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ 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 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)
+ })
+
+ 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', () => {
+ 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_EXCESSES_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'))
+ })
+
+ 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/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 ed5dd87cd..fdf1ef6c7 100644
--- a/contracts/wrappers/jetton/JettonMinter.ts
+++ b/contracts/wrappers/jetton/JettonMinter.ts
@@ -66,7 +66,7 @@ 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,
}
@@ -77,7 +77,7 @@ export type MintMessage = {
tonAmount: bigint
jettonAmount: bigint
from: Maybe
- responseDestination: Maybe
+ responseDestination: Address
customPayload?: Cell | null
forwardTonAmount?: bigint
}
@@ -116,6 +116,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(MinterOpcodes.TOP_UP, 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..0c1f7eab5 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,
@@ -75,7 +76,7 @@ export type AskToTransferWithFwdPayload = {
export type BurnMessage = {
queryId: bigint
jettonAmount: bigint
- responseDestination: Address | null
+ responseDestination: Address
customPayload: Cell | null
}
@@ -117,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.JettonMinter.compile.ts b/contracts/wrappers/wton.JettonMinter.compile.ts
new file mode 100644
index 000000000..1fec5cb05
--- /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,
+}
diff --git a/contracts/wrappers/wton.JettonWallet.compile.ts b/contracts/wrappers/wton.JettonWallet.compile.ts
new file mode 100644
index 000000000..cc63f5673
--- /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,
+}
diff --git a/contracts/wrappers/wton/errors.ts b/contracts/wrappers/wton/errors.ts
new file mode 100644
index 000000000..0a9052a1e
--- /dev/null
+++ b/contracts/wrappers/wton/errors.ts
@@ -0,0 +1,3 @@
+export const ERROR_ALREADY_INITIALIZED = 75
+export const ERROR_UNSUFFICIENT_AMOUNT = 76
+export const ERROR_INVALID_EXCESSES_DESTINATION = 77
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'
|