diff --git a/contracts/Acton.toml b/contracts/Acton.toml
index c7e03e988..c74440f7a 100644
--- a/contracts/Acton.toml
+++ b/contracts/Acton.toml
@@ -50,6 +50,24 @@ src = "contracts/ccip/merkle_root/contract.tolk"
domain = "ccip"
depends = []
+[contracts.TokenPool]
+display-name = "link.chain.ton.ccip.TokenPool"
+src = "contracts/ccip/pools/token_pool_contract.tolk"
+domain = "ccip/pools"
+depends = []
+
+[contracts.LockReleaseTokenPool]
+display-name = "link.chain.ton.ccip.LockReleaseTokenPool"
+src = "contracts/ccip/pools/lock_release_token_pool/contract.tolk"
+domain = "ccip/pools"
+depends = []
+
+[contracts.BurnMintTokenPool]
+display-name = "link.chain.ton.ccip.BurnMintTokenPool"
+src = "contracts/ccip/pools/burn_mint_token_pool/contract.tolk"
+domain = "ccip/pools"
+depends = []
+
[contracts.Timelock]
display-name = "link.chain.ton.mcms.Timelock"
src = "contracts/mcms/rbac_timelock.tolk"
diff --git a/contracts/contracts/ccip/cct/JettonMinter.tolk b/contracts/contracts/ccip/cct/JettonMinter.tolk
new file mode 100644
index 000000000..2990dc99d
--- /dev/null
+++ b/contracts/contracts/ccip/cct/JettonMinter.tolk
@@ -0,0 +1,208 @@
+import "@stdlib/strings"
+import "../../lib/jetton/errors"
+import "../../lib/jetton/jetton-utils"
+import "../../lib/jetton/storage"
+import "../../lib/jetton/messages"
+import "fees-management"
+
+contract JettonMinter {
+ author: "The Tolk Team"
+ incomingMessages: AllowedMessageToMinter
+ storage: MinterStorage
+}
+
+type AllowedMessageToMinter =
+ | MintNewJettons
+ | BurnNotificationForMinter
+ | RequestWalletAddress
+ | ChangeMinterAdmin
+ | ClaimMinterAdmin
+ | DropMinterAdmin
+ | ChangeMinterMetadataUri
+ | UpgradeMinterCode
+ | TopUpTons
+
+fun onBouncedMessage(in: InMessageBounced) {
+ in.bouncedBody.skipBouncedPrefix();
+ // process only mint bounces; on other messages, an exception will be thrown, it's okay
+ val msg = lazy InternalTransferStep.fromSlice(in.bouncedBody);
+
+ var storage = lazy MinterStorage.load();
+ storage.totalSupply -= msg.jettonAmount;
+ storage.save();
+}
+
+fun assertSenderIsAdmin(senderAddress: address, adminAddress: address?) {
+ // theoretically, minter's admin can be dropped and be `null`, so being precise, we should check:
+ // ```
+ // assert (adminAddress != null) throw ERROR_NOT_OWNER;
+ // ```
+ // but in practice, the above assertion is reduntant, we just bypass the nullability check:
+ assert (senderAddress == adminAddress!) throw ERROR_NOT_OWNER;
+ // then, if admin is `null`, error code 7 will be thrown (while executing operator `==`),
+ // it's suitable for current implementation
+}
+
+fun onInternalMessage(in: InMessage) {
+ val msg = lazy AllowedMessageToMinter.fromSlice(in.body);
+
+ match (msg) {
+ BurnNotificationForMinter => {
+ var storage = lazy MinterStorage.load();
+ assert (in.senderAddress == calcAddressOfJettonWallet(msg.burnInitiator, contract.getAddress(), storage.jettonWalletCode)) throw ERROR_NOT_VALID_WALLET;
+ storage.totalSupply -= msg.jettonAmount;
+ storage.save();
+
+ if (msg.sendExcessesTo == null) {
+ return;
+ }
+
+ val excessesMsg = createMessage({
+ bounce: BounceMode.NoBounce,
+ dest: msg.sendExcessesTo,
+ value: 0,
+ body: ReturnExcessesBack {
+ queryId: msg.queryId
+ }
+ });
+ excessesMsg.send(SEND_MODE_IGNORE_ERRORS | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
+ }
+
+ RequestWalletAddress => {
+ var ownerAddress: Cell
? = msg.includeOwnerAddress
+ ? msg.ownerAddress.toCell()
+ : null;
+
+ var walletAddress: address? = null;
+ if (msg.ownerAddress.getWorkchain() == MY_WORKCHAIN) {
+ val storage = lazy MinterStorage.load();
+ walletAddress = calcAddressOfJettonWallet(msg.ownerAddress, contract.getAddress(), storage.jettonWalletCode);
+ }
+
+ val respondMsg = createMessage({
+ bounce: BounceMode.NoBounce,
+ dest: in.senderAddress,
+ value: 0,
+ body: ResponseWalletAddress {
+ queryId: msg.queryId,
+ jettonWalletAddress: walletAddress,
+ ownerAddress: ownerAddress,
+ }
+ });
+ respondMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ MintNewJettons => {
+ var storage = lazy MinterStorage.load();
+ assertSenderIsAdmin(in.senderAddress, storage.adminAddress);
+ assert (msg.mintRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN;
+
+ val internalTransferMsg = lazy msg.internalTransferMsg.load({
+ throwIfOpcodeDoesNotMatch: ERROR_INVALID_OP
+ });
+ var forwardTonAmount = internalTransferMsg.forwardTonAmount;
+ internalTransferMsg.forwardPayload.checkIsCorrectTLBEither();
+
+ // a little more than needed, it’s ok since it’s sent by the admin and excesses will return back
+ checkAmountIsEnoughToTransfer(msg.tonAmount, forwardTonAmount, in.originalForwardFee);
+
+ storage.totalSupply += internalTransferMsg.jettonAmount;
+ storage.save();
+
+ reserveToncoinsOnBalance(ton("0.01"), RESERVE_MODE_EXACT_AMOUNT); // reserve for storage fees
+
+ val deployMsg = createMessage({
+ bounce: BounceMode.Only256BitsOfBody,
+ dest: calcDeployedJettonWallet(msg.mintRecipient, contract.getAddress(), storage.jettonWalletCode),
+ value: msg.tonAmount,
+ body: msg.internalTransferMsg,
+ });
+ deployMsg.send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ ChangeMinterAdmin => {
+ var storage = lazy MinterStorage.load();
+ assertSenderIsAdmin(in.senderAddress, storage.adminAddress);
+ storage.nextAdminAddress = msg.newAdminAddress;
+ storage.save();
+ }
+
+ ClaimMinterAdmin => {
+ var storage = lazy MinterStorage.load();
+ assertSenderIsAdmin(in.senderAddress, storage.nextAdminAddress);
+ storage.adminAddress = storage.nextAdminAddress;
+ storage.nextAdminAddress = null;
+ storage.save();
+ }
+
+ DropMinterAdmin => {
+ var storage = lazy MinterStorage.load();
+ assertSenderIsAdmin(in.senderAddress, storage.adminAddress);
+ storage.adminAddress = null;
+ storage.nextAdminAddress = null;
+ storage.save();
+ }
+
+ ChangeMinterMetadataUri => {
+ var storage = lazy MinterStorage.load();
+ assertSenderIsAdmin(in.senderAddress, storage.adminAddress);
+ // convert "inlined snake string" to a normal (ref) string, bypassing the type system
+ storage.metadataUri = msg.newMetadataUri.toCell() as unknown as string;
+ storage.save();
+ }
+
+ UpgradeMinterCode => {
+ var storage = lazy MinterStorage.load();
+ assertSenderIsAdmin(in.senderAddress, storage.adminAddress);
+ contract.setData(msg.newData);
+ contract.setCodePostponed(msg.newCode);
+ }
+
+ TopUpTons => {
+ // just accept tons
+ }
+
+ else => throw 0xFFFF
+ }
+}
+
+
+
+struct JettonDataReply {
+ totalSupply: int
+ mintable: bool
+ adminAddress: address?
+ jettonContent: Cell
+ jettonWalletCode: cell
+}
+
+struct (0x00) OnchainMetadataReply {
+ contentDict: map
+}
+
+get fun get_jetton_data(): JettonDataReply {
+ val storage = lazy MinterStorage.load();
+ var metadata: OnchainMetadataReply = {
+ contentDict: []
+ };
+ metadata.contentDict.set("uri".sha256(), storage.metadataUri.prefixWith00());
+ metadata.contentDict.set("decimals".sha256(), "9".prefixWith00());
+
+ return {
+ totalSupply: storage.totalSupply,
+ mintable: true,
+ adminAddress: storage.adminAddress,
+ 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);
+}
+
+get fun get_next_admin_address(): address? {
+ val storage = lazy MinterStorage.load();
+ return storage.nextAdminAddress;
+}
diff --git a/contracts/contracts/ccip/cct/JettonWallet.tolk b/contracts/contracts/ccip/cct/JettonWallet.tolk
new file mode 100644
index 000000000..507f674e0
--- /dev/null
+++ b/contracts/contracts/ccip/cct/JettonWallet.tolk
@@ -0,0 +1,163 @@
+import "@stdlib/gas-payments"
+import "../../lib/jetton/errors"
+import "../../lib/jetton/jetton-utils"
+import "../../lib/jetton/storage"
+import "../../lib/jetton/messages"
+import "fees-management"
+
+contract JettonWallet {
+ author: "SmartContract Chainlink Limited SEZC"
+ version: "0.0.1"
+ description: "link.chain.ton.ccip.cct.JettonWallet"
+
+ storage: WalletStorage
+ incomingMessages: AllowedMessageToWallet
+}
+
+type AllowedMessageToWallet =
+ | AskToTransfer
+ | AskToBurn
+ | InternalTransferStep
+ | TopUpTons
+
+type BounceOpToHandle = InternalTransferStep | BurnNotificationForMinter
+
+fun onBouncedMessage(in: InMessageBounced) {
+ in.bouncedBody.skipBouncedPrefix();
+
+ val msg = lazy BounceOpToHandle.fromSlice(in.bouncedBody);
+ val restoreAmount = match (msg) {
+ InternalTransferStep => msg.jettonAmount, // safe to fetch jettonAmount, because
+ BurnNotificationForMinter => msg.jettonAmount, // it's in the beginning of a message
+ };
+
+ var storage = lazy WalletStorage.load();
+ storage.jettonBalance += restoreAmount;
+ storage.save();
+}
+
+fun onInternalMessage(in: InMessage) {
+ val msg = lazy AllowedMessageToWallet.fromSlice(in.body);
+
+ match (msg) {
+ InternalTransferStep => {
+ var storage = lazy WalletStorage.load();
+ if (in.senderAddress != storage.minterAddress) {
+ assert (in.senderAddress == calcAddressOfJettonWallet(msg.transferInitiator!, storage.minterAddress, contract.getCode())) throw ERROR_NOT_VALID_WALLET;
+ }
+ storage.jettonBalance += msg.jettonAmount;
+ storage.save();
+
+ if (msg.forwardTonAmount != 0) {
+ val notifyOwnerMsg = createMessage({
+ bounce: BounceMode.NoBounce,
+ dest: storage.ownerAddress,
+ value: msg.forwardTonAmount,
+ body: TransferNotificationForRecipient {
+ queryId: msg.queryId,
+ jettonAmount: msg.jettonAmount,
+ transferInitiator: msg.transferInitiator,
+ forwardPayload: msg.forwardPayload,
+ }
+ });
+ notifyOwnerMsg.send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ if (msg.sendExcessesTo != null) {
+ var toLeaveOnBalance = contract.getOriginalBalance() - in.valueCoins + contract.getStorageDuePayment();
+ reserveToncoinsOnBalance(max(toLeaveOnBalance, calculateJettonWalletMinStorageFee()), RESERVE_MODE_AT_MOST);
+
+ val excessesMsg = createMessage({
+ bounce: BounceMode.NoBounce,
+ dest: msg.sendExcessesTo,
+ value: 0,
+ body: ReturnExcessesBack {
+ queryId: msg.queryId
+ }
+ });
+ excessesMsg.send(SEND_MODE_CARRY_ALL_BALANCE | SEND_MODE_IGNORE_ERRORS);
+ }
+ }
+
+ AskToTransfer => {
+ msg.forwardPayload.checkIsCorrectTLBEither();
+ assert (msg.transferRecipient.getWorkchain() == MY_WORKCHAIN) throw ERROR_WRONG_WORKCHAIN;
+ checkAmountIsEnoughToTransfer(in.valueCoins, msg.forwardTonAmount, in.originalForwardFee);
+
+ var storage = lazy WalletStorage.load();
+ assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER;
+ assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR;
+ storage.jettonBalance -= msg.jettonAmount;
+ storage.save();
+
+ val deployMsg = createMessage({
+ bounce: BounceMode.Only256BitsOfBody,
+ dest: calcDeployedJettonWallet(msg.transferRecipient, storage.minterAddress, contract.getCode()),
+ value: 0,
+ body: InternalTransferStep {
+ queryId: msg.queryId,
+ jettonAmount: msg.jettonAmount,
+ transferInitiator: storage.ownerAddress,
+ sendExcessesTo: msg.sendExcessesTo,
+ forwardTonAmount: msg.forwardTonAmount,
+ forwardPayload: msg.forwardPayload,
+ }
+ });
+ deployMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ AskToBurn => {
+ checkAmountIsEnoughToBurn(in.valueCoins);
+
+ var storage = lazy WalletStorage.load();
+ assert (in.senderAddress == storage.ownerAddress) throw ERROR_NOT_OWNER;
+ assert (storage.jettonBalance >= msg.jettonAmount) throw ERROR_BALANCE_ERROR;
+ storage.jettonBalance -= msg.jettonAmount;
+ storage.save();
+
+ val notifyMinterMsg = createMessage({
+ bounce: BounceMode.Only256BitsOfBody,
+ dest: storage.minterAddress,
+ value: 0,
+ body: BurnNotificationForMinter {
+ queryId: msg.queryId,
+ jettonAmount: msg.jettonAmount,
+ burnInitiator: storage.ownerAddress,
+ sendExcessesTo: msg.sendExcessesTo,
+ }
+ });
+ notifyMinterMsg.send(SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE | SEND_MODE_BOUNCE_ON_ACTION_FAIL);
+ }
+
+ TopUpTons => {
+ // just accept tons
+ }
+
+ else => throw 0xFFFF
+ }
+}
+
+
+
+struct JettonWalletDataReply {
+ jettonBalance: coins
+ ownerAddress: address
+ minterAddress: address
+ jettonWalletCode: cell
+}
+
+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/ccip/cct/fees-management.tolk b/contracts/contracts/ccip/cct/fees-management.tolk
new file mode 100644
index 000000000..bc0609003
--- /dev/null
+++ b/contracts/contracts/ccip/cct/fees-management.tolk
@@ -0,0 +1,71 @@
+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/contracts/ccip/common/messages.tolk b/contracts/contracts/ccip/common/messages.tolk
deleted file mode 100644
index 03a448bc8..000000000
--- a/contracts/contracts/ccip/common/messages.tolk
+++ /dev/null
@@ -1,9 +0,0 @@
-// SPDX-License-Identifier: BUSL-1.1
-
-// nolint:opcode
-struct (0x7362d09c) Common_JettonTransferNotification {
- queryId: uint64;
- amount: coins;
- sender: address;
- forwardPayload: cell?; // could also be RemainingBitsAndRefs
-}
diff --git a/contracts/contracts/ccip/fee_quoter/types.tolk b/contracts/contracts/ccip/fee_quoter/types.tolk
index 193981a77..8fd2353aa 100644
--- a/contracts/contracts/ccip/fee_quoter/types.tolk
+++ b/contracts/contracts/ccip/fee_quoter/types.tolk
@@ -78,8 +78,6 @@ const VAL_1E18: uint256 = 1000000000000000000;
/// The fixed bytes does not cover struct data (this is represented by MESSAGE_FIXED_BYTES_PER_TOKEN)
const TON_2_EVM_MESSAGE_FIXED_BYTES = 15 * 32;
-const CCIP_LOCK_OR_BURN_V1_RET_BYTES = 32;
-
const CHAIN_FAMILY_SELECTOR_EVM: uint32 = 0x2812d52c;
const CHAIN_FAMILY_SELECTOR_SVM: uint32 = 0x1e10bdc4;
const CHAIN_FAMILY_SELECTOR_TVM: uint32 = 0x647e2ba9;
diff --git a/contracts/contracts/ccip/pools/burn_mint_token_pool/contract.tolk b/contracts/contracts/ccip/pools/burn_mint_token_pool/contract.tolk
new file mode 100644
index 000000000..4b08816fb
--- /dev/null
+++ b/contracts/contracts/ccip/pools/burn_mint_token_pool/contract.tolk
@@ -0,0 +1,291 @@
+// SPDX-License-Identifier: BUSL-1.1
+tolk 1.4.1
+
+import "../../../lib/utils"
+import "../../../lib/access/ownable_2step"
+import "../../../lib/jetton/jetton_client"
+import "../../../lib/jetton/messages"
+import "../../../lib/jetton/jetton-utils"
+import "../../rmn_remote/lib"
+import "../token_pool"
+import "../types"
+import "../messages"
+import "../events"
+import "../errors"
+
+import "messages"
+import "types"
+import "errors"
+import "storage"
+
+contract BurnMintTokenPool {
+ author: "SmartContract Chainlink Limited SEZC"
+ version: "0.1.0"
+ description: "link.chain.ton.ccip.BurnMintTokenPool"
+
+ storage: Storage
+ incomingMessages: TokenPool_InMessage | BurnMintTokenPool_InMessage // TODO: all incoming messages should be registered
+}
+
+fun onInternalMessage(in: InMessage) {
+ var st = Storage.load();
+ var pool = loadPool(st);
+ val handled = pool.onInternalMessage(in.senderAddress, in.valueCoins, in.body);
+ if (handled) {
+ st = pool.context != null ? pool.context! : st;
+ st.poolData = pool.data.toCell();
+ st.store();
+ return;
+ }
+
+ val msg = lazy BurnMintTokenPool_InMessage.fromSlice(in.body);
+ match (msg) {
+ BurnMintTokenPool_ClaimMinterAdmin => {
+ onClaimMinterAdmin(mutate st, in.senderAddress, msg);
+ st.store();
+ }
+ ReturnExcessesBack => {
+ onReturnExcessesBack(mutate st, msg, in.senderAddress);
+ st.store();
+ }
+ else => {
+ assert(in.body.isEmpty()) throw 0xFFFF;
+ }
+ }
+}
+
+fun onClaimMinterAdmin(
+ mutate st: Storage,
+ sender: address,
+ msg: BurnMintTokenPool_ClaimMinterAdmin,
+) {
+ st.poolData.load().adminConfig.load().ownable.load().requireOwner(sender);
+
+ createMessage({
+ bounce: true,
+ value: BurnMintTokenPool_CLAIM_ADMIN_VALUE,
+ dest: st.jettonClient.load().masterAddress,
+ body: ClaimMinterAdmin {
+ queryId: msg.queryId,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY);
+}
+
+/// @notice Token pool used for burn and mint tokens. This uses a burn and mint mechanism.
+/// @dev One token per BurnMintTokenPool. In this variant the pool contract owns the Jetton wallet and the Jetton minter admin role.
+fun doLockOrBurn(
+ st: Storage,
+ sender: address,
+ _: TransferNotificationForRecipient,
+ requestMsg: TokenPool_LockOrBurn,
+ prepared: TokenPool_LockOrBurnPrepared,
+): Storage {
+ assert(!st.pendingBurns.get(requestMsg.queryId).isFound, Error.PendingBurnAlreadyExists);
+
+ val jettonClient = st.jettonClient.load();
+ st.pendingBurns.set(requestMsg.queryId, BurnMintTokenPool_PendingBurn {
+ replyTo: requestMsg.replyTo,
+ request: prepared.request.toCell(),
+ out: prepared.out.toCell(),
+ destTokenAmount: prepared.destTokenAmount,
+ expectedSender: jettonClient.masterAddress,
+ }.toCell());
+
+ createMessage({
+ bounce: true,
+ value: BurnMintTokenPool_BURN_VALUE,
+ dest: sender,
+ body: AskToBurn {
+ queryId: requestMsg.queryId,
+ jettonAmount: prepared.destTokenAmount as coins,
+ sendExcessesTo: contract.getAddress(),
+ customPayload: null,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY);
+
+ return st;
+}
+
+fun handleReleaseOrMintMessage(
+ ctx: Storage?,
+ sender: address,
+ msg: TokenPool_ReleaseOrMint,
+ prepared: TokenPool_ReleaseOrMintPrepared,
+): Storage? {
+ assert(ctx != null, TokenPool_Error.UnsupportedOperation);
+ var st = ctx!;
+ assert(!st.pendingMints.get(msg.queryId).isFound, Error.PendingMintAlreadyExists);
+ val jettonClient = st.jettonClient.load();
+
+ val recipientWallet = calcAddressOfJettonWallet(
+ prepared.request.receiver,
+ jettonClient.masterAddress,
+ jettonClient.jettonWalletCode,
+ );
+
+ st.pendingMints.set(msg.queryId, BurnMintTokenPool_PendingMint {
+ replyTo: msg.replyTo,
+ request: prepared.request.toCell(),
+ out: prepared.out.toCell(),
+ expectedSender: recipientWallet,
+ }.toCell());
+
+ createMessage({
+ bounce: true,
+ value: BurnMintTokenPool_MINT_VALUE,
+ dest: jettonClient.masterAddress,
+ body: MintNewJettons {
+ queryId: msg.queryId,
+ mintRecipient: prepared.request.receiver,
+ tonAmount: BurnMintTokenPool_MINT_VALUE,
+ internalTransferMsg: InternalTransferStep {
+ queryId: msg.queryId,
+ jettonAmount: prepared.localAmount as coins,
+ transferInitiator: null,
+ sendExcessesTo: contract.getAddress(),
+ forwardTonAmount: 0,
+ forwardPayload: beginCell().storeUint(0, 1).endCell().beginParse(),
+ }.toCell(),
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY);
+
+ return st;
+}
+
+fun onReturnExcessesBack(
+ mutate st: Storage,
+ msg: ReturnExcessesBack,
+ sender: address,
+) {
+ val burnEntry = st.pendingBurns.get(msg.queryId);
+ if (burnEntry.isFound) {
+ val pending = burnEntry.loadValue().load();
+ assert(sender == pending.expectedSender, Error.UnexpectedBurnConfirmationSender);
+
+ st.pendingBurns.delete(msg.queryId);
+ val request = pending.request.load();
+
+ emit(TOKEN_POOL_LOCKED_OR_BURNED_TOPIC, TokenPool_LockedOrBurned {
+ remoteChainSelector: request.remoteChainSelector,
+ details: TokenPool_LockedOrBurnedDetails {
+ token: request.localToken,
+ sender: request.originalSender,
+ amount: pending.destTokenAmount,
+ }.toCell(),
+ });
+
+ if (pending.replyTo != null) {
+ createMessage({
+ bounce: true,
+ value: BurnMintTokenPool_REPLY_VALUE,
+ dest: pending.replyTo!,
+ body: TokenPool_LockOrBurnResponse {
+ queryId: msg.queryId,
+ out: pending.out,
+ destTokenAmount: pending.destTokenAmount,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
+ }
+ return;
+ }
+
+ val mintEntry = st.pendingMints.get(msg.queryId);
+ assert(mintEntry.isFound, Error.PendingMintNotFound);
+
+ val pending = mintEntry.loadValue().load();
+ assert(sender == pending.expectedSender, Error.UnexpectedMintConfirmationSender);
+
+ st.pendingMints.delete(msg.queryId);
+ val request = pending.request.load();
+ val out = pending.out.load();
+
+ emit(TOKEN_POOL_RELEASED_OR_MINTED_TOPIC, TokenPool_ReleasedOrMinted {
+ remoteChainSelector: request.remoteChainSelector,
+ details: TokenPool_ReleasedOrMintedDetails {
+ token: request.localToken,
+ amount: out.destinationAmount,
+ participants: TokenPool_ReleasedOrMintedParticipants {
+ sender: contract.getAddress(),
+ recipient: request.receiver,
+ }.toCell(),
+ }.toCell(),
+ });
+
+ if (pending.replyTo != null) {
+ createMessage({
+ bounce: true,
+ value: BurnMintTokenPool_REPLY_VALUE,
+ dest: pending.replyTo!,
+ body: TokenPool_ReleaseOrMintResponse {
+ queryId: msg.queryId,
+ out: pending.out,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
+ }
+}
+
+fun loadPool(st: Storage): TokenPool {
+ return TokenPool {
+ data: st.poolData.load(),
+ context: st,
+ hooks: TokenPool_Hooks {
+ ensureOutboundAccess: null,
+ ensureInboundAccess: null,
+ ensureNotCursed: null,
+ preflightCheck: null,
+ postflightCheck: null,
+ applyLockOrBurn: null,
+ applyReleaseOrMint: null,
+ onLockOrBurn: null,
+ validateLockOrBurn: null,
+ doLockOrBurn,
+ handleReleaseOrMintMessage,
+ },
+ };
+}
+
+get fun typeAndVersion(): (slice, slice) {
+ return (BurnMintTokenPool_CONTRACT_NAME, BurnMintTokenPool_CONTRACT_VERSION);
+}
+
+get fun token(): address {
+ return Storage.load().poolData.load().token;
+}
+
+get fun tokenDecimals(): uint8 {
+ return Storage.load().poolData.load().tokenDecimals;
+}
+
+get fun isSupportedChain(remoteChainSelector: uint64): bool {
+ return Storage.load().poolData.load().remoteChainConfigs.get(remoteChainSelector).isFound;
+}
+
+get fun onRamp(remoteChainSelector: uint64): address? {
+ val entry = Storage.load().poolData.load().mirroredPolicy.load().onRamps.get(remoteChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+get fun offRamp(remoteChainSelector: uint64): address? {
+ val entry = Storage.load().poolData.load().mirroredPolicy.load().offRamps.get(remoteChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+get fun hasPendingBurn(queryId: uint64): bool {
+ return Storage.load().pendingBurns.get(queryId).isFound;
+}
+
+get fun hasPendingMint(queryId: uint64): bool {
+ return Storage.load().pendingMints.get(queryId).isFound;
+}
+
+get fun getRMNProxy(): address {
+ return Storage.load().poolData.load().adminConfig.load().rmnProxy;
+}
+
+get fun verifyNotCursed(subject: uint128): bool {
+ return !Storage.load().poolData.load().mirroredPolicy.load().cursedSubjects.isCursed(subject);
+}
+
+get fun owner(): address {
+ return Storage.load().poolData.load().adminConfig.load().ownable.load().owner;
+}
diff --git a/contracts/contracts/ccip/pools/burn_mint_token_pool/errors.tolk b/contracts/contracts/ccip/pools/burn_mint_token_pool/errors.tolk
new file mode 100644
index 000000000..b175b8f58
--- /dev/null
+++ b/contracts/contracts/ccip/pools/burn_mint_token_pool/errors.tolk
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+const BurnMintTokenPool_FACILITY_NAME = "link.chain.ton.ccip.BurnMintTokenPool";
+const BurnMintTokenPool_FACILITY_ID = 412; // (crc32() % 640) + 10
+
+enum Error {
+ IncorrectJettonSender = BurnMintTokenPool_FACILITY_ID * 100
+ PendingBurnAlreadyExists
+ PendingBurnNotFound
+ PendingMintAlreadyExists
+ PendingMintNotFound
+ UnexpectedBurnConfirmationSender
+ UnexpectedMintConfirmationSender
+}
\ No newline at end of file
diff --git a/contracts/contracts/ccip/pools/burn_mint_token_pool/messages.tolk b/contracts/contracts/ccip/pools/burn_mint_token_pool/messages.tolk
new file mode 100644
index 000000000..ec9ec2dd9
--- /dev/null
+++ b/contracts/contracts/ccip/pools/burn_mint_token_pool/messages.tolk
@@ -0,0 +1,11 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/jetton/messages"
+import "../messages"
+
+struct (0x39898e4d) BurnMintTokenPool_ClaimMinterAdmin {
+ queryId: uint64;
+}
+
+type BurnMintTokenPool_InMessage =
+ | BurnMintTokenPool_ClaimMinterAdmin
+ | ReturnExcessesBack;
diff --git a/contracts/contracts/ccip/pools/burn_mint_token_pool/storage.tolk b/contracts/contracts/ccip/pools/burn_mint_token_pool/storage.tolk
new file mode 100644
index 000000000..40697d580
--- /dev/null
+++ b/contracts/contracts/ccip/pools/burn_mint_token_pool/storage.tolk
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/jetton/jetton_client"
+import "../../../lib/access/ownable_2step"
+import "../../rmn_remote/lib"
+import "../token_pool"
+import "../types"
+
+import "types"
+
+struct Storage {
+ poolData: Cell;
+ jettonClient: Cell;
+ pendingBurns: map>;
+ pendingMints: map>;
+}
+
+fun Storage.load(): Storage {
+ return Storage.fromCell(contract.getData());
+}
+
+fun Storage.store(self) {
+ contract.setData(self.toCell());
+}
+
+fun BurnMintTokenPool_initialStorage(config: BurnMintTokenPool_Config): Storage {
+ return Storage {
+ poolData: TokenPool_Data {
+ adminConfig: TokenPool_AdminConfig {
+ ownable: Ownable2Step {
+ owner: config.owner,
+ pendingOwner: null,
+ }.toCell(),
+ rmnProxy: config.rmnProxy,
+ dynamicConfig: TokenPool_DynamicConfig {
+ router: config.router,
+ rateLimitAdmin: null,
+ feeAdmin: null,
+ }.toCell(),
+ allowedFinalityConfig: TOKEN_POOL_DEFAULT_FINALITY,
+ }.toCell(),
+ mirroredPolicy: TokenPool_MirroredPolicy {
+ onRamps: createEmptyMap(),
+ offRamps: createEmptyMap(),
+ cursedSubjects: CursedSubjects {
+ data: createEmptyMap(),
+ },
+ }.toCell(),
+ token: config.token,
+ tokenDecimals: config.tokenDecimals,
+ remoteChainConfigs: createEmptyMap(),
+ tokenTransferFeeConfigs: createEmptyMap(),
+ }.toCell(),
+ jettonClient: config.jettonClient.toCell(),
+ pendingBurns: createEmptyMap(),
+ pendingMints: createEmptyMap(),
+ };
+}
\ No newline at end of file
diff --git a/contracts/contracts/ccip/pools/burn_mint_token_pool/types.tolk b/contracts/contracts/ccip/pools/burn_mint_token_pool/types.tolk
new file mode 100644
index 000000000..c77b2b188
--- /dev/null
+++ b/contracts/contracts/ccip/pools/burn_mint_token_pool/types.tolk
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/jetton/jetton_client"
+import "../../rmn_remote/lib"
+import "../types"
+
+const BurnMintTokenPool_CONTRACT_NAME = "link.chain.ton.ccip.BurnMintTokenPool".literalSlice();
+const BurnMintTokenPool_CONTRACT_VERSION = "0.1.0".literalSlice();
+const BurnMintTokenPool_CLAIM_ADMIN_VALUE = ton("0.05");
+const BurnMintTokenPool_BURN_VALUE = ton("0.05");
+const BurnMintTokenPool_MINT_VALUE = ton("0.1");
+const BurnMintTokenPool_REPLY_VALUE = ton("0.01");
+
+struct BurnMintTokenPool_PendingBurn {
+ replyTo: address? = null;
+ request: Cell;
+ out: Cell;
+ destTokenAmount: uint256;
+ expectedSender: address;
+}
+
+struct BurnMintTokenPool_PendingMint {
+ replyTo: address? = null;
+ request: Cell;
+ out: Cell;
+ expectedSender: address;
+}
+
+struct BurnMintTokenPool_Config {
+ owner: address;
+ token: address;
+ tokenDecimals: uint8;
+ rmnProxy: address;
+ router: address;
+ jettonClient: JettonClient;
+}
diff --git a/contracts/contracts/ccip/pools/errors.tolk b/contracts/contracts/ccip/pools/errors.tolk
new file mode 100644
index 000000000..c2cbae147
--- /dev/null
+++ b/contracts/contracts/ccip/pools/errors.tolk
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../lib/utils"
+
+const TokenPool_FACILITY_NAME = "link.chain.ton.ccip.TokenPool";
+const TokenPool_FACILITY_ID = 149; // (crc32() % 640) + 10
+
+enum TokenPool_Error {
+ InvalidTransferFeeBps = TokenPool_FACILITY_ID * 100 // (uint256 bps);
+ InvalidTokenTransferFeeConfig // (uint64 destChainSelector);
+ CallerIsNotARampOnRouter // (address caller);
+ ZeroAddressInvalid // ();
+ NonExistentChain // (uint64 remoteChainSelector);
+ ChainNotAllowed // (uint64 remoteChainSelector);
+ CursedByRMN // ();
+ ChainAlreadyExists // (uint64 chainSelector);
+ InvalidSourcePoolAddress // (bytes sourcePoolAddress);
+ InvalidToken // (address token);
+ Unauthorized // (address caller);
+ PoolAlreadyAdded // (uint64 remoteChainSelector, bytes remotePoolAddress);
+ InvalidRemotePoolForChain // (uint64 remoteChainSelector, bytes remotePoolAddress);
+ InvalidRemoteChainDecimals // (bytes sourcePoolData);
+ OverflowDetected // (uint8 remoteDecimals, uint8 localDecimals, uint256 remoteAmount);
+ InvalidDecimalArgs // (uint8 expected, uint8 actual);
+ CallerIsNotOwnerOrFeeAdmin // (address caller);
+ UnsupportedOperation // ();
+
+ // TODO: extra
+ MissingForwardPayload
+ MissingTransferInitiator
+ AmountMismatch
+ InvalidRequestedFinality
+ RateLimitExceeded
+}
+
+@inline
+fun TokenPool_errorCode(local: uint16): uint16 {
+ return getErrorCode(stringCrc32("link.chain.ton.ccip.TokenPool"), local);
+}
diff --git a/contracts/contracts/ccip/pools/events.tolk b/contracts/contracts/ccip/pools/events.tolk
new file mode 100644
index 000000000..6ae2141ad
--- /dev/null
+++ b/contracts/contracts/ccip/pools/events.tolk
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../common/types"
+
+import "types"
+
+const TOKEN_POOL_LOCKED_OR_BURNED_TOPIC = stringCrc32("TokenPool_LockedOrBurned");
+const TOKEN_POOL_RELEASED_OR_MINTED_TOPIC = stringCrc32("TokenPool_ReleasedOrMinted");
+const TOKEN_POOL_CHAIN_ADDED_TOPIC = stringCrc32("TokenPool_ChainAdded");
+const TOKEN_POOL_CHAIN_REMOVED_TOPIC = stringCrc32("TokenPool_ChainRemoved");
+const TOKEN_POOL_REMOTE_POOL_ADDED_TOPIC = stringCrc32("TokenPool_RemotePoolAdded");
+const TOKEN_POOL_REMOTE_POOL_REMOVED_TOPIC = stringCrc32("TokenPool_RemotePoolRemoved");
+const TOKEN_POOL_DYNAMIC_CONFIG_SET_TOPIC = stringCrc32("TokenPool_DynamicConfigSet");
+const TOKEN_POOL_RAMP_ACCESS_UPDATED_TOPIC = stringCrc32("TokenPool_RampAccessUpdated");
+const TOKEN_POOL_CURSED_SUBJECTS_UPDATED_TOPIC = stringCrc32("TokenPool_CursedSubjectsUpdated");
+const TOKEN_POOL_FINALITY_CONFIG_SET_TOPIC = stringCrc32("TokenPool_FinalityConfigSet");
+
+struct TokenPool_LockedOrBurnedDetails {
+ token: address;
+ sender: address;
+ amount: uint256;
+}
+
+// event LockedOrBurned(uint64 indexed remoteChainSelector, address token, address sender, uint256 amount);
+// event ReleasedOrMinted(
+// uint64 indexed remoteChainSelector, address token, address sender, address recipient, uint256 amount
+// );
+struct TokenPool_LockedOrBurned {
+ remoteChainSelector: uint64;
+ details: Cell;
+}
+
+struct TokenPool_ReleasedOrMintedParticipants {
+ sender: address;
+ recipient: address;
+}
+
+struct TokenPool_ReleasedOrMintedDetails {
+ token: address;
+ amount: uint256;
+ participants: Cell;
+}
+
+struct TokenPool_ReleasedOrMinted {
+ remoteChainSelector: uint64;
+ details: Cell;
+}
+
+struct TokenPool_ChainAdded {
+ remoteChainSelector: uint64;
+ remoteTokenAddress: Cell;
+ // outboundRateLimiterConfig: RateLimiter.Config;
+ // inboundRateLimiterConfig: RateLimiter.Config;
+}
+
+struct TokenPool_ChainRemoved {
+ remoteChainSelector: uint64;
+}
+
+// TODO: make reply message
+struct TokenPool_RemotePoolAdded {
+ remoteChainSelector: uint64;
+ remotePoolAddress: Cell;
+}
+
+// TODO: make reply message
+struct TokenPool_RemotePoolRemoved {
+ remoteChainSelector: uint64;
+ remotePoolAddress: Cell;
+}
+
+// event DynamicConfigSet(address router, address rateLimitAdmin, address feeAdmin);
+struct TokenPool_DynamicConfigSet {
+ router: address;
+ rateLimitAdmin: address?;
+ feeAdmin: address?;
+}
+
+// TODO: make used
+struct TokenPool_OutboundRateLimitConsumed {
+ remoteChainSelector: uint64;
+ token: address;
+ amount: uint256;
+}
+
+// TODO: make used
+struct TokenPool_InboundRateLimitConsumed {
+ remoteChainSelector: uint64;
+ token: address;
+ amount: uint256;
+}
+
+// TODO: make used
+struct TokenPool_TokenTransferFeeConfigUpdated {
+ remoteChainSelector: uint64;
+ tokenTransferFeeConfig: Cell;
+}
+
+// TODO: make used
+struct TokenPool_TokenTransferFeeConfigDeleted {
+ remoteChainSelector: uint64;
+}
+
+// event FinalityConfigSet(bytes4 allowedFinality);
+struct TokenPool_FinalityConfigSet {
+ allowedFinalityConfig: uint32;
+}
+
+// event FastFinalityOutboundRateLimitConsumed(uint64 indexed remoteChainSelector, address token, uint256 amount);
+// event FastFinalityInboundRateLimitConsumed(uint64 indexed remoteChainSelector, address token, uint256 amount);
+// event RateLimitConfigured(
+// uint64 indexed remoteChainSelector,
+// bool fastFinality,
+// RateLimiter.Config outboundRateLimiterConfig,
+// RateLimiter.Config inboundRateLimiterConfig
+// );
+// event AdvancedPoolHooksUpdated(IAdvancedPoolHooks oldHook, IAdvancedPoolHooks newHook);
+
+struct TokenPool_RampAccessUpdated {
+ remoteChainSelector: uint64;
+ onRamp: address? = null;
+ offRamp: address? = null;
+}
+
+struct TokenPool_CursedSubjectsUpdated {}
diff --git a/contracts/contracts/ccip/pools/lock_release_token_pool/contract.tolk b/contracts/contracts/ccip/pools/lock_release_token_pool/contract.tolk
new file mode 100644
index 000000000..9a066ecd7
--- /dev/null
+++ b/contracts/contracts/ccip/pools/lock_release_token_pool/contract.tolk
@@ -0,0 +1,273 @@
+// SPDX-License-Identifier: BUSL-1.1
+tolk 1.4.1
+
+import "../../../lib/utils"
+import "../../../lib/access/ownable_2step"
+import "../../../lib/jetton/messages"
+import "../../../lib/jetton/jetton-utils"
+import "../../../lib/jetton/jetton_client"
+import "../../rmn_remote/lib"
+import "../token_pool"
+import "../types"
+import "../messages"
+import "../events"
+import "../errors"
+
+import "messages"
+import "types"
+import "errors"
+import "storage"
+
+contract LockReleaseTokenPool {
+ author: "SmartContract Chainlink Limited SEZC"
+ version: "0.1.0"
+ description: "link.chain.ton.ccip.LockReleaseTokenPool"
+
+ storage: Storage
+ incomingMessages: TokenPool_InMessage | LockReleaseTokenPool_InMessage // TODO: all incoming messages should be registered
+}
+
+// TODO:
+// _lockOrBurn -> after async checks, (1) does nothing, or (2) deposits to the Lockbox
+// _releaseOrMint -> after async checks, (1) transfers from own wallet, or (2) withdraws from the Lockbox
+
+fun onInternalMessage(in: InMessage) {
+ var st = Storage.load();
+ var pool = loadPool(st);
+ val handled = pool.onInternalMessage(in.senderAddress, in.valueCoins, in.body);
+ if (handled) {
+ st = pool.context != null ? pool.context! : st;
+ st.poolData = pool.data.toCell();
+ st.store();
+ return;
+ }
+
+ val msg = lazy LockReleaseTokenPool_InMessage.fromSlice(in.body);
+ match (msg) {
+ ReturnExcessesBack => {
+ onReturnExcessesBack(mutate st, msg, in.senderAddress);
+ st.store();
+ }
+ else => {
+ assert(in.body.isEmpty()) throw 0xFFFF;
+ }
+ }
+}
+
+fun onBouncedMessage(in: InMessageBounced) {
+ val msg = lazy LockReleaseTokenPool_BouncedMessage.fromSlice(in.bouncedBody.skipBouncedPrefix());
+ match (msg) {
+ AskToTransfer => {
+ var st = Storage.load();
+ onReleaseTransferBounced(mutate st, msg, in.senderAddress);
+ st.store();
+ }
+ }
+}
+
+/// @notice Token pool used for tokens on their native chain. This uses a lock and release mechanism.
+/// @dev One token per LockReleaseTokenPool. In this non-lockbox variant the pool contract's Jetton wallet holds custody directly.
+fun doLockOrBurn(
+ st: Storage, // TODO: should be TokenPool so I get access to hooks and context
+ sender: address,
+ msg: TransferNotificationForRecipient,
+ requestMsg: TokenPool_LockOrBurn,
+ prepared: TokenPool_LockOrBurnPrepared,
+): Storage {
+ var request = requestMsg.request.load();
+ emit(TOKEN_POOL_LOCKED_OR_BURNED_TOPIC, TokenPool_LockedOrBurned {
+ remoteChainSelector: request.remoteChainSelector,
+ details: TokenPool_LockedOrBurnedDetails {
+ token: request.localToken,
+ sender: msg.transferInitiator!,
+ amount: prepared.destTokenAmount,
+ }.toCell(),
+ });
+
+ if (requestMsg.replyTo != null) {
+ createMessage({
+ bounce: true,
+ value: LockReleaseTokenPool_REPLY_VALUE,
+ dest: requestMsg.replyTo!,
+ body: TokenPool_LockOrBurnResponse {
+ queryId: requestMsg.queryId,
+ out: prepared.out.toCell(),
+ destTokenAmount: prepared.destTokenAmount,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
+ }
+
+ return st;
+}
+
+fun handleReleaseOrMintMessage(
+ ctx: Storage?,
+ sender: address,
+ msg: TokenPool_ReleaseOrMint,
+ prepared: TokenPool_ReleaseOrMintPrepared,
+): Storage? {
+ assert(ctx != null, TokenPool_Error.UnsupportedOperation);
+ var st = ctx!;
+ assert(!st.pendingReleases.get(msg.queryId).isFound, Error.PendingReleaseAlreadyExists);
+ val jettonClient = st.jettonClient.load();
+
+ val recipientWallet = calcAddressOfJettonWallet(
+ prepared.request.receiver,
+ jettonClient.masterAddress,
+ jettonClient.jettonWalletCode,
+ );
+
+ st.pendingReleases.set(msg.queryId, LockReleaseTokenPool_PendingRelease {
+ replyTo: msg.replyTo,
+ request: prepared.request.toCell(),
+ out: prepared.out.toCell(),
+ expectedSender: recipientWallet,
+ }.toCell());
+
+ jettonClient.sendSimple(
+ JettonMessageOptions {
+ bounce: true,
+ value: LockReleaseTokenPool_RELEASE_TRANSFER_VALUE,
+ },
+ SEND_MODE_PAY_FEES_SEPARATELY,
+ msg.queryId,
+ prepared.localAmount as coins,
+ prepared.request.receiver,
+ contract.getAddress(),
+ );
+
+ return st;
+}
+
+fun onReturnExcessesBack(
+ mutate st: Storage,
+ msg: ReturnExcessesBack,
+ sender: address,
+) {
+ val pendingEntry = st.pendingReleases.get(msg.queryId);
+ assert(pendingEntry.isFound, Error.PendingReleaseNotFound);
+
+ val pending = pendingEntry.loadValue().load();
+ assert(sender == pending.expectedSender, Error.UnexpectedReleaseConfirmationSender);
+
+ st.pendingReleases.delete(msg.queryId);
+ val request = pending.request.load();
+ val out = pending.out.load();
+
+ emit(TOKEN_POOL_RELEASED_OR_MINTED_TOPIC, TokenPool_ReleasedOrMinted {
+ remoteChainSelector: request.remoteChainSelector,
+ details: TokenPool_ReleasedOrMintedDetails {
+ token: request.localToken,
+ amount: out.destinationAmount,
+ participants: TokenPool_ReleasedOrMintedParticipants {
+ sender: contract.getAddress(),
+ recipient: request.receiver,
+ }.toCell(),
+ }.toCell(),
+ });
+
+ if (pending.replyTo != null) {
+ createMessage({
+ bounce: true,
+ value: LockReleaseTokenPool_REPLY_VALUE,
+ dest: pending.replyTo!,
+ body: TokenPool_ReleaseOrMintResponse {
+ queryId: msg.queryId,
+ out: pending.out,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
+ }
+}
+
+fun onReleaseTransferBounced(
+ mutate st: Storage,
+ msg: AskToTransfer,
+ sender: address,
+) {
+ val jettonClient = st.jettonClient.load();
+ assert(jettonClient.isWallet(sender), Error.UnexpectedReleaseBounce);
+
+ val pendingEntry = st.pendingReleases.get(msg.queryId);
+ assert(pendingEntry.isFound, Error.PendingReleaseNotFound);
+
+ val pending = pendingEntry.loadValue().load();
+ st.pendingReleases.delete(msg.queryId);
+
+ if (pending.replyTo != null) {
+ createMessage({
+ bounce: true,
+ value: LockReleaseTokenPool_REPLY_VALUE,
+ dest: pending.replyTo!,
+ body: TokenPool_ReleaseOrMintFailure {
+ queryId: msg.queryId,
+ errorCode: Error.UnexpectedReleaseBounce as uint16,
+ },
+ }).send(SEND_MODE_PAY_FEES_SEPARATELY | SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
+ }
+}
+
+fun loadPool(st: Storage): TokenPool {
+ return TokenPool {
+ data: st.poolData.load(),
+ context: st,
+ hooks: TokenPool_Hooks {
+ ensureOutboundAccess: null,
+ ensureInboundAccess: null,
+ ensureNotCursed: null,
+ preflightCheck: null,
+ postflightCheck: null,
+ applyLockOrBurn: null,
+ applyReleaseOrMint: null,
+ onLockOrBurn: null,
+ validateLockOrBurn: null,
+ doLockOrBurn,
+ handleReleaseOrMintMessage,
+ },
+ };
+}
+
+get fun typeAndVersion(): (slice, slice) {
+ return (LockReleaseTokenPool_CONTRACT_NAME, LockReleaseTokenPool_CONTRACT_VERSION);
+}
+
+get fun token(): address {
+ return Storage.load().poolData.load().token;
+}
+
+get fun tokenDecimals(): uint8 {
+ return Storage.load().poolData.load().tokenDecimals;
+}
+
+get fun isSupportedChain(remoteChainSelector: uint64): bool {
+ return Storage.load().poolData.load().remoteChainConfigs.get(remoteChainSelector).isFound;
+}
+
+get fun onRamp(remoteChainSelector: uint64): address? {
+ val entry = Storage.load().poolData.load().mirroredPolicy.load().onRamps.get(remoteChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+get fun offRamp(remoteChainSelector: uint64): address? {
+ val entry = Storage.load().poolData.load().mirroredPolicy.load().offRamps.get(remoteChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+get fun hasPendingRelease(queryId: uint64): bool {
+ return Storage.load().pendingReleases.get(queryId).isFound;
+}
+
+get fun getRMNProxy(): address {
+ return Storage.load().poolData.load().adminConfig.load().rmnProxy;
+}
+
+get fun verifyNotCursed(subject: uint128): bool {
+ return !Storage.load().poolData.load().mirroredPolicy.load().cursedSubjects.isCursed(subject);
+}
+
+get fun owner(): address {
+ return Storage.load().poolData.load().adminConfig.load().ownable.load().get_owner();
+}
+
+get fun pendingOwner(): address? {
+ return Storage.load().poolData.load().adminConfig.load().ownable.load().get_pendingOwner();
+}
diff --git a/contracts/contracts/ccip/pools/lock_release_token_pool/errors.tolk b/contracts/contracts/ccip/pools/lock_release_token_pool/errors.tolk
new file mode 100644
index 000000000..98f3b4771
--- /dev/null
+++ b/contracts/contracts/ccip/pools/lock_release_token_pool/errors.tolk
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/utils"
+
+const LockReleaseTokenPool_FACILITY_NAME = "link.chain.ton.ccip.LockReleaseTokenPool";
+const LockReleaseTokenPool_FACILITY_ID = 263; // (crc32() % 640) + 10
+
+enum Error {
+ IncorrectJettonSender = LockReleaseTokenPool_FACILITY_ID * 100
+ PendingReleaseAlreadyExists
+ PendingReleaseNotFound
+ UnexpectedReleaseConfirmationSender
+ UnexpectedReleaseBounce
+}
diff --git a/contracts/contracts/ccip/pools/lock_release_token_pool/events.tolk b/contracts/contracts/ccip/pools/lock_release_token_pool/events.tolk
new file mode 100644
index 000000000..52817d6e9
--- /dev/null
+++ b/contracts/contracts/ccip/pools/lock_release_token_pool/events.tolk
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+const LOCKED_OR_BURNED_TOPIC = stringCrc32("LockReleaseTokenPool_LockedOrBurned");
+const RELEASED_OR_MINTED_TOPIC = stringCrc32("LockReleaseTokenPool_ReleasedOrMinted");
+const RAMP_ACCESS_UPDATED_TOPIC = stringCrc32("LockReleaseTokenPool_RampAccessUpdated");
+const CURSED_SUBJECTS_UPDATED_TOPIC = stringCrc32("LockReleaseTokenPool_CursedSubjectsUpdated");
+
+struct LockedOrBurnedDetails {
+ token: address;
+ sender: address;
+ amount: uint256;
+}
+
+struct LockedOrBurned {
+ remoteChainSelector: uint64;
+ details: Cell;
+}
+
+struct ReleasedOrMintedDetails {
+ token: address;
+ amount: uint256;
+ participants: Cell;
+}
+
+struct ReleasedOrMintedParticipants {
+ sender: address;
+ recipient: address;
+}
+
+struct ReleasedOrMinted {
+ remoteChainSelector: uint64;
+ details: Cell;
+}
+
+struct RampAccessUpdated {
+ remoteChainSelector: uint64;
+ onRamp: address? = null;
+ offRamp: address? = null;
+}
diff --git a/contracts/contracts/ccip/pools/lock_release_token_pool/messages.tolk b/contracts/contracts/ccip/pools/lock_release_token_pool/messages.tolk
new file mode 100644
index 000000000..0d5d3747a
--- /dev/null
+++ b/contracts/contracts/ccip/pools/lock_release_token_pool/messages.tolk
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/jetton/messages"
+import "../messages"
+
+type LockReleaseTokenPool_InMessage =
+ | ReturnExcessesBack;
+
+type LockReleaseTokenPool_BouncedMessage = AskToTransfer;
diff --git a/contracts/contracts/ccip/pools/lock_release_token_pool/storage.tolk b/contracts/contracts/ccip/pools/lock_release_token_pool/storage.tolk
new file mode 100644
index 000000000..b01b51136
--- /dev/null
+++ b/contracts/contracts/ccip/pools/lock_release_token_pool/storage.tolk
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/jetton/jetton_client"
+import "../../../lib/access/ownable_2step"
+import "../../rmn_remote/lib"
+import "../types"
+import "../token_pool"
+
+import "types"
+
+struct Storage {
+ poolData: Cell;
+ jettonClient: Cell;
+ pendingReleases: map>;
+}
+
+fun Storage.load(): Storage {
+ return Storage.fromCell(contract.getData());
+}
+
+fun Storage.store(self) {
+ contract.setData(self.toCell());
+}
+
+fun LockReleaseTokenPool_initialStorage(config: LockReleaseTokenPool_Config): Storage {
+ return Storage {
+ poolData: TokenPool_Data {
+ adminConfig: TokenPool_AdminConfig {
+ ownable: Ownable2Step {
+ owner: config.owner,
+ pendingOwner: null,
+ }.toCell(),
+ rmnProxy: config.rmnProxy,
+ dynamicConfig: TokenPool_DynamicConfig {
+ router: config.router,
+ rateLimitAdmin: null,
+ feeAdmin: null,
+ }.toCell(),
+ allowedFinalityConfig: TOKEN_POOL_DEFAULT_FINALITY,
+ }.toCell(),
+ mirroredPolicy: TokenPool_MirroredPolicy {
+ onRamps: createEmptyMap(),
+ offRamps: createEmptyMap(),
+ cursedSubjects: CursedSubjects {
+ data: createEmptyMap(),
+ },
+ }.toCell(),
+ token: config.token,
+ tokenDecimals: config.tokenDecimals,
+ remoteChainConfigs: createEmptyMap(),
+ tokenTransferFeeConfigs: createEmptyMap(),
+ }.toCell(),
+ jettonClient: config.jettonClient.toCell(),
+ pendingReleases: createEmptyMap(),
+ };
+}
diff --git a/contracts/contracts/ccip/pools/lock_release_token_pool/types.tolk b/contracts/contracts/ccip/pools/lock_release_token_pool/types.tolk
new file mode 100644
index 000000000..e27c3ee91
--- /dev/null
+++ b/contracts/contracts/ccip/pools/lock_release_token_pool/types.tolk
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../../lib/jetton/jetton_client"
+import "../types"
+
+const LockReleaseTokenPool_CONTRACT_NAME = "link.chain.ton.ccip.LockReleaseTokenPool".literalSlice();
+const LockReleaseTokenPool_CONTRACT_VERSION = "0.1.0".literalSlice();
+const LockReleaseTokenPool_RELEASE_TRANSFER_VALUE = ton("0.05");
+const LockReleaseTokenPool_REPLY_VALUE = ton("0.01");
+
+struct LockReleaseTokenPool_PendingRelease {
+ replyTo: address? = null;
+ request: Cell;
+ out: Cell;
+ expectedSender: address;
+}
+
+struct LockReleaseTokenPool_Config {
+ owner: address;
+ token: address;
+ tokenDecimals: uint8;
+ rmnProxy: address;
+ router: address;
+ jettonClient: JettonClient;
+}
diff --git a/contracts/contracts/ccip/pools/messages.tolk b/contracts/contracts/ccip/pools/messages.tolk
new file mode 100644
index 000000000..33d8353b0
--- /dev/null
+++ b/contracts/contracts/ccip/pools/messages.tolk
@@ -0,0 +1,117 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../lib/jetton/messages"
+import "../../lib/utils"
+import "../common/types"
+import "../rmn_remote/lib"
+
+import "types"
+
+struct (0x56f73d37) TokenPool_ApplyChainUpdates {
+ queryId: uint64;
+ remoteChainSelectorsToRemove: SnakedCell;
+ chainsToAdd: SnakedCell;
+}
+
+struct (0x17c242dc) TokenPool_AddRemotePool {
+ queryId: uint64;
+ remoteChainSelector: uint64;
+ remotePoolAddress: Cell;
+}
+
+struct (0x426b8cc4) TokenPool_RemoveRemotePool {
+ queryId: uint64;
+ remoteChainSelector: uint64;
+ remotePoolAddress: Cell;
+}
+
+struct (0xd7712810) TokenPool_SetDynamicConfig {
+ queryId: uint64;
+ router: address;
+ rateLimitAdmin: address? = null;
+ feeAdmin: address? = null;
+}
+
+struct (0x3c50a39b) TokenPool_SetAllowedFinalityConfig {
+ queryId: uint64;
+ allowedFinalityConfig: uint32;
+}
+
+struct (0x4fe2d26c) TokenPool_SetRateLimitConfig {
+ queryId: uint64;
+ updates: SnakedCell;
+}
+
+struct (0x30a1d1f7) TokenPool_ApplyTokenTransferFeeConfigUpdates {
+ queryId: uint64;
+ updates: SnakedCell;
+ disableChainSelectors: SnakedCell;
+}
+
+struct (0xe30764be) TokenPool_UpdateRampAccess {
+ queryId: uint64;
+ updates: SnakedCell;
+}
+
+/// Sets the RMN proxy address, a role which is responsible for updating the cursed subjects list.
+struct (0x9929b642) TokenPool_SetRMNProxy {
+ queryId: uint64;
+ rmnProxy: address;
+}
+
+struct (0x2c906eb7) TokenPool_UpdateCursedSubjects {
+ queryId: uint64;
+ cursedSubjects: CursedSubjects;
+}
+
+/// Lock tokens into the pool or burn the tokens.
+/// @param lockOrBurnIn Encoded data fields for the processing of tokens on the source chain.
+/// @param requestedFinalityConfig Requested finality encoding (see `FinalityCodec`).
+/// @param tokenArgs Additional token arguments.
+struct (0xfa7da444) TokenPool_LockOrBurn {
+ queryId: uint64;
+ request: Cell; // TODO: consider renaming
+ requestedFinalityConfig: uint32;
+ tokenArgs: cell?;
+ replyTo: address?;
+}
+
+struct (0x6c060424) TokenPool_LockOrBurnResponse {
+ queryId: uint64;
+ // TODO: unwrap and inline this type
+ out: Cell;
+ destTokenAmount: uint256;
+}
+
+/// @Releases or mints tokens on the destination chain.
+/// @param releaseOrMintIn Encoded data fields for the processing of tokens on the destination chain.
+/// @param requestedFinalityConfig Requested finality encoding (see `FinalityCodec`).
+struct (0x351f77e3) TokenPool_ReleaseOrMint {
+ queryId: uint64;
+ request: Cell;
+ requestedFinalityConfig: uint32;
+ replyTo: address? = null;
+}
+
+struct (0x78dc2232) TokenPool_ReleaseOrMintResponse {
+ queryId: uint64;
+ out: Cell;
+}
+
+struct (0xef0cb36e) TokenPool_ReleaseOrMintFailure {
+ queryId: uint64;
+ errorCode: uint16;
+}
+
+type TokenPool_InMessage =
+ | TokenPool_ApplyChainUpdates
+ | TokenPool_AddRemotePool
+ | TokenPool_RemoveRemotePool
+ | TokenPool_SetDynamicConfig
+ | TokenPool_SetAllowedFinalityConfig
+ | TokenPool_SetRateLimitConfig
+ | TokenPool_ApplyTokenTransferFeeConfigUpdates
+ | TokenPool_UpdateRampAccess
+ | TokenPool_SetRMNProxy
+ | TokenPool_UpdateCursedSubjects
+ | TokenPool_ReleaseOrMint
+ | TransferNotificationForRecipient; // fwdPayload = TokenPool_LockOrBurn
diff --git a/contracts/contracts/ccip/pools/rate_limiter.tolk b/contracts/contracts/ccip/pools/rate_limiter.tolk
new file mode 100644
index 000000000..b14ccfadf
--- /dev/null
+++ b/contracts/contracts/ccip/pools/rate_limiter.tolk
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: BUSL-1.1
+
+const RateLimiter_FACILITY_NAME = "link.chain.ton.ccip.rateLimiter";
+const RateLimiter_FACILITY_ID = 263; // (crc32() % 640) + 10 // TODO: update with computed val
+
+enum RateLimiter_Error {
+ BucketOverfilled = RateLimiter_FACILITY_ID * 100
+ TokenMaxCapacityExceeded // (uint256 capacity, uint256 requested, address tokenAddress);
+ TokenRateLimitReached // (uint256 minWaitInSeconds, uint256 available, address tokenAddress);
+ InvalidRateLimitConfig // (Config rateLimiterConfig);
+ DisabledNonZeroRateLimit // (Config config);
+}
+
+struct RateLimiter_Config {
+ isEnabled: bool // Indication whether the rate limiting should be enabled.
+ capacity: uint128 // ──╮ Specifies the capacity of the rate limiter.
+ rate: uint128 // ──────╯ Specifies the rate of the rate limiter.
+}
+
+struct RateLimiter_TokenBucket {
+ tokens: uint128 // ────╮ Current number of tokens that are in the bucket.
+ lastUpdated: uint64 // │ Timestamp in seconds of the last token refill, good for 100+ years.
+ isEnabled: bool // ────╯ Indication whether the rate limiting is enabled or not.
+ capacity: uint128 // ──╮ Maximum number of tokens that can be in the bucket.
+ rate: uint128 // ──────╯ Number of tokens per second that the bucket is refilled.
+}
+
+fun RateLimiter_TokenBucket.fromConfig(config: RateLimiter_Config): RateLimiter_TokenBucket {
+ return RateLimiter_TokenBucket {
+ tokens: config.capacity,
+ lastUpdated: blockchain.now(),
+ isEnabled: config.isEnabled,
+ capacity: config.capacity,
+ rate: config.rate,
+ };
+}
+
+/// @notice _consume removes the given tokens from the pool, lowering the rate tokens allowed to be
+/// consumed for subsequent calls.
+/// @param requestTokens The total tokens to be consumed from the bucket.
+/// @param tokenAddress The token to consume capacity for, use 0x0 to indicate aggregate value capacity.
+/// @dev Reverts when requestTokens exceeds bucket capacity or available tokens in the bucket.
+/// @dev emits removal of requestTokens if requestTokens is > 0.
+fun RateLimiter_TokenBucket._consume(mutate self, requestTokens: uint256, _tokenAddress: address) {
+ // If there is no value to remove or rate limiting is turned off, skip this step to reduce gas usage.
+ if (!self.isEnabled || requestTokens == 0) {
+ return;
+ }
+
+ var tokens = self.tokens;
+ val capacity = self.capacity;
+ val timeDiff = blockchain.now() - self.lastUpdated;
+
+ if (timeDiff != 0) {
+ if (tokens > capacity) {
+ throw RateLimiter_Error.BucketOverfilled;
+ }
+
+ // Refill tokens when arriving at a new block time.
+ tokens = _calculateRefill(capacity, tokens, timeDiff, self.rate);
+
+ self.lastUpdated = blockchain.now();
+ }
+
+ if (capacity < requestTokens) {
+ throw RateLimiter_Error.TokenMaxCapacityExceeded;
+ }
+ if (tokens < requestTokens) {
+ var rate = self.rate;
+ if (rate == 0) {
+ // No tokens will ever be refilled. Check is required to avoid division by zero later.
+ throw RateLimiter_Error.TokenRateLimitReached;
+ }
+ // Wait required until the bucket is refilled enough to accept this value, round up to next higher second.
+ // Consume is not guaranteed to succeed after wait time passes if there is competing traffic.
+ // This acts as a lower bound of wait time.
+ val _minWaitInSeconds = ((requestTokens - tokens) + (rate - 1)) / rate;
+
+ throw RateLimiter_Error.TokenRateLimitReached; // TODO this should be a return? revert TokenRateLimitReached(minWaitInSeconds, tokens, tokenAddress);
+ }
+ tokens -= requestTokens;
+
+ // Downcast is safe here, as tokens is not larger than capacity.
+ self.tokens = tokens as uint128;
+}
+
+/// @notice Gets the token bucket with its values for the block it was requested at.
+/// @return The token bucket.
+fun RateLimiter_TokenBucket._currentTokenBucketState(mutate self): RateLimiter_TokenBucket {
+ // We update the bucket to reflect the status at the exact time of the call. This means we might need to refill a
+ // part of the bucket based on the time that has passed since the last update.
+ self.tokens = _calculateRefill(
+ self.capacity,
+ self.tokens,
+ blockchain.now() - self.lastUpdated,
+ self.rate
+ );
+ self.lastUpdated = blockchain.now();
+ return self;
+}
+
+/// @notice Sets the rate limited config.
+/// @param s_bucket The token bucket.
+/// @param config The new config.
+fun RateLimiter_TokenBucket._setTokenBucketConfig(mutate self, config: RateLimiter_Config) {
+ config.validate();
+
+ self.isEnabled = config.isEnabled;
+ self.tokens = config.capacity;
+ self.capacity = config.capacity;
+ self.rate = config.rate;
+ self.lastUpdated = blockchain.now();
+}
+
+/// @notice Validates the token bucket config.
+fun RateLimiter_Config.validate(self) {
+ if (self.isEnabled) {
+ if (self.rate > self.capacity) {
+ throw RateLimiter_Error.InvalidRateLimitConfig;
+ }
+ } else {
+ if (self.rate != 0 || self.capacity != 0) {
+ throw RateLimiter_Error.DisabledNonZeroRateLimit;
+ }
+ }
+}
+
+/// @notice Calculate refilled tokens.
+/// @param capacity bucket capacity.
+/// @param tokens current bucket tokens.
+/// @param timeDiff block time difference since last refill.
+/// @param rate bucket refill rate.
+/// @return the value of tokens after refill.
+fun _calculateRefill(capacity: uint128, tokens: uint128, timeDiff: uint256, rate: uint128): uint128 {
+ return _min(capacity, (tokens as uint256 + timeDiff * rate as uint256) as uint128);
+}
+
+/// @notice Return the smallest of two integers.
+/// @param a first int.
+/// @param b second int.
+/// @return smallest.
+fun _min(a: uint128, b: uint128): uint128 {
+ if (a < b) {
+ return a;
+ }
+ return b;
+}
diff --git a/contracts/contracts/ccip/pools/token_pool.tolk b/contracts/contracts/ccip/pools/token_pool.tolk
new file mode 100644
index 000000000..9fb71ed30
--- /dev/null
+++ b/contracts/contracts/ccip/pools/token_pool.tolk
@@ -0,0 +1,1114 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "@stdlib/lisp-lists"
+
+import "../../lib/math"
+import "../../lib/utils"
+import "../../lib/jetton/messages"
+import "../../lib/jetton/jetton_client"
+import "../../lib/access/ownable_2step"
+import "../common/types"
+import "../rmn_remote/lib"
+
+import "types"
+import "errors"
+import "messages"
+import "events"
+import "rate_limiter"
+
+struct TokenPool_AdminConfig {
+ ownable: Cell;
+ rmnProxy: address;
+ dynamicConfig: Cell;
+ allowedFinalityConfig: uint32 = TOKEN_POOL_DEFAULT_FINALITY;
+}
+
+struct TokenPool_Data {
+ adminConfig: Cell;
+ mirroredPolicy: Cell;
+ token: address;
+ tokenDecimals: uint8;
+ remoteChainConfigs: map;
+ tokenTransferFeeConfigs: map;
+}
+
+@inline
+fun TokenPool_Data.fromContractData() {
+ return TokenPool_Data.fromCell(contract.getData());
+}
+
+@inline
+fun TokenPool_Data.storeAsContractData(self) {
+ contract.setData(self.toCell());
+}
+
+// --- Hooks struct (extensions) ---
+
+/// Hook extensions exposed by the TokenPool contract.
+struct TokenPool_Hooks {
+ ensureOutboundAccess: ((T?, address, uint64) -> void)?;
+ ensureInboundAccess: ((T?, address, uint64) -> void)?;
+ ensureNotCursed: ((T?, uint64) -> void)?;
+ preflightCheck: ((T?, TokenPool_LockOrBurnInV1, uint32, cell?, uint256) -> void)?;
+ postflightCheck: ((T?, TokenPool_ReleaseOrMintInV1, uint256, uint32) -> void)?;
+ applyLockOrBurn: ((T?, TokenPool_LockOrBurnPrepared) -> void)?;
+ applyReleaseOrMint: ((T?, TokenPool_ReleaseOrMintPrepared) -> void)?;
+
+ // LockOrBurn flow
+ onLockOrBurn: ((T, address, TransferNotificationForRecipient) -> T)?;
+ validateLockOrBurn: ((T, address, TokenPool_LockOrBurnInV1, uint32, cell?) -> (T, TokenPool_LockOrBurnPrepared))?;
+ doLockOrBurn: ((T, address, TransferNotificationForRecipient, TokenPool_LockOrBurn, TokenPool_LockOrBurnPrepared) -> T)?;
+ handleReleaseOrMintMessage: ((T?, address, TokenPool_ReleaseOrMint, TokenPool_ReleaseOrMintPrepared) -> T?)?;
+}
+
+struct TokenPool {
+ data: TokenPool_Data;
+ context: T? = null;
+ hooks: TokenPool_Hooks? = null;
+}
+
+@inline
+fun TokenPool.load(context: T? = null, hooks: TokenPool_Hooks? = null): TokenPool {
+ val data = TokenPool_Data.fromContractData();
+ return TokenPool { data, context, hooks };
+}
+
+fun TokenPool.store(self) {
+ self.data.storeAsContractData();
+}
+
+/// Returns if the token pool supports the given token.
+/// @param token The address of the token.
+@inline
+fun TokenPool.isSupportedToken(self, token: address): bool {
+ return self.data.token == token;
+}
+
+/// Gets the Jetton token that this pool can lock or burn.
+/// @return token The Jetton token representation.
+@inline
+fun TokenPool.getToken(self): address {
+ return self.data.token;
+}
+
+/// Gets the Jetton token decimals on the local chain.
+@inline
+fun TokenPool.getTokenDecimals(self): uint8 {
+ return self.data.tokenDecimals;
+}
+
+@inline
+fun TokenPool.getRmnProxy(self): address {
+ return self.data.adminConfig.load().rmnProxy;
+}
+
+/// Gets the pools dynamic configuration.
+@inline
+fun TokenPool.getDynamicConfig(self): TokenPool_DynamicConfig {
+ return self.data.adminConfig.load().dynamicConfig.load();
+}
+
+/// Gets the finality config as defined in the FinalityCodec library. This value does NOT 1:1 translate to
+/// a block depth. The finality config contains special flags and should only be encoded/decoded using the
+/// FinalityCodec library. Checks must happen by calling `FinalityCodec._ensureRequestedFinalityAllowed`.
+@inline
+fun TokenPool.getAllowedFinalityConfig(self): uint32 {
+ return self.data.adminConfig.load().allowedFinalityConfig;
+}
+
+// TODO: implement me
+/// Gets the advanced pool hook contract address used by this pool.
+@inline
+fun TokenPool.getAdvancedPoolHooks(self): address? {
+ return null;
+}
+
+@inline
+fun TokenPool.getOnRamp(self, remoteChainSelector: uint64): address? {
+ val entry = self.data.mirroredPolicy.load().onRamps.get(remoteChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+@inline
+fun TokenPool.getOffRamp(self, remoteChainSelector: uint64): address? {
+ val entry = self.data.mirroredPolicy.load().offRamps.get(remoteChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+@inline
+fun TokenPool.verifyNotCursed(self, subject: uint128): bool {
+ return !self.data.mirroredPolicy.load().cursedSubjects.isCursed(subject);
+}
+
+/// Checks whether a remote chain is supported in the token pool.
+/// @param remoteChainSelector The remote chain selector to check.
+@inline
+fun TokenPool.isSupportedChain(self, remoteChainSelector: uint64): bool {
+ return self.data.remoteChainConfigs.get(remoteChainSelector).isFound;
+}
+
+/// Checks if the pool address is configured on the remote chain.
+/// @param remoteChainSelector Remote chain selector.
+/// @param remotePoolAddress The address of the remote pool.
+fun TokenPool.isRemotePool(self, remoteChainSelector: uint64, remotePoolAddress: Cell): bool {
+ val entry = self.data.remoteChainConfigs.get(remoteChainSelector);
+ if (!entry.isFound) {
+ return false;
+ }
+
+ val config = entry.loadValue();
+ return config.remotePools.get(TokenPool_hashCrossChainAddress(remotePoolAddress)).isFound;
+}
+
+/// Gets the token address on the remote chain.
+/// @param remoteChainSelector Remote chain selector.
+fun TokenPool.getRemoteToken(self, remoteChainSelector: uint64): Cell {
+ val config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ return config.remoteTokenAddress;
+}
+
+/// Gets the pool address on the remote chain.
+/// @param remoteChainSelector Remote chain selector.
+fun TokenPool.getRemotePools(self, remoteChainSelector: uint64): lisp_list> {
+ val config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ var list: lisp_list> = [];
+ var entry = config.remotePools.findFirst();
+ while (entry.isFound) {
+ list.prependHead(entry.loadValue());
+ entry = config.remotePools.iterateNext(entry);
+ }
+ return list;
+}
+
+/// Sets the dynamic configuration for the pool.
+/// @param router The address of the router contract.
+/// @param rateLimitAdmin The address of the rate limiter admin.
+/// @param feeAdmin An additional address that can withdraw fees from this contract.
+/// @dev FeeTokenHandler will revert if feeAdmin is zero when withdrawing fees.
+/// @dev If only the owner can withdraw fees, set feeAdmin to address(0).
+fun TokenPool.setDynamicConfig(
+ mutate self,
+ sender: address,
+ router: address,
+ rateLimitAdmin: address?,
+ feeAdmin: address?,
+) {
+ var adminConfig = self.data.adminConfig.load();
+ adminConfig.ownable.load().requireOwner(sender);
+
+ // TODO: check if address is valid (not zero)
+ // assert(!router.isNone(), TokenPool_Error.ZeroAddressInvalid);
+ adminConfig.dynamicConfig = TokenPool_DynamicConfig {
+ router,
+ rateLimitAdmin,
+ feeAdmin,
+ }.toCell();
+ self.data.adminConfig = adminConfig.toCell();
+
+ // TODO: reply back to sender with excess
+ emit(TOKEN_POOL_DYNAMIC_CONFIG_SET_TOPIC, TokenPool_DynamicConfigSet {
+ router,
+ rateLimitAdmin,
+ feeAdmin,
+ });
+}
+
+/// Sets the finality config according to the FinalityCodec library encoding.
+/// @param allowedFinality The finality settings allowed in this pool, according to the FinalityCodec encoding.
+fun TokenPool.setAllowedFinalityConfig(mutate self, sender: address, allowedFinalityConfig: uint32) {
+ var adminConfig = self.data.adminConfig.load();
+ adminConfig.ownable.load().requireOwner(sender);
+ // Any bytes4 value is accepted as allowedFinality; the FinalityCodec semantics are enforced when requests are
+ // checked against this value via FinalityCodec._ensureRequestedFinalityAllowed.
+ adminConfig.allowedFinalityConfig = allowedFinalityConfig;
+ self.data.adminConfig = adminConfig.toCell();
+
+ // TODO: reply back to sender with excess
+ emit(TOKEN_POOL_FINALITY_CONFIG_SET_TOPIC, TokenPool_FinalityConfigSet {
+ allowedFinalityConfig,
+ });
+}
+
+// TODO: add updateAdvancedPoolHooks
+
+/// Adds a remote pool for a given chain selector. This could be due to a pool being upgraded on the remote
+/// chain. We don't simply want to replace the old pool as there could still be valid inflight messages from the old
+/// pool. This function allows for multiple pools to be added for a single chain selector.
+/// @param remoteChainSelector The remote chain selector for which the remote pool address is being added.
+/// @param remotePoolAddress The address of the new remote pool.
+fun TokenPool.addRemotePool(
+ mutate self,
+ sender: address,
+ remoteChainSelector: uint64,
+ remotePoolAddress: Cell,
+) {
+ self.data.adminConfig.load().ownable.load().requireOwner(sender);
+ assert(self.isSupportedChain(remoteChainSelector), TokenPool_Error.NonExistentChain);
+
+ var config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ self._setRemotePool(mutate config, remotePoolAddress);
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ // TODO: reply back to sender with excess
+ emit(TOKEN_POOL_REMOTE_POOL_ADDED_TOPIC, TokenPool_RemotePoolAdded {
+ remoteChainSelector,
+ remotePoolAddress,
+ });
+}
+
+/// Removes the remote pool address for a given chain selector.
+/// @dev All inflight txs from the remote pool will be rejected after it is removed. To ensure no loss of funds, there
+/// should be no inflight txs from the given pool.
+/// @param remoteChainSelector The remote chain selector.
+/// @param remotePoolAddress The remote pool address to remove.
+fun TokenPool.removeRemotePool(
+ mutate self,
+ sender: address,
+ remoteChainSelector: uint64,
+ remotePoolAddress: Cell,
+) {
+ self.data.adminConfig.load().ownable.load().requireOwner(sender);
+ assert(self.isSupportedChain(remoteChainSelector), TokenPool_Error.NonExistentChain);
+
+ var config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ val deleted = config.remotePools.delete(TokenPool_hashCrossChainAddress(remotePoolAddress));
+ assert(deleted, TokenPool_Error.InvalidRemotePoolForChain);
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ // TODO: reply back to sender with excess
+ emit(TOKEN_POOL_REMOTE_POOL_REMOVED_TOPIC, TokenPool_RemotePoolRemoved {
+ remoteChainSelector,
+ remotePoolAddress,
+ });
+}
+
+/// Sets the permissions for a list of chains selectors. Actual senders for these chains
+/// need to be allowed on the Router to interact with this pool.
+/// @param remoteChainSelectorsToRemove A list of chain selectors to remove.
+/// @param chainsToAdd A list of chains and their new permission status & rate limits. Rate limits
+/// are only used when the chain is being added through `allowed` being true.
+/// @dev Only callable by the owner
+fun TokenPool.applyChainUpdates(
+ mutate self,
+ sender: address,
+ remoteChainSelectorsToRemove: SnakedCell,
+ chainsToAdd: SnakedCell,
+) {
+ self.data.adminConfig.load().ownable.load().requireOwner(sender);
+
+ var rmIter = remoteChainSelectorsToRemove.iter();
+ while (!rmIter.empty()) {
+ val remoteChainSelector = rmIter.next();
+ val deleted = self.data.remoteChainConfigs.delete(remoteChainSelector);
+ assert(deleted, TokenPool_Error.NonExistentChain);
+
+ emit(TOKEN_POOL_CHAIN_REMOVED_TOPIC, TokenPool_ChainRemoved { remoteChainSelector });
+ }
+
+ var addIter = chainsToAdd.iter();
+ while (!addIter.empty()) {
+ val newChain = addIter.next();
+ val remoteTokenAddress = newChain.remoteTokenAddress;
+ assert(!TokenPool_isEmptyCrossChainAddress(remoteTokenAddress), TokenPool_Error.ZeroAddressInvalid);
+
+ val remoteChainSelector = newChain.remoteChainSelector;
+ assert(!self.data.remoteChainConfigs.get(remoteChainSelector).isFound, TokenPool_Error.ChainAlreadyExists);
+
+ val rlConfigDefault = RateLimiter_Config {
+ isEnabled: false,
+ capacity: 0,
+ rate: 0,
+ };
+
+ var rateLimitConfigs = newChain.rateLimitConfigs.load();
+ var config = TokenPool_RemoteChainConfig {
+ remoteTokenAddress,
+ remotePools: createEmptyMap(),
+ rateLimiters: TokenPool_RateLimiterPair {
+ outbound: RateLimiter_TokenBucket.fromConfig(rateLimitConfigs.outbound.load()).toCell(),
+ inbound: RateLimiter_TokenBucket.fromConfig(rateLimitConfigs.inbound.load()).toCell(),
+ }.toCell(),
+ fastFinalityRateLimiters: TokenPool_RateLimiterPair {
+ outbound: RateLimiter_TokenBucket.fromConfig(rlConfigDefault).toCell(),
+ inbound: RateLimiter_TokenBucket.fromConfig(rlConfigDefault).toCell(),
+ }.toCell(),
+ };
+
+ var rpAddrIter = newChain.remotePoolAddresses.iter();
+ while (!rpAddrIter.empty()) {
+ val remotePoolAddress = rpAddrIter.next().toCell();
+ self._setRemotePool(mutate config, remotePoolAddress);
+
+ emit(TOKEN_POOL_REMOTE_POOL_ADDED_TOPIC, TokenPool_RemotePoolAdded {
+ remoteChainSelector,
+ remotePoolAddress,
+ });
+ }
+
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ emit(TOKEN_POOL_CHAIN_ADDED_TOPIC, TokenPool_ChainAdded {
+ remoteChainSelector,
+ remoteTokenAddress,
+ });
+ }
+
+ // TODO: reply back to sender with excess
+}
+
+fun TokenPool.applyRampAccessUpdates(
+ mutate self,
+ sender: address,
+ updates: SnakedCell,
+) {
+ var adminConfig = self.data.adminConfig.load();
+ var ownable = adminConfig.ownable.load();
+ ownable.requireOwner(sender);
+ adminConfig.ownable = ownable.toCell();
+ self.data.adminConfig = adminConfig.toCell();
+
+ var mirroredPolicy = self.data.mirroredPolicy.load();
+ var iter = updates.iter();
+ while (!iter.empty()) {
+ val update = iter.next();
+
+ if (update.onRamp != null) {
+ mirroredPolicy.onRamps.set(update.remoteChainSelector, update.onRamp!);
+ } else {
+ mirroredPolicy.onRamps.delete(update.remoteChainSelector);
+ }
+
+ if (update.offRamp != null) {
+ mirroredPolicy.offRamps.set(update.remoteChainSelector, update.offRamp!);
+ } else {
+ mirroredPolicy.offRamps.delete(update.remoteChainSelector);
+ }
+
+ emit(TOKEN_POOL_RAMP_ACCESS_UPDATED_TOPIC, TokenPool_RampAccessUpdated {
+ remoteChainSelector: update.remoteChainSelector,
+ onRamp: update.onRamp,
+ offRamp: update.offRamp,
+ });
+ }
+
+ self.data.mirroredPolicy = mirroredPolicy.toCell();
+}
+
+fun TokenPool.setRMNProxy(mutate self, sender: address, rmnProxy: address) {
+ var adminConfig = self.data.adminConfig.load();
+ adminConfig.ownable.load().requireOwner(sender);
+
+ adminConfig.rmnProxy = rmnProxy;
+ self.data.adminConfig = adminConfig.toCell();
+}
+
+fun TokenPool.setCursedSubjects(
+ mutate self,
+ sender: address,
+ cursedSubjects: CursedSubjects,
+) {
+ assert(sender == self.data.adminConfig.load().rmnProxy, TokenPool_Error.Unauthorized);
+
+ var mirroredPolicy = self.data.mirroredPolicy.load();
+ mirroredPolicy.cursedSubjects = cursedSubjects;
+ self.data.mirroredPolicy = mirroredPolicy.toCell();
+
+ emit(TOKEN_POOL_CURSED_SUBJECTS_UPDATED_TOPIC, TokenPool_CursedSubjectsUpdated {})}
+
+fun TokenPool.onInternalMessage(mutate self, msgSender: address, msgValue: coins, msgBody: slice): bool {
+ val msg = lazy TokenPool_InMessage.fromSlice(msgBody);
+
+ match (msg) {
+ TokenPool_ApplyChainUpdates => {
+ self.applyChainUpdates(msgSender, msg.remoteChainSelectorsToRemove, msg.chainsToAdd);
+ return true;
+ }
+ TokenPool_AddRemotePool => {
+ self.addRemotePool(msgSender, msg.remoteChainSelector, msg.remotePoolAddress);
+ return true;
+ }
+ TokenPool_RemoveRemotePool => {
+ self.removeRemotePool(msgSender, msg.remoteChainSelector, msg.remotePoolAddress);
+ return true;
+ }
+ TokenPool_SetDynamicConfig => {
+ self.setDynamicConfig(msgSender, msg.router, msg.rateLimitAdmin, msg.feeAdmin);
+ return true;
+ }
+ TokenPool_SetAllowedFinalityConfig => {
+ self.setAllowedFinalityConfig(msgSender, msg.allowedFinalityConfig);
+ return true;
+ }
+ TokenPool_SetRateLimitConfig => {
+ self.setRateLimitConfig(msgSender, msg.updates);
+ return true;
+ }
+ TokenPool_ApplyTokenTransferFeeConfigUpdates => {
+ self.applyTokenTransferFeeConfigUpdates(msgSender, msg.updates, msg.disableChainSelectors);
+ return true;
+ }
+ TokenPool_UpdateRampAccess => {
+ self.applyRampAccessUpdates(msgSender, msg.updates);
+ return true;
+ }
+ TokenPool_SetRMNProxy => {
+ self.setRMNProxy(msgSender, msg.rmnProxy);
+ return true;
+ }
+ TokenPool_UpdateCursedSubjects => {
+ self.setCursedSubjects(msgSender, msg.cursedSubjects);
+ return true;
+ }
+ TokenPool_ReleaseOrMint => {
+ val prepared = self.prepareReleaseOrMint(msgSender, msg.request.load(), msg.requestedFinalityConfig);
+ assert(self.hooks != null && self.hooks.handleReleaseOrMintMessage != null, TokenPool_Error.UnsupportedOperation);
+ self.context = self.hooks.handleReleaseOrMintMessage(self.context, msgSender, msg, prepared);
+ return true;
+ }
+ TransferNotificationForRecipient => {
+ self.onLockOrBurn(msgSender, msg);
+ return true;
+ }
+ else => {
+ var adminConfig = self.data.adminConfig.load();
+ var ownable = adminConfig.ownable.load();
+ val handled = ownable.onInternalMessage(msgSender, msgBody);
+ if (handled) {
+ adminConfig.ownable = ownable.toCell();
+ self.data.adminConfig = adminConfig.toCell();
+ return true;
+ }
+
+ return msgBody.isEmpty();
+ }
+ }
+}
+
+fun TokenPool.applyTokenTransferFeeConfigUpdates(
+ mutate self,
+ sender: address,
+ updates: SnakedCell,
+ disableChainSelectors: SnakedCell,
+) {
+ self.data.adminConfig.load().ownable.load().requireOwner(sender);
+
+ var iter = updates.iter();
+ while (!iter.empty()) {
+ val update = iter.next();
+ assert(self.isSupportedChain(update.destChainSelector), TokenPool_Error.NonExistentChain);
+ assert(update.tokenTransferFeeConfig.isEnabled, TokenPool_Error.InvalidTokenTransferFeeConfig);
+ assert((update.tokenTransferFeeConfig.finalityTransferFeeBps as uint256) < TOKEN_POOL_BPS_DIVIDER, TokenPool_Error.InvalidTransferFeeBps);
+ assert((update.tokenTransferFeeConfig.fastFinalityTransferFeeBps as uint256) < TOKEN_POOL_BPS_DIVIDER, TokenPool_Error.InvalidTransferFeeBps);
+ assert(update.tokenTransferFeeConfig.destGasOverhead > 0, TokenPool_Error.InvalidTokenTransferFeeConfig);
+ self.data.tokenTransferFeeConfigs.set(update.destChainSelector, update.tokenTransferFeeConfig);
+ }
+
+ var disableIter = disableChainSelectors.iter();
+ while (!disableIter.empty()) {
+ self.data.tokenTransferFeeConfigs.delete(disableIter.next());
+ }
+}
+
+fun TokenPool.getTokenTransferFeeConfig(self, destChainSelector: uint64): TokenPool_TokenTransferFeeConfig? {
+ val entry = self.data.tokenTransferFeeConfigs.get(destChainSelector);
+ return entry.isFound ? entry.loadValue() : null;
+}
+
+/// Returns the pool fee parameters that will apply to a transfer.
+/// @param destChainSelector The destination lane selector.
+/// @param requestedFinalityConfig Requested finality encoding (see `FinalityCodec`).
+fun TokenPool.getFee(
+ self,
+ _localToken: address,
+ destChainSelector: uint64,
+ _amount: uint256,
+ _feeToken: address,
+ requestedFinalityConfig: uint32,
+ _tokenArgs: cell?,
+): (uint256, uint32, uint32, uint16, bool) {
+ TokenPool_ensureRequestedFinalityAllowed(requestedFinalityConfig, self.data.adminConfig.load().allowedFinalityConfig);
+
+ val entry = self.data.tokenTransferFeeConfigs.get(destChainSelector);
+ if (!entry.isFound) {
+ return (0, 0, 0, 0, false);
+ }
+
+ val feeConfig = entry.loadValue();
+ // If config is disabled, return zeros with isEnabled=false to signal OnRamp to use FeeQuoter defaults.
+ if (!feeConfig.isEnabled) {
+ return (0, 0, 0, 0, false);
+ }
+
+ if (requestedFinalityConfig != TOKEN_POOL_WAIT_FOR_FINALITY_FLAG) {
+ return (
+ feeConfig.fastFinalityFeeUSDCents,
+ feeConfig.destGasOverhead,
+ feeConfig.destBytesOverhead,
+ feeConfig.fastFinalityTransferFeeBps,
+ true,
+ );
+ }
+
+ return (
+ feeConfig.finalityFeeUSDCents,
+ feeConfig.destGasOverhead,
+ feeConfig.destBytesOverhead,
+ feeConfig.finalityTransferFeeBps,
+ true,
+ );
+}
+
+/// @dev Calculates the fee based on the transferred amount, and the configured basis points.
+/// @param lockOrBurnIn The original lock or burn request.
+/// @param requestedFinalityConfig The requested finality encoding (see `FinalityCodec`).
+/// A value of zero (FinalityCodec.WAIT_FOR_FINALITY_FLAG) applies default finality fees.
+/// Returns the fee amount.
+fun TokenPool.getFeeAmount(
+ self,
+ request: TokenPool_LockOrBurnInV1,
+ requestedFinalityConfig: uint32,
+): uint256 {
+ val entry = self.data.tokenTransferFeeConfigs.get(request.remoteChainSelector);
+ if (!entry.isFound) {
+ return 0;
+ }
+
+ val feeConfig = entry.loadValue();
+ if (!feeConfig.isEnabled) {
+ return 0;
+ }
+
+ if (requestedFinalityConfig != TOKEN_POOL_WAIT_FOR_FINALITY_FLAG) {
+ return (request.amount * (feeConfig.fastFinalityTransferFeeBps as uint256)) / TOKEN_POOL_BPS_DIVIDER;
+ }
+
+ return (request.amount * (feeConfig.finalityTransferFeeBps as uint256)) / TOKEN_POOL_BPS_DIVIDER;
+}
+
+/// Validates the lock or burn input for correctness on
+/// - token to be locked or burned
+/// - RMN curse status
+/// - if the sender is a valid onRamp
+/// - rate limiting for either default or FTF transfer messages.
+/// - preflight checks hooks (if enabled)
+/// @param lockOrBurnIn The input to validate.
+/// @param requestedFinality The requested finality speed according to the FinalityCodec encoding.
+/// @param tokenArgs Additional token arguments passed in by the sender of the message.
+/// @param feeAmount The fee amount deducted from the transfer amount.
+/// @dev This function should always be called before executing a lock or burn. Not doing so would allow
+/// for various exploits.
+fun TokenPool.validateLockOrBurn(
+ mutate self,
+ sender: address,
+ request: TokenPool_LockOrBurnInV1,
+ requestedFinalityConfig: uint32 = TOKEN_POOL_DEFAULT_FINALITY,
+ tokenArgs: cell? = null,
+): TokenPool_LockOrBurnPrepared {
+ if (self.hooks != null && self.hooks.validateLockOrBurn != null) {
+ val (newContext, prepared)
+ = self.hooks.validateLockOrBurn(self.context!, sender, request, requestedFinalityConfig, tokenArgs);
+ self.context = newContext;
+
+ return prepared;
+ }
+
+ assert(self.isSupportedToken(request.localToken), TokenPool_Error.InvalidToken);
+ self.requireSupportedChain(request.remoteChainSelector);
+ self.ensureNotCursed(request.remoteChainSelector);
+ self.ensureOutboundAccess(sender, request.remoteChainSelector);
+
+ val feeAmount = self.getFeeAmount(request, requestedFinalityConfig);
+ val destTokenAmount = request.amount - feeAmount;
+ val usingFastFinality = requestedFinalityConfig != TOKEN_POOL_WAIT_FOR_FINALITY_FLAG;
+ if (usingFastFinality) {
+ TokenPool_ensureRequestedFinalityAllowed(requestedFinalityConfig, self.data.adminConfig.load().allowedFinalityConfig);
+ self.consumeFastFinalityOutboundRateLimit(request.remoteChainSelector, destTokenAmount);
+ } else {
+ self.consumeOutboundRateLimit(request.remoteChainSelector, destTokenAmount);
+ }
+
+ // TODO: preflightCheck needs to indicate if we can continue or wait for an async trigger (to continue)
+ self.preflightCheck(request, requestedFinalityConfig, tokenArgs, destTokenAmount);
+
+ return TokenPool_LockOrBurnPrepared {
+ request,
+ requestedFinalityConfig,
+ tokenArgs,
+ feeAmount,
+ destTokenAmount,
+ usingFastFinality,
+ out: TokenPool_LockOrBurnOutV1 {
+ destTokenAddress: self.getRemoteToken(request.remoteChainSelector),
+ destPoolData: self.encodeLocalDecimals(),
+ },
+ };
+}
+
+fun TokenPool.onLockOrBurn(
+ mutate self,
+ sender: address,
+ msg: TransferNotificationForRecipient,
+): void {
+ if (self.hooks != null && self.hooks.onLockOrBurn != null) {
+ self.context = self.hooks.onLockOrBurn(self.context!, sender, msg);
+
+ return;
+ }
+
+ // TODO: hooks in this flow
+ // - lockOrBurn (this)
+ // - _getFee
+ // - _validateLockOrBurn
+ // - _lockOrBurn
+
+ // val jettonClient = st.jettonClient.load();
+ // assert(jettonClient.isWallet(sender), Error.IncorrectJettonSender);
+
+ val payload = loadForwardPayloadAsSlice(msg.forwardPayload);
+ assert(payload != null, TokenPool_Error.MissingForwardPayload);
+
+ val requestMsg = TokenPool_LockOrBurn.fromSlice(payload!);
+ val request = requestMsg.request.load();
+ assert(request.amount == msg.jettonAmount, TokenPool_Error.AmountMismatch);
+ assert(msg.transferInitiator != null, TokenPool_Error.MissingTransferInitiator);
+
+ val transferInitiator = msg.transferInitiator!;
+
+ // TODO: should indicate if we need to wait for an async callback before proceeding (e.g., preflight check)
+ val prepared = self.validateLockOrBurn(transferInitiator, request, requestMsg.requestedFinalityConfig, requestMsg.tokenArgs);
+
+ // TODO: should indicate if we need to wait for an async callback before proceeding (e.g., burn process)
+ self.doLockOrBurn(sender, msg, requestMsg, prepared);
+}
+
+// /// Contains the specific lock or burn token logic for a pool.
+// /// @dev overriding this method allows us to create pools with different lock/burn signatures
+// /// without duplicating the underlying logic.
+// /// @param remoteChainSelector The selector of the remote chain.
+// /// @param amount The amount of tokens to lock or burn.
+// fun TokenPool.doLockOrBurn(
+// mutate self,
+// sender: address,
+// uint64 remoteChainSelector,
+// uint256 amount,
+// ): void {}
+
+// TODO: should be simplified to above fun signature
+fun TokenPool.doLockOrBurn(
+ mutate self,
+ sender: address,
+ msg: TransferNotificationForRecipient,
+ requestMsg: TokenPool_LockOrBurn,
+ prepared: TokenPool_LockOrBurnPrepared,
+): bool {
+ var wait = false;
+ if (self.hooks != null && self.hooks.doLockOrBurn != null) {
+ // TODO: hooks.doLockOrBurn should indicate if we need to wait for an async callback before proceeding
+ self.context = self.hooks.doLockOrBurn(self.context!, sender, msg, requestMsg, prepared);
+ }
+
+ return wait;
+}
+
+
+// function lockOrBurn(
+// Pool.LockOrBurnInV1 calldata lockOrBurnIn,
+// bytes4 requestedFinalityConfig,
+// bytes calldata tokenArgs
+// ) public virtual returns (Pool.LockOrBurnOutV1 memory, uint256 destTokenAmount) {
+// uint256 feeAmount = _getFee(lockOrBurnIn, requestedFinalityConfig);
+// _validateLockOrBurn(lockOrBurnIn, requestedFinalityConfig, tokenArgs, feeAmount);
+// destTokenAmount = lockOrBurnIn.amount - feeAmount;
+// _lockOrBurn(lockOrBurnIn.remoteChainSelector, destTokenAmount);
+
+// emit LockedOrBurned({
+// remoteChainSelector: lockOrBurnIn.remoteChainSelector,
+// token: lockOrBurnIn.localToken,
+// sender: msg.sender,
+// amount: destTokenAmount
+// });
+
+// return (
+// Pool.LockOrBurnOutV1({
+// destTokenAddress: getRemoteToken(lockOrBurnIn.remoteChainSelector), destPoolData: _encodeLocalDecimals()
+// }),
+// destTokenAmount
+// );
+// }
+
+fun TokenPool.prepareReleaseOrMint(
+ mutate self,
+ sender: address,
+ request: TokenPool_ReleaseOrMintInV1,
+ requestedFinalityConfig: uint32 = TOKEN_POOL_DEFAULT_FINALITY,
+): TokenPool_ReleaseOrMintPrepared {
+ assert(self.isSupportedToken(request.localToken), TokenPool_Error.InvalidToken);
+ self.requireSupportedChain(request.remoteChainSelector);
+ self.ensureNotCursed(request.remoteChainSelector);
+ self.ensureInboundAccess(sender, request.remoteChainSelector);
+ assert(self.isRemotePool(request.remoteChainSelector, request.sourcePoolAddress), TokenPool_Error.InvalidRemotePoolForChain);
+
+ val remoteDecimals = self.parseRemoteDecimals(request.sourcePoolData);
+ val localAmount = self.calculateLocalAmount(request.sourceDenominatedAmount, remoteDecimals);
+ val usingFastFinality = requestedFinalityConfig != TOKEN_POOL_WAIT_FOR_FINALITY_FLAG;
+ if (usingFastFinality) {
+ TokenPool_ensureRequestedFinalityAllowed(requestedFinalityConfig, self.data.adminConfig.load().allowedFinalityConfig);
+ self.consumeFastFinalityInboundRateLimit(request.remoteChainSelector, localAmount);
+ } else {
+ self.consumeInboundRateLimit(request.remoteChainSelector, localAmount);
+ }
+
+ self.postflightCheck(request, localAmount, requestedFinalityConfig);
+
+ return TokenPool_ReleaseOrMintPrepared {
+ request,
+ requestedFinalityConfig,
+ localAmount,
+ usingFastFinality,
+ out: TokenPool_ReleaseOrMintOutV1 {
+ destinationAmount: localAmount,
+ },
+ };
+}
+
+fun TokenPool.releaseOrMint(
+ mutate self,
+ sender: address,
+ request: TokenPool_ReleaseOrMintInV1,
+ requestedFinalityConfig: uint32 = TOKEN_POOL_DEFAULT_FINALITY,
+): TokenPool_ReleaseOrMintPrepared {
+ val prepared = self.prepareReleaseOrMint(sender, request, requestedFinalityConfig);
+ if (self.hooks != null && self.hooks.applyReleaseOrMint != null) {
+ self.hooks.applyReleaseOrMint(self.context, prepared);
+ }
+ return prepared;
+}
+
+fun TokenPool.encodeLocalDecimals(self): cell {
+ return beginCell().storeUint(self.data.tokenDecimals as uint256, 256).endCell();
+}
+
+fun TokenPool.parseRemoteDecimals(self, sourcePoolData: cell?): uint8 {
+ // Fallback to the local token decimals if the source pool data is empty. This allows for backwards compatibility.
+ if (sourcePoolData == null) {
+ return self.data.tokenDecimals;
+ }
+
+ var s = sourcePoolData!.beginParse();
+ assert(s.remainingBitsCount() == 256 && s.remainingRefsCount() == 0, TokenPool_Error.InvalidRemoteChainDecimals);
+ val remoteDecimals = s.loadUint(256);
+ s.assertEnd();
+ assert(remoteDecimals <= MAX_UINT8, TokenPool_Error.InvalidRemoteChainDecimals);
+
+ return remoteDecimals as uint8;
+}
+
+ /// Calculates the local amount based on the remote amount and decimals.
+ /// @param remoteAmount The amount on the remote chain.
+ /// @param remoteDecimals The decimals of the token on the remote chain.
+ /// @return The local amount.
+ /// @dev This function protects against overflows. If there is a transaction that hits the overflow check, it is
+ /// probably incorrect as that means the amount cannot be represented on this chain. If the local decimals have been
+ /// wrongly configured, the token issuer could redeploy the pool with the correct decimals and manually re-execute the
+ /// CCIP tx to fix the issue.
+fun TokenPool.calculateLocalAmount(self, remoteAmount: uint256, remoteDecimals: uint8): uint256 {
+ if (remoteDecimals == self.data.tokenDecimals) {
+ return remoteAmount;
+ }
+
+ if (remoteDecimals > self.data.tokenDecimals) {
+ val decimalsDiff = remoteDecimals - self.data.tokenDecimals;
+ // This is a safety check to prevent overflow in the next calculation.
+ assert(decimalsDiff <= MAX_EXP10, TokenPool_Error.OverflowDetected);
+
+ return remoteAmount / pow10(decimalsDiff);
+ }
+
+ // This is a safety check to prevent overflow in the next calculation.
+ // More than 77 would never fit in a uint256 and would cause an overflow.
+ // We also check if the resulting amount would overflow.
+ val decimalsDiff = self.data.tokenDecimals - remoteDecimals;
+ assert(decimalsDiff <= MAX_EXP10, TokenPool_Error.OverflowDetected);
+ val scale = pow10(decimalsDiff);
+ assert(scale == 0 || remoteAmount <= MAX_UINT256 / scale, TokenPool_Error.OverflowDetected);
+
+ return remoteAmount * scale;
+}
+
+fun TokenPool.requireSupportedChain(self, remoteChainSelector: uint64) {
+ assert(self.isSupportedChain(remoteChainSelector), TokenPool_Error.ChainNotAllowed);
+}
+
+fun TokenPool.requireOwnerOrRateLimitAdmin(self, sender: address) {
+ val adminConfig = self.data.adminConfig.load();
+ val ownable = adminConfig.ownable.load();
+ if (sender == ownable.owner) {
+ return;
+ }
+ val dynamicConfig = adminConfig.dynamicConfig.load();
+ assert(dynamicConfig.rateLimitAdmin != null && sender == dynamicConfig.rateLimitAdmin, TokenPool_Error.Unauthorized);
+}
+
+fun TokenPool.ensureNotCursed(self, remoteChainSelector: uint64) {
+ assert(self.verifyNotCursed(remoteChainSelector as uint128), TokenPool_Error.CursedByRMN);
+ if (self.hooks != null && self.hooks.ensureNotCursed != null) {
+ self.hooks.ensureNotCursed(self.context, remoteChainSelector);
+ }
+}
+
+fun TokenPool.ensureOutboundAccess(self, sender: address, remoteChainSelector: uint64) {
+ val onRamp = self.getOnRamp(remoteChainSelector);
+ assert(onRamp != null, TokenPool_Error.Unauthorized);
+ assert(onRamp! == sender, TokenPool_Error.Unauthorized);
+ if (self.hooks != null && self.hooks.ensureOutboundAccess != null) {
+ self.hooks.ensureOutboundAccess(self.context, sender, remoteChainSelector);
+ }
+}
+
+fun TokenPool.ensureInboundAccess(self, sender: address, remoteChainSelector: uint64) {
+ val offRamp = self.getOffRamp(remoteChainSelector);
+ assert(offRamp != null, TokenPool_Error.Unauthorized);
+ assert(offRamp! == sender, TokenPool_Error.Unauthorized);
+ if (self.hooks != null && self.hooks.ensureInboundAccess != null) {
+ self.hooks.ensureInboundAccess(self.context, sender, remoteChainSelector);
+ }
+}
+
+fun TokenPool.preflightCheck(
+ self,
+ request: TokenPool_LockOrBurnInV1,
+ requestedFinalityConfig: uint32,
+ tokenArgs: cell?,
+ amountPostFee: uint256,
+) {
+ if (self.hooks != null && self.hooks.preflightCheck != null) {
+ self.hooks.preflightCheck(self.context, request, requestedFinalityConfig, tokenArgs, amountPostFee);
+ }
+}
+
+fun TokenPool.postflightCheck(
+ self,
+ request: TokenPool_ReleaseOrMintInV1,
+ localAmount: uint256,
+ requestedFinalityConfig: uint32,
+) {
+ if (self.hooks != null && self.hooks.postflightCheck != null) {
+ self.hooks.postflightCheck(self.context, request, localAmount, requestedFinalityConfig);
+ }
+}
+
+// ================================================================
+// │ Rate limiting │
+// ================================================================
+
+/// @dev The inbound rate limits should be slightly higher than the outbound rate limits. This is because many chains
+/// finalize blocks in batches. CCIP also commits messages in batches: the commit plugin bundles multiple messages in
+/// a single merkle root.
+/// Imagine the following scenario.
+/// - Chain A has an inbound and outbound rate limit of 100 tokens capacity and 1 token per second refill rate.
+/// - Chain B has an inbound and outbound rate limit of 100 tokens capacity and 1 token per second refill rate.
+///
+/// At time 0:
+/// - Chain A sends 100 tokens to Chain B.
+/// At time 5:
+/// - Chain A sends 5 tokens to Chain B.
+/// At time 6:
+/// The epoch that contains blocks [0-5] is finalized.
+/// Both transactions will be included in the same merkle root and become executable at the same time. This means
+/// the token pool on chain B requires a capacity of 105 to successfully execute both messages at the same time.
+/// The exact additional capacity required depends on the refill rate and the size of the source chain epochs and the
+/// CCIP round time. For simplicity, a 5-10% buffer should be sufficient in most cases.
+
+/// Consumes outbound rate limiting capacity in this pool.
+/// @param remoteChainSelector The remote chain selector.
+/// @param amount The amount of tokens consumed.
+fun TokenPool.consumeOutboundRateLimit(mutate self, remoteChainSelector: uint64, amount: uint256) {
+ var config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ var rateLimiters = config.rateLimiters.load();
+ var bucket = rateLimiters.outbound.load();
+ bucket._consume(amount, self.data.token);
+ rateLimiters.outbound = bucket.toCell();
+ config.rateLimiters = rateLimiters.toCell();
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ // TODO: emit event with updated rate limiter state
+ // emit OutboundRateLimitConsumed({token: token, remoteChainSelector: remoteChainSelector, amount: amount});
+}
+
+/// Consumes inbound rate limiting capacity in this pool.
+/// @param remoteChainSelector The remote chain selector.
+/// @param amount The amount of tokens consumed.
+fun TokenPool.consumeInboundRateLimit(mutate self, remoteChainSelector: uint64, amount: uint256) {
+ var config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ var rateLimiters = config.rateLimiters.load();
+ var bucket = rateLimiters.inbound.load();
+ bucket._consume(amount, self.data.token);
+ rateLimiters.inbound = bucket.toCell();
+ config.rateLimiters = rateLimiters.toCell();
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ // TODO: emit event with updated rate limiter state
+ // emit InboundRateLimitConsumed({token: token, remoteChainSelector: remoteChainSelector, amount: amount});
+}
+
+/// Consumes fast finality outbound rate limiting capacity in this pool.
+/// @dev If fast finality rate limiter is not enabled for the chain, it will fallback to the default
+/// rate limiter.
+/// @param remoteChainSelector The remote chain selector.
+/// @param amount The amount of tokens consumed.
+fun TokenPool.consumeFastFinalityOutboundRateLimit(mutate self, remoteChainSelector: uint64, amount: uint256) {
+ var config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ var fastRateLimiters = config.fastFinalityRateLimiters.load();
+ var fastBucket = fastRateLimiters.outbound.load();
+ if (!fastBucket.isEnabled) {
+ var rateLimiters = config.rateLimiters.load();
+ var bucket = rateLimiters.outbound.load();
+ bucket._consume(amount, self.data.token);
+ rateLimiters.outbound = bucket.toCell();
+ config.rateLimiters = rateLimiters.toCell();
+ } else {
+ fastBucket._consume(amount, self.data.token);
+ fastRateLimiters.outbound = fastBucket.toCell();
+ config.fastFinalityRateLimiters = fastRateLimiters.toCell();
+ }
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ // TODO: emit event with updated rate limiter state
+ // emit FastFinalityOutboundRateLimitConsumed({token: token, remoteChainSelector: remoteChainSelector, amount: amount});
+}
+
+/// Consumes fast finality inbound rate limiting capacity in this pool.
+/// @dev If fast finality rate limiter is not enabled for the chain, it will fallback to the default
+/// rate limiter.
+/// @param remoteChainSelector The remote chain selector.
+/// @param amount The amount of tokens consumed.
+fun TokenPool.consumeFastFinalityInboundRateLimit(mutate self, remoteChainSelector: uint64, amount: uint256) {
+ var config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ var fastRateLimiters = config.fastFinalityRateLimiters.load();
+ var fastBucket = fastRateLimiters.inbound.load();
+ if (!fastBucket.isEnabled) {
+ var rateLimiters = config.rateLimiters.load();
+ var bucket = rateLimiters.inbound.load();
+ bucket._consume(amount, self.data.token);
+ rateLimiters.inbound = bucket.toCell();
+ config.rateLimiters = rateLimiters.toCell();
+ } else {
+ fastBucket._consume(amount, self.data.token);
+ fastRateLimiters.inbound = fastBucket.toCell();
+ config.fastFinalityRateLimiters = fastRateLimiters.toCell();
+ }
+ self.data.remoteChainConfigs.set(remoteChainSelector, config);
+
+ // TODO: emit event with updated rate limiter state
+ // emit FastFinalityInboundRateLimitConsumed({token: token, remoteChainSelector: remoteChainSelector, amount: amount});
+}
+
+/// Returns the outbound and inbound rate limiter state for the given remote chain at the time of the call.
+/// @param remoteChainSelector The remote chain selector.
+/// @param fastFinality Whether to get the fast finality rate limiter state.
+fun TokenPool.getCurrentRateLimiterState(
+ self,
+ remoteChainSelector: uint64,
+ fastFinality: bool,
+): TokenPool_RateLimiterPair {
+ val config = self.data.remoteChainConfigs.mustGet(remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ if (fastFinality) {
+ val rateLimiters = config.fastFinalityRateLimiters.load();
+ // TODO: recompute with _currentTokenBucketState
+ return TokenPool_RateLimiterPair {
+ outbound: rateLimiters.outbound,
+ inbound: rateLimiters.inbound,
+ };
+ }
+
+ val rateLimiters = config.rateLimiters.load();
+ // TODO: recompute with _currentTokenBucketState
+ return TokenPool_RateLimiterPair {
+ outbound: rateLimiters.outbound,
+ inbound: rateLimiters.inbound,
+ };
+}
+
+/// Sets the rate limit configurations for specified remote chains.
+/// @param rateLimitConfigArgs Array of structs containing remote chain selectors and their rate limiter configs.
+fun TokenPool.setRateLimitConfig(
+ mutate self,
+ sender: address,
+ updates: SnakedCell,
+) {
+ self.requireOwnerOrRateLimitAdmin(sender);
+
+ var iter = updates.iter();
+ while (!iter.empty()) {
+ val update = iter.next();
+ var config = self.data.remoteChainConfigs.mustGet(update.remoteChainSelector, TokenPool_Error.NonExistentChain as int);
+ if (update.fastFinality) {
+ var fastRateLimiters = config.fastFinalityRateLimiters.load();
+ // TODO: use setter RateLimiter._setTokenBucketConfig
+ fastRateLimiters.outbound = RateLimiter_TokenBucket.fromConfig(update.outboundRateLimiterConfig.load()).toCell();
+ fastRateLimiters.inbound = RateLimiter_TokenBucket.fromConfig(update.inboundRateLimiterConfig.load()).toCell();
+ config.fastFinalityRateLimiters = fastRateLimiters.toCell();
+ } else {
+ var rateLimiters = config.rateLimiters.load();
+ // TODO: use setter RateLimiter._setTokenBucketConfig
+ rateLimiters.outbound = RateLimiter_TokenBucket.fromConfig(update.outboundRateLimiterConfig.load()).toCell();
+ rateLimiters.inbound = RateLimiter_TokenBucket.fromConfig(update.inboundRateLimiterConfig.load()).toCell();
+ config.rateLimiters = rateLimiters.toCell();
+ }
+ self.data.remoteChainConfigs.set(update.remoteChainSelector, config);
+
+ // TODO: emit event with updated rate limiter config
+ // emit RateLimitConfigured(
+ // remoteChainSelector,
+ // configArgs.fastFinality,
+ // configArgs.outboundRateLimiterConfig,
+ // configArgs.inboundRateLimiterConfig
+ // );
+ }
+
+ // TODO: reply back to sender with excess
+}
+
+/// Adds a pool address to the allowed remote token pools for a particular chain.
+/// @param remoteChainSelector The remote chain selector for which the remote pool address is being added.
+/// @param remotePoolAddress The address of the new remote pool.
+fun TokenPool._setRemotePool(mutate self, mutate config: TokenPool_RemoteChainConfig, remotePoolAddress: Cell) {
+ assert(!TokenPool_isEmptyCrossChainAddress(remotePoolAddress), TokenPool_Error.ZeroAddressInvalid);
+ val poolHash = TokenPool_hashCrossChainAddress(remotePoolAddress);
+ assert(!config.remotePools.get(poolHash).isFound, TokenPool_Error.PoolAlreadyAdded);
+ // Add the pool to the mapping to be able to un-hash it later.
+ config.remotePools.set(poolHash, remotePoolAddress);
+}
+
+// TODO: withdrawFeeTokens
+
+fun TokenPool_ensureRequestedFinalityAllowed(requestedFinalityConfig: uint32, allowedFinalityConfig: uint32) {
+ if (requestedFinalityConfig == TOKEN_POOL_WAIT_FOR_FINALITY_FLAG) {
+ return;
+ }
+ assert(allowedFinalityConfig != TOKEN_POOL_WAIT_FOR_FINALITY_FLAG && requestedFinalityConfig == allowedFinalityConfig, TokenPool_Error.InvalidRequestedFinality);
+}
+
+fun TokenPool_hashCrossChainAddress(value: Cell): uint256 {
+ var cs = value.beginParse();
+ val addressBytes = CrossChainAddress.unpackFromSlice(mutate cs);
+ cs.assertEnd();
+ return keccak256(beginCell().storeSlice(addressBytes));
+}
+
+fun TokenPool_isEmptyCrossChainAddress(value: Cell): bool {
+ var cs = value.beginParse();
+ val addressBytes = CrossChainAddress.unpackFromSlice(mutate cs);
+ cs.assertEnd();
+ return addressBytes.remainingBitsCount() == 0;
+}
+
+fun TokenPool_boxCrossChainAddress(addressBytes: CrossChainAddress): Cell {
+ var addressSlice = addressBytes;
+ val bits = addressSlice.remainingBitsCount();
+ assert(bits % 8 == 0, TokenPool_Error.OverflowDetected);
+ val pureAddressBits = addressSlice.loadBits(bits);
+ return beginCell()
+ .storeUint(bits / 8, 8)
+ .storeSlice(pureAddressBits)
+ .endCell() as Cell;
+}
diff --git a/contracts/contracts/ccip/pools/token_pool_contract.tolk b/contracts/contracts/ccip/pools/token_pool_contract.tolk
new file mode 100644
index 000000000..eb965d7be
--- /dev/null
+++ b/contracts/contracts/ccip/pools/token_pool_contract.tolk
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "@stdlib/lisp-lists"
+
+import "types"
+import "errors"
+import "messages"
+import "events"
+import "token_pool"
+
+contract TokenPool {
+ author: "SmartContract Chainlink Limited SEZC"
+ version: "0.1.0"
+ description: "link.chain.ton.ccip.TokenPool"
+
+ storage: TokenPool_Data
+ incomingMessages: TokenPool_InMessage // TODO: all incoming messages should be registered
+
+ // TODO: should be used in hooks of the core library,
+ // but for now we need to register them here.
+ forceAbiExport: TokenPool_LockOrBurn
+ | TokenPool_LockOrBurnResponse
+ | TokenPool_ReleaseOrMintResponse
+}
+
+fun onInternalMessage(_: InMessage) {
+ throw 0xFFFF; // template for bindings, should not be deployed
+}
+
+get fun typeAndVersion(): (slice, slice) {
+ throw 0xFFFF;
+}
+
+get fun token(): address {
+ throw 0xFFFF;
+}
+
+get fun tokenDecimals(): uint8 {
+ throw 0xFFFF;
+}
+
+get fun isSupportedChain(remoteChainSelector: uint64): bool {
+ throw 0xFFFF;
+}
+
+get fun onRamp(remoteChainSelector: uint64): address? {
+ throw 0xFFFF;
+}
+
+get fun offRamp(remoteChainSelector: uint64): address? {
+ throw 0xFFFF;
+}
+
+get fun hasPendingRelease(queryId: uint64): bool {
+ throw 0xFFFF;
+}
+
+get fun getRMNProxy(): address {
+ throw 0xFFFF;
+}
+
+get fun verifyNotCursed(subject: uint128): bool {
+ throw 0xFFFF;
+}
+
+get fun owner(): address {
+ throw 0xFFFF;
+}
+
+get fun pendingOwner(): address? {
+ throw 0xFFFF;
+}
diff --git a/contracts/contracts/ccip/pools/types.tolk b/contracts/contracts/ccip/pools/types.tolk
new file mode 100644
index 000000000..308863f49
--- /dev/null
+++ b/contracts/contracts/ccip/pools/types.tolk
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: BUSL-1.1
+import "../../lib/utils"
+import "../common/types"
+import "../rmn_remote/lib"
+
+import "rate_limiter"
+
+const TOKEN_POOL_WAIT_FOR_FINALITY_FLAG: uint32 = 0;
+const TOKEN_POOL_DEFAULT_FINALITY: uint32 = TOKEN_POOL_WAIT_FOR_FINALITY_FLAG;
+/// The division factor for bps. This also represents the maximum bps fee.
+const TOKEN_POOL_BPS_DIVIDER: uint256 = 10000;
+
+// The number of bytes in the return data for a pool v1 releaseOrMint call.
+// This should match the size of the ReleaseOrMintOutV1 struct.
+const CCIP_POOL_V1_RET_BYTES = 32;
+
+// The default max number of bytes in the return data for a pool v1 lockOrBurn call.
+// This data can be used to send information to the destination chain token pool. Can be overwritten
+// in the TokenTransferFeeConfig.destBytesOverhead if more data is required.
+const CCIP_LOCK_OR_BURN_V1_RET_BYTES = 32;
+
+struct TokenPool_DynamicConfig {
+ router: address;
+ rateLimitAdmin: address?;
+ feeAdmin: address?;
+}
+
+struct TokenPool_MirroredPolicy {
+ onRamps: map;
+ offRamps: map;
+ cursedSubjects: CursedSubjects;
+}
+
+struct TokenPool_RampUpdate {
+ remoteChainSelector: uint64;
+ onRamp: address? = null;
+ offRamp: address? = null;
+}
+
+struct TokenPool_RateLimiterPair {
+ outbound: Cell;
+ inbound: Cell;
+}
+
+struct TokenPool_RateLimitConfigPair {
+ outbound: Cell; // Outbound rate limited config, meaning the rate limits for all of the onRamps for the given chain.
+ inbound: Cell; // Inbound rate limited config, meaning the rate limits for all of the offRamps for the given chain.
+}
+
+struct TokenPool_ChainUpdate {
+ remoteChainSelector: uint64; // Remote chain selector.
+ remotePoolAddresses: SnakedCell; // Address of the remote pool, ABI encoded in the case of a remote EVM chain.
+ remoteTokenAddress: Cell; // Address of the remote token, ABI encoded in the case of a remote EVM chain.
+ rateLimitConfigs: Cell; // Rate limiter configs.
+}
+
+struct TokenPool_RemoteChainConfig {
+ remoteTokenAddress: Cell; // Address of the remote token, ABI encoded in the case of a remote EVM chain.
+ // TODO: EnumerableSet.Bytes32Set remotePools;
+ remotePools: map>; // Set of remote pool hashes, ABI encoded in the case of a remote EVM chain.
+ rateLimiters: Cell; // Rate limiter configs.
+ fastFinalityRateLimiters: Cell; // Rate limiter configs for fast finality transfers.
+}
+
+struct TokenPool_RateLimitConfigArgs {
+ remoteChainSelector: uint64; // Remote chain selector.
+ fastFinality: bool; // Whether the rate limit config is for fast finality transfers.
+ outboundRateLimiterConfig: Cell; // Outbound rate limiter configuration.
+ inboundRateLimiterConfig: Cell; // Inbound rate limiter configuration.
+}
+
+/// Struct with args for setting the token transfer fee configurations for a destination chain and a set of tokens.
+struct TokenPool_TokenTransferFeeConfigArgs {
+ destChainSelector: uint64; // Destination chain selector.
+ tokenTransferFeeConfig: TokenPool_TokenTransferFeeConfig; // Token transfer fee configuration.
+}
+
+// TODO: triage new types below
+
+struct TokenPool_LockOrBurnPrepared {
+ request: TokenPool_LockOrBurnInV1;
+ requestedFinalityConfig: uint32;
+ tokenArgs: cell?;
+ feeAmount: uint256;
+ destTokenAmount: uint256;
+ usingFastFinality: bool;
+ out: TokenPool_LockOrBurnOutV1;
+}
+
+struct TokenPool_ReleaseOrMintPrepared {
+ request: TokenPool_ReleaseOrMintInV1;
+ requestedFinalityConfig: uint32;
+ localAmount: uint256;
+ usingFastFinality: bool;
+ out: TokenPool_ReleaseOrMintOutV1;
+}
+
+// IPoolV2
+
+// TODO: reorder the fields in the struct
+// struct TokenTransferFeeConfig {
+// uint32 destGasOverhead; // ──────────╮ Gas charged to execute the token transfer on the destination chain.
+// uint32 destBytesOverhead; // │ Data availability bytes.
+// uint32 finalityFeeUSDCents; // │ Fee to charge for token transfer with default (wait-for-finality) finality, multiples of 0.01 USD.
+// uint32 fastFinalityFeeUSDCents; // │ Fee to charge for token transfer with fast finality (FTF), multiples of 0.01 USD.
+// // │ The following two fee is deducted from the transferred asset, not added on top.
+// uint16 finalityTransferFeeBps; // │ Fee in basis points for default finality transfers [0-10_000].
+// uint16 fastFinalityTransferFeeBps; //│ Fee in basis points for custom finality transfers [0-10_000].
+// bool isEnabled; // ──────────────────╯ Whether this config is enabled.
+// }
+
+struct TokenPool_TokenTransferFeeConfig {
+ isEnabled: bool;
+ finalityFeeUSDCents: uint256;
+ fastFinalityFeeUSDCents: uint256;
+ destGasOverhead: uint32;
+ destBytesOverhead: uint32;
+ finalityTransferFeeBps: uint16;
+ fastFinalityTransferFeeBps: uint16;
+}
+
+enum TokenPool_MessageDirection: uint8 {
+ Outbound = 0
+ Inbound = 1
+}
+
+struct TokenPool_LockOrBurnInV1 {
+ receiver: Cell; // The recipient of the tokens on the destination chain. For EVM source chains, this is abi-encoded (32 bytes).
+ remoteChainSelector: uint64; // ─╮ The chain ID of the destination chain.
+ originalSender: address; // ─╯ The original sender of the tx on the source chain.
+ amount: uint256; // The amount of tokens to lock or burn, denominated in the source token's decimals.
+ localToken: address; // The address on this chain of the token to lock or burn.
+}
+
+struct TokenPool_LockOrBurnOutV1 {
+ // The address of the destination token, abi encoded in the case of EVM chains.
+ // This value is UNTRUSTED as any pool owner can return whatever value they want.
+ destTokenAddress: Cell;
+ // Optional pool data to be transferred to the destination chain. Be default this is capped at
+ // CCIP_LOCK_OR_BURN_V1_RET_BYTES bytes. If more data is required, the TokenTransferFeeConfig.destBytesOverhead
+ // has to be set for the specific token.
+ destPoolData: cell;
+}
+
+struct TokenPool_ReleaseOrMintInV1 {
+ originalSender: Cell; // The original sender of the tx on the source chain, abi encoded in the case of EVM chains.
+ remoteChainSelector: uint64; // ──╮ The chain ID of the source chain.
+ receiver: address; // -─╯ The recipient of the tokens on the destination chain.
+ sourceDenominatedAmount: uint256; // The amount of tokens to release or mint, denominated in the source token's decimals.
+ localToken: address; // The address on this chain of the token to release or mint.
+ /// @dev WARNING: sourcePoolAddress should be checked prior to any processing of funds. Make sure it matches the
+ /// expected pool address for the given remoteChainSelector.
+ sourcePoolAddress: Cell; // The address of the source pool, abi encoded in the case of EVM chains.
+ sourcePoolData: cell?; // The data received from the source pool to process the release or mint.
+ /// @dev WARNING: offchainTokenData is untrusted data.
+ offchainTokenData: cell?; // The offchain data to process the release or mint.
+}
+
+struct TokenPool_ReleaseOrMintOutV1 {
+ // The number of tokens released or minted on the destination chain, denominated in the local token's decimals.
+ // This value is expected to be equal to the ReleaseOrMintInV1.amount in the case where the source and destination
+ // chain have the same number of decimals.
+ destinationAmount: uint256;
+}
diff --git a/contracts/contracts/lib/math.tolk b/contracts/contracts/lib/math.tolk
index a53a897f3..00c3f36e4 100644
--- a/contracts/contracts/lib/math.tolk
+++ b/contracts/contracts/lib/math.tolk
@@ -26,6 +26,11 @@ const INT_MIN: int = -(1 << 255) - (1 << 255);
// 2^(15×8) - 1 = 2^120 - 1
const COIN_MAX: int = (1 << 120) - 1;
+const MAX_UINT8: uint8 = 0xFF; // 255
+const MAX_UINT256: uint256 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
+
+const MAX_EXP10: uint8 = 77;
+
// Returns (result, errorCode), where errorCode is 0 if no error.
fun safeAdd(a: int, b: int): (int, int) {
// Check for positive overflow: a > INT_MAX - b
@@ -114,3 +119,21 @@ fun mustCastToCoin(value: int, errCode: int): coins {
}
return value;
}
+
+// @dev Calculates 10^n for n <= 77 in O(log n). For n > 77, it would overflow uint256.
+fun pow10(n: uint8): uint256 {
+ var result: uint256 = 1;
+ var base: uint256 = 10;
+
+ while (n > 0) {
+ if ((n & 1) == 1) {
+ // Check for potential overflow before multiplication
+ assert(result == 0 || base <= MAX_UINT256 / result, 4); // exit code 4: Integer overflow
+ result *= base;
+ }
+ base *= base;
+ n >>= 1;
+ }
+
+ return result;
+}
diff --git a/contracts/src/utils/dict.ts b/contracts/src/utils/dict.ts
index aef130f7c..01b0a70b4 100644
--- a/contracts/src/utils/dict.ts
+++ b/contracts/src/utils/dict.ts
@@ -21,3 +21,9 @@ export function loadDict(dict: Dictionary
return map
}
+
+// Returns an DictionaryValue<[]> key (serialized as bool), used for map
+// where value is an empty tesnor (not important, only presence of key matters)
+export function createEmptyTensorValue() {
+ return Dictionary.Values.Bool() as unknown as DictionaryValue<[]>
+}
diff --git a/contracts/src/utils/types.ts b/contracts/src/utils/types.ts
index 5cc280742..d32893104 100644
--- a/contracts/src/utils/types.ts
+++ b/contracts/src/utils/types.ts
@@ -33,6 +33,10 @@ export function uint8ArrayToBigInt(bytes: Uint8Array): bigint {
return result
}
+export function asSnakedCellEmpty(): Cell {
+ return asSnakedCell([], (item: T) => new Builder())
+}
+
export function asSnakedCell(array: T[], builderFn: (item: T) => Builder): Cell {
const cells: Builder[] = []
let builder = beginCell()
diff --git a/contracts/tests/ccip/pools/BurnMintTokenPool.spec.ts b/contracts/tests/ccip/pools/BurnMintTokenPool.spec.ts
new file mode 100644
index 000000000..27c80f1d5
--- /dev/null
+++ b/contracts/tests/ccip/pools/BurnMintTokenPool.spec.ts
@@ -0,0 +1,533 @@
+import '@ton/test-utils'
+import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox'
+import { Address, beginCell, Cell, Dictionary, toNano } from '@ton/core'
+import { asSnakedCell, asSnakedCellEmpty } from '../../../src/utils'
+import { JettonMinter, JettonWallet } from '../../../wrappers/examples/jetton'
+import { CCTJettonMinter } from '../../../wrappers/ccip/CCTJettonMinter'
+import { CCTJettonMinterCode, CCTJettonWalletCode } from '../../../wrappers/ccip/CCTJettonCode'
+import { setupGenBindings } from '../../../wrappers/gen'
+import {
+ Ownable2Step,
+ CrossChainAddress,
+ CursedSubjects,
+ RateLimiter_Config,
+ TokenPool,
+ TokenPool_Data,
+ TokenPool_AdminConfig,
+ TokenPool_RampUpdate,
+ TokenPool_RateLimitConfigPair,
+ TokenPool_ChainUpdate,
+ TokenPool_LockOrBurn,
+ TokenPool_LockOrBurnInV1,
+ TokenPool_LockOrBurnResponse,
+ TokenPool_ReleaseOrMintInV1,
+ TokenPool_ReleaseOrMintResponse,
+ TokenPool_MirroredPolicy,
+ TokenPool_DynamicConfig,
+} from '../../../wrappers/gen/ccip/pools/TokenPool'
+import { BurnMintTokenPool, JettonClient } from '../../../wrappers/gen/ccip/pools/BurnMintTokenPool'
+import { runTokenPoolBehaviorTests } from './TokenPool.behavior'
+
+import * as rtOld from '../../../wrappers/ccip/Router'
+
+function crossChainAddressFromBuffer(buffer: Buffer): CrossChainAddress {
+ const addrSlice = rtOld.builder.data.crossChainAddress.encode(buffer).asSlice()
+ return CrossChainAddress.fromSlice(addrSlice)
+}
+
+describe('BurnMintTokenPool', () => {
+ let blockchain: Blockchain
+ let deployer: SandboxContract
+ let offRamp: SandboxContract
+ let unauthorized: SandboxContract
+ let recipient: SandboxContract
+
+ let cctMinter: SandboxContract
+ let cctMinterRuntime: SandboxContract
+ let burnMintPool: SandboxContract
+ let pool: SandboxContract
+ let cctWalletCode: Cell
+
+ let userWallet: (address: Address) => Promise>
+
+ const remoteChainSelector = 91000001n
+
+ let sourcePoolAddress: CrossChainAddress
+ let destTokenAddress: CrossChainAddress
+ let receiverAddress: CrossChainAddress
+
+ beforeAll(async () => {
+ setupGenBindings()
+
+ sourcePoolAddress = crossChainAddressFromBuffer(Buffer.from('source-pool'))
+ destTokenAddress = crossChainAddressFromBuffer(Buffer.from('dest-token'))
+ receiverAddress = crossChainAddressFromBuffer(Buffer.from('receiver'))
+ })
+
+ beforeEach(async () => {
+ blockchain = await Blockchain.create()
+ deployer = await blockchain.treasury('deployer')
+ offRamp = await blockchain.treasury('offramp')
+ unauthorized = await blockchain.treasury('unauthorized')
+ recipient = await blockchain.treasury('recipient')
+
+ cctWalletCode = await CCTJettonWalletCode()
+ const cctMinterCode = await CCTJettonMinterCode()
+
+ cctMinter = blockchain.openContract(
+ CCTJettonMinter.createFromConfig(
+ {
+ totalSupply: 0n,
+ adminAddress: deployer.address,
+ nextAdminAddress: null,
+ jettonWalletCode: cctWalletCode,
+ metadataUri: 'cct-test',
+ },
+ cctMinterCode,
+ ),
+ )
+ await cctMinter.sendDeploy(deployer.getSender(), toNano('1'))
+ cctMinterRuntime = blockchain.openContract(JettonMinter.createFromAddress(cctMinter.address))
+
+ burnMintPool = blockchain.openContract(
+ BurnMintTokenPool.fromStorage({
+ poolData: {
+ ref: TokenPool_Data.create({
+ adminConfig: {
+ ref: TokenPool_AdminConfig.create({
+ ownable: {
+ ref: Ownable2Step.create({ owner: deployer.address, pendingOwner: null }),
+ },
+ rmnProxy: deployer.address,
+ dynamicConfig: {
+ ref: TokenPool_DynamicConfig.create({
+ router: deployer.address,
+ rateLimitAdmin: null,
+ feeAdmin: null,
+ }),
+ },
+ allowedFinalityConfig: 0n,
+ }),
+ },
+ mirroredPolicy: {
+ ref: TokenPool_MirroredPolicy.create({
+ onRamps: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ offRamps: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ cursedSubjects: CursedSubjects.create({
+ data: Dictionary.empty(Dictionary.Keys.BigInt(128)),
+ }),
+ }),
+ },
+ token: cctMinter.address,
+ tokenDecimals: 9n,
+ remoteChainConfigs: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ tokenTransferFeeConfigs: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ }),
+ },
+ jettonClient: {
+ ref: JettonClient.create({
+ masterAddress: cctMinter.address,
+ jettonWalletCode: cctWalletCode,
+ }),
+ },
+ pendingMints: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ pendingBurns: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ }),
+ )
+ await burnMintPool.sendDeploy(deployer.getSender(), toNano('2'))
+
+ // Standard TokenPool interface
+ pool = blockchain.openContract(TokenPool.fromAddress(burnMintPool.address))
+
+ {
+ const r = await burnMintPool.sendTokenPoolApplyChainUpdates(
+ deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 1n,
+ remoteChainSelectorsToRemove: asSnakedCellEmpty(),
+ chainsToAdd: asSnakedCell(
+ [
+ TokenPool_ChainUpdate.create({
+ remoteChainSelector,
+ remotePoolAddresses: asSnakedCell([sourcePoolAddress], (item) => {
+ let b = beginCell()
+ CrossChainAddress.store(item, b)
+ return b
+ }),
+ remoteTokenAddress: { ref: destTokenAddress },
+ rateLimitConfigs: {
+ ref: TokenPool_RateLimitConfigPair.create({
+ outbound: {
+ ref: RateLimiter_Config.create({
+ isEnabled: true,
+ capacity: toNano('100'),
+ rate: 1n,
+ }),
+ },
+ inbound: {
+ ref: RateLimiter_Config.create({
+ isEnabled: true,
+ capacity: toNano('100'),
+ rate: 1n,
+ }),
+ },
+ }),
+ },
+ }),
+ ],
+ (item) => TokenPool_ChainUpdate.toCell(item).asBuilder(),
+ ),
+ },
+ )
+
+ expect(r.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: burnMintPool.address,
+ success: true,
+ })
+ }
+
+ await burnMintPool.sendTokenPoolUpdateRampAccess(deployer.getSender(), toNano('0.2'), {
+ queryId: 2n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector,
+ onRamp: deployer.address,
+ offRamp: offRamp.address,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ })
+
+ // Mint user-side test balance before handing minter admin to the pool.
+ const mintToOnRamp = await cctMinterRuntime.sendMint(deployer.getSender(), {
+ value: toNano('1'),
+ mintOpcode: 0x00000015,
+ message: {
+ queryId: 101n,
+ destination: deployer.address,
+ tonAmount: toNano('0.05'),
+ jettonAmount: toNano('10'),
+ from: deployer.address,
+ responseDestination: deployer.address,
+ forwardTonAmount: 0n,
+ },
+ })
+ expect(mintToOnRamp.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: cctMinter.address,
+ success: true,
+ })
+
+ await cctMinterRuntime.sendMint(deployer.getSender(), {
+ value: toNano('1'),
+ mintOpcode: 0x00000015,
+ message: {
+ queryId: 102n,
+ destination: unauthorized.address,
+ tonAmount: toNano('0.05'),
+ jettonAmount: toNano('2'),
+ from: deployer.address,
+ responseDestination: deployer.address,
+ forwardTonAmount: 0n,
+ },
+ })
+
+ // Admin handoff: deployer sets pending admin to pool, pool claims ownership itself.
+ const changeAdminResult = await cctMinterRuntime.sendChangeAdmin(deployer.getSender(), {
+ value: toNano('0.2'),
+ message: {
+ queryId: 201n,
+ newAdmin: burnMintPool.address,
+ },
+ })
+ expect(changeAdminResult.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: cctMinter.address,
+ success: true,
+ })
+
+ const claimAdminResult = await burnMintPool.sendBurnMintTokenPoolClaimMinterAdmin(
+ deployer.getSender(),
+ toNano('0.2'),
+ { queryId: 202n },
+ )
+ expect(claimAdminResult.transactions).toHaveTransaction({
+ from: burnMintPool.address,
+ to: cctMinter.address,
+ success: true,
+ })
+
+ const jettonData = await cctMinterRuntime.getJettonData()
+ expect(jettonData.admin).toEqualAddress(burnMintPool.address)
+ expect(await cctMinterRuntime.getNextAdminAddress()).toBeNull()
+
+ userWallet = async (address: Address) => {
+ return blockchain.openContract(
+ JettonWallet.createFromAddress(await cctMinterRuntime.getWalletAddress(address)),
+ )
+ }
+ })
+
+ runTokenPoolBehaviorTests('BurnMintTokenPool', async () => ({
+ pool,
+ deployer,
+ offRamp,
+ altOffRamp: deployer,
+ unauthorized,
+ recipient,
+ remoteChainSelector,
+ unsupportedChainSelector: remoteChainSelector + 1n,
+ unknownSourcePoolAddress: crossChainAddressFromBuffer(Buffer.from('unknown-source-pool')),
+ remoteTokenAddress: destTokenAddress,
+ onRampAddress: deployer.address,
+ destTokenAddress,
+ sourcePoolAddress,
+ localToken: cctMinter.address,
+ }))
+
+ it('has no pending burn or mint by default', async () => {
+ expect(await burnMintPool.getHasPendingBurn(300n)).toBe(false)
+ expect(await burnMintPool.getHasPendingMint(301n)).toBe(false)
+ })
+
+ it('rejects claim-minter-admin from non-owner sender', async () => {
+ const result = await burnMintPool.sendBurnMintTokenPoolClaimMinterAdmin(
+ unauthorized.getSender(),
+ toNano('0.2'),
+ { queryId: 302n },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: unauthorized.address,
+ to: burnMintPool.address,
+ success: false,
+ })
+ })
+
+ it('reverts lockOrBurn when caller is not configured on-ramp', async () => {
+ const unauthorizedWallet = await userWallet(unauthorized.address)
+ const poolWallet = await userWallet(burnMintPool.address)
+ const result = await unauthorizedWallet.sendTransfer(unauthorized.getSender(), {
+ value: toNano('2'),
+ message: {
+ queryId: 303,
+ jettonAmount: toNano('1'),
+ destination: burnMintPool.address,
+ responseDestination: unauthorized.address,
+ customPayload: null,
+ forwardTonAmount: toNano('0.2'),
+ forwardPayload: TokenPool_LockOrBurn.toCell(
+ TokenPool_LockOrBurn.create({
+ queryId: 303n,
+ request: {
+ ref: TokenPool_LockOrBurnInV1.create({
+ receiver: { ref: receiverAddress },
+ remoteChainSelector,
+ originalSender: unauthorized.address,
+ amount: toNano('1'),
+ localToken: cctMinter.address,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ tokenArgs: null,
+ replyTo: unauthorized.address,
+ }),
+ ),
+ },
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: poolWallet.address,
+ to: burnMintPool.address,
+ success: false,
+ })
+ })
+
+ it('reverts lockOrBurn when payload amount does not match transferred amount', async () => {
+ const onRampWallet = await userWallet(deployer.address)
+ const poolWallet = await userWallet(burnMintPool.address)
+ const result = await onRampWallet.sendTransfer(deployer.getSender(), {
+ value: toNano('2'),
+ message: {
+ queryId: 304,
+ jettonAmount: toNano('2'),
+ destination: burnMintPool.address,
+ responseDestination: deployer.address,
+ customPayload: null,
+ forwardTonAmount: toNano('0.2'),
+ forwardPayload: TokenPool_LockOrBurn.toCell(
+ TokenPool_LockOrBurn.create({
+ queryId: 304n,
+ request: {
+ ref: TokenPool_LockOrBurnInV1.create({
+ receiver: { ref: receiverAddress },
+ remoteChainSelector,
+ originalSender: deployer.address,
+ amount: toNano('1'),
+ localToken: cctMinter.address,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ tokenArgs: null,
+ replyTo: deployer.address,
+ }),
+ ),
+ },
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: poolWallet.address,
+ to: burnMintPool.address,
+ success: false,
+ })
+ })
+
+ it('burns tokens on lockOrBurn path and clears pending burn on confirmation', async () => {
+ const onRampWallet = await userWallet(deployer.address)
+ const poolWallet = await userWallet(burnMintPool.address)
+
+ const result = await onRampWallet.sendTransfer(deployer.getSender(), {
+ value: toNano('2'),
+ message: {
+ queryId: 11,
+ jettonAmount: toNano('3'),
+ destination: burnMintPool.address,
+ responseDestination: deployer.address,
+ customPayload: null,
+ forwardTonAmount: toNano('0.2'),
+ forwardPayload: TokenPool_LockOrBurn.toCell(
+ TokenPool_LockOrBurn.create({
+ queryId: 11n,
+ request: {
+ ref: TokenPool_LockOrBurnInV1.create({
+ receiver: { ref: receiverAddress },
+ remoteChainSelector,
+ originalSender: deployer.address,
+ amount: toNano('3'),
+ localToken: cctMinter.address,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ tokenArgs: null,
+ replyTo: deployer.address,
+ }),
+ ),
+ },
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: onRampWallet.address,
+ success: true,
+ })
+
+ expect(await burnMintPool.getHasPendingBurn(11n)).toBe(false)
+ expect(await poolWallet.getJettonBalance()).toEqual(0n)
+
+ expect(result.transactions).toHaveTransaction({
+ from: burnMintPool.address,
+ to: deployer.address,
+ success: true,
+ op: TokenPool_LockOrBurnResponse.PREFIX,
+ })
+ })
+
+ it('mints tokens on releaseOrMint path and clears pending mint on confirmation', async () => {
+ const result = await burnMintPool.sendTokenPoolReleaseOrMint(
+ offRamp.getSender(),
+ toNano('0.6'),
+ {
+ queryId: 22n,
+ request: {
+ ref: TokenPool_ReleaseOrMintInV1.create({
+ originalSender: { ref: sourcePoolAddress },
+ remoteChainSelector,
+ receiver: recipient.address,
+ sourceDenominatedAmount: toNano('2'),
+ localToken: cctMinter.address,
+ sourcePoolAddress: { ref: sourcePoolAddress },
+ sourcePoolData: null,
+ offchainTokenData: null,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ replyTo: deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: offRamp.address,
+ to: burnMintPool.address,
+ success: true,
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: burnMintPool.address,
+ to: cctMinter.address,
+ success: true,
+ })
+
+ expect(await burnMintPool.getHasPendingMint(22n)).toBe(false)
+
+ expect(result.transactions).toHaveTransaction({
+ from: burnMintPool.address,
+ to: deployer.address,
+ success: true,
+ op: TokenPool_ReleaseOrMintResponse.PREFIX,
+ body(body) {
+ if (!body) return false
+ const response = TokenPool_ReleaseOrMintResponse.fromSlice(body.beginParse())
+ return response.queryId === 22n && response.out.ref.destinationAmount === toNano('2')
+ },
+ })
+ })
+
+ it('mints on releaseOrMint with null replyTo without emitting response message', async () => {
+ const result = await burnMintPool.sendTokenPoolReleaseOrMint(
+ offRamp.getSender(),
+ toNano('0.6'),
+ {
+ queryId: 305n,
+ request: {
+ ref: TokenPool_ReleaseOrMintInV1.create({
+ originalSender: { ref: sourcePoolAddress },
+ remoteChainSelector,
+ receiver: recipient.address,
+ sourceDenominatedAmount: toNano('1'),
+ localToken: cctMinter.address,
+ sourcePoolAddress: { ref: sourcePoolAddress },
+ sourcePoolData: null,
+ offchainTokenData: null,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ replyTo: null,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: offRamp.address,
+ to: burnMintPool.address,
+ success: true,
+ })
+ expect(result.transactions).toHaveTransaction({
+ from: burnMintPool.address,
+ to: cctMinter.address,
+ success: true,
+ })
+
+ const releaseResponses = result.transactions.filter((tx: any) => {
+ return (
+ tx.inMessage?.info?.src?.equals?.(burnMintPool.address) &&
+ tx.inMessage?.body?.beginParse?.().preloadUint?.(32) ===
+ TokenPool_ReleaseOrMintResponse.PREFIX
+ )
+ })
+ expect(releaseResponses.length).toBe(0)
+ expect(await burnMintPool.getHasPendingMint(305n)).toBe(false)
+ })
+})
diff --git a/contracts/tests/ccip/pools/LockReleaseTokenPool.spec.ts b/contracts/tests/ccip/pools/LockReleaseTokenPool.spec.ts
new file mode 100644
index 000000000..a1cff9bc9
--- /dev/null
+++ b/contracts/tests/ccip/pools/LockReleaseTokenPool.spec.ts
@@ -0,0 +1,519 @@
+import '@ton/test-utils'
+import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox'
+import { Address, Cell, beginCell, Dictionary, toNano } from '@ton/core'
+import { createEmptyTensorValue, loadMap } from '../../../src/utils/dict'
+import { JettonMinter, JettonSender, JettonWallet } from '../../../wrappers/examples/jetton'
+import * as jetton from '../../../wrappers/jetton/JettonCode'
+import {
+ CrossChainAddress,
+ CursedSubjects,
+ RateLimiter_Config,
+ TokenPool,
+ TokenPool_Data,
+ TokenPool_AdminConfig,
+ TokenPool_DynamicConfig,
+ TokenPool_MirroredPolicy,
+ TokenPool_ReleaseOrMintResponse,
+ TokenPool_LockOrBurn,
+ TokenPool_LockOrBurnInV1,
+ TokenPool_ReleaseOrMintInV1,
+ TokenPool_RateLimitConfigPair,
+ TokenPool_RampUpdate,
+ TokenPool_ChainUpdate,
+ Ownable2Step,
+} from '../../../wrappers/gen/ccip/pools/TokenPool'
+import {
+ JettonClient,
+ LockReleaseTokenPool,
+} from '../../../wrappers/gen/ccip/pools/LockReleaseTokenPool'
+import { setupGenBindings } from '../../../wrappers/gen'
+
+import * as rtOld from '../../../wrappers/ccip/Router'
+import { runTokenPoolBehaviorTests } from './TokenPool.behavior'
+import { asSnakedCell, asSnakedCellEmpty } from '../../../src/utils'
+
+function crossChainAddressFromBuffer(buffer: Buffer): CrossChainAddress {
+ const addrSlice = rtOld.builder.data.crossChainAddress.encode(buffer).asSlice()
+ return CrossChainAddress.fromSlice(addrSlice)
+}
+
+describe('LockReleaseTokenPool', () => {
+ let blockchain: Blockchain
+ let deployer: SandboxContract
+ let offRamp: SandboxContract
+ let recipient: SandboxContract
+
+ let jettonMinter: SandboxContract
+ let jettonSender: SandboxContract
+ let lockReleasePool: SandboxContract
+ let pool: SandboxContract
+ let jettonWalletCode: Cell
+
+ let userWallet: (address: Address) => Promise>
+
+ const remoteChainSelector = 90000001n
+
+ let sourcePoolAddress: CrossChainAddress
+ let destTokenAddress: CrossChainAddress
+ let receiverAddress: CrossChainAddress
+
+ beforeAll(async () => {
+ setupGenBindings()
+
+ sourcePoolAddress = crossChainAddressFromBuffer(Buffer.from('source-pool'))
+ destTokenAddress = crossChainAddressFromBuffer(Buffer.from('dest-token'))
+ receiverAddress = crossChainAddressFromBuffer(Buffer.from('receiver'))
+ })
+
+ beforeEach(async () => {
+ blockchain = await Blockchain.create()
+ deployer = await blockchain.treasury('deployer')
+ offRamp = await blockchain.treasury('offramp')
+ recipient = await blockchain.treasury('recipient')
+
+ jettonWalletCode = await jetton.JettonWalletCode()
+ const jettonMinterCode = await jetton.JettonMinterCode()
+
+ jettonMinter = blockchain.openContract(
+ JettonMinter.createFromConfig(
+ {
+ admin: deployer.address,
+ transferAdmin: null,
+ walletCode: jettonWalletCode,
+ jettonContent: beginCell().storeStringTail('pool-test').endCell(),
+ totalSupply: 0n,
+ },
+ jettonMinterCode,
+ ),
+ )
+ await jettonMinter.sendDeploy(deployer.getSender(), toNano('1'))
+
+ const jettonSenderCode = await JettonSender.code()
+ jettonSender = blockchain.openContract(
+ JettonSender.createFromConfig(
+ {
+ jettonClient: {
+ masterAddress: jettonMinter.address,
+ jettonWalletCode,
+ },
+ },
+ jettonSenderCode,
+ ),
+ )
+ await jettonSender.sendDeploy(deployer.getSender(), toNano('1'))
+
+ lockReleasePool = blockchain.openContract(
+ LockReleaseTokenPool.fromStorage({
+ poolData: {
+ ref: TokenPool_Data.create({
+ adminConfig: {
+ ref: TokenPool_AdminConfig.create({
+ ownable: {
+ ref: Ownable2Step.create({ owner: deployer.address, pendingOwner: null }),
+ },
+ rmnProxy: deployer.address,
+ dynamicConfig: {
+ ref: TokenPool_DynamicConfig.create({
+ router: deployer.address,
+ rateLimitAdmin: null,
+ feeAdmin: null,
+ }),
+ },
+ allowedFinalityConfig: 0n,
+ }),
+ },
+ mirroredPolicy: {
+ ref: TokenPool_MirroredPolicy.create({
+ onRamps: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ offRamps: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ cursedSubjects: CursedSubjects.create({
+ data: Dictionary.empty(Dictionary.Keys.BigInt(128)),
+ }),
+ }),
+ },
+ token: jettonMinter.address,
+ tokenDecimals: 9n,
+ remoteChainConfigs: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ tokenTransferFeeConfigs: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ }),
+ },
+ jettonClient: {
+ ref: JettonClient.create({
+ masterAddress: jettonMinter.address,
+ jettonWalletCode,
+ }),
+ },
+ pendingReleases: Dictionary.empty(Dictionary.Keys.BigInt(64)),
+ }),
+ )
+ await lockReleasePool.sendDeploy(deployer.getSender(), toNano('2'))
+
+ // Standard TokenPool interface
+ pool = blockchain.openContract(TokenPool.fromAddress(lockReleasePool.address))
+
+ const applyChains = await lockReleasePool.sendTokenPoolApplyChainUpdates(
+ deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 1n,
+ remoteChainSelectorsToRemove: asSnakedCellEmpty(),
+ chainsToAdd: asSnakedCell(
+ [
+ TokenPool_ChainUpdate.create({
+ remoteChainSelector,
+ remotePoolAddresses: asSnakedCell([sourcePoolAddress], (item) => {
+ let b = beginCell()
+ CrossChainAddress.store(item, b)
+ return b
+ }),
+ remoteTokenAddress: { ref: destTokenAddress },
+ rateLimitConfigs: {
+ ref: TokenPool_RateLimitConfigPair.create({
+ outbound: {
+ ref: RateLimiter_Config.create({
+ isEnabled: true,
+ capacity: toNano('100'),
+ rate: 1n,
+ }),
+ },
+ inbound: {
+ ref: RateLimiter_Config.create({
+ isEnabled: true,
+ capacity: toNano('100'),
+ rate: 1n,
+ }),
+ },
+ }),
+ },
+ }),
+ ],
+ (item) => TokenPool_ChainUpdate.toCell(item).asBuilder(),
+ ),
+ },
+ )
+
+ expect(applyChains.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: lockReleasePool.address,
+ success: true,
+ })
+
+ const updateRampAccess = await lockReleasePool.sendTokenPoolUpdateRampAccess(
+ deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 2n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector,
+ onRamp: jettonSender.address,
+ offRamp: offRamp.address,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ },
+ )
+
+ expect(updateRampAccess.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: lockReleasePool.address,
+ success: true,
+ })
+
+ const mintToOnRamp = await jettonMinter.sendMint(deployer.getSender(), {
+ value: toNano('1'),
+ message: {
+ queryId: 0n,
+ destination: jettonSender.address,
+ tonAmount: toNano('0.05'),
+ jettonAmount: toNano('10'),
+ from: deployer.address,
+ responseDestination: deployer.address,
+ forwardTonAmount: 0n,
+ },
+ })
+ expect(mintToOnRamp.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: jettonMinter.address,
+ success: true,
+ })
+
+ userWallet = async (address: Address) => {
+ return blockchain.openContract(
+ JettonWallet.createFromAddress(await jettonMinter.getWalletAddress(address)),
+ )
+ }
+ })
+
+ runTokenPoolBehaviorTests('LockReleaseTokenPool', async () => ({
+ pool,
+ deployer,
+ offRamp,
+ altOffRamp: deployer,
+ unauthorized: recipient,
+ recipient,
+ remoteChainSelector,
+ unsupportedChainSelector: remoteChainSelector + 1n,
+ unknownSourcePoolAddress: crossChainAddressFromBuffer(Buffer.from('unknown-source-pool')),
+ remoteTokenAddress: destTokenAddress,
+ onRampAddress: jettonSender.address,
+ destTokenAddress,
+ sourcePoolAddress,
+ localToken: jettonMinter.address,
+ }))
+
+ it('has no pending release by default', async () => {
+ expect(await lockReleasePool.getHasPendingRelease(999n)).toBe(false)
+ })
+
+ it('reverts lockOrBurn when forwarded amount does not match transfer amount', async () => {
+ const onRampWallet = await userWallet(jettonSender.address)
+ const poolWallet = await userWallet(lockReleasePool.address)
+
+ const result = await jettonSender.sendJettonsExtended(deployer.getSender(), {
+ value: toNano('2'),
+ message: {
+ queryId: 44n,
+ amount: toNano('3'),
+ destination: lockReleasePool.address,
+ customPayload: beginCell().storeBit(1).endCell(),
+ forwardTonAmount: toNano('0.2'),
+ forwardPayload: TokenPool_LockOrBurn.toCell(
+ TokenPool_LockOrBurn.create({
+ queryId: 44n,
+ request: {
+ ref: TokenPool_LockOrBurnInV1.create({
+ receiver: { ref: receiverAddress },
+ remoteChainSelector,
+ originalSender: deployer.address,
+ amount: toNano('2'),
+ localToken: jettonMinter.address,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ tokenArgs: null,
+ replyTo: deployer.address,
+ }),
+ ),
+ },
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: poolWallet.address,
+ to: lockReleasePool.address,
+ success: false,
+ })
+ })
+
+ it('reverts lockOrBurn when forward payload is malformed', async () => {
+ const onRampWallet = await userWallet(jettonSender.address)
+ const poolWallet = await userWallet(lockReleasePool.address)
+
+ const result = await jettonSender.sendJettonsExtended(deployer.getSender(), {
+ value: toNano('2'),
+ message: {
+ queryId: 45n,
+ amount: toNano('1'),
+ destination: lockReleasePool.address,
+ customPayload: beginCell().storeBit(1).endCell(),
+ forwardTonAmount: toNano('0.2'),
+ forwardPayload: beginCell().storeUint(0, 32).endCell(),
+ },
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: poolWallet.address,
+ to: lockReleasePool.address,
+ success: false,
+ })
+ })
+
+ it('reverts releaseOrMint when requested amount exceeds pool liquidity', async () => {
+ const result = await lockReleasePool.sendTokenPoolReleaseOrMint(
+ offRamp.getSender(),
+ toNano('0.4'),
+ {
+ queryId: 46n,
+ request: {
+ ref: TokenPool_ReleaseOrMintInV1.create({
+ originalSender: { ref: sourcePoolAddress },
+ remoteChainSelector,
+ receiver: recipient.address,
+ sourceDenominatedAmount: toNano('999999'),
+ localToken: jettonMinter.address,
+ sourcePoolAddress: { ref: sourcePoolAddress },
+ sourcePoolData: null,
+ offchainTokenData: null,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ replyTo: deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: offRamp.address,
+ to: lockReleasePool.address,
+ success: false,
+ })
+ expect(await lockReleasePool.getHasPendingRelease(46n)).toBe(false)
+ })
+
+ it('locks tokens through a jetton transfer notification and credits the pool wallet', async () => {
+ const onRampWallet = await userWallet(jettonSender.address)
+ const poolWallet = await userWallet(lockReleasePool.address)
+
+ const result = await jettonSender.sendJettonsExtended(deployer.getSender(), {
+ value: toNano('2'),
+ message: {
+ queryId: 11n,
+ amount: toNano('3'),
+ destination: lockReleasePool.address,
+ customPayload: beginCell().storeBit(1).endCell(),
+ forwardTonAmount: toNano('0.2'),
+ forwardPayload: TokenPool_LockOrBurn.toCell(
+ TokenPool_LockOrBurn.create({
+ queryId: 11n,
+ request: {
+ ref: TokenPool_LockOrBurnInV1.create({
+ receiver: { ref: receiverAddress },
+ remoteChainSelector,
+ originalSender: deployer.address,
+ amount: toNano('3'),
+ localToken: jettonMinter.address,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ tokenArgs: null,
+ replyTo: deployer.address,
+ }),
+ ),
+ },
+ })
+
+ expect(result.transactions).toHaveTransaction({
+ from: jettonSender.address,
+ to: onRampWallet.address,
+ success: true,
+ })
+
+ expect(await poolWallet.getJettonBalance()).toEqual(toNano('3'))
+ })
+
+ it('releases tokens from pool custody after off-ramp request and clears pending state on confirmation', async () => {
+ const poolWallet = await userWallet(lockReleasePool.address)
+ const recipientWallet = await userWallet(recipient.address)
+
+ await jettonMinter.sendMint(deployer.getSender(), {
+ value: toNano('1'),
+ message: {
+ queryId: 0n,
+ destination: lockReleasePool.address,
+ tonAmount: toNano('0.05'),
+ jettonAmount: toNano('5'),
+ from: deployer.address,
+ responseDestination: deployer.address,
+ forwardTonAmount: 0n,
+ },
+ })
+
+ const result = await lockReleasePool.sendTokenPoolReleaseOrMint(
+ offRamp.getSender(),
+ toNano('0.4'),
+ {
+ queryId: 22n,
+ request: {
+ ref: TokenPool_ReleaseOrMintInV1.create({
+ originalSender: { ref: sourcePoolAddress },
+ remoteChainSelector,
+ receiver: recipient.address,
+ sourceDenominatedAmount: toNano('2'),
+ localToken: jettonMinter.address,
+ sourcePoolAddress: { ref: sourcePoolAddress },
+ sourcePoolData: null,
+ offchainTokenData: null,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ replyTo: deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: offRamp.address,
+ to: lockReleasePool.address,
+ success: true,
+ })
+
+ expect(await recipientWallet.getJettonBalance()).toEqual(toNano('2'))
+ expect(await poolWallet.getJettonBalance()).toEqual(toNano('3'))
+ expect(await lockReleasePool.getHasPendingRelease(22n)).toBe(false)
+
+ expect(result.transactions).toHaveTransaction({
+ from: lockReleasePool.address,
+ to: deployer.address,
+ success: true,
+ op: TokenPool_ReleaseOrMintResponse.PREFIX,
+ body(body) {
+ if (!body) return false
+ const response = TokenPool_ReleaseOrMintResponse.fromSlice(body.beginParse())
+ return response.queryId === 22n && response.out.ref.destinationAmount === toNano('2')
+ },
+ })
+ })
+
+ it('mirrors cursed state locally and blocks release while cursed', async () => {
+ const curseUpdate = await lockReleasePool.sendTokenPoolUpdateCursedSubjects(
+ deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 901n,
+ cursedSubjects: CursedSubjects.create({
+ data: loadMap(
+ Dictionary.Keys.BigInt(128),
+ createEmptyTensorValue(),
+ new Map([[remoteChainSelector, []]]),
+ ),
+ }),
+ },
+ )
+
+ expect(curseUpdate.transactions).toHaveTransaction({
+ from: deployer.address,
+ to: lockReleasePool.address,
+ success: true,
+ })
+
+ expect(await lockReleasePool.getVerifyNotCursed(remoteChainSelector)).toBe(false)
+
+ const result = await lockReleasePool.sendTokenPoolReleaseOrMint(
+ offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 33n,
+ request: {
+ ref: TokenPool_ReleaseOrMintInV1.create({
+ originalSender: { ref: sourcePoolAddress },
+ remoteChainSelector,
+ receiver: recipient.address,
+ sourceDenominatedAmount: toNano('1'),
+ localToken: jettonMinter.address,
+ sourcePoolAddress: { ref: sourcePoolAddress },
+ sourcePoolData: null,
+ offchainTokenData: null,
+ }),
+ },
+ requestedFinalityConfig: 0n,
+ replyTo: deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: offRamp.address,
+ to: lockReleasePool.address,
+ success: false,
+ })
+ })
+})
diff --git a/contracts/tests/ccip/pools/TokenPool.behavior.ts b/contracts/tests/ccip/pools/TokenPool.behavior.ts
new file mode 100644
index 000000000..bc53fcf4b
--- /dev/null
+++ b/contracts/tests/ccip/pools/TokenPool.behavior.ts
@@ -0,0 +1,522 @@
+import '@ton/test-utils'
+import { SandboxContract, TreasuryContract } from '@ton/sandbox'
+import { Address, beginCell, Cell, Dictionary, DictionaryValue, Sender, toNano } from '@ton/core'
+import {
+ CrossChainAddress,
+ CursedSubjects,
+ TokenPool,
+ TokenPool_ChainUpdate,
+ TokenPool_RampUpdate,
+ TokenPool_RateLimitConfigPair,
+ TokenPool_ReleaseOrMintInV1,
+ RateLimiter_Config,
+} from '../../../wrappers/gen/ccip/pools/TokenPool'
+import { asSnakedCell, asSnakedCellEmpty } from '../../../src/utils'
+import { createEmptyTensorValue, loadMap } from '../../../src/utils/dict'
+
+export type TokenPoolBehaviorContext = {
+ pool: SandboxContract
+ deployer: SandboxContract
+ offRamp: SandboxContract
+ unauthorized: SandboxContract
+ recipient: SandboxContract
+ onRampAddress: Address
+ remoteChainSelector: bigint
+ destTokenAddress: CrossChainAddress
+ sourcePoolAddress: CrossChainAddress
+ localToken: Address
+}
+
+function releaseRequest(
+ ctx: TokenPoolBehaviorContext,
+ overrides: Partial = {},
+): TokenPool_ReleaseOrMintInV1 {
+ return TokenPool_ReleaseOrMintInV1.create({
+ originalSender: { ref: ctx.sourcePoolAddress },
+ remoteChainSelector: ctx.remoteChainSelector,
+ receiver: ctx.recipient.address,
+ sourceDenominatedAmount: 1n,
+ localToken: ctx.localToken,
+ sourcePoolAddress: { ref: ctx.sourcePoolAddress },
+ sourcePoolData: null,
+ offchainTokenData: null,
+ ...overrides,
+ })
+}
+
+export function runTokenPoolBehaviorTests(
+ name: string,
+ setup: () => Promise,
+) {
+ describe(`${name} TokenPool behavior`, () => {
+ it('mirrors ramp access and supported chain state after setup', async () => {
+ const ctx = await setup()
+
+ expect(await ctx.pool.getIsSupportedChain(ctx.remoteChainSelector)).toBe(true)
+ expect(await ctx.pool.getOnRamp(ctx.remoteChainSelector)).not.toBeNull()
+ expect(await ctx.pool.getOffRamp(ctx.remoteChainSelector)).toEqualAddress(ctx.offRamp.address)
+ })
+
+ it('reverts releaseOrMint when caller is not configured off-ramp', async () => {
+ const ctx = await setup()
+
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.unauthorized.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 901n,
+ request: { ref: releaseRequest(ctx) },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.unauthorized.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('reverts releaseOrMint while chain is cursed', async () => {
+ const ctx = await setup()
+
+ await ctx.pool.sendTokenPoolUpdateCursedSubjects(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 901n,
+ cursedSubjects: CursedSubjects.create({
+ data: loadMap(
+ Dictionary.Keys.BigInt(128),
+ createEmptyTensorValue(),
+ new Map([[ctx.remoteChainSelector, []]]),
+ ),
+ }),
+ })
+ expect(await ctx.pool.getVerifyNotCursed(ctx.remoteChainSelector)).toBe(false)
+
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 902n,
+ request: { ref: releaseRequest(ctx) },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.offRamp.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('starts with chain not cursed', async () => {
+ const ctx = await setup()
+ expect(await ctx.pool.getVerifyNotCursed(ctx.remoteChainSelector)).toBe(true)
+ })
+
+ it('returns null ramps for unknown chain', async () => {
+ const ctx = await setup()
+ const unknownChainSelector = ctx.remoteChainSelector + 1n
+ expect(await ctx.pool.getOnRamp(unknownChainSelector)).toBeNull()
+ expect(await ctx.pool.getOffRamp(unknownChainSelector)).toBeNull()
+ })
+
+ it('returns unsupported for unknown chain', async () => {
+ const ctx = await setup()
+ const unknownChainSelector = ctx.remoteChainSelector + 1n
+ expect(await ctx.pool.getIsSupportedChain(unknownChainSelector)).toBe(false)
+ })
+
+ it('rejects applyChainUpdates from non-owner', async () => {
+ const ctx = await setup()
+ const result = await ctx.pool.sendTokenPoolApplyChainUpdates(
+ ctx.unauthorized.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 903n,
+ remoteChainSelectorsToRemove: asSnakedCellEmpty(),
+ chainsToAdd: asSnakedCellEmpty(),
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.unauthorized.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('rejects updateRampAccess from non-owner', async () => {
+ const ctx = await setup()
+ const result = await ctx.pool.sendTokenPoolUpdateRampAccess(
+ ctx.unauthorized.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 904n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector: ctx.remoteChainSelector,
+ onRamp: ctx.onRampAddress,
+ offRamp: ctx.unauthorized.address,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.unauthorized.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('rejects cursed-subject updates from non-rmn sender', async () => {
+ const ctx = await setup()
+ const result = await ctx.pool.sendTokenPoolUpdateCursedSubjects(
+ ctx.unauthorized.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 904n,
+ cursedSubjects: CursedSubjects.create({
+ data: loadMap(
+ Dictionary.Keys.BigInt(128),
+ createEmptyTensorValue(),
+ new Map([[ctx.remoteChainSelector, []]]),
+ ),
+ }),
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.unauthorized.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('can clear cursed subject back to not cursed', async () => {
+ const ctx = await setup()
+ await ctx.pool.sendTokenPoolUpdateCursedSubjects(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 901n,
+ cursedSubjects: CursedSubjects.create({
+ data: loadMap(
+ Dictionary.Keys.BigInt(128),
+ createEmptyTensorValue(),
+ new Map([[ctx.remoteChainSelector, []]]),
+ ),
+ }),
+ })
+ expect(await ctx.pool.getVerifyNotCursed(ctx.remoteChainSelector)).toBe(false)
+
+ await ctx.pool.sendTokenPoolUpdateCursedSubjects(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 902n,
+ cursedSubjects: CursedSubjects.create({
+ data: Dictionary.empty(Dictionary.Keys.BigInt(128)),
+ }),
+ })
+ expect(await ctx.pool.getVerifyNotCursed(ctx.remoteChainSelector)).toBe(true)
+ })
+
+ it('removes configured chain via applyChainUpdates', async () => {
+ const ctx = await setup()
+ const result = await ctx.pool.sendTokenPoolApplyChainUpdates(
+ ctx.deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 905n,
+ remoteChainSelectorsToRemove: asSnakedCell([ctx.remoteChainSelector], (item: bigint) =>
+ beginCell().storeUint(item, 64),
+ ),
+ chainsToAdd: asSnakedCellEmpty(),
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.deployer.address,
+ to: ctx.pool.address,
+ success: true,
+ })
+ expect(await ctx.pool.getIsSupportedChain(ctx.remoteChainSelector)).toBe(false)
+ })
+
+ it('reverts releaseOrMint after configured chain is removed', async () => {
+ const ctx = await setup()
+ await ctx.pool.sendTokenPoolApplyChainUpdates(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 906n,
+ remoteChainSelectorsToRemove: asSnakedCell([ctx.remoteChainSelector], (item: bigint) =>
+ beginCell().storeUint(item, 64),
+ ),
+ chainsToAdd: asSnakedCellEmpty(),
+ })
+
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 907n,
+ request: { ref: releaseRequest(ctx) },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.offRamp.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('rejects removing a non-existent chain', async () => {
+ const ctx = await setup()
+ const result = await ctx.pool.sendTokenPoolApplyChainUpdates(
+ ctx.deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 908n,
+ remoteChainSelectorsToRemove: asSnakedCell(
+ [ctx.remoteChainSelector + 1n],
+ (item: bigint) => beginCell().storeUint(item, 64),
+ ),
+ chainsToAdd: asSnakedCellEmpty(),
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.deployer.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('can replace off-ramp mapping via updateRampAccess', async () => {
+ const ctx = await setup()
+ const result = await ctx.pool.sendTokenPoolUpdateRampAccess(
+ ctx.deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 909n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector: ctx.remoteChainSelector,
+ onRamp: ctx.onRampAddress,
+ offRamp: ctx.unauthorized.address,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.deployer.address,
+ to: ctx.pool.address,
+ success: true,
+ })
+ expect(await ctx.pool.getOffRamp(ctx.remoteChainSelector)).toEqualAddress(
+ ctx.unauthorized.address,
+ )
+ })
+
+ it('rejects old off-ramp sender after remapping off-ramp', async () => {
+ const ctx = await setup()
+ await ctx.pool.sendTokenPoolUpdateRampAccess(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 910n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector: ctx.remoteChainSelector,
+ onRamp: ctx.onRampAddress,
+ offRamp: ctx.unauthorized.address,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ })
+
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 911n,
+ request: { ref: releaseRequest(ctx) },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.offRamp.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('rejects releaseOrMint when source pool is not configured', async () => {
+ const ctx = await setup()
+ const wrongSourcePoolAddress = beginCell()
+ .storeUint(4, 8)
+ .storeBuffer(Buffer.from('evil'))
+ .endCell()
+ .beginParse()
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 912n,
+ request: {
+ ref: releaseRequest(ctx, { sourcePoolAddress: { ref: wrongSourcePoolAddress } }),
+ },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.offRamp.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('rejects releaseOrMint when local token does not match pool token', async () => {
+ const ctx = await setup()
+ const wrongLocalToken = ctx.deployer.address
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 913n,
+ request: { ref: releaseRequest(ctx, { localToken: wrongLocalToken }) },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.offRamp.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('clears existing off-ramp when update passes null off-ramp', async () => {
+ const ctx = await setup()
+ await ctx.pool.sendTokenPoolUpdateRampAccess(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 914n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector: ctx.remoteChainSelector,
+ onRamp: ctx.onRampAddress,
+ offRamp: null,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ })
+
+ expect(await ctx.pool.getOffRamp(ctx.remoteChainSelector)).toBeNull()
+ })
+
+ it('rejects existing off-ramp sender after null off-ramp update', async () => {
+ const ctx = await setup()
+ await ctx.pool.sendTokenPoolUpdateRampAccess(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 915n,
+ updates: asSnakedCell(
+ [
+ TokenPool_RampUpdate.create({
+ remoteChainSelector: ctx.remoteChainSelector,
+ onRamp: ctx.onRampAddress,
+ offRamp: null,
+ }),
+ ],
+ (item) => TokenPool_RampUpdate.toCell(item).asBuilder(),
+ ),
+ })
+
+ const result = await ctx.pool.sendTokenPoolReleaseOrMint(
+ ctx.offRamp.getSender(),
+ toNano('0.3'),
+ {
+ queryId: 916n,
+ request: { ref: releaseRequest(ctx) },
+ requestedFinalityConfig: 0n,
+ replyTo: ctx.deployer.address,
+ },
+ )
+
+ expect(result.transactions).toHaveTransaction({
+ from: ctx.offRamp.address,
+ to: ctx.pool.address,
+ success: false,
+ })
+ })
+
+ it('can re-add chain after remove via applyChainUpdates', async () => {
+ const ctx = await setup()
+
+ await ctx.pool.sendTokenPoolApplyChainUpdates(ctx.deployer.getSender(), toNano('0.2'), {
+ queryId: 917n,
+ remoteChainSelectorsToRemove: asSnakedCell([ctx.remoteChainSelector], (item) =>
+ beginCell().storeUint(item, 64),
+ ),
+ chainsToAdd: asSnakedCellEmpty(),
+ })
+
+ const addResult = await ctx.pool.sendTokenPoolApplyChainUpdates(
+ ctx.deployer.getSender(),
+ toNano('0.2'),
+ {
+ queryId: 918n,
+ remoteChainSelectorsToRemove: asSnakedCell([], (item) => beginCell().storeUint(item, 64)),
+ chainsToAdd: asSnakedCell(
+ [
+ TokenPool_ChainUpdate.create({
+ remoteChainSelector: ctx.remoteChainSelector,
+ remotePoolAddresses: asSnakedCell([ctx.sourcePoolAddress], (item) => {
+ let b = beginCell()
+ CrossChainAddress.store(item, b)
+ return b
+ }),
+ remoteTokenAddress: { ref: ctx.destTokenAddress },
+ rateLimitConfigs: {
+ ref: TokenPool_RateLimitConfigPair.create({
+ outbound: {
+ ref: RateLimiter_Config.create({
+ isEnabled: true,
+ capacity: toNano('100'),
+ rate: 1n,
+ }),
+ },
+ inbound: {
+ ref: RateLimiter_Config.create({
+ isEnabled: true,
+ capacity: toNano('100'),
+ rate: 1n,
+ }),
+ },
+ }),
+ },
+ }),
+ ],
+ (item) => TokenPool_ChainUpdate.toCell(item).asBuilder(),
+ ),
+ },
+ )
+
+ expect(addResult.transactions).toHaveTransaction({
+ from: ctx.deployer.address,
+ to: ctx.pool.address,
+ success: true,
+ })
+ expect(await ctx.pool.getIsSupportedChain(ctx.remoteChainSelector)).toBe(true)
+ })
+ })
+}
diff --git a/contracts/tests/ccip/router/Router.getFee.spec.ts b/contracts/tests/ccip/router/Router.getFee.spec.ts
index f1e1c4d5f..62714f02c 100644
--- a/contracts/tests/ccip/router/Router.getFee.spec.ts
+++ b/contracts/tests/ccip/router/Router.getFee.spec.ts
@@ -4,7 +4,6 @@ import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox'
import { asSnakeDataUint, fromSnakeData, WRAPPED_NATIVE } from '../../../src/utils'
import * as coverage from '../../coverage/coverage'
-import * as rtOld from '../../../wrappers/ccip/Router'
import * as rt from '../../../wrappers/gen/ccip/Router'
import * as or from '../../../wrappers/ccip/OnRamp'
import {
@@ -13,6 +12,7 @@ import {
EVM_ADDRESS,
contractsCoverageConfig,
} from './Router.Setup'
+import { setupGenBindings } from '../../../wrappers/gen'
const EVM_CC_ADDRESS: rt.CrossChainAddress = beginCell().storeBuffer(EVM_ADDRESS).asSlice()
@@ -25,6 +25,8 @@ describe('Router', () => {
let onRamp: SandboxContract
beforeAll(async () => {
+ setupGenBindings()
+
blockchain = await Blockchain.create()
blockchain.verbosity = {
print: true,
@@ -39,22 +41,6 @@ describe('Router', () => {
}
feeQuoter = await blockchain.treasury('feeQuoter')
onRamp = await blockchain.treasury('onRamp')
-
- function CrossChainAddress__packToBuilder(self: rt.CrossChainAddress, b: Builder): void {
- const src = self.clone()
- const buffer = src.loadBuffer(src.remainingBits / 8)
- b.storeBuilder(rtOld.builder.data.crossChainAddress.encode(buffer))
- }
- function CrossChainAddress__unpackFromSlice(s: Slice): rt.CrossChainAddress {
- const buff = rtOld.builder.data.crossChainAddress.load(s)
- return beginCell().storeBuffer(buff).asSlice() as rt.CrossChainAddress
- }
-
- rt.Router.registerCustomPackUnpack(
- 'CrossChainAddress',
- CrossChainAddress__packToBuilder,
- CrossChainAddress__unpackFromSlice,
- )
})
beforeEach(async () => {
diff --git a/contracts/wrappers/ccip.cct.JettonMinter.compile.ts b/contracts/wrappers/ccip.cct.JettonMinter.compile.ts
new file mode 100644
index 000000000..229320137
--- /dev/null
+++ b/contracts/wrappers/ccip.cct.JettonMinter.compile.ts
@@ -0,0 +1,7 @@
+import { CompilerConfig } from '@ton/blueprint'
+
+export const compile: CompilerConfig = {
+ lang: 'tolk',
+ entrypoint: 'contracts/ccip/cct/JettonMinter.tolk',
+ withStackComments: true,
+}
diff --git a/contracts/wrappers/ccip.cct.JettonWallet.compile.ts b/contracts/wrappers/ccip.cct.JettonWallet.compile.ts
new file mode 100644
index 000000000..4694eb8ad
--- /dev/null
+++ b/contracts/wrappers/ccip.cct.JettonWallet.compile.ts
@@ -0,0 +1,7 @@
+import { CompilerConfig } from '@ton/blueprint'
+
+export const compile: CompilerConfig = {
+ lang: 'tolk',
+ entrypoint: 'contracts/ccip/cct/JettonWallet.tolk',
+ withStackComments: true,
+}
diff --git a/contracts/wrappers/ccip.pools.BurnMintTokenPool.compile.ts b/contracts/wrappers/ccip.pools.BurnMintTokenPool.compile.ts
new file mode 100644
index 000000000..dca1ee093
--- /dev/null
+++ b/contracts/wrappers/ccip.pools.BurnMintTokenPool.compile.ts
@@ -0,0 +1,7 @@
+import { CompilerConfig } from '@ton/blueprint'
+
+export const compile: CompilerConfig = {
+ lang: 'tolk',
+ entrypoint: 'contracts/ccip/pools/burn_mint_token_pool/contract.tolk',
+ withStackComments: true,
+}
diff --git a/contracts/wrappers/ccip.pools.LockReleaseTokenPool.compile.ts b/contracts/wrappers/ccip.pools.LockReleaseTokenPool.compile.ts
new file mode 100644
index 000000000..84b478484
--- /dev/null
+++ b/contracts/wrappers/ccip.pools.LockReleaseTokenPool.compile.ts
@@ -0,0 +1,7 @@
+import { CompilerConfig } from '@ton/blueprint'
+
+export const compile: CompilerConfig = {
+ lang: 'tolk',
+ entrypoint: 'contracts/ccip/pools/lock_release_token_pool/contract.tolk',
+ withStackComments: true,
+}
diff --git a/contracts/wrappers/ccip/CCTJettonCode.ts b/contracts/wrappers/ccip/CCTJettonCode.ts
new file mode 100644
index 000000000..807206fc2
--- /dev/null
+++ b/contracts/wrappers/ccip/CCTJettonCode.ts
@@ -0,0 +1,10 @@
+import { Cell } from '@ton/core'
+import { contractCode } from '../codeLoader'
+
+export async function CCTJettonMinterCode(): Promise {
+ return contractCode.ccip.local('ccip.cct.JettonMinter')
+}
+
+export async function CCTJettonWalletCode(): Promise {
+ return contractCode.ccip.local('ccip.cct.JettonWallet')
+}
diff --git a/contracts/wrappers/ccip/CCTJettonMinter.ts b/contracts/wrappers/ccip/CCTJettonMinter.ts
new file mode 100644
index 000000000..b8103138a
--- /dev/null
+++ b/contracts/wrappers/ccip/CCTJettonMinter.ts
@@ -0,0 +1,122 @@
+import {
+ Address,
+ beginCell,
+ Cell,
+ Contract,
+ contractAddress,
+ ContractProvider,
+ Sender,
+ SendMode,
+} from '@ton/core'
+import { contractCode } from '../codeLoader'
+import { builder as jettonMinterBuilder } from '../jetton/JettonMinter'
+
+export type CCTJettonMinterConfig = {
+ totalSupply: bigint
+ adminAddress: Address | null
+ nextAdminAddress: Address | null
+ jettonWalletCode: Cell
+ metadataUri: string
+}
+
+export type CCTMintMessage = {
+ queryId: bigint
+ destination: Address
+ tonAmount: bigint
+ jettonAmount: bigint
+ from: Address | null
+ responseDestination: Address | null
+ forwardTonAmount?: bigint
+}
+
+export class CCTJettonMinter implements Contract {
+ constructor(
+ readonly address: Address,
+ readonly init?: { code: Cell; data: Cell },
+ ) {}
+
+ static createFromAddress(address: Address) {
+ return new CCTJettonMinter(address)
+ }
+
+ static createFromConfig(config: CCTJettonMinterConfig, code: Cell, workchain = 0) {
+ const data = beginCell()
+ .storeCoins(config.totalSupply)
+ .storeAddress(config.adminAddress)
+ .storeAddress(config.nextAdminAddress)
+ .storeRef(config.jettonWalletCode)
+ .storeStringRefTail(config.metadataUri)
+ .endCell()
+
+ const init = { code, data }
+ return new CCTJettonMinter(contractAddress(workchain, init), init)
+ }
+
+ static code(): Promise| {
+ return contractCode.ccip.local('ccip.cct.JettonMinter')
+ }
+
+ async sendDeploy(provider: ContractProvider, via: Sender, value: bigint) {
+ await provider.internal(via, {
+ value,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ body: Cell.EMPTY,
+ })
+ }
+
+ async sendMint(
+ provider: ContractProvider,
+ via: Sender,
+ opts: {
+ value: bigint
+ message: CCTMintMessage
+ },
+ ) {
+ const internalTransferMsg = beginCell()
+ .storeUint(0x178d4519, 32)
+ .storeUint(opts.message.queryId, 64)
+ .storeCoins(opts.message.jettonAmount)
+ .storeAddress(opts.message.from)
+ .storeAddress(opts.message.responseDestination)
+ .storeCoins(opts.message.forwardTonAmount ?? 0n)
+ .storeSlice(beginCell().endCell().beginParse())
+ .endCell()
+
+ await provider.internal(via, {
+ value: opts.value,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ body: beginCell()
+ .storeUint(0x00000015, 32)
+ .storeUint(opts.message.queryId, 64)
+ .storeAddress(opts.message.destination)
+ .storeCoins(opts.message.tonAmount)
+ .storeRef(internalTransferMsg)
+ .endCell(),
+ })
+ }
+
+ async sendChangeMinterAdmin(
+ provider: ContractProvider,
+ via: Sender,
+ opts: {
+ value: bigint
+ queryId?: bigint
+ newAdminAddress: Address
+ },
+ ) {
+ await provider.internal(via, {
+ value: opts.value,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ body: jettonMinterBuilder.messages.in.changeMinterAdmin
+ .encode({ queryId: opts.queryId ?? 0n, newAdmin: opts.newAdminAddress })
+ .asCell(),
+ })
+ }
+
+ async getWalletAddress(provider: ContractProvider, ownerAddress: Address): Promise {
+ const { stack } = await provider.get('get_wallet_address', [
+ { type: 'slice', cell: beginCell().storeAddress(ownerAddress).endCell() },
+ ])
+ return stack.readAddress()
+ }
+}
diff --git a/contracts/wrappers/gen/ccip/pools/BurnMintTokenPool.ts b/contracts/wrappers/gen/ccip/pools/BurnMintTokenPool.ts
new file mode 100644
index 000000000..738ab7768
--- /dev/null
+++ b/contracts/wrappers/gen/ccip/pools/BurnMintTokenPool.ts
@@ -0,0 +1,3336 @@
+// AUTO-GENERATED, do not edit
+// It's a TypeScript wrapper for a BurnMintTokenPool contract in Tolk.
+/* eslint-disable */
+
+import * as c from '@ton/core';
+import { beginCell, ContractProvider, Sender, SendMode } from '@ton/core';
+
+// ————————————————————————————————————————————
+// predefined types and functions
+//
+
+type RemainingBitsAndRefs = c.Slice
+
+type StoreCallback = (obj: T, b: c.Builder) => void
+type LoadCallback = (s: c.Slice) => T
+
+export type CellRef = {
+ ref: T
+}
+
+function makeCellFrom(self: T, storeFn_T: StoreCallback): c.Cell {
+ let b = beginCell();
+ storeFn_T(self, b);
+ return b.endCell();
+}
+
+function loadAndCheckPrefix32(s: c.Slice, expected: number, structName: string): void {
+ let prefix = s.loadUint(32);
+ if (prefix !== expected) {
+ throw new Error(`Incorrect prefix for '${structName}': expected 0x${expected.toString(16).padStart(8, '0')}, got 0x${prefix.toString(16).padStart(8, '0')}`);
+ }
+}
+
+function lookupPrefix(s: c.Slice, expected: number, prefixLen: number): boolean {
+ return s.remainingBits >= prefixLen && s.preloadUint(prefixLen) === expected;
+}
+
+function throwNonePrefixMatch(fieldPath: string): never {
+ throw new Error(`Incorrect prefix for '${fieldPath}': none of variants matched`);
+}
+
+function storeCellRef(cell: CellRef, b: c.Builder, storeFn_T: StoreCallback): void {
+ let b_ref = c.beginCell();
+ storeFn_T(cell.ref, b_ref);
+ b.storeRef(b_ref.endCell());
+}
+
+function loadCellRef(s: c.Slice, loadFn_T: LoadCallback): CellRef {
+ let s_ref = s.loadRef().beginParse();
+ return { ref: loadFn_T(s_ref) };
+}
+
+function storeTolkRemaining(v: RemainingBitsAndRefs, b: c.Builder): void {
+ b.storeSlice(v);
+}
+
+function loadTolkRemaining(s: c.Slice): RemainingBitsAndRefs {
+ let rest = s.clone();
+ s.loadBits(s.remainingBits);
+ while (s.remainingRefs) {
+ s.loadRef();
+ }
+ return rest;
+}
+
+function storeTolkNullable(v: T | null, b: c.Builder, storeFn_T: StoreCallback): void {
+ if (v === null) {
+ b.storeUint(0, 1);
+ } else {
+ b.storeUint(1, 1);
+ storeFn_T(v, b);
+ }
+}
+
+function createDictionaryValue(loadFn_V: LoadCallback, storeFn_V: StoreCallback): c.DictionaryValue {
+ return {
+ serialize(self: V, b: c.Builder) {
+ storeFn_V(self, b);
+ },
+ parse(s: c.Slice): V {
+ const value = loadFn_V(s);
+ s.endParse();
+ return value;
+ }
+ }
+}
+
+// ————————————————————————————————————————————
+// parse get methods result from a TVM stack
+//
+
+class StackReader {
+ constructor(private tuple: c.TupleItem[]) {
+ }
+
+ static fromGetMethod(expectedN: number, getMethodResult: { stack: c.TupleReader }): StackReader {
+ let tuple = [] as c.TupleItem[];
+ while (getMethodResult.stack.remaining) {
+ tuple.push(getMethodResult.stack.pop());
+ }
+ if (tuple.length !== expectedN) {
+ throw new Error(`expected ${expectedN} stack width, got ${tuple.length}`);
+ }
+ return new StackReader(tuple);
+ }
+
+ private popExpecting(itemType: string): ItemT {
+ const item = this.tuple.shift();
+ if (item?.type === itemType) {
+ return item as ItemT;
+ }
+ throw new Error(`not '${itemType}' on a stack`);
+ }
+
+ private popCellLike(): c.Cell {
+ const item = this.tuple.shift();
+ if (item && (item.type === 'cell' || item.type === 'slice' || item.type === 'builder')) {
+ return item.cell;
+ }
+ throw new Error(`not cell/slice on a stack`);
+ }
+
+ readBigInt(): bigint {
+ return this.popExpecting('int').value;
+ }
+
+ readBoolean(): boolean {
+ return this.popExpecting('int').value !== 0n;
+ }
+
+ readCell(): c.Cell {
+ return this.popCellLike();
+ }
+
+ readSlice(): c.Slice {
+ return this.popCellLike().beginParse();
+ }
+
+ readNullable(readFn_T: (r: StackReader) => T): T | null {
+ if (this.tuple[0].type === 'null') {
+ this.tuple.shift();
+ return null;
+ }
+ return readFn_T(this);
+ }
+}
+
+// ————————————————————————————————————————————
+// custom packToBuilder and unpackFromSlice
+//
+
+type CustomPackToBuilderFn = (self: T, b: c.Builder) => void
+type CustomUnpackFromSliceFn = (s: c.Slice) => T
+
+let customSerializersRegistry: Map | null, CustomUnpackFromSliceFn | null]> = new Map;
+
+function ensureCustomSerializerRegistered(typeName: string) {
+ if (!customSerializersRegistry.has(typeName)) {
+ throw new Error(`Custom packToBuilder/unpackFromSlice was not registered for type 'BurnMintTokenPool.${typeName}'.\n(in Tolk code, they have custom logic \`fun ${typeName}__packToBuilder\`)\nSteps to fix:\n1) in your code, create and implement\n > function ${typeName}__packToBuilder(self: ${typeName}, b: Builder): void { ... }\n > function ${typeName}__unpackFromSlice(s: Slice): ${typeName} { ... }\n2) register them in advance by calling\n > BurnMintTokenPool.registerCustomPackUnpack('${typeName}', ${typeName}__packToBuilder, ${typeName}__unpackFromSlice);`);
+ }
+}
+
+function invokeCustomPackToBuilder(typeName: string, self: T, b: c.Builder) {
+ ensureCustomSerializerRegistered(typeName);
+ customSerializersRegistry.get(typeName)![0]!(self, b);
+}
+
+function invokeCustomUnpackFromSlice(typeName: string, s: c.Slice): T {
+ ensureCustomSerializerRegistered(typeName);
+ return customSerializersRegistry.get(typeName)![1]!(s);
+}
+
+// ————————————————————————————————————————————
+// auto-generated serializers to/from cells
+//
+
+type coins = bigint
+
+type uint8 = bigint
+type uint16 = bigint
+type uint32 = bigint
+type uint64 = bigint
+type uint128 = bigint
+type uint256 = bigint
+
+/**
+ > type SnakedCell = cell
+ */
+export type SnakedCell = c.Cell
+
+/**
+ > struct Ownable2Step {
+ > owner: address
+ > pendingOwner: address?
+ > }
+ */
+export interface Ownable2Step {
+ readonly $: 'Ownable2Step'
+ owner: c.Address
+ pendingOwner: c.Address | null
+}
+
+export const Ownable2Step = {
+ create(args: {
+ owner: c.Address
+ pendingOwner: c.Address | null
+ }): Ownable2Step {
+ return {
+ $: 'Ownable2Step',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): Ownable2Step {
+ return {
+ $: 'Ownable2Step',
+ owner: s.loadAddress(),
+ pendingOwner: s.loadMaybeAddress(),
+ }
+ },
+ store(self: Ownable2Step, b: c.Builder): void {
+ b.storeAddress(self.owner);
+ b.storeAddress(self.pendingOwner);
+ },
+ toCell(self: Ownable2Step): c.Cell {
+ return makeCellFrom(self, Ownable2Step.store);
+ }
+}
+
+/**
+ > struct Ownable2Step_OwnershipTransferRequested {
+ > queryId: uint64
+ > newOwner: address
+ > }
+ */
+export interface Ownable2Step_OwnershipTransferRequested {
+ readonly $: 'Ownable2Step_OwnershipTransferRequested'
+ queryId: uint64
+ newOwner: c.Address
+}
+
+export const Ownable2Step_OwnershipTransferRequested = {
+ create(args: {
+ queryId: uint64
+ newOwner: c.Address
+ }): Ownable2Step_OwnershipTransferRequested {
+ return {
+ $: 'Ownable2Step_OwnershipTransferRequested',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): Ownable2Step_OwnershipTransferRequested {
+ return {
+ $: 'Ownable2Step_OwnershipTransferRequested',
+ queryId: s.loadUintBig(64),
+ newOwner: s.loadAddress(),
+ }
+ },
+ store(self: Ownable2Step_OwnershipTransferRequested, b: c.Builder): void {
+ b.storeUint(self.queryId, 64);
+ b.storeAddress(self.newOwner);
+ },
+ toCell(self: Ownable2Step_OwnershipTransferRequested): c.Cell {
+ return makeCellFrom(self, Ownable2Step_OwnershipTransferRequested.store);
+ }
+}
+
+/**
+ > struct Ownable2Step_OwnershipTransferred {
+ > queryId: uint64
+ > oldOwner: address
+ > newOwner: address
+ > }
+ */
+export interface Ownable2Step_OwnershipTransferred {
+ readonly $: 'Ownable2Step_OwnershipTransferred'
+ queryId: uint64
+ oldOwner: c.Address
+ newOwner: c.Address
+}
+
+export const Ownable2Step_OwnershipTransferred = {
+ create(args: {
+ queryId: uint64
+ oldOwner: c.Address
+ newOwner: c.Address
+ }): Ownable2Step_OwnershipTransferred {
+ return {
+ $: 'Ownable2Step_OwnershipTransferred',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): Ownable2Step_OwnershipTransferred {
+ return {
+ $: 'Ownable2Step_OwnershipTransferred',
+ queryId: s.loadUintBig(64),
+ oldOwner: s.loadAddress(),
+ newOwner: s.loadAddress(),
+ }
+ },
+ store(self: Ownable2Step_OwnershipTransferred, b: c.Builder): void {
+ b.storeUint(self.queryId, 64);
+ b.storeAddress(self.oldOwner);
+ b.storeAddress(self.newOwner);
+ },
+ toCell(self: Ownable2Step_OwnershipTransferred): c.Cell {
+ return makeCellFrom(self, Ownable2Step_OwnershipTransferred.store);
+ }
+}
+
+/**
+ > struct JettonClient {
+ > masterAddress: address
+ > jettonWalletCode: cell
+ > }
+ */
+export interface JettonClient {
+ readonly $: 'JettonClient'
+ masterAddress: c.Address
+ jettonWalletCode: c.Cell
+}
+
+export const JettonClient = {
+ create(args: {
+ masterAddress: c.Address
+ jettonWalletCode: c.Cell
+ }): JettonClient {
+ return {
+ $: 'JettonClient',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): JettonClient {
+ return {
+ $: 'JettonClient',
+ masterAddress: s.loadAddress(),
+ jettonWalletCode: s.loadRef(),
+ }
+ },
+ store(self: JettonClient, b: c.Builder): void {
+ b.storeAddress(self.masterAddress);
+ b.storeRef(self.jettonWalletCode);
+ },
+ toCell(self: JettonClient): c.Cell {
+ return makeCellFrom(self, JettonClient.store);
+ }
+}
+
+/**
+ > type ForwardPayloadRemainder = RemainingBitsAndRefs
+ */
+export type ForwardPayloadRemainder = RemainingBitsAndRefs
+
+export const ForwardPayloadRemainder = {
+ fromSlice(s: c.Slice): ForwardPayloadRemainder {
+ return loadTolkRemaining(s);
+ },
+ store(self: ForwardPayloadRemainder, b: c.Builder): void {
+ storeTolkRemaining(self, b);
+ },
+ toCell(self: ForwardPayloadRemainder): c.Cell {
+ return makeCellFrom(self, ForwardPayloadRemainder.store);
+ }
+}
+
+/**
+ > struct (0x7362d09c) TransferNotificationForRecipient {
+ > queryId: uint64
+ > jettonAmount: coins
+ > transferInitiator: address?
+ > forwardPayload: ForwardPayloadRemainder
+ > }
+ */
+export interface TransferNotificationForRecipient {
+ readonly $: 'TransferNotificationForRecipient'
+ queryId: uint64
+ jettonAmount: coins
+ transferInitiator: c.Address | null
+ forwardPayload: ForwardPayloadRemainder
+}
+
+export const TransferNotificationForRecipient = {
+ PREFIX: 0x7362d09c,
+
+ create(args: {
+ queryId: uint64
+ jettonAmount: coins
+ transferInitiator: c.Address | null
+ forwardPayload: ForwardPayloadRemainder
+ }): TransferNotificationForRecipient {
+ return {
+ $: 'TransferNotificationForRecipient',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): TransferNotificationForRecipient {
+ loadAndCheckPrefix32(s, 0x7362d09c, 'TransferNotificationForRecipient');
+ return {
+ $: 'TransferNotificationForRecipient',
+ queryId: s.loadUintBig(64),
+ jettonAmount: s.loadCoins(),
+ transferInitiator: s.loadMaybeAddress(),
+ forwardPayload: ForwardPayloadRemainder.fromSlice(s),
+ }
+ },
+ store(self: TransferNotificationForRecipient, b: c.Builder): void {
+ b.storeUint(0x7362d09c, 32);
+ b.storeUint(self.queryId, 64);
+ b.storeCoins(self.jettonAmount);
+ b.storeAddress(self.transferInitiator);
+ ForwardPayloadRemainder.store(self.forwardPayload, b);
+ },
+ toCell(self: TransferNotificationForRecipient): c.Cell {
+ return makeCellFrom(self, TransferNotificationForRecipient.store);
+ }
+}
+
+/**
+ > struct (0x178d4519) InternalTransferStep {
+ > queryId: uint64
+ > jettonAmount: coins
+ > transferInitiator: address?
+ > sendExcessesTo: address?
+ > forwardTonAmount: coins
+ > forwardPayload: ForwardPayloadRemainder
+ > }
+ */
+export interface InternalTransferStep {
+ readonly $: 'InternalTransferStep'
+ queryId: uint64
+ jettonAmount: coins
+ transferInitiator: c.Address | null
+ sendExcessesTo: c.Address | null
+ forwardTonAmount: coins
+ forwardPayload: ForwardPayloadRemainder
+}
+
+export const InternalTransferStep = {
+ PREFIX: 0x178d4519,
+
+ create(args: {
+ queryId: uint64
+ jettonAmount: coins
+ transferInitiator: c.Address | null
+ sendExcessesTo: c.Address | null
+ forwardTonAmount: coins
+ forwardPayload: ForwardPayloadRemainder
+ }): InternalTransferStep {
+ return {
+ $: 'InternalTransferStep',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): InternalTransferStep {
+ loadAndCheckPrefix32(s, 0x178d4519, 'InternalTransferStep');
+ return {
+ $: 'InternalTransferStep',
+ queryId: s.loadUintBig(64),
+ jettonAmount: s.loadCoins(),
+ transferInitiator: s.loadMaybeAddress(),
+ sendExcessesTo: s.loadMaybeAddress(),
+ forwardTonAmount: s.loadCoins(),
+ forwardPayload: ForwardPayloadRemainder.fromSlice(s),
+ }
+ },
+ store(self: InternalTransferStep, b: c.Builder): void {
+ b.storeUint(0x178d4519, 32);
+ b.storeUint(self.queryId, 64);
+ b.storeCoins(self.jettonAmount);
+ b.storeAddress(self.transferInitiator);
+ b.storeAddress(self.sendExcessesTo);
+ b.storeCoins(self.forwardTonAmount);
+ ForwardPayloadRemainder.store(self.forwardPayload, b);
+ },
+ toCell(self: InternalTransferStep): c.Cell {
+ return makeCellFrom(self, InternalTransferStep.store);
+ }
+}
+
+/**
+ > struct (0xd53276db) ReturnExcessesBack {
+ > queryId: uint64
+ > }
+ */
+export interface ReturnExcessesBack {
+ readonly $: 'ReturnExcessesBack'
+ queryId: uint64
+}
+
+export const ReturnExcessesBack = {
+ PREFIX: 0xd53276db,
+
+ create(args: {
+ queryId: uint64
+ }): ReturnExcessesBack {
+ return {
+ $: 'ReturnExcessesBack',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): ReturnExcessesBack {
+ loadAndCheckPrefix32(s, 0xd53276db, 'ReturnExcessesBack');
+ return {
+ $: 'ReturnExcessesBack',
+ queryId: s.loadUintBig(64),
+ }
+ },
+ store(self: ReturnExcessesBack, b: c.Builder): void {
+ b.storeUint(0xd53276db, 32);
+ b.storeUint(self.queryId, 64);
+ },
+ toCell(self: ReturnExcessesBack): c.Cell {
+ return makeCellFrom(self, ReturnExcessesBack.store);
+ }
+}
+
+/**
+ > struct (0x595f07bc) AskToBurn {
+ > queryId: uint64
+ > jettonAmount: coins
+ > sendExcessesTo: address?
+ > customPayload: cell?
+ > }
+ */
+export interface AskToBurn {
+ readonly $: 'AskToBurn'
+ queryId: uint64
+ jettonAmount: coins
+ sendExcessesTo: c.Address | null
+ customPayload: c.Cell | null
+}
+
+export const AskToBurn = {
+ PREFIX: 0x595f07bc,
+
+ create(args: {
+ queryId: uint64
+ jettonAmount: coins
+ sendExcessesTo: c.Address | null
+ customPayload: c.Cell | null
+ }): AskToBurn {
+ return {
+ $: 'AskToBurn',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): AskToBurn {
+ loadAndCheckPrefix32(s, 0x595f07bc, 'AskToBurn');
+ return {
+ $: 'AskToBurn',
+ queryId: s.loadUintBig(64),
+ jettonAmount: s.loadCoins(),
+ sendExcessesTo: s.loadMaybeAddress(),
+ customPayload: s.loadBoolean() ? s.loadRef() : null,
+ }
+ },
+ store(self: AskToBurn, b: c.Builder): void {
+ b.storeUint(0x595f07bc, 32);
+ b.storeUint(self.queryId, 64);
+ b.storeCoins(self.jettonAmount);
+ b.storeAddress(self.sendExcessesTo);
+ storeTolkNullable(self.customPayload, b,
+ (v,b) => b.storeRef(v)
+ );
+ },
+ toCell(self: AskToBurn): c.Cell {
+ return makeCellFrom(self, AskToBurn.store);
+ }
+}
+
+/**
+ > struct (0x00000015) MintNewJettons {
+ > queryId: uint64
+ > mintRecipient: address
+ > tonAmount: coins
+ > internalTransferMsg: Cell
+ > }
+ */
+export interface MintNewJettons {
+ readonly $: 'MintNewJettons'
+ queryId: uint64
+ mintRecipient: c.Address
+ tonAmount: coins
+ internalTransferMsg: CellRef
+}
+
+export const MintNewJettons = {
+ PREFIX: 0x00000015,
+
+ create(args: {
+ queryId: uint64
+ mintRecipient: c.Address
+ tonAmount: coins
+ internalTransferMsg: CellRef
+ }): MintNewJettons {
+ return {
+ $: 'MintNewJettons',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): MintNewJettons {
+ loadAndCheckPrefix32(s, 0x00000015, 'MintNewJettons');
+ return {
+ $: 'MintNewJettons',
+ queryId: s.loadUintBig(64),
+ mintRecipient: s.loadAddress(),
+ tonAmount: s.loadCoins(),
+ internalTransferMsg: loadCellRef(s, InternalTransferStep.fromSlice),
+ }
+ },
+ store(self: MintNewJettons, b: c.Builder): void {
+ b.storeUint(0x00000015, 32);
+ b.storeUint(self.queryId, 64);
+ b.storeAddress(self.mintRecipient);
+ b.storeCoins(self.tonAmount);
+ storeCellRef(self.internalTransferMsg, b, InternalTransferStep.store);
+ },
+ toCell(self: MintNewJettons): c.Cell {
+ return makeCellFrom(self, MintNewJettons.store);
+ }
+}
+
+/**
+ > struct (0xfb88e119) ClaimMinterAdmin {
+ > queryId: uint64
+ > }
+ */
+export interface ClaimMinterAdmin {
+ readonly $: 'ClaimMinterAdmin'
+ queryId: uint64
+}
+
+export const ClaimMinterAdmin = {
+ PREFIX: 0xfb88e119,
+
+ create(args: {
+ queryId: uint64
+ }): ClaimMinterAdmin {
+ return {
+ $: 'ClaimMinterAdmin',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): ClaimMinterAdmin {
+ loadAndCheckPrefix32(s, 0xfb88e119, 'ClaimMinterAdmin');
+ return {
+ $: 'ClaimMinterAdmin',
+ queryId: s.loadUintBig(64),
+ }
+ },
+ store(self: ClaimMinterAdmin, b: c.Builder): void {
+ b.storeUint(0xfb88e119, 32);
+ b.storeUint(self.queryId, 64);
+ },
+ toCell(self: ClaimMinterAdmin): c.Cell {
+ return makeCellFrom(self, ClaimMinterAdmin.store);
+ }
+}
+
+/**
+ > struct CursedSubjects {
+ > data: map
+ > }
+ */
+export interface CursedSubjects {
+ readonly $: 'CursedSubjects'
+ data: c.Dictionary
+}
+
+export const CursedSubjects = {
+ create(args: {
+ data: c.Dictionary
+ }): CursedSubjects {
+ return {
+ $: 'CursedSubjects',
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): CursedSubjects {
+ return {
+ $: 'CursedSubjects',
+ data: c.Dictionary.load(c.Dictionary.Keys.BigUint(128), createDictionaryValue<[]>(
+ (s) => [],
+ (v,b) => { {} }
+ ), s),
+ }
+ },
+ store(self: CursedSubjects, b: c.Builder): void {
+ b.storeDict(self.data, c.Dictionary.Keys.BigUint(128), createDictionaryValue<[]>(
+ (s) => [],
+ (v,b) => { {} }
+ ));
+ },
+ toCell(self: CursedSubjects): c.Cell {
+ return makeCellFrom(self, CursedSubjects.store);
+ }
+}
+
+/**
+ > struct TokenPool_AdminConfig {
+ > ownable: Cell
+ > rmnProxy: address
+ > dynamicConfig: Cell
+ > allowedFinalityConfig: uint32
+ > }
+ */
+export interface TokenPool_AdminConfig {
+ readonly $: 'TokenPool_AdminConfig'
+ ownable: CellRef
+ rmnProxy: c.Address
+ dynamicConfig: CellRef
+ allowedFinalityConfig: uint32 /* = 0 as uint32 */
+}
+
+export const TokenPool_AdminConfig = {
+ create(args: {
+ ownable: CellRef
+ rmnProxy: c.Address
+ dynamicConfig: CellRef
+ allowedFinalityConfig?: uint32 /* = 0 as uint32 */
+ }): TokenPool_AdminConfig {
+ return {
+ $: 'TokenPool_AdminConfig',
+ allowedFinalityConfig: 0n,
+ ...args
+ }
+ },
+ fromSlice(s: c.Slice): TokenPool_AdminConfig {
+ return {
+ $: 'TokenPool_AdminConfig',
+ ownable: loadCellRef(s, Ownable2Step.fromSlice),
+ rmnProxy: s.loadAddress(),
+ dynamicConfig: loadCellRef(s, TokenPool_DynamicConfig.fromSlice),
+ allowedFinalityConfig: s.loadUintBig(32),
+ }
+ },
+ store(self: TokenPool_AdminConfig, b: c.Builder): void {
+ storeCellRef(self.ownable, b, Ownable2Step.store);
+ b.storeAddress(self.rmnProxy);
+ storeCellRef(self.dynamicConfig, b, TokenPool_DynamicConfig.store);
+ b.storeUint(self.allowedFinalityConfig, 32);
+ },
+ toCell(self: TokenPool_AdminConfig): c.Cell {
+ return makeCellFrom | | | | |