tokenblockchaintelegramton

How to cancel a Jetton(TON) token transfer transaction


I have a problem with transaction rollback when transferring Jetton tokens: for example one user wants to transfer Jetton tokens to another user whose owner is another smart contract. The transfer is done in three transactions:

  1. The first transaction is a tokenTransfer message that is sent to the sender's wallet, where he sends the necessary data such as the recipient's address, the number of tokens to be transferred, etc.
  2. The second transaction is sending a transferInternal message to the recipient's wallet.
  3. The third transaction is sending a tokenNotification message to the address of the owner of this wallet (the recipient's address).

Actually the problem may be the following: when the contract receives a tokenNotification message from the wallet contract and for some reason wants to roll back this transaction (for example, in the code that is responsible for processing the tokenNotification message, there are some checks that have not been passed), then he will be able to cancel only the transaction with the tokenNotification message (that is the third transaction), but the token transfer itself was in the second.

One of the proposed options was to send tokens back to the user, but the problem here is that transferring tokens back is another transaction that is initiated by my contract, which means my contract must pay a commission for the transfer. That is, the user can thus attack my contract, so that he will send many transactions that my contract will consider invalid and will try to send tokens back, spending his TON on the commission.

Here is code of my Jetton contract

@interface("org.ton.jetton.wallet")
contract JettonDefaultWallet {
    const minTonsForStorage: Int = ton("0.019");
    const gasConsumption: Int = ton("0.013");
    balance: Int as coins = 0;
    owner: Address;
    master: Address;

    init(owner: Address, master: Address){
        self.balance = 0;
        self.owner = owner;
        self.master = master;
    }

    receive(msg: TokenTransfer){
        // 0xf8a7ea5
        let ctx: Context = context(); // Check sender
        require(ctx.sender == self.owner, "Invalid sender");
        let final: Int =
            (((ctx.readForwardFee() * 2 + 2 * self.gasConsumption) + self.minTonsForStorage) + msg.forward_ton_amount); // Gas checks, forward_ton = 0.152
        require(ctx.value > final, "Invalid value");
        // Update balance
        self.balance = (self.balance - msg.amount);
        require(self.balance >= 0, "Invalid balance");
        let init: StateInit = initOf JettonDefaultWallet(msg.sender, self.master);
        let wallet_address: Address = contractAddress(init);
        send(SendParameters{
                to: wallet_address,
                value: 0,
                mode: SendRemainingValue,
                bounce: true,
                body: TokenTransferInternal{ // 0x178d4519
                    query_id: msg.query_id,
                    amount: msg.amount,
                    from: self.owner,
                    response_destination: msg.response_destination,
                    forward_ton_amount: msg.forward_ton_amount,
                    forward_payload: msg.forward_payload
                }.toCell(),
                code: init.code,
                data: init.data
            }
        );
    }

    receive(msg: TokenTransferInternal){

        // 0x178d4519
        let ctx: Context = context();                
        if (ctx.sender != self.master) {
            let sinit: StateInit = initOf JettonDefaultWallet(msg.from, self.master);
            require(contractAddress(sinit) == ctx.sender, "Invalid sender!");
        }
        // Update balance
        self.balance = (self.balance + msg.amount);
        require(self.balance >= 0, "Invalid balance");
        // Get value for gas
        let msg_value: Int = self.msg_value(ctx.value);
        let fwd_fee: Int = ctx.readForwardFee();
        if (msg.forward_ton_amount > 0) {
            msg_value = ((msg_value - msg.forward_ton_amount) - fwd_fee);
            send(SendParameters{
                    to: self.owner,
                    value: msg.forward_ton_amount,
                    mode: SendPayGasSeparately,
                    bounce: false,
                    body: TokenNotification{ // 0x7362d09c -- Remind the new Owner
                        query_id: msg.query_id,
                        amount: msg.amount,
                        from: msg.from,
                        forward_payload: msg.forward_payload
                    }.toCell()
                }
            );
        }
        // 0xd53276db -- Cashback to the original Sender
        if (msg.response_destination != null && msg_value > 0) {
            send(SendParameters{
                    to: msg.response_destination!!,
                    value: msg_value,
                    bounce: false,
                    body: TokenExcesses{query_id: msg.query_id}.toCell(),
                    mode: SendPayGasSeparately
                }
            );
        }
    }

    receive(msg: TokenBurn){
        let ctx: Context = context();
        require(ctx.sender == self.owner, "Invalid sender"); // Check sender

        self.balance = (self.balance - msg.amount); // Update balance
        require(self.balance >= 0, "Invalid balance");
        let fwd_fee: Int = ctx.readForwardFee(); // Gas checks
        require(ctx.value > ((fwd_fee + 2 * self.gasConsumption) + self.minTonsForStorage), "Invalid value - Burn");
        // Burn tokens
        send(SendParameters{
                to: self.master,
                value: 0,
                mode: SendRemainingValue,
                bounce: true,
                body: TokenBurnNotification{
                    query_id: msg.query_id,
                    amount: msg.amount,
                    sender: self.owner,
                    response_destination: msg.response_destination
                }.toCell()
            }
        );
    }

    fun msg_value(value: Int): Int {
        let msg_value1: Int = value;
        let ton_balance_before_msg: Int = (myBalance() - msg_value1);
        let storage_fee: Int = (self.minTonsForStorage - min(ton_balance_before_msg, self.minTonsForStorage));
        msg_value1 = (msg_value1 - (storage_fee + self.gasConsumption));
        return msg_value1;
    }

    bounced(msg: bounced<TokenTransferInternal>){
        self.balance = (self.balance + msg.amount);
    }

    bounced(msg: bounced<TokenBurnNotification>){
        self.balance = (self.balance + msg.amount);
    }

    get fun get_wallet_data(): JettonWalletData {
        return
            JettonWalletData{
                balance: self.balance,
                owner: self.owner,
                master: self.master,
                code: initOf JettonDefaultWallet(self.owner, self.master).code
            };
    }
}

Solution

  • There are some misconceptions in what you have described:

    when the contract [let's call it R] receives a tokenNotification message from the wallet contract and for some reason wants to roll back this transaction (for example, in the code that is responsible for processing the tokenNotification message, there are some checks that have not been passed), then he will be able to cancel only the transaction with the tokenNotification message (that is the third transaction), but the token transfer itself was in the second.

    in your terms R won't be able to roll back even the third transaction. Let's make a diagram:

    S -[tokenTransfer]-> SW -[transferInternal]-> RW -[tokenNotification]-> R
    

    each message is denoted here with -[op_name]->, contracts are in between. Transactions are done on each contract, i.e. a tx consists of:

    Once a message is sent, the tx is complete, and you can't roll it back. If R has received the tokenNotification from RW, than the tx on RW is complete.

    What you can do is to

    Note that even bouncing will require some commision (sending message requires gas), so any approach to reverting this chain requires some amount of TON. The only way to automate such reverting is to

    1. implement Jettons that support such saga reverting and
    2. send enough TON with the tokenTransfer message to allow the reverting process (ideally, in the case when reverting is not done, you have to also implement sending extra TON [not used for reverting] back from R to S).