Ponieważ kontekst ma znaczenie – call vs delegatecall

Przemek/ 9 maja, 2023

Dla osób, które miały kiedykolwiek styczność z programowaniem, a w szczególności z językami takimi jak Java, JavaScript, C# itp., język Solidity może wydawać się bardzo podobny do tego, co już znają. Nic w tym dziwnego, ponieważ sama składnia w dużej mierze opiera się właśnie na wspomnianych technologiach. Niestety, to, co na pozór wygląda znajomo, może powodować wiele nieporozumień ze względu na różnice w zachowaniu, które nie zawsze są oczywiste na pierwszy rzut oka. To może prowadzić do poważnych problemów. Dlatego w dzisiejszym poście skupię się na wyjaśnieniu sposobu, w jaki zmienia się kontekst wywoływania funkcji smart kontraktów oraz na omówieniu dwóch rodzajów odwołań zwanych “call” i “delegatecall“.

Od tego się wszystko zaczyna

Rysunek 1. Wielki wybuch
(źródło: https://www.space.com/25126-big-bang-theory.html)

Zanim przejdziemy do zmiany kontekstu wywołań, warto przypomnieć o blockchainowych kontach. Temat ten został już poruszony w tym wpisie , dlatego teraz przedstawimy tylko krótkie podsumowanie.

Na blockchainie Ethereum oraz innych blockchainach EVM, rozróżniamy dwa rodzaje kont. Pierwszym z nich są tzw. EOA (Externally Owned Account) – konta, których właścicielami są byty spoza blockchaina. Możemy je określić mianem kont zarządzanych przy użyciu klucza prywatnego. Innymi słowy, każde EOA ma przypisany swój klucz, który służy do zatwierdzania transakcji. Ważne jest, że zarządzanie takim kontem może być realizowane zarówno przez żywą osobę, jak i przez zewnętrzne oprogramowanie.

Drugim rodzajem konta występującym w tych sieciach jest tzw. Contract Account (konto kontraktu). Jak sama nazwa wskazuje, jest ono powiązane ze smart kontraktem. Istnieje kilka istotnych różnic między tymi dwoma rodzajami bytów, i zachęcam do zapoznania się z nimi pod tym adresem. Jedną z nich jest brak klucza prywatnego w przypadku kont związanych ze smart kontraktami. Oznacza to, że smart kontrakt nie może podpisywać transakcji, tak jak EOA. Konsekwencją tego jest konieczność inicjowania każdej transakcji na blockchainie przez zewnętrzne konto. Można to uproszczenie wyrazić mówiąc, że ktoś musi kliknąć coś, aby cokolwiek na blockchainie się zadziało.

W wyniku takiego projektu systemu, tylko EOA może pokrywać opłaty transakcyjne. Warto wspomnieć, że obecnie trwa dyskusja na temat EIP-4337, dotyczącego tzw. Account Abstraction, które umożliwiłoby kontraktom pokrywanie kosztów transakcji. Niemniej jednak, nawet jeśli to zostanie wprowadzone, wciąż na początku trzeba by utworzyć EOA, chyba że sam protokół Ethereum zostanie przeprojektowany. Jest to jednak oddzielny temat, który omówię w przyszłości.

Teraz możemy wrócić do naszego tematu zmiany kontekstu.

Pochodzenia nie zmienisz, ale otoczenie jak najbardziej

Rysunek 2. Dziedzictwo
(źródło: https://cheezburger.com/4594191872/your-inheritance)

Język Solidity pozwala nam sprawdzić, jaki adres zainicjował transakcję. Możemy to zrobić, odwołując się do zmiennej o nazwie “tx.origin“.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Origin {
   function whoCall() external view returns(address) {
       return tx.origin;
   }
}

Jeżeli teraz wywołasz tę funkcję, otrzymasz jako wynik swój adres portfela. Co jednak w przypadku zagnieżdżenia tego kodu?

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Origin {
   function whoCall() external view returns(address) {
       return tx.origin;
   }
}

contract Caller {
   Origin origin;
   constructor(Origin _origin) {
       origin = _origin;
   }
   function callAnother() external view returns(address) {
       return origin.whoCall();
   }
}

W tym przypadku chcemy osobiście wywołać funkcję z kontraktu “Caller”, a ta funkcja odwołuje się do kontraktu “Origin”. Z tak napisanym kodem, wynik, jaki ostatecznie otrzymamy, będzie identyczny jak poprzednio, czyli dostaniemy adres naszego konta EOA. Niezależnie od tego, jak bardzo zagnieżdżamy to wywołanie, “tx.origin” nie zmieni się.

Jednak istnieje druga zmienna, która zachowuje się nieco inaczej i nazywa się “msg.sender“.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Origin {
   function whoCall() external view returns(address) {
       return msg.sender;
   }
}

Powyższy kod jest niemal identyczny jak ten pierwszy, z tą różnicą, że tym razem chcemy zwrócić wartość innej zmiennej. Wynik, jaki otrzymamy w tym przypadku, będzie… taki sam jak na początku, czyli zwrócony zostanie nasz adres EOA. Gdzie zatem jest różnica? Różnica ta pojawi się, gdy zagnieździmy wywołanie.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Origin {
   function whoCall() external view returns(address) {
       return msg.sender;
   }
}

contract Caller {
   Origin origin;
   constructor(Origin _origin) {
       origin = _origin;
   }
   function callAnother() external view returns(address) {
       return origin.whoCall();
   }
}

Tym razem otrzymamy inny wynik – zostanie zwrócony adres smart kontraktu “Caller“. Dlaczego tak się dzieje? Zmienna “msg.sender” przechowuje informację o adresie, który jest ostatnim w łańcuchu wywołań. W tym przypadku będzie to adres kontraktu “Caller“, ponieważ to on zawołał funkcję “whoCall()“. Jeśli istniałoby kolejne zagnieżdżenie, adres ponownie uległby zmianie. Aby lepiej zrozumieć tę zasadę, spójrz na poniższy obrazek.

Można śmiało stwierdzić, że w tym przypadku nie jest to skomplikowane, jednak niewłaściwe zrozumienie tego konceptu może prowadzić do poważnych błędów w kodzie. Przykład takiej luki został pokazany w jednym z poprzednich wpisów.

Moglibyśmy zakończyć ten wywód tutaj, ale istnieje pewien mechanizm, który nieco komplikuje całą sytuację.

Delegować czy nie delegować?

Rysunek 3. Delegowanie pracy
(źródło: https://quotesgram.com/delegation-quotes-funny/)

Jeśli chodzi o odwoływanie się do funkcji zaimplementowanych przez smart kontrakty, możemy to robić tak, jak w poprzednich przykładach, ale istnieje także możliwość wykorzystania tzw. niskopoziomowych wywołań. Istnieją trzy takie metody: “call“, “staticcall” i “delegatecall“. Pierwsze dwie są podobne, z tą różnicą, że przy użyciu “staticcall” nie możemy modyfikować stanu kontraktu. Oznacza to, że ta metoda służy jedynie do odczytywania informacji.

Pomijając tę informację, można powiedzieć, że te dwa rodzaje wywołań są podobne do tradycyjnego odwoływania się do funkcji, jak to widzieliśmy w poprzednich przykładach. Chociaż składnia i sposób otrzymywania informacji mogą się nieco różnić, nie jest to teraz bardzo istotne. Natomiast w przypadku “delegatecall” różnica jest znacząca, ponieważ całkowicie zmienia się kontekst wywołania. Na początku przeanalizujmy kod, który pomoże nam zrozumieć ten mechanizm.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Origin {
   function whoCall() external view returns(address) {
       return msg.sender;
   }
}

contract Caller {
   address origin;
   address public sender;
   constructor(address _origin) {
       origin = _origin;
   }
   function callAnother() external {
       (,bytes memory data) = origin.call(abi.encodeCall(Origin.whoCall, ()));
       sender = abi.decode(data, (address));
   }

   function delegateCallAnother() external {
       (,bytes memory data) = origin.delegatecall(abi.encodeCall(Origin.whoCall, ()));
       sender = abi.decode(data, (address));
   }
}

Powyżej znajdują się dwa wcześniej wykorzystane kontrakty, z “drobną” modyfikacją. Zauważ, że teraz kontrakt “Caller” przechowuje zarówno adres kontraktu “Origin“, jak i pole, w którym będziemy przechowywać informację otrzymane ze zmiennej “msg.sender“. Ta modyfikacja była konieczna ze względu na wymagania dotyczące niskopoziomowych wywołań.

W pierwszej metodzie używamy funkcji “call“. Przekazujemy do niej zakodowaną nazwę funkcji “whoCall” oraz jej parametry (w tym przypadku brak parametrów). Otrzymany wynik musi zostać zdekodowany i przypisany do pola “sender“.

Druga funkcja działa podobnie, ale korzysta z wywołania “delegatecall“. Reszta jest identyczna. Co się stanie po wykonaniu tych metod?

W przypadku pierwszej metody do pola “sender” zostanie zapisany adres kontraktu “Caller“, co jest dokładnie tym samym rezultatem, który mieliśmy wcześniej. Jednak jeśli wywołamy funkcję “delegateCallAnother()“, do pola “sender” zostanie zapisany adres naszego konta EOA. Dzieje się tak, ponieważ wywołanie “delegatecall” zachowuje kontekst wywołującego. W tym przypadku otrzymana wartość jest identyczna z tym, co byłoby pod zmienną “tx.origin“. Jednak gdybyśmy mieli bardziej zagnieżdżone wywołania, te dwa adresy mogłyby się różnić. Przedstawia to poniższy obrazek.

Jednakże to nie jedyna różnica pomiędzy tymi operacjami.

Czy Ty to ja, czy ja to Ty?

Gdy wykorzystujemy “delegatecall“, wartość w zmiennej “msg.sender” nie ulega zmianie z tego powodu, że nasze wywołania w pewnym sensie nie opuszczają kontraktu. Tak naprawdę kontekst wywołującego się nie zmienia, ponieważ dzięki “delegatecall” niejako wypożyczamy kod z kontraktu, do którego się odwołujemy, i wykonujemy go w pierwotnym kontekście. Może to być trudne do zrozumienia na pierwszy rzut oka, dlatego posłużę się prostym przykładem.

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Box {
   uint256 public value;
   function increase() external {
       value++;
   }
}

contract Caller {
   uint256 public value;
   address public box;
   constructor(address _box) {
       box = _box;
   }

   function delegate() external {
       box.delegatecall(abi.encodeCall(Box.increase, ()));
   }
}

Powyżej znajdują się dwa kontrakty. Pierwszy z nich przechowuje jedną wartość oraz posiada funkcję do jej zwiększania. Drugi również ma pole odpowiadające za przechowywanie wartości, ale także posiada referencję do pierwszego kontraktu oraz funkcję służącą do wywołania metody “increase()” przy użyciu “delegatecall“.

Jeśli teraz wgramy oba kontrakty na blockchain i wywołamy funkcję “delegate” z kontraktu “Caller“, to otrzymany wynik może nas zaskoczyć. Oczekiwalibyśmy zwiększenia wartości w kontrakcie “Box“. Jednak po sprawdzeniu obecnej wartości okaże się, że nie uległa zmianie i wciąż wynosi okrągłe zero. Niemniej jednak, nie wystąpił żaden błąd, co oznacza, że kod musiał zadziałać. I faktycznie, wszystko jest poprawne, a zwiększona wartość została zapisana w kontrakcie “Caller” pod zmienną “value“. To jest właśnie to “wypożyczenie”, o którym wspomniałem. Można powiedzieć, że kontrakt “Caller” wziął kod z kontraktu “Box” i wykonał go w swoim kontekście. Ponieważ również posiadał zmienną “value“, nowa wartość została do niej zapisana.

Zróbmy teraz małą modyfikację, która spowoduje dużą zmianę. 

//SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

contract Box {
   uint256 public value;
   function increase() external {
       value++;
   }
}

contract Caller {
   address public box;
   uint256 public value;
   constructor(address _box) {
       box = _box;
   }

   function delegate() external {
       box.delegatecall(abi.encodeCall(Box.increase, ()));
   }
}

Jedyna różnica polega na zamianie miejscami zmiennych “value” i “box” w kontrakcie “Caller“. Po ponownym wgraniu kontraktów i wywołaniu metody “delegate“, okaże się, że nowa wartość nie została zapisana ani w zmiennej “value” w kontrakcie “Caller“, ani w kontrakcie “Box“. Ponownie, nie wystąpił żaden błąd. Jednak jeśli sprawdzimy aktualną wartość zmiennej przechowującej referencję do pierwszego kontraktu, okaże się, że jest to nasza zaginiona liczba, ale przekonwertowana na typ “address“. Skąd takie zachowanie?

W przypadku “delegatecall” kolejność zmiennych ma znaczenie, a nie ich nazwa, co może być nieintuicyjne. Jest to subtelność, która odróżnia język Solidity od innych bardziej znanych technologii. Sam problem związany z nieprawidłowym zapisem danych pod oczekiwaną zmienną nazywany jest “storage collision”.

Zrozumienie tego mechanizmu pozwoli uniknąć wielu nieoczekiwanych sytuacji. Nawet jeśli nie planujesz tworzyć kontraktów, dzięki tej wiedzy będziesz w stanie sprawdzić, czy używany przez Ciebie protokół nie posiada takiego potencjalnego błędu.

Niestety wielu twórców nie do końca rozumie technologię, której używa, co prowadzi do powstawania luki w ich rozwiązaniach, zwłaszcza w przypadku mechanizmów opartych na blockchainie. Czasami prosty błąd może prowadzić do milionowych strat. Możesz się o tym przekonać sledzącna blogu w serii poświęconej wyzwaniom Ethernaut.

Dzięki zdobytym dzisiaj umiejętnością będziemy mogli kontynuować pracę nad kolejnymi zadaniami. Jednak nasza wiedza przyda się nie tylko w tej dziedzinie, ponieważ ten mechanizm umożliwia stworzenie rozwiązania, które pozwala na pełne aktualizowanie smart kontraktów. O tym i wielu innych rzeczach opowiemy już niedługo.

Share this Post
Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments