near / nearcore

Reference client for NEAR Protocol
https://near.org
GNU General Public License v3.0
2.33k stars 627 forks source link

Difficulties Managing Gas Allocation for Chained Cross-Contract Calls #11977

Open rm-Umar opened 2 months ago

rm-Umar commented 2 months ago

I’m currently developing a smart contract that requires multiple chained cross-contract calls. Specifically, I am using ft_transfer_call in a sequence of operations. The main issue I’m encountering is related to gas management, particularly when trying to allocate and efficiently utilize gas across these chained calls. I need to do multiple cross contract calls but the maximum gas limit is 300Tgas Problem Details:

1. Initial Gas Assignment:

Example Code:

Promise::new(AccountId::from_str(&token_in.clone()).unwrap())
    .function_call(
        "ft_transfer_call".to_string(),
        json!({
            "receiver_id": receiver_id,
            "amount": "100",
            "msg": message
        })
        .to_string()
        .into_bytes(),
        NearToken::from_yoctonear(1),
        Gas::from_tgas(85), // Assigning 85 TGas upfront
    );

2. Real-world Example with Excessive Gas Allocation:

--- Logs --------------------------- Logs [eth.fakes.testnet]: Transfer 1000000000000000000 from umar25.testnet to ref-finance-101.testnet Logs [ref-finance-101.testnet]: Swapped 1000000000000000000 eth.fakes.testnet for 31934400827911866534 dai.fakes.testnet Swapped 31934400827911866534 dai.fakes.testnet for 4897188229574644179151 wrap.testnet Swapped 4897188229574644179151 wrap.testnet for 15 banana.ft-fin.testnet Logs [umar25.testnet]: No logs

--- Result ------------------------- "1000000000000000000"

Gas burned: 25.2 Tgas Transaction fee: 0.0024293414532766 NEAR

- In this case, the actual gas consumed was only `25.2 TGas`, even though I assigned `120 TGas upfront`. This inefficiency makes it difficult to manage gas dynamically across a series of chained contract calls.
## 3. Chaining Calls with NEP-0264:
- I tried using `NEP-0264` to chain the calls and pass the remaining gas to subsequent functions using .then. However, the remaining gas is not passed directly; instead, gas is allocated based on weights, making it difficult to manage the gas efficiently across multiple chained calls.

**Example Scenario:**
```rust
let initial_gas = env::prepaid_gas() - env::used_gas(); // prepaidgas - used for basic operations
Promise::new(AccountId::from_str(&token_in.clone()).unwrap())
    .function_call_weight(
        "ft_transfer_call".to_string(),
        json!({
            "receiver_id": receiver_id,
            "amount": "100",
            "msg": message
        })
        .to_string()
        .into_bytes(),
        NearToken::from_yoctonear(1),
        Gas::from_tgas(initial_gas), // Trying to use the full gas allocation
        near_sdk::GasWeight(1),
    )
    .then(
        Promise::new(env::current_account_id())
            .function_call_weight(
                "callback_check_gas_1".to_string(),
                vec![],
                NearToken::from_near(0),
                Gas::from_gas(0), // Remaining gas should be passed here, but it's not direct
                near_sdk::GasWeight(1),
            )
    );

4. Callback Hell and Gas Allocation:

Example of Callback Hell:

pub fn callback_check_gas_1(&self) {
    let remaining_gas = env::prepaid_gas() - env::used_gas(); // Remaining gas after first call
    env::log_str(&format!("Gas remaining after callback 1: {} TGas", Gas::from_gas(remaining_gas).as_tgas()));

    match env::promise_result(0) {
        PromiseResult::Successful(_) => {
            env::log_str("First call successful");
            self.call_second_promise(); // Call next function with remaining gas
        },
        _ => {
            env::panic_str("First swap operation failed. Withdrawal will not be processed.");
        }
    }
}

Problem Summary:

What I’m Looking For:

Any guidance on how to better handle gas allocation in these scenarios, or enhancements to NEP-0264 to facilitate this, would be greatly appreciated.

birchmd commented 2 months ago

Thanks for posting this issue in such great detail @rm-Umar

I don't have a good solution for you, but hopefully I can explain why the gas allocation works the way it does and encourage you that you are on the right track to get this working.

First of all we need to distinguish "gas burnt" from "gas used". The values you are quoting which are much lower than the attached gas are the "burnt" values. "Gas burnt" represents how much gas was actually spent on computation (burned) during the execution of that receipt. However, in the case that the receipt generated a new promise, this gas burnt will be lower than the "gas used". "Gas used" represents the total amount of gas that the receipt did something with -- including passing on to another receipt. In your ft_transfer_call example, that even though only 25 Tgas was burnt, if the transaction fails with less than 120 Tgas attached then it means all 120 Tgas was in fact used. At the end of the transaction, if the burnt gas is significantly lower than the used gas it is because each receipt along the way over-estimated how much it needed to attach to subsequent receipts.

You can use the nearblocks explorer to see this overestimation more clearly. Here is the link for your ft_transfer_call example on testnet. You can expand each receipt involved in the transaction and click inspect to see the gas limit (i.e. attached gas) and gas burnt on each. This also let's you see used vs burnt gas more clearly. The ft_transfer_call receipt burns 3.89 Tgas, but uses 98.89 Tgas because it attaches 90 Tgas to the ft_on_trasnfer promise and 5 Tgas to the ft_resolve_transfer promise. You can also see ft_on_transfer wants so much gas because it produces two promises each with 20 Tgas attached. But it is also clear this is a vast over-estimate when each of those use less than 3 Tgas.

Perhaps you knew all this already and you are still wondering why Near works in this way. The key point is that all outgoing receipts from an execution, including callbacks, are generated immediately and they have their gas allocated to them at the time they are created. This design gives the guarantee that a callback will have a specified amount of gas regardless of what happens to the execution of the receipt the callback is waiting for. This is important because contracts are often designed under the assumption that its callback will always execute, even if the call itself fails. For example this gives the contract a chance to do error handling on the failed call.

Of course, in theory it would be possible to have both a guaranteed minimum and also get any unused gas from the prior call. But this is not implemented in the Near protocol, and I think it would be a hard feature to use even if it was implemented. As you point out in your "callback hell" section, if you just pass the remaining gas along to the next call then you can get into a situation where you run out of gas and the chain stops without any opportunity for error handling. Similarly, if a contract relied on its prior call using less gas than it was given then there is a chance its callback would run out of gas if that call did actually use all its gas. The only way to guarantee a callback runs to completion is to give it enough gas upfront.

This last point is exactly what leads to so much overestimation in how much gas is attached to promises. Developers must code defensively and attach as much gas as the worst case execution requires. So even though it might look ridiculous that ft_on_transfer attaches 20 Tgas to a receipt that only spend 3 Tgas, this is just one case and if there is any case at all where all 20 Tgas is used then this is still the amount that needs to be attached.

Hopefully that makes it clear why this (obviously suboptimal) situation exists. Now, what can be done about it? I've heard rumours of an idea that we could loosen the 300 Tgas restriction such that it only applies to burnt gas instead of used gas. Then it would be possible to create much longer receipt chains. For example, you could attach 1000 gas to your initial transaction so that there is more gas available to assign to later promises, but it would still be true that no individual receipt in that chain of executions could burn more than 300 Tgas. This would be a protocol change and therefore have to go through the NEP process. I don't think this is an official proposal yet, but maybe it will be (you could maybe even propose it yourself to get the conversation going).

Unfortunately, I do not think there is anything you can do about this as an application developer. If you are hitting the 300 Tgas limit then the only option would be to split what you are doing across multiple transactions. You could maybe use the yield/resume mechanism to help you save on some gas if there are parts of your application that can happen off-chain instead.

rm-Umar commented 2 months ago

Thanks for the detailed reply @birchmd things are clear to me now. I'll see if I can create a proposal. Thanks again for pointing me in the right direction.