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'