Solidity 9 — Akıllı Kontrat Çağrıları

Engin UNAL
8 min readNov 14, 2022

--

Bir akıllı kontrattan diğerini çağırmanın yollarını ve nasıl yapıldığını inceleyeceğiz.

Bazı durumlarda geliştirdiğiniz bir akıllı kontrat içinden başka bir kontrat çağırmanız ve bu işleme göre bazı süreçleri tetiklemeniz gerekebilir. Bu tip çağrı işlemlerine yönelik Solidity tarafında çeşitli çözümler bulunmaktadır. Geliştiriciler, kullanacakları çağrı mekanizmalarını doğru seçebilmek ve güvenlik risklerini minimize edebilmek için bu konularda yeterli bilgiye sahip olmalıdır.

Bazı durumlarda gelişmiş çağrı yöntemlerine ihtiyaç duyulmadan doğrudan diğer kontrata erişimle işler çözülebilir. Yazıda Solidity ekosisteminde yaygın olarak kullanılan çağrı yöntemleri ve farklarını göreceğiz.

Yazıda çağrı yöntemlerini iki temel başlıkta gruplamaya çalıştım. İlk bölümde kodlarına erişilebilen bir kontratı çağırma yöntemlerini inceleyeceğim. İkinci bölümde ise direkt olarak fonksiyon çağrısı yapma yöntemlerini inceleyeceğim.

Kodlarına Erişilebilen Kontrat Çağrıları

Bir akıllı kontrat içinden başka bir akıllı kontratı çağırmanın farklı yöntemleri olduğundan söz etmiştim. Çağıracağımız kontratın kodlarına veya türediği interface kodlarına erişebiliyorsak aşağıdaki yöntemlerle o kontratı çalıştırabiliriz.

1 — Kontrat İçinden Yeni Kontrat Oluşturmak Ve Çağırmak

Madde başlığı uzun olmasına rağmen konuyu özetlemekte. Bir akıllı kontrat içinden başka bir akıllı kontrat oluşturmak ve bunun fonksiyonlarını doğrudan çağırmak mümkündür. Kontrat içinden oluşturulan yeni kontrat da blockchain’e yayınlanır ve bir adrese sahip olur.

Aşağıdaki örnek üzerinden inceleyelim.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.7;
contract A {
function Hi() public pure returns(string memory) {
return "Hi from contract A";
}
}
contract B {
A public a;
function createContractA() public returns(string memory) {
a = new A();
return a.Hi();
}
}

A ve B isminde iki akıllı kontrat kodlaması yaptık. B içinden A oluşturuluyor ve Hi fonksiyonu çağırılıyor.

Önemsiz Not: A kontratını başka bir sol dosyası içine da alabiliriz, eğer böyle yaparsak A kontratını import etmemiz gerekir. B kontratımızın dosyası BContract.sol olursa ve A kontrat dosyası da AContract.sol olursa bu durumda B içinden import “./AContract.sol” tanımı ile A kontratını import ederek kullanabiliriz. Yukarıdaki ile aynı şeyi ifade eder sadece kontrat dosyalarını ayırmış oluruz. Daha sade olması açısından yukarıdaki örnek üzerinden devam edelim.

a = new A(); kodlaması ile yeni bir A kontrat oluşturuluyor ve devamında a.Hi() çağrısı ile yeni kontratın Hi fonksiyonu çağırılıp döndürdüğü string değer createContractA fonksiyonunun dönüş değeri oluyor.

Bu şekildeki kullanımda A ve B iki farklı akıllı kontrat olarak farklı adreslere yayınlanmış olur. A kontratını yayınlayan B kontratıdır. createContractA fonksiyonu çalıştırıldığında transaction(internal transaction) ile A kontratı oluşturulur.

Yukarıdaki örnekte A’nın yayınlandığı adresi alalım sonraki örnekte çağrı için kullanacağız.

2 — Mevcut Bir Kontratı Adresi İle Çağırmak

Bunu iki maddede inceleyebiliriz. Biri kontrat kodları bizimle paylaşılmış ise diğeri ise kontratın türediği interface kodları paylaşılmış ise.

a) Kontrat Kodları Paylaşılmış İse

Adresini bildiğimiz yani daha önce yayınlanmış ve kodlarına ulaşabileceğimiz bir kontrata erişmek ve fonksiyonlarını çalıştırabilmek mümkündür.

Önceki örnekte kontratı yayınlayıp doğrudan erişiyorduk şimdi ise yayınlanmış bir kontrata erişeceğiz. Bunun için adresinin ve kodlarının paylaşılmış olması gerekmektedir. Önceki örnekteki B akıllı kontrat kodlamasında aşağıdaki değişikliği yapalım. A olduğu gibi kalacak ve önceden yayınlandığı için adresini bilmemiz yeterli.

contract B {
function callContractA(address _contractA) public pure returns(string memory) {
A a = A(_contractA);
return a.Hi();
}
}

Şimdi B kontratı deploy edelim. A’nın adresini B contract içindeki createContractA fonksiyonuna geçmek için kullanacağız.

A’nın adresi: 0xD7B8da9bC7dbe4E5A…..

callContractA fonksiyonuna bu adresi parametre olarak geçip kontratı çalıştırdığımızda A kontrat içindeki Hi fonksiyonu çalışır ve “Hi from contract A” değerini döndürür.

Özetle, bu örnekte daha önce bir adrese yayınlanmış akıllı kontrata eriştik ve çalıştırdık.

Bu noktada önemli bir ayrıma yaklaştık. Dikkat edilirse şimdiye kadar kodlarına erişebildiğimiz akıllı kontratları çağırmak için kullanılabilecek yöntemleri gördük. Bu yöntemlerin eksi tarafı gereksiz kodları da barındırmamız gereksinimidir. Belki de hiç kullanmayacağımız fonksiyonlar olmasına rağmen bir kontratı saklamak verimsiz bir yöntemdir. Oysa bazen gerekli olan tüm kontrat değil daha dar kapsamlı bölümleri olabilir. Örneğin sadece bir fonksiyonu çağırmak yeterli olabilir fakat bunun için tüm kontrat kodlarını saklamak istemeyiz. Bu duruma çözümler mevcuttur. Devam ederek interface ile erişimi inceleyelim.

b) Kontrat Interface Kodları Paylaşılmış İse

Interface tanımlayarak daha dar kapsamdaki bölümleri parçalara ayırıp kullanabiliriz. Örnek üzerinden görelim. Aşağıdaki örnekte iki adet interface tanımı bulunmakta. A kontratı bunlardan ikisini de implemente etmekte ve B kontrat içerisinden sadece biri kullanılmakta.

B kontrat içindeki callContractA fonksiyonunda ihtiyacımız olan şey sadece A kontratta tanımlı Hi isimli fonksiyonu çağırmaktır. A kontrat içerisindeki diğer fonksiyonlar ile şu aşamada bir işimiz yoktur. A kontrat çeşitli fonksiyonlar içerebilir fakat B kontrat bunlardan ihtiyacı kadarına erişmek ve kullanmak isteyebilir. Bu tip durumlarda sadece gereken fonksiyonların tanımlandığı interface’ler kullanılarak çağrı yapılabilmektedir.

IHelloFunc interface içinde sadece Hi fonksiyonu tanımlı ve B kontrat için de bu yeterli. Böylece IHelloFunc interface ile birlikte A kontrat adresi verildiğinde sadece Hi fonksiyonuna erişebilir ve kullanabiliriz.

IHelloFunc a = IHelloFunc(_contractA);
val = a.Hi();

Yukarıda görüleceği üzere a.Hi çağrısı ile A kontratına erişilip Hi fonksiyonu çağırılır ve fonksiyondan dönen “Hi from contract A” değeri B kontrat içinde tanımlı olan val değişkenine atanır.

Örneği verilen bu yöntemle de diğer akıllı kontratların interface tanımlarındaki fonksiyonlarına erişip çalıştırmak mümkün olmaktadır.

Doğrudan Fonksiyon Çağrıları

İlk bölümdeki örneklerde çağıracağımız kontratın kodlarına veya interface kodlarına erişimimiz olduğunda nasıl çağrı yapabileceğimizi inceledik. Bu bölümde çağırmak istediğimiz akıllı kontratın adresini ve ilgili fonksiyon ve parametrelerini bildiğimiz durumda doğrudan fonksiyon çağrısı yapmanın yöntemlerini inceleyeceğiz.

Bu yöntemlerde çağrı oluşturmak için karşı kontratın adresi, fonksiyon adı ve parametreleri bilinmelidir. Interface veya kod içeriği gibi başka verilere ihtiyaç duyulmadan karşı kontrat fonksiyonlarının çağrılması için düşük seviye çağrılar(low level call) kullanılır. Bunlar call, delegatecall ve staticcall çağrılarıdır. Birazdan bunları inceleyeceğiz. Peki çağırmak istediğimiz fonksiyonu ve parametrelerini çağrıya hazır hale nasıl getirebiliriz? Öncelikle bunu görelim.

Bu noktada Function Signature ve Function Selector kavramları önümüzde çıkar. Solidity, bir fonksiyon çağrısı yapılmak istendiğinde çağrılmak istenen fonksiyonun Function Selector verisini ister. Bu 4-byte’lık bir veridir ve ilgili akıllı kontrat içindeki function’ı işaret eder, oluşturmak için Function Signature değerinin bilinmesi gerekir.

Function Signature

Kontrat içindeki fonksiyonun adı ve parametre tiplerinin verildiği ve parametre isimlerinin olmadığı tanıma function signature denir. Aşağıdaki gibi deneme isminde bir fonksiyonumuz olsun:

function deneme(int8 a) public pure returns(int8) { return a; }

Function Signature değeri:

deneme(int8)

Function Selector

Function Signature hash değerinin ilk 4-byte’lık kısmıdır. Yani bytes4(keccak256(signatureDeğeri)) olacaktır. Yukarıdaki deneme isimli fonksiyonumuz için signature değerini hesaplarsak.

bytes4 denemeSelector;
denemeSelector = bytes4(keccak256(bytes("deneme(int8)")));
//sonuc = 0x80a2fb15

Selector değeri farklı yöntemlerle kolayca hesaplanabilir. Aşağıdaki kod örneğinde signature ve selector için hesaplama örneği verilmiştir.

Çağrılarda sadece fonksiyon selector değeri elbette yeterli olmayacaktır. Eğer çağırdığımız fonksiyon parametre alıyorsa bunu da çağrımıza eklemeliyiz. Yukarıdaki örnekte deneme(int8 a) fonksiyonu int8 a parametresi alıyordu. Function selector ve parametre değerini birleştiren ve çağrıya hazır duruma getiren Solidity fonksiyonları bulunmaktadır.

Bunlar encodeWithSignature ve encodeWithSelector fonksiyonlarıdır. Bu iki fonksiyonun ilk parametreleri çağırılacak fonksiyon tanımı ile ilgili olmakla birlikte sonraki parametreler ise çağırılacak fonksiyona geçilecek parametre değerleridir. Özetle bunların yaptığı işlem, selector ve fonksiyona gönderilecek parametreleri byte tipinde hazırlamaktır. Yukarıdaki kod örneğindeki son iki fonksiyon buna örnektir.

Kod örneğindeki getCallParamsSig fonksiyonunu veya getCallParamsSel fonksiyonunu çağırdığımızda aşağıdaki çıktıyı verecektir.

0x80a2fb150000000000000000000000000000000000000000000000000000000000000064

Görüleceği üzere ilk 4-byte’lık 0x80a2fb15 değeri deneme fonksiyonunun function selector değeridir. Sonraki 32-byte’lık kısımda 0x64 yani decimal 100 değeri tutulmaktadır.

Fonksiyon çağrıları için bilmemiz gereken temel bilgileri tamamladık. Çağrı nasıl yapılıyor? Farkları nelerdir? Sorularına yanıtlarla ve örnek kodlamalarla call, delegatecall ve staticcall çağrılarını görelim.

Call

Önceki yazıda call fonksiyonunun ödeme süreçlerindeki kullanımından bahsetmiştim bu nedenle bu yazıda sadece fonksiyon çağrısı yapılması ile ilgili özelliklerini göreceğiz. Call fonksiyonu, adresi bilinen bir kontrattaki fonksiyon tanımı ve parametreleri bililen bir fonksiyonu çağırmak için kullanılan düşük seviye bir çağrı fonksiyonudur. Önerilen bir fonksiyon çağrı yöntemi değildir, kullanmadan önce durum değerlendirmesi yapılıp eğer diğer yöntemlerle çağırma imkanı yoksa kullanılması önerilmektedir.

Neden önerilmez? Type checking yapmaz, fonksiyon var mı kontrolü yapmaz vs. Bunun gibi kontrolleri yapmadan doğrudan çağırılan kontrattaki fonksiyonu ve parametreleri varmış varsayarak çağırmaya çalışır. Bu da sorunlara neden olabilir. Bunun yerine eğer imkan varsa fonksiyonu çağırılacak kontratın interface’i import edilmeli ve bu şekilde çağrı yapılması önerilmektedir.

Kullanımı ve örnek kodlaması aşağıda verilmiştir.

(bool success, bytes memory result) = address.call(abi.encodeWithSignature("funcToCall(type)", "valParam"));
veya
(bool success, bytes memory result) = address.call{value: 1 ether}(abi.encodeWithSignature("funcToCall(type)", "valParam")));
veya
(bool success, bytes memory result) = address.call{gas: 1000000, value: 1 ether}(abi.encodeWithSignature("funcToCall(type)", "valParam")));

Çağırılacak fonksiyon payable ise doğrudan o fonksiyona ödeme yapılabilir. Çağırılacak fonksiyon eğer adresi verilen kontrat içinde yoksa veya tanımı aynı değilse fallback fonksiyonu tetiklenir.

Dönüş değeri olarak işlemin başarılı olup olmadığını belirten bool tipinde bir dönüş değeri ve bytes tipinde dönüş verisini içeren değişken döner. Çağırılan fonksiyon bir dönüş değeri döndürüyorsa bu bytes tipindeki değişkende depolanır. Çağırılan fonksiyonun kaç adet dönüş değeri ve hangi tiplerde döndürdüğünü bilmek gerekir bu bilgiyle decode fonksiyonu çağırılarak dönüş verisi alınır.

Aşağıdaki kodlama örneğinde deneme isimli bir fonksiyonumuz var int8 tipinde bir parametre alıp yine int8 tipinde değer döndürüyor. Diğer kontrattaki denemeCall fonksiyonu ise bu fonksiyonumuzu çağırarak parametre olarak verilen değeri geçiyor ve dönüş değerini decode ederek int8 tipinde bir değişkene atıyor.

Bazı notlarla call konusunu bitirelim. Call ile çağırdığınız kontrat fonksiyonu, çağıran kontratın context’inde çalışmaz. Başka bir ifadeyle çağırılan fonksiyon ait olduğu kontrat içinde çalışır ve o kontratın state değişkenlerine erişebilir. Yukarıdaki örnekteki Deneme kontratı içindeki deneme fonksiyonu CallSamples kontratından çağırılıyor. Yapılan bu çağırma işleminde deneme fonksiyonu sadece Deneme kontratındaki değişkenlere müdahale edebilir çünkü o kontrat içinde çalışır. Bu bilgi delegatecall konusuna geçmeden önce önemli, delegatecall farklı şekilde çalışmaktadır.

Delegatecall

Buraya kadar akıllı kontratlarla ilgili hep aynı çalışma mantığını takip ettik. Buna göre her kontrat kendi çalışma alanında çalışır, her kontrat fonksiyonu sadece kendi kontratındaki state değişkenlerine erişebilir ve işlem yapabilir. Böylece bir kontrattan diğerini çağırdığımızda her ikisi de farklı adreslerde farklı storage alanlarında çalışıyordu ve doğal olarak fonksiyonları da birbirlerinin çalışma alanlarına müdahale edemiyordu. Özetle farklı context’lerde çalışmaktaydılar.

Bu durumun ihlal edildiği bir yapı mümkündür. Bunu delegatecall çağrıları sağlar. Bu tip çağrılarda çağırılan fonksiyon, çağıran kontrat içinde çalışıyormuş gibi davranır. Yani çağıran kontratın context’inde çalışır. Ve state değişkenlerine müdahale imkanına sahip olur.

Bunu başka bir kontrattan fonksiyon ödünç almak gibi düşünebiliriz. Ödünç alınan fonksiyon bizim kontratımız içinde sanki bizim yazdığımız fonksiyonmıuş gibi çalışarak işlemini tamamlar.

Tabi böyle çalışma imkanı verilen bir fonksiyon çağrısı da beraberinde yüksek güvenlik risklerini de birlikte getirir. Doğrudan kontratınıza müdahale edebilen ve sizin yazmadığınız bir fonksiyona güvenerek onu çağırmanız kontrolün kaybedilmesi riskini doğurabilir.

Kullanım alanlarına bakılırsa çoğunlukla library fonksiyonları kullanımında ve proxy kontratlar tarafından kullanılmaktadır.

Kod örneğiyle inceleyelim. Önceki kodlama örneğimize aşağıdaki denemeDelegatecall fonksiyonunu ekleyelim.

function denemeDelegatecall(int8 _newVal) public returns(int8) {
(bool success, bytes memory result) = s_addr.delegatecall(abi.encodeWithSignature("deneme(int8)", _newVal));
require(success, "call failed");
int8 a = abi.decode(result, (int8));
return a;
}

Çalıştırdığımızda farklı bir çalışma şeklini göreceğiz. Deneme kontrat içindeki s_val değerine baktığımızda değişmediğini görürüz fakat CallSamples kontrat içindeki s_val değişkeni ise değişmiştir. deneme fonksiyonu bizim CallSamples kontrat içinde olmamasına rağmen CallSamples kontrat içinde tanımlı s_val state değişkenini değiştirebildi ve Deneme kontrat içindeki değişkene müdahale edemedi.

Şu sonucu çıkarabiliriz: delegatecall ile çağırılan fonksiyon tanımlandığı kontrat içinde değil çağıran kontrat içinde çalışmaktadır. Yani deneme fonksiyonunu CallSamples içinde çalışması için ödünç almış olduk.

Peki bu çağrı yöntemi neden var? Ağırlıklı olarak library kullanırken yapılan çağrılar veya proxy pattern ile geliştirilen kontratlarda kullanılmaktadır. Özellikle dikkat edilmesi gereken bir yöntemdir, kontrolün başka bir kontrat içindeki fonksiyona verilmesi doğal olarak önceden tahmin edilmesi zor sorunlara neden olabilir.

Buna örnek olarak aşağıdaki kodlamayı inceleyebilirsiniz. İki kontrat tanımlandı. A ve B akıllı kontratları. A içerisindeki deneme fonksiyonu kontratın owner’ını değiştirebiliyor. B içinde ise owner değişimi engellenmiş sadece görüntülenebiliyor. B içinden A’daki deneme fonksiyonu delegatecall ile çağırıldığında B’nin owner’ı değiştirilerek B kontratı hackleniyor ve sahibi değiştirilebiliyor.

B kontratı owner’ını korumaya aldığını düşünerek kodlama yapmasına rağmen çağırdığı A kontratındaki deneme fonksiyonu owner’a erişebildiği için herhangi bir koruma kalmıyor ve B kontratı güvensiz hale düşüyor. Bunun gibi güvenlik açıkları oluşturabileceğinden kullanıma dikkat edilmelidir.

Staticcall

staticcall çağrıları call ile aynı şekilde çalışmaktadır. Aralarındaki fark staticcall ile çağrı yaptığınız fonksiyonun içerisinde state değişimi olamaz. Yani view ve pure fonksiyon çağrısı gibi çalışmalıdır. Eğer çağırılan fonksiyon içerisinde state değişikliği oluyorsa hata üretilir ve işlem revert edilir.

Kod örneğiyle yazıyı bitirelim. Önceki kod örneğine deneme2 fonksiyonu eklendi ve bu fonksiyon view olduğundan state üzerinde değişiklik yapmıyor. A kontratı çağıracağımız fonksiyonların tanımlandığı kontrat. CallAFunctions kontratı ise çağrıları başlattığımız akıllı kontrattır.

Yukarıdaki denemeStaticcall fonksiyonunda A kontrat içindeki deneme2 fonksiyonu çağırılıyor ve dönüş değeri alınıyor. staticcall ile deneme fonksiyonunu çağırsaydık işlem gerçekleşmeyecekti ve revert edilecekti.

Solidity çağrıları ile ilgili bu yazımızın sonuna geldik. Okuduğunuz için teşekkürler.

Serideki diğer yazılar:

Temel Bilgiler

İlk Akıllı Kontrat ve Remix

Tipler

Contract ve Function

Interface ve Library

Veri Yönetimi

Logs ve Events

Ödeme İşlemleri

Engin Ünal

--

--