tact-lang / tact

Tact compiler main repository
https://tact-lang.org
MIT License
275 stars 56 forks source link

Improving Developer Experience (DX) #433

Open StuartFarmer opened 1 week ago

StuartFarmer commented 1 week ago

Tact is a great language, but there are some things that can be improved to make the developer experience much smoother and improve the speed of development. Improving developer experience allows for more developers to be onboarded because the language is easier to learn, and increases productivity because the time to develop is reduced. This is a win-win for Ton.

I built a fully functional Python smart contracting system for my last startup (https://github.com/lamden/contracting) which was heavily focused on developer experience and speed of development, so I'm pretty passionate about this subject and would love to discuss how to improve the DX for Tact.

Let's use this thread to discuss potential DX improvements that we would like to see.

Firstly, Ton is a message-based blockchain. Therefore, Tact diverges from traditional 'function calls' and opts for processing messages through a singular 'receive' system. However, this breaks down when there are many different types of messages to receive, and it causes the developer to have to continuously look up what the components of each message struct are. Example:

message(0x693d3950) GetRoyaltyParams {
    query_id: Int as uint64;
}

receive(msg: GetRoyaltyParams) {   
        let ctx: Context = context(); // get sender Info
        send(SendParameters{
            to: ctx.sender,
            value: 0,
            mode: 64, 
            bounce: false,
            body: ReportRoyaltyParams {
                query_id: msg.query_id,
                numerator:  (self.royalty_params!!).numerator,
                denominator: (self.royalty_params!!).denominator,
                destination: self.owner_address
            }.toCell()
        });        
    }

From this function, I do not know what GetRoyaltyParams contains without opening up my separate messages.tact file. This makes auditing and development frustrating.

What if receive functions were mocked up more like RPC calls?

receive GetRoyaltyParams(query_id: Int) {
    let ctx: Context = context(); // get sender Info
    send(SendParameters{
            to: ctx.sender,
            value: 0,
            mode: 64, 
            bounce: false,
            body: ReportRoyaltyParams {
                query_id: msg.query_id,
                numerator:  (self.royalty_params!!).numerator,
                denominator: (self.royalty_params!!).denominator,
                destination: self.owner_address
            }.toCell()
        });        
}

Tact would be able to construct the message code dynamically here because it is essentially encapsulated in the function definition. Consider a more complex example:

message(0x5fcc3d14) Transfer {
    query_id: Int as uint64;
    new_owner: Address;
    response_destination: Address?;
    custom_payload: Cell?;
    forward_amount: Int as coins;
    forward_payload: Slice as remaining;
}

receive(msg: Transfer){
        let ctx: Context = context(); // Reference: https://docs.tact-lang.org/language/ref/common#context
        let msgValue: Int = self.msgValue(ctx.value);

        if (self.is_initialized == false) {  // Initial Transfer, aka the "Minting" of the NFT
            require(ctx.sender == self.collection_address, "initialized tx need from collection");
            self.is_initialized = true;
            self.owner = msg.new_owner;
            self.individual_content = msg.custom_payload;
            send(SendParameters{
                to: msg.response_destination!!,
                value: msgValue,
                mode: SendPayGasSeparately,
                body: Excesses { query_id: msg.query_id }.toCell()
            });
        } else {
            require(ctx.sender == self.owner!!, "not owner");
            self.owner = msg.new_owner;  // change current owner to the new_owner
            if (msg.forward_amount > 0) {
                send(SendParameters{
                    to: msg.new_owner,
                    value: msg.forward_amount,
                    mode:  SendPayGasSeparately, 
                    bounce: true,
                    body: OwnershipAssigned{
                        query_id: msg.query_id,
                        prev_owner: ctx.sender,
                        forward_payload: msg.forward_payload
                    }.toCell()
                }); 
            }

            msgValue = msgValue - ctx.readForwardFee(); 
            if (msg.response_destination != null) { 
                send(SendParameters{ 
                    to: msg.response_destination!!,
                    value: msgValue - msg.forward_amount,
                    mode: SendPayGasSeparately,
                    bounce: true,
                    body: Excesses { query_id: msg.query_id }.toCell()
                });
            } 
        }
    }

In the new concept, it would be simplified to:

receive Transfer(query_id: Int, new_owner: Address, response_destination: Address?, custom_payload: Cell?, forward_amount: Int, forward_payload: Slice){
        let ctx: Context = context(); // Reference: https://docs.tact-lang.org/language/ref/common#context
        let msgValue: Int = self.msgValue(ctx.value);

        if (self.is_initialized == false) {  // Initial Transfer, aka the "Minting" of the NFT
            require(ctx.sender == self.collection_address, "initialized tx need from collection");
            self.is_initialized = true;
            self.owner = msg.new_owner;
            self.individual_content = msg.custom_payload;
            send(SendParameters{
                to: msg.response_destination!!,
                value: msgValue,
                mode: SendPayGasSeparately,
                body: Excesses { query_id: msg.query_id }.toCell()
            });
        } else {
            require(ctx.sender == self.owner!!, "not owner");
            self.owner = msg.new_owner;  // change current owner to the new_owner
            if (msg.forward_amount > 0) {
                send(SendParameters{
                    to: msg.new_owner,
                    value: msg.forward_amount,
                    mode:  SendPayGasSeparately, 
                    bounce: true,
                    body: OwnershipAssigned{
                        query_id: msg.query_id,
                        prev_owner: ctx.sender,
                        forward_payload: msg.forward_payload
                    }.toCell()
                }); 
            }

            msgValue = msgValue - ctx.readForwardFee(); 
            if (msg.response_destination != null) { 
                send(SendParameters{ 
                    to: msg.response_destination!!,
                    value: msgValue - msg.forward_amount,
                    mode: SendPayGasSeparately,
                    bounce: true,
                    body: Excesses { query_id: msg.query_id }.toCell()
                });
            } 
        }
    }

Nice.

I would love all your thoughts on this. I also have some ideas around the send(SendParameters{}) function call which is used a lot which I will write something up on.

anton-trunov commented 1 week ago

Hey Stuart, thanks for opening this issue! The example you shared is also covered in #9 and I still think that having messages defined separately from functions is a better option than embedding that information into the definition of receivers. This is due to the fact that you need to share message declarations between several contracts or even several projects to ensure compatibility.

Having said that, I think we should improve the Tact tooling to support smart contract programmers in this area. For instance, the 'go-to-definition' functionality in editors/IDEs would come handy when you need to see the fields of messages.

Another useful tool that we can implement would be something that gives you a diagram of inter-contract communication with all contracts' senders and receivers linked to each other with the message type info included.

also have some ideas around the send(SendParameters{}) function call which is used a lot which I will write something up on

Please do share, I'd love to hear your thought on this. This is an especially good time for this, since we are designing Tact 2.0: #249. (The design phase is not very active right now, since the Tact team is focusing on adding new features and fixing bugs in Tact v1.0)