Вызов других контрактов (send, call, callcode, delegatecall)

Вызов других контрактов изнутри вашего контракта - очень полезная, но потенциально опасная операция. Мы рассмотрим различные способы, которыми вы можете этого добиться, и оценим риски каждого метода. Вкратце, риски возникают из-за того, что вы можете многого не знать о контракте, который вы вызываете, или о контракте, который вызывает ваш контракт. При написании умных контрактов вы должны помнить, что, хотя в основном вы ожидаете иметь дело с EOA, ничто не помешает произвольно сложным и, возможно, вредоносным контрактам вызываться вашим кодом.

Обращение к существующему экземпляру

Другим способом вызова контракта является приведение адреса существующего экземпляра контракта. С помощью этого метода вы применяете известный интерфейс к существующему экземпляру. Поэтому очень важно, чтобы вы точно знали, что экземпляр, к которому вы обращаетесь, действительно имеет тип, который вы предполагаете. Давайте рассмотрим пример: импортировать "Faucet.sol";

import "Faucet.sol";

contract Token is Mortal {

    Faucet _faucet;

    constructor(address _f) {
        _faucet = Faucet(_f);
        _faucet.withdraw(0.1 ether);
    }
}

Здесь мы берем адрес, предоставленный в качестве аргумента конструктора _f, и приводим его к объекту Faucet. Это намного рискованнее, чем предыдущий механизм, потому что мы не знаем наверняка, является ли этот адрес объектом Faucet. Когда мы вызываем функцию withdraw, мы предполагаем, что она принимает те же аргументы и выполняет тот же код, что и наше объявление Faucet, но мы не можем быть уверены. Насколько нам известно, функция withdraw по этому адресу может выполнить что-то совершенно отличное от того, что мы ожидаем, даже если она названа так же. Поэтому использование адресов, переданных в качестве входных данных, и их преобразование в конкретные объекты гораздо опаснее, чем самостоятельное создание контракта.

Необработанный вызов, вызов делегата

Solidity предлагает несколько еще более "низкоуровневых" функций для вызова других контрактов. Они напрямую соответствуют одноименным опкодам EVM и позволяют нам построить вызов контракта на контракт вручную. Как таковые, они представляют собой наиболее гибкий и наиболее опасный механизм вызова других контрактов.

Вот тот же пример с использованием метода вызова:

contract Token is Mortal {
	constructor(address _faucet) {
		_faucet.call("withdraw", 0.1 ether);
	}
}

Как видите, этот тип вызова является слепым вызовом функции, что очень похоже на создание необработанной транзакции, только в контексте контракта. Это может подвергнуть ваш контракт ряду рисков безопасности, главным из которых является реентерабельность, которую мы обсудим более подробно в [reentrancy_security]. Функция вызова вернет false, если возникнет проблема, поэтому вы можете оценить возвращаемое значение для обработки ошибок:

contract Token is Mortal {
	constructor(address _faucet) {
		if !(_faucet.call("withdraw", 0.1 ether)) {
			revert("Withdrawal from faucet failed");
		}
	}
}

Другим вариантом call является delegatecall, который заменил более опасный callcode. Метод callcode скоро будет устаревшим, поэтому его не следует использовать.

Как упоминалось в объекте адреса, делегатный вызов отличается от вызова тем, что контекст msg не изменяется. Например, в то время как вызов изменяет значение msg.sender на вызывающий контракт, delegatecall сохраняет тот же msg.sender, что и в вызывающем контракте. По сути, delegatecall запускает код другого контракта в контексте выполнения текущего контракта. Чаще всего он используется для вызова кода из библиотеки. Он также позволяет использовать библиотечные функции, хранящиеся в другом месте, но чтобы этот код работал с данными хранилища вашего контракта.

Вызов делегата следует использовать с большой осторожностью. Это может привести к неожиданным последствиям, особенно если вызываемый вами контракт не был разработан как библиотека.

Давайте на примере контракта продемонстрируем различные семантики вызова, используемые call и delegatecall для вызова библиотек и контрактов. В CallExamples.sol: Пример различных семантик вызова, мы используем событие для регистрации деталей каждого вызова и видим, как контекст вызова меняется в зависимости от типа вызова.

Пример 4. CallExamples.sol: Пример различных семантик вызовов link:code/truffle/CallExamples/contracts/CallExamples.sol[]

Как видно из этого примера, наш основной контракт - caller, который вызывает библиотеку под названиемLibrary и контракт под названиемContract. И вызываемая библиотека, и контракт имеют одинаковые функции calledFunction, которые испускают событие callEvent. Событие callEvent регистрирует три части данных: msg.sender, tx.origin и this. Каждый раз, когда вызывается callFunction, она может иметь разный контекст выполнения (с разными значениями потенциально всех контекстных переменных), в зависимости от того, вызывается ли она напрямую или через delegatecall.

В caller мы сначала вызываем контракт и библиотеку напрямую, вызывая callFunction в каждой из них. Затем мы явно используем низкоуровневые функции call и delegatecall для вызова calledContract.calledFunction. Таким образом, мы можем увидеть, как ведут себя различные механизмы вызова.

Давайте запустим это в среде разработки Truffle и зафиксируем события, чтобы посмотреть, как это выглядит:

truffle(develop)> migrate
Using network 'develop'.
[...]
Saving artifacts...
truffle(develop)> (await web3.eth.getAccounts())[0]
'0x627306090abab3a6e1400e9345bc60c78a8bef57'
truffle(develop)> caller.address
'0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'
truffle(develop)> calledContract.address
'0x345ca3e014aaf5dca488057592ee47305d9b3e10'
truffle(develop)> calledLibrary.address
'0xf25186b5081ff5ce73482ad761db0eb0d25abfbf'
truffle(develop)> caller.deployed().then( i => { callerDeployed = i })

truffle(develop)> callerDeployed.make_calls(calledContract.address).then(res => \
                  { res.logs.forEach( log => { console.log(log.args) })})
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }
{ sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10' }
{ sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
  from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f' }

Давайте посмотрим, что здесь произошло. Мы вызвали функцию make_calls и передали адрес callContract, а затем поймали четыре события, испускаемые каждым из различных вызовов. Давайте рассмотрим функцию make_calls и пройдемся по каждому шагу.

Первый звонок: _calledContract.calledFunction();

Здесь мы вызываем callContract.calledFunction напрямую, используя высокоуровневый ABI для calledFunction. Вызывается следующее событие:

sender: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x345ca3e014aaf5dca488057592ee47305d9b3e10'

Как вы можете видеть, msg.sender - это адрес контракта caller. tx.origin - это адрес нашего аккаунта, web3.eth.accounts[0], который отправил транзакцию вызывающему контракту. Событие было вызвано callContract, как видно из последнего аргумента в событии. Следующее обращение в make_calls - к библиотеке:

calledLibrary.calledFunction();

Оно выглядит идентично тому, как мы вызывали контракт, но ведет себя совершенно по-другому. Давайте посмотрим на второе испускаемое событие:

sender: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
origin: '0x627306090abab3a6e1400e9345bc60c78a8bef57',
from: '0x8f0483125fcb9aaaefa9209d8e9d7b9c8b9fb90f'

На этот раз msg.sender - это не адрес звонящего. Вместо этого он является адресом нашего аккаунта и совпадает с адресом происхождения транзакции. Это происходит потому, что при вызове библиотеки вызов всегда является делегативным и выполняется в контексте вызывающей стороны. Таким образом, когда код вызываемой библиотеки выполнялся, он наследовал контекст выполнения вызывающей стороны, как если бы его код выполнялся внутри вызывающей стороны. Переменная this (показанная как from в испускаемом событии) является адресом вызывающей стороны, даже если доступ к ней осуществляется изнутри вызываемой библиотеки.

Следующие два вызова, использующие низкоуровневый вызов и вызов делегата, подтверждают наши ожидания, вызывая события, которые отражают то, что мы только что видели.