Proposal to improve multicall, allowing to chain transactions

Simple summary

A proposal to allow multicalls where a transaction uses a previous transaction output (from the same multicall) as an input.
Here is the proof of concept repo: GitHub - Th0rgal/better-multicall: An experiment to improve multicall on StarkNet

Why is this important?

Multicall is a great tool on Starknet, it improves the user experience by not having to wait for one transaction to complete and helps reducing fees. For instance, this allows you to change your ETH allowance for a specific contract and call this contract function which spends this allowance in one go. You save two data writes and take advantage of sublinear computation fees. The thing is that for now, this is limited to functions that are not dependent on each other and whose parameters can be determined in advance. If you want to mint an NFT and then change its name, you have to do two transactions because you can’t know your nft id before minting it (it might not be the same if someone mints another nft at the same time).

Proposed solution

I propose to change the execute function so that it can take felts or references to felts as parameters. I used the execute implementation from open zeppelin repo (cairo 0.10.0) as a basis.

I created a Felt struct, which takes two parameters : a type (it can be a default felt, or a reference), I also added a Type struct to be used as an enum to make the code cleaner. Instead of using a felt in your calldata, you could use Felt(Type.DEFAULT, your_value). To put a reference to the first felt from the returned output, you could use Felt(Type.REFERENCE, 0). Please note that you can only use a reference to an output given by a previous call. If you merge 2 calls that both return a felt, you can only use the first returned felt as an input for the second call, since you don’t know the rest of the output.
This is just a proposal and I am curious to hear your ideas to make it clearer.

Example

Here is an example contract interface where this improved multicall could be useful. Implementation is available here: better-multicall/contract.cairo at master · Th0rgal/better-multicall · GitHub.
This contract allows you to mint a NFT and returns its id to you. It also allows you to change a NFT name if you own it and know its id. You can read the NFT to find its owner and its name.

Example contract

struct NFT {
    owner: felt,
    name: felt,
}

@contract_interface
namespace ExampleContract {
    func mint_nft() -> (nft_id: felt) {
    }

    func set_nft_name(id, name) {
    }

    func read_nft(id) -> (nft: NFT) {
    }
}

Using the old fashion (two calls)

    // 1st tx: minting the nft
    let (callarray: AccountCallArray*) = alloc();
    assert callarray[0] = AccountCallArray(example_contract_addr, mint_nft_selector, 0, 0);
    let (result_len, result: felt*) = execute(1, callarray, 0, new ());
    assert result_len = 1;
    local minted_nft = result[0];

    // 2nd tx: changing nft name
    let (calldata: felt*) = alloc();
    assert calldata[0] = minted_nft;
    assert calldata[1] = 'aloha';
    let (callarray: AccountCallArray*) = alloc();
    assert callarray[0] = AccountCallArray(example_contract_addr, set_nft_name_selector, 0, 2);
    let (result_len, result: felt*) = execute(1, callarray, 2, calldata);

Using the improved execute (a single transaction)

    let (calldata: Felt*) = alloc();
    assert calldata[0] = Felt(Type.REFERENCE, 0);
    assert calldata[1] = Felt(Type.DEFAULT, 'aloha');

    let (callarray: BetterAccountCallArray*) = alloc();
    assert callarray[0] = BetterAccountCallArray(example_contract_addr, mint_nft_selector, 0, 0);
    assert callarray[1] = BetterAccountCallArray(example_contract_addr, set_nft_name_selector, 0, 2);

    let (result_len, result: felt*) = better_execute(2, callarray, 2, calldata);

Full test is available here (written in cairo 0.10.0 using protostar): actually I don’t have the right to post more than two links, it’s in tests folder on the github repo.

Please let me know what you think. I’m not sure if we should replace the account interface, or add a second dedicated execute, or maybe even do this as a plugin? My guess is that it would be nice to have it available on all accounts so that dapps can assume that it’s possible, but if it turns out that it adds a significant additional cost for transactions that don’t require it, maybe having two versions of execute would make sense.

63 Likes

Thanks for this proposal Thomas.
Generally I think it makes sense and that it could enable interesting use cases.
We should consider a security analysis to check if it could introduce unexpected attack vectors.
Assuming we move forward with this proposal and we want to enable this in all account contracts I would suggest that we add a second execute. It would be better in terms of backward compatibility, and also let more flexibility regarding using in it or not, since it adds a little overhead.

29 Likes

btw @th0rgal could you benchmark the overhead if we replace the existing exectue ?

22 Likes

When running the tests through protostar with this example, I get:

Using two executes:
steps=734
memory_holes=50

Using better multicall:
steps=725 (-1%)
memory_holes=48 (-4%)

I’m going to try with a normal call which doesn’t benefit from this new execute.

25 Likes

Ok thanks. Let us know the results.

22 Likes

If secure, this is a great idea.
There are a bunch more scenarios in which I could see this saving some gas as you will not have to write/read to/from storage in order for the 2nd transaction to evaluate results of the 1st one.

20 Likes

Thanks for reminding me. I tried calling only the mint function from the test contract using current execute and better execute. I got:

Current execute:
steps=307
memory_holes=15

Using better execute:
steps=319 (+4%)
memory_holes=16 (+6%)

The difference would probably increase if the calldata is very large as it would take up more space in the execute.

18 Likes

Great proposal th0rgal :pray: I’d like to see this in production as well. My use case:

Imagine a defi protocol which has two primitive functions: open an account and fund an account. The first one returns an account ID, second one expects an ID. To call open and fund in a single TX, I have to create a wrapper function open_and_fund, because I don’t know the account ID to pass in to fund in advance. Obviously, I’d much rather do it using this proposed mechanism and ditch the wrapper function altogether.

18 Likes

I like this idea. How flexible is it when output interface doesn’t match the input of the next call?

16 Likes

Hello Jakub, I am not sure to understand the question. In your next call you can specify any felt from any previous call as an input (or a hardcoded felt value). The output of a specific call doesn’t need to match input of next one, you are really free to use them like you want, like if you were declaring the outputs with multiple let.

16 Likes

Thank you, that’s exactly what I wanted to know.
I didn’t notice that you have a structure called Felt that declares usage of data or reference. So you can reference any output as long as you know it’s position, nice.

Let’s say we have a function like this:

func get_winners() -> (winners_len: felt, winners: felt*, winning_value: felt)

It seems that without knowing winners_len it is not possible to chain it with calls to functions:

  • accepting all winners - since the supported calldata must be constant,
  • accepting winning_value - since we don’t know the position of winning_value in result.

Is this correct? I just want to see understand what would be possible with this proposal.

17 Likes

That’s a great point, I think you are right. May be a Felt should be able to contain a calculation using Felt as a reference, so that you could define the reference using another reference?

So for example, if your calldata is something like:
array_len
array_value_0
…
array_value_n
thing1
thing2
thing3

And you want to use thing3, you should be able to give something like Felt(Type.COMPUTED_REFERENCE, function_to_compute );

where function to compute is defined like Felt(Type.REFERENCE, 0) + Felt(Type.DEFAULT, 2 )

I think this is possible, lemme know if you have a better idea, I can try to work on a poc.

16 Likes

I am afraid that going this route (calculating the end position) would make it very complex and users would not know how to use it. I would rather stick with the original approach in this case.

I think a structural approach could simplify the flow. Let’s say you have functions like this:

func get_winners() -> (winners_len: felt, winners: felt*, winning_value: felt)
func process_all_winners(winners_len: felt, winners: felt*) -> ()

ABI would have to be available for all used functions, either provided by the caller or added as some meta info by the StarkNet OS.
User would be able to specify that process_all_winners should take the second result from get_winners and since account knows that it is an array it will process it properly. So instead of operating on calldata level it would operate on values with their types.

I think it would make it easier for users, but on the cost of complicating the code and increasing overhead.

17 Likes

Hey Jakub, thank you for the proposition. I thought for a long time about it and I have something to show you. What’s cool is that it’s more flexible so we can add features if we want to.

I changed the interface to make sure it stays compatible with existing one, we no longer need to replace felt by the Felt struct and AccountCallArray stays the same. What changes is you will be able to replace any felt from the calldata by a sequence of multiple felts. The first one would be a prefix indicating how to handle the next ones. I thought about those:
VALUE → 0
REF → 1
CALL_REF → 2
FUNC → 3
FUNC_CALL → 4

Value would mean you just need to use the next felt as it is. Ref would mean you need to look into the output values at index the next felt. Call ref would be followed by two felts : the first one would be the call index, the second one the index of a value from this call. Func would take a contract and a function and call it with the output* to find the felt. Func_call would be the same but would only pass a specific call output to the function.
I already implemented and tested VALUE, REF and CALL_REF. Here is what it looks like:

    let (calldata: felt*) = alloc();
    assert calldata[0] = CallDataType.CALL_REF;
    assert calldata[1] = 1;  // we want to get data from second call
    assert calldata[2] = 0;  // at index 0
    assert calldata[3] = CallDataType.VALUE;
    assert calldata[4] = 'aloha';

    let (callarray: AccountCallArray*) = alloc();
    assert callarray[0] = AccountCallArray(example_contract_addr, get_bullshit_selector, 0, 0);
    assert callarray[1] = AccountCallArray(example_contract_addr, mint_nft_selector, 0, 0);
    assert callarray[2] = AccountCallArray(example_contract_addr, set_nft_name_selector, 0, 2);

Here you can see I first call the get_bullshit() function which returns my an array of an unknown size, but it doesn’t matter : I can still refer to the first value returned by mint_nft. I still need to implement the function calls to be able to do more magic things. Imagine taking the output of a call, doing some arithmetics operations or security assertions to it before using it as an input for another call, that would be so cool. Please tell me what you think about it and if you have other ideas

16 Likes

I am not sure if I understand FUNC and FUNC_CALL. Do you mean that there would be a contract call performed to process calldata?

15 Likes

Yes exactly. I don’t know if it will be useful in real life but it would be a fallback solution to manage if the necessary felt is located after an indeterminate number of elements (for example several arrays).

16 Likes

Hey,

I started a rewrite in Cairo 1: GitHub - Th0rgal/multicalls: Alternative interpretations of the Starknet transaction calls, allowing their composition.

I still need to test it, but hopefully we could soon deploy a Cairo 1 account with composable multicall feature. What @juniset from Argent suggested is to support both multicall and composable (better) multicall by checking the first call: if it’s a call to the contract 0 on the selector “bettermulticall”, the rest of the calls would be interpreted with the composition algorithm. This allows to keep support with existing transactions and make sure you can’t forge a tx that would be malicious only on specific accounts.

supporting both will be a good idea
using multicall can be confusing or complex for users sometime .
this was a good thread too.