Ana içeriğe atla

Çağrı Verisi Optimizasyonu için Kısa ABI'ler

katman 2 (l2)
Orta
Ori Pomerantz
1 Nisan 2022
12 dakikalık okuma

Giriş

Bu makalede, iyimser toplamalar, bunlar üzerindeki işlemlerin maliyeti ve bu farklı maliyet yapısının bizi Ethereum Ana Ağı'ndakinden farklı şeyler için optimize etmeye nasıl zorladığı hakkında bilgi edineceksiniz. Ayrıca bu optimizasyonu nasıl uygulayacağınızı da öğreneceksiniz.

Tam açıklama

Tam zamanlı bir Optimism (opens in a new tab) çalışanıyım, bu nedenle bu makaledeki örnekler Optimism üzerinde çalışacaktır. Ancak, burada açıklanan teknik diğer toplamalar için de aynı derecede iyi çalışmalıdır.

Terminoloji

Toplamalar tartışılırken, 'katman 1 (l1)' terimi, üretim Ethereum ağı olan Ana Ağ için kullanılır. 'katman 2 (l2)' terimi, Rollup veya güvenlik için L1'e dayanan ancak işlemlerinin çoğunu zincir dışı yapan diğer herhangi bir sistem için kullanılır.

L2 işlemlerinin maliyetini nasıl daha da azaltabiliriz?

İyimser toplamalar, herkesin bunları inceleyebilmesi ve mevcut durumun doğru olduğunu doğrulayabilmesi için her geçmiş işlemin bir kaydını tutmalıdır. Ethereum Ana Ağı'na veri almanın en ucuz yolu, onu çağrı verisi olarak yazmaktır. Bu çözüm hem Optimism (opens in a new tab) hem de Arbitrum (opens in a new tab) tarafından seçilmiştir.

L2 işlemlerinin maliyeti

L2 işlemlerinin maliyeti iki bileşenden oluşur:

  1. Genellikle son derece ucuz olan L2 işleme
  2. Ana Ağ Gaz maliyetlerine bağlı olan L1 depolama

Bunu yazarken, Optimism'de L2 Gaz maliyeti 0.001 Gwei'dir. Öte yandan L1 Gaz maliyeti yaklaşık 40 Gwei'dir. Güncel fiyatları buradan görebilirsiniz (opens in a new tab).

Bir baytlık çağrı verisi ya 4 Gaz (sıfır ise) ya da 16 Gaz (başka bir değer ise) tutar. EVM'deki en pahalı işlemlerden biri depolamaya yazmaktır. L2'de depolamaya 32 baytlık bir kelime yazmanın maksimum maliyeti 22100 Gaz'dır. Şu anda bu 22.1 Gwei'dir. Yani tek bir sıfır baytlık çağrı verisi tasarrufu yapabilirsek, depolamaya yaklaşık 200 bayt yazabilir ve yine de kârlı çıkabiliriz.

ABI

İşlemlerin büyük çoğunluğu bir sözleşmeye harici olarak sahip olunan bir hesaptan erişir. Çoğu sözleşme Solidity dilinde yazılır ve veri alanlarını uygulama ikili arayüzüne (ABI) (opens in a new tab) göre yorumlar.

Ancak ABI, bir baytlık çağrı verisinin binden fazla aritmetik işleme mal olduğu L2 için değil, bir baytlık çağrı verisinin yaklaşık dört aritmetik işlemle aynı maliyete sahip olduğu L1 için tasarlanmıştır. Çağrı verisi şu şekilde bölünmüştür:

BölümUzunlukBaytlarBoşa harcanan baytlarBoşa harcanan GazGerekli baytlarGerekli Gaz
İşlev seçici40-3348116
Sıfırlar124-15124800
Hedef adres2016-350020320
Miktar3236-67176415240
Toplam68160576

Açıklama:

  • İşlev seçici: Sözleşmenin 256'dan az işlevi vardır, bu nedenle onları tek bir bayt ile ayırt edebiliriz. Bu baytlar tipik olarak sıfır değildir ve bu nedenle on altı Gaz'a mal olur (opens in a new tab).
  • Sıfırlar: Bu baytlar her zaman sıfırdır çünkü yirmi baytlık bir adresin tutulması için otuz iki baytlık bir kelime gerekmez. Sıfır tutan baytlar dört Gaz'a mal olur (Sarı Bülten'e bakın (opens in a new tab), Ek G, s. 27, Gtxdatazero değeri).
  • Miktar: Bu sözleşmede decimals değerinin on sekiz (normal değer) olduğunu ve transfer ettiğimiz maksimum Token miktarının 1018 olacağını varsayarsak, maksimum 1036 miktarı elde ederiz. 25615 > 1036, yani on beş bayt yeterlidir.

L1'de 160 Gaz israfı normalde göz ardı edilebilir. Bir işlem en az 21.000 Gaz'a (opens in a new tab) mal olur, bu nedenle fazladan %0,8'in bir önemi yoktur. Ancak L2'de işler farklıdır. İşlemin neredeyse tüm maliyeti onu L1'e yazmaktır. İşlem çağrı verisine ek olarak, 109 baytlık işlem başlığı (hedef adres, imza vb.) vardır. Bu nedenle toplam maliyet 109*16+576+160=2480'dir ve bunun yaklaşık %6,5'ini boşa harcıyoruz.

Hedefi kontrol etmediğinizde maliyetleri azaltmak

Hedef sözleşme üzerinde kontrolünüz olmadığını varsayarsak, yine de buna (opens in a new tab) benzer bir çözüm kullanabilirsiniz. İlgili dosyaların üzerinden geçelim.

Token.sol

Bu, hedef sözleşmedir (opens in a new tab). Ek bir özelliğe sahip standart bir ERC-20 sözleşmesidir. Bu faucet işlevi, herhangi bir kullanıcının kullanmak üzere bir miktar Token almasını sağlar. Üretimdeki bir ERC-20 sözleşmesini işe yaramaz hale getirirdi, ancak bir ERC-20 yalnızca test etmeyi kolaylaştırmak için var olduğunda hayatı kolaylaştırır.

    /**
     * @dev Çağırana oynaması için 1000 Token verir
     */
    function faucet() external {
        _mint(msg.sender, 1000);
    }   // function faucet

CalldataInterpreter.sol

Bu, işlemlerin daha kısa çağrı verisiyle çağırması beklenen sözleşmedir (opens in a new tab). Satır satır üzerinden geçelim.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;


import { OrisUselessToken } from "./Token.sol";

Nasıl çağıracağımızı bilmek için Token işlevine ihtiyacımız var.

contract CalldataInterpreter {
    OrisUselessToken public immutable token;

Vekil kontratı olduğumuz Token'ın adresi.

Token adresi, belirtmemiz gereken tek parametredir.

    function calldataVal(uint startByte, uint length)
        private pure returns (uint) {

Çağrı verisinden bir değer okuyun.

        uint _retVal;

        require(length < 0x21,
            "calldataVal length limit is 32 bytes");

        require(length + startByte <= msg.data.length,
            "calldataVal trying to read beyond calldatasize");

Belleğe tek bir 32 baytlık (256 bit) kelime yükleyeceğiz ve istediğimiz alanın parçası olmayan baytları kaldıracağız. Bu algoritma 32 bayttan uzun değerler için çalışmaz ve elbette çağrı verisinin sonrasını okuyamayız. L1'de Gazdan tasarruf etmek için bu testleri atlamak gerekebilir, ancak L2'de Gaz son derece ucuzdur, bu da aklımıza gelebilecek her türlü mantık kontrolünü yapmamızı sağlar.

        assembly {
            _retVal := calldataload(startByte)
        }

Verileri fallback() çağrısından kopyalayabilirdik (aşağıya bakın), ancak EVM'nin assembly dili olan Yul (opens in a new tab)'u kullanmak daha kolaydır.

Burada, startByte ile startByte+31 arasındaki baytları yığına okumak için CALLDATALOAD işlem kodunu (opens in a new tab) kullanıyoruz. Genel olarak, Yul'daki bir işlem kodunun sözdizimi <opcode name>(<first stack value, if any>,<second stack value, if any>...) şeklindedir.


        _retVal = _retVal >> (256-length*8);

Yalnızca en anlamlı length bayt alanın bir parçasıdır, bu nedenle diğer değerlerden kurtulmak için sağa kaydırma (opens in a new tab) yaparız. Bunun, değeri alanın sağına taşıma gibi ek bir avantajı vardır, bu nedenle değer çarpı 256bir şey yerine değerin kendisi olur.


        return _retVal;
    }


    fallback() external {

Bir Solidity sözleşmesine yapılan çağrı işlev imzalarından hiçbiriyle eşleşmediğinde, fallback() işlevini (opens in a new tab) çağırır (bir tane olduğunu varsayarsak). CalldataInterpreter durumunda, başka hiçbir external veya public işlevi olmadığı için herhangi bir çağrı buraya gelir.

        uint _func;

        _func = calldataVal(0, 1);

Bize işlevi söyleyen çağrı verisinin ilk baytını okuyun. Bir işlevin burada bulunmamasının iki nedeni vardır:

  1. pure veya view olan işlevler durumu değiştirmez ve (zincir dışı çağrıldığında) Gaz maliyeti oluşturmaz. Gaz maliyetlerini düşürmeye çalışmanın bir anlamı yoktur.
  2. msg.sender (opens in a new tab)'a dayanan işlevler. msg.sender değeri, çağıranın değil, CalldataInterpreter'nin adresi olacaktır.

Ne yazık ki, ERC-20 spesifikasyonlarına bakıldığında (opens in a new tab), bu geriye yalnızca bir işlev bırakır: transfer. Bu bizi yalnızca iki işlevle baş başa bırakır: transfer (transferFrom çağırabildiğimiz için) ve faucet (Token'ları bizi çağıran kişiye geri transfer edebildiğimiz için).


        // Token'ın durum değiştiren metotlarını şunları kullanarak çağırın:
        // çağrı verisinden gelen bilgi

        // faucet
        if (_func == 1) {

Parametreleri olmayan faucet() çağrısı.

            token.faucet();
            token.transfer(msg.sender,
                token.balanceOf(address(this)));
        }

token.faucet() çağırdıktan sonra Token'lar alırız. Ancak, vekil kontrat olarak Token'lara ihtiyacımız yoktur. Bizi çağıran EOA (harici olarak sahip olunan hesap) veya sözleşmenin ihtiyacı vardır. Bu yüzden tüm Token'larımızı bizi çağıran kişiye transfer ediyoruz.

        // transfer (bunun için bir harcama iznimiz olduğunu varsayalım)
        if (_func == 2) {

Token'ları transfer etmek iki parametre gerektirir: hedef adres ve miktar.

            token.transferFrom(
                msg.sender,

Yalnızca çağıranların sahip oldukları Token'ları transfer etmelerine izin veriyoruz

                address(uint160(calldataVal(1, 20))),

Hedef adres 1. bayttan başlar (0. bayt işlevdir). Bir adres olarak 20 bayt uzunluğundadır.

                calldataVal(21, 2)

Bu özel sözleşme için, herhangi birinin transfer etmek isteyeceği maksimum Token sayısının iki bayta (65536'dan az) sığdığını varsayıyoruz.

            );
        }

Genel olarak, bir transfer 35 baytlık çağrı verisi alır:

BölümUzunlukBaytlar
İşlev seçici10
Hedef adres321-32
Miktar233-34
    }   // fallback

}       // contract CalldataInterpreter

test.js

Bu JavaScript birim testi (opens in a new tab) bize bu mekanizmayı nasıl kullanacağımızı (ve doğru çalıştığını nasıl doğrulayacağımızı) gösterir. chai (opens in a new tab) ve ethers (opens in a new tab)'ı anladığınızı varsayacağım ve yalnızca sözleşmeye özel olarak uygulanan kısımları açıklayacağım.

Her iki sözleşmeyi de dağıtarak başlıyoruz.

    // Oynamak için Token alın
    const faucetTx = {

İşlemler oluşturmak için normalde kullanacağımız üst düzey işlevleri (token.faucet() gibi) kullanamayız, çünkü ABI'yi takip etmiyoruz. Bunun yerine, işlemi kendimiz oluşturmalı ve ardından göndermeliyiz.

      to: cdi.address,
      data: "0x01"

İşlem için sağlamamız gereken iki parametre vardır:

  1. to, hedef adres. Bu, çağrı verisi yorumlayıcı sözleşmesidir.
  2. data, gönderilecek çağrı verisi. Bir musluk çağrısı durumunda, veri tek bir bayttır, 0x01.

    }
    await (await signer.sendTransaction(faucetTx)).wait()

Hedefi (faucetTx.to) zaten belirttiğimiz ve işlemin imzalanması gerektiği için imzalayanın sendTransaction yöntemini (opens in a new tab) çağırıyoruz.

// Faucet'in Token'ları doğru şekilde sağlayıp sağlamadığını kontrol edin
expect(await token.balanceOf(signer.address)).to.equal(1000)

Burada bakiyeyi doğruluyoruz. view işlevlerinde Gaz tasarrufu yapmaya gerek yoktur, bu yüzden onları normal şekilde çalıştırırız.

// CDI'ye bir harcama izni verin (onaylara vekillik edilemez)
const approveTX = await token.approve(cdi.address, 10000)
await approveTX.wait()
expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)

Çağrı verisi yorumlayıcısına transfer yapabilmesi için bir harcama izni verin.

// Token'ları transfer et
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}

Bir transfer işlemi oluşturun. İlk bayt "0x02"dir, ardından hedef adres ve son olarak miktar (ondalık olarak 256 olan 0x0100) gelir.

Hedef sözleşmeyi kontrol ettiğinizde maliyeti azaltmak

Hedef sözleşme üzerinde kontrolünüz varsa, çağrı verisi yorumlayıcısına güvendikleri için msg.sender kontrollerini atlayan işlevler oluşturabilirsiniz. Bunun nasıl çalıştığına dair bir örneği burada, control-contract dalında görebilirsiniz (opens in a new tab).

Sözleşme yalnızca harici işlemlere yanıt veriyor olsaydı, sadece bir sözleşmeye sahip olarak idare edebilirdik. Ancak bu, birleştirilebilirliği bozardı. Normal ERC-20 çağrılarına yanıt veren bir sözleşmeye ve kısa çağrı verisine sahip işlemlere yanıt veren başka bir sözleşmeye sahip olmak çok daha iyidir.

Token.sol

Bu örnekte Token.sol'ü değiştirebiliriz. Bu, yalnızca vekil kontratın çağırabileceği bir dizi işleve sahip olmamızı sağlar. İşte yeni kısımlar:

    // CalldataInterpreter Adresini belirtmesine izin verilen tek Adres
    address owner;

    // CalldataInterpreter Adresi
    address proxy = address(0);

ERC-20 sözleşmesinin yetkili vekil kontratın kimliğini bilmesi gerekir. Ancak, değeri henüz bilmediğimiz için bu değişkeni kurucuda ayarlayamayız. Vekil kontrat, kurucusunda Token'ın adresini beklediği için önce bu sözleşme örneklendirilir.

    /**
     * @dev ERC20 kurucusunu çağırır.
     */
    constructor(
    ) ERC20("Oris useless token-2", "OUT-2") {
        owner = msg.sender;
    }

Oluşturucunun adresi (owner olarak adlandırılır) burada saklanır çünkü vekil kontratı ayarlamasına izin verilen tek adres budur.

Vekil kontrat ayrıcalıklı erişime sahiptir, çünkü güvenlik kontrollerini atlayabilir. Vekil kontrata güvenebileceğimizden emin olmak için yalnızca owner'ın bu işlevi çağırmasına izin veriyoruz ve yalnızca bir kez. proxy gerçek bir değere (sıfır değil) sahip olduğunda, bu değer değişemez, bu nedenle sahibi kötü niyetli olmaya karar verse veya anımsatıcısı (mnemonic) ortaya çıksa bile hala güvendeyiz.

    /**
     * @dev Bazı fonksiyonlar yalnızca vekil tarafından çağrılabilir.
     */
    modifier onlyProxy {

Bu bir modifier işlevidir (opens in a new tab), diğer işlevlerin çalışma şeklini değiştirir.

      require(msg.sender == proxy);

İlk olarak, vekil kontrat tarafından çağrıldığımızı ve başka kimse tarafından çağrılmadığımızı doğrulayın. Değilse, revert.

      _;
    }

Eğer öyleyse, değiştirdiğimiz işlevi çalıştırın.

Bunlar normalde mesajın doğrudan Token'ları transfer eden veya bir harcama iznini onaylayan varlıktan gelmesini gerektiren üç işlemdir. Burada bu işlemlerin bir vekil kontrat sürümüne sahibiz:

  1. onlyProxy() tarafından değiştirilir, böylece başka hiç kimsenin onları kontrol etmesine izin verilmez.
  2. Normalde msg.sender olacak adresi ekstra bir parametre olarak alır.

CalldataInterpreter.sol

Çağrı verisi yorumlayıcısı, vekil kontrat işlevlerinin bir msg.sender parametresi alması ve transfer için bir harcama iznine gerek olmaması dışında yukarıdakiyle neredeyse aynıdır.

Test.js

Önceki test kodu ile bu kod arasında birkaç değişiklik var.

const Cdi = await ethers.getContractFactory("CalldataInterpreter")
const cdi = await Cdi.deploy(token.address)
await cdi.deployed()
await token.setProxy(cdi.address)

ERC-20 sözleşmesine hangi vekil kontrata güveneceğini söylememiz gerekiyor

console.log("CalldataInterpreter addr:", cdi.address)

// Harcama izinlerini doğrulamak için iki imzalayıcıya ihtiyaç var
const signers = await ethers.getSigners()
const signer = signers[0]
const poorSigner = signers[1]

approve() ve transferFrom()'ı kontrol etmek için ikinci bir imzalayana ihtiyacımız var. Buna poorSigner diyoruz çünkü Token'larımızdan hiçbirini almıyor (elbette ETH'ye sahip olması gerekiyor).

// Token'ları transfer et
const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"
const transferTx = {
  to: cdi.address,
  data: "0x02" + destAddr.slice(2, 42) + "0100",
}
await (await signer.sendTransaction(transferTx)).wait()

ERC-20 sözleşmesi vekil kontrata (cdi) güvendiği için, transferleri iletmek için bir harcama iznine ihtiyacımız yoktur.

İki yeni işlevi test edin. transferFromTx'un iki adres parametresi gerektirdiğini unutmayın: harcama iznini veren ve alan.

Sonuç

Hem Optimism (opens in a new tab) hem de Arbitrum (opens in a new tab), L1'e yazılan çağrı verisinin boyutunu ve dolayısıyla işlemlerin maliyetini azaltmanın yollarını arıyor. Ancak, genel çözümler arayan altyapı sağlayıcıları olarak yeteneklerimiz sınırlıdır. Merkeziyetsiz uygulama (dapp) geliştiricisi olarak, çağrı verinizi genel bir çözümde yapabileceğimizden çok daha iyi optimize etmenizi sağlayan uygulamaya özel bilgiye sahipsiniz. Umarım bu makale ihtiyaçlarınız için ideal çözümü bulmanıza yardımcı olur.

Çalışmalarımın daha fazlası için buraya bakın (opens in a new tab).