Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
rm-Umar opened this issue Aug 21, 2024 · 2 comments
Open

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

rm-Umar opened this issue Aug 21, 2024 · 2 comments
Labels
community Issues created by community

Comments

@rm-Umar
Copy link

rm-Umar commented Aug 21, 2024

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:

  • I need to assign a minimum of 85 TGas upfront for ft_transfer_call to ensure it doesn't fail. However, the actual gas usage is around 20 TGas, leaving a significant amount of unused gas.

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:

  • Here’s an actual call where I needed to assign 120 TGas to the ft_transfer_call, but it only consumed 25.2 TGas. This leaves a significant portion of gas unused, which could have been allocated to subsequent calls in the chain.
$ near contract call-function as-transaction eth.fakes.testnet ft_transfer_call \
  json-args '{
    "receiver_id": "ref-finance-101.testnet",
    "amount": "1000000000000000000",
    "msg": "{\"force\":0,\"actions\":[{\"pool_id\":410,\"token_in\":\"eth.fakes.testnet\",\"token_out\":\"dai.fakes.testnet\",\"amount_in\":\"1000000000000000000\",\"min_amount_out\":\"0\"},{\"pool_id\":849,\"token_in\":\"dai.fakes.testnet\",\"token_out\":\"wrap.testnet\",\"min_amount_out\":\"0\"},{\"pool_id\":12,\"token_in\":\"wrap.testnet\",\"token_out\":\"banana.ft-fin.testnet\",\"min_amount_out\":\"0\"}]}",
    "receiver_id": "ref-finance-101.testnet"
  }' \
  prepaid-gas '120.0 Tgas' attached-deposit '1 yoctoNEAR' sign-as umar25.testnet \
  network-config testnet sign-with-legacy-keychain send

--- 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:

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:

  • I attempted to create a "callback hell" where each callback after a successful ft_transfer_call initiates the next transfer. However, this approach led to issues where either the initial ft_transfer_call failed due to insufficient gas or the subsequent calls in the callbacks failed due to inadequate 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:

  • Gas Allocation Issue: The difficulty is in efficiently managing gas across multiple chained calls. With NEP-0264, the remaining gas is not passed directly, and managing it through callback chains is proving challenging.
  • Inefficiency: Due to the need to assign 85-120 TGas upfront but only using a fraction of it, the remaining gas is not effectively utilized in subsequent calls.
    -Failure of Subsequent Calls: Depending on how gas is allocated—either more to the initial call or the callback—some parts of the chain fail due to insufficient gas.

What I’m Looking For:

  • Optimal Solution: I am seeking a way to dynamically allocate and pass all remaining gas from one contract call to the next within a chained sequence.
  • Alternative Approaches: Suggestions or patterns that could help ensure each call in the sequence has sufficient gas without leaving significant unused gas or causing failures in subsequent operations.

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
Copy link
Contributor

birchmd commented Aug 21, 2024

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
Copy link
Author

rm-Umar commented Aug 22, 2024

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.

@telezhnaya telezhnaya added the community Issues created by community label Sep 27, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
community Issues created by community
Projects
None yet
Development

No branches or pull requests

3 participants