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.

8 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.

2 Likes

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

1 Like

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.

1 Like

Ok thanks. Let us know the results.

1 Like

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.

2 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.

1 Like

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.

1 Like

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