• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья "Разрабатываем" шаблонное мышление, или стереотипы программирования Solidity

вавилонец

CPU register
Пользователь
Регистрация
17.06.2021
Сообщения
1 116
Реакции
1 265
ОРИГИНАЛЬНАЯ СТАТЬЯ
ПЕРЕВЕДЕНО СПЕЦИАЛЬНО ДЛЯ xss.pro плюс немного отсебятины для нашего развития
$10 на gas для Jolah Molivski ---> 0x5B1f2Ac9cF5616D9d7F1819d1519912e85eb5C09

Децентрализованный мир растет в геометрической прогрессии с точки зрения принятия пользователями. Компании ежегодно создают сотни инновационных приложений dApp на Ethereum для удовлетворения различных требований рынка. В основе этих dApps лежат смарт-контракты, совместимые с EVM, в основном написанные на Solidity. Хотя изучение синтаксиса и мотодов разработки на solidity несложно, но для создания масштабируемого, удобного и безопасного контракта необходимо понимать правильные шаблоны проектирования.

image


Зачем заботиться о шаблонах проектирования?​

Понимать — значит воспринимать закономерности
- Исайя Берлин

Одно из основных различий между программистом-новичком и профессиональным разработчиком заключается в понимании соответствующих шаблонов проектирования. Эти шаблоны являются проверенными решениями для распространенных проблем и могут стать вашим самым мощным инструментом.
По сравнению с другими направлениями разработки программного обеспечения, разработка надежных смарт-контрактов является сложной задачей. Распределенный конечный автомат запускает вашу развернутую программу 24/7, предоставляя доступ ко всему миру, это палка о двух концах. Чтобы убедиться, что ваша бизнес-логика работает безопасным, надежным и детерминированным образом, необходимо следовать лучшим практикам проектирования.
На платформе, где самая маленькая ошибка может стоить вам миллионы долларов, вы бы рискнули?

Снимок экрана_2022-09-12_22-24-26.translated.jpg






Шаблоны авторизации Solidity​

Хотя смарт-контракты легко доступны по сети любому, это может не быть желаемой целью программы. Могут существовать определенные функции, которые должны использоваться только определенными группами пользователей.
Неспособность правильно справиться с этим может привести к непредсказуемым вариантам использования.
Некоторые цели, которые могут быть достигнуты шаблонами авторизации:
  1. Разрешить выполнение функции поставторизации группой пользователей
  2. Разрешить выполнение транзакции после аутентификации вызывающего абонента.
  3. Ограничить возможности контракта создателем
Я определил 4 типа шаблонов авторизации.


1. Ограничение доступа

Концепция:

Перед выполнением логики функции вызывающая сторона должна выполнить определенные условия. Эти условия могут быть связаны с личностью вызывающего абонента, входными параметрами, состоянием контракта и т. д. Функция запускается после выполнения всех требований. Если требования не выполняются, EVM обрабатывает ошибку, возвращая состояние, не внося изменений в вызов функции. Если подобное ограничение необходимо для нескольких функций, можно использовать защитную проверку с модификаторами. Кроме того, если вы не хотите, чтобы переменные и функции были доступны публично, защитите данные их состоянии, выбрав частную видимость.

Пример кода:
Код:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
 
 
/**
* @title An incremental time-bound donation receiver
    */
contract AccessRestriction {
/**
* @dev public variables in global, visible to everyone
    */
address public treasury = msg.sender;
uint256 public creationTime = block.timestamp;
uint256 public minimumDonation;
/**
* @dev private visibility of winner address
    */
address private winner;
 
/**********Modifier Blocks********/
/**
* @dev check if donation period has started
    */
   modifier onlyBefore(uint256 _time) {
require(block.timestamp < _time);
       _;
   }
 
/**
* @dev check if donation period has ended
    */
   modifier onlyAfter(uint256 _time) {
require(block.timestamp > _time);
       _;
   }
 
   modifier isHigherDonation() {
require(msg.value > minimumDonation, "Please send higher amount");
       winner = msg.sender;
       minimumDonation = msg.value;
       _;
   }
 
function sendDonation()
external
payable
onlyBefore(creationTime + 1 weeks)
isHigherDonation
   {
       payable(treasury).transfer(msg.value);
   }
 
function revealHighestDonor()
external
view
onlyAfter(creationTime + 1 weeks)
returns (address)
   {
return winner;
   }
}

2. Мульти авторизация

Концепция:

Бывают ситуации, когда транзакция или вызов функции могут потребовать подтверждения нескольких пользователей. Таким образом, если пул авторизаторов имеет размер X и нам требуется подмножество Y, нет авторизаций (где X≥Y) для выполнения транзакции. Это полезный подход, когда задействованы платежные транзакции на основе мультиподписи. Проблема в этом подходе заключается в том, что члены в группе авторизации должны быть определены заранее, а минимальное количество авторизаторов должно быть доступно во время выполнения для подписи. Также в случае компрометации/утери ключа учетная запись авторизатора становится бесполезной.

1663036509694.png

Код:
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

import "./base/ModuleManager.sol";
import "./base/OwnerManager.sol";
import "./base/FallbackManager.sol";
import "./base/GuardManager.sol";
import "./common/EtherPaymentFallback.sol";
import "./common/Singleton.sol";
import "./common/SignatureDecoder.sol";
import "./common/SecuredTokenTransfer.sol";
import "./common/StorageAccessible.sol";
import "./interfaces/ISignatureValidator.sol";
import "./external/GnosisSafeMath.sol";

/// @title Gnosis Safe - A multisignature wallet with support for confirmations using signed messages based on ERC191.
/// @author Stefan George - <stefan@gnosis.io>
/// @author Richard Meissner - <richard@gnosis.io>
contract GnosisSafe is
    EtherPaymentFallback,
    Singleton,
    ModuleManager,
    OwnerManager,
    SignatureDecoder,
    SecuredTokenTransfer,
    ISignatureValidatorConstants,
    FallbackManager,
    StorageAccessible,
    GuardManager
{
    using GnosisSafeMath for uint256;

    string public constant VERSION = "1.3.0";

    // keccak256(
    //     "EIP712Domain(uint256 chainId,address verifyingContract)"
    // );
    bytes32 private constant DOMAIN_SEPARATOR_TYPEHASH = 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218;

    // keccak256(
    //     "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"
    // );
    bytes32 private constant SAFE_TX_TYPEHASH = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8;

    event SafeSetup(address indexed initiator, address[] owners, uint256 threshold, address initializer, address fallbackHandler);
    event ApproveHash(bytes32 indexed approvedHash, address indexed owner);
    event SignMsg(bytes32 indexed msgHash);
    event ExecutionFailure(bytes32 txHash, uint256 payment);
    event ExecutionSuccess(bytes32 txHash, uint256 payment);

    uint256 public nonce;
    bytes32 private _deprecatedDomainSeparator;
    // Mapping to keep track of all message hashes that have been approved by ALL REQUIRED owners
    mapping(bytes32 => uint256) public signedMessages;
    // Mapping to keep track of all hashes (message or transaction) that have been approved by ANY owners
    mapping(address => mapping(bytes32 => uint256)) public approvedHashes;

    // This constructor ensures that this contract can only be used as a master copy for Proxy contracts
    constructor() {
        // By setting the threshold it is not possible to call setup anymore,
        // so we create a Safe with 0 owners and threshold 1.
        // This is an unusable Safe, perfect for the singleton
        threshold = 1;
    }

    /// @dev Setup function sets initial storage of contract.
    /// @param _owners List of Safe owners.
    /// @param _threshold Number of required confirmations for a Safe transaction.
    /// @param to Contract address for optional delegate call.
    /// @param data Data payload for optional delegate call.
    /// @param fallbackHandler Handler for fallback calls to this contract
    /// @param paymentToken Token that should be used for the payment (0 is ETH)
    /// @param payment Value that should be paid
    /// @param paymentReceiver Address that should receive the payment (or 0 if tx.origin)
    function setup(
        address[] calldata _owners,
        uint256 _threshold,
        address to,
        bytes calldata data,
        address fallbackHandler,
        address paymentToken,
        uint256 payment,
        address payable paymentReceiver
    ) external {
        // setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
        setupOwners(_owners, _threshold);
        if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
        // As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
        setupModules(to, data);

        if (payment > 0) {
            // To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
            // baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
            handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
        }
        emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
    }

    /// @dev Allows to execute a Safe transaction confirmed by required number of owners and then pays the account that submitted the transaction.
    ///      Note: The fees are always transferred, even if the user transaction fails.
    /// @param to Destination address of Safe transaction.
    /// @param value Ether value of Safe transaction.
    /// @param data Data payload of Safe transaction.
    /// @param operation Operation type of Safe transaction.
    /// @param safeTxGas Gas that should be used for the Safe transaction.
    /// @param baseGas Gas costs that are independent of the transaction execution(e.g. base transaction fee, signature check, payment of the refund)
    /// @param gasPrice Gas price that should be used for the payment calculation.
    /// @param gasToken Token address (or 0 if ETH) that is used for the payment.
    /// @param refundReceiver Address of receiver of gas payment (or 0 if tx.origin).
    /// @param signatures Packed signature data ({bytes32 r}{bytes32 s}{uint8 v})
    function execTransaction(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address payable refundReceiver,
        bytes memory signatures
    ) public payable virtual returns (bool success) {
        bytes32 txHash;
        // Use scope here to limit variable lifetime and prevent `stack too deep` errors
        {
            bytes memory txHashData =
                encodeTransactionData(
                    // Transaction info
                    to,
                    value,
                    data,
                    operation,
                    safeTxGas,
                    // Payment info
                    baseGas,
                    gasPrice,
                    gasToken,
                    refundReceiver,
                    // Signature info
                    nonce
                );
            // Increase nonce and execute transaction.
            nonce++;
            txHash = keccak256(txHashData);
            checkSignatures(txHash, txHashData, signatures);
        }
        address guard = getGuard();
        {
            if (guard != address(0)) {
                Guard(guard).checkTransaction(
                    // Transaction info
                    to,
                    value,
                    data,
                    operation,
                    safeTxGas,
                    // Payment info
                    baseGas,
                    gasPrice,
                    gasToken,
                    refundReceiver,
                    // Signature info
                    signatures,
                    msg.sender
                );
            }
        }
        // We require some gas to emit the events (at least 2500) after the execution and some to perform code until the execution (500)
        // We also include the 1/64 in the check that is not send along with a call to counteract potential shortings because of EIP-150
        require(gasleft() >= ((safeTxGas * 64) / 63).max(safeTxGas + 2500) + 500, "GS010");
        // Use scope here to limit variable lifetime and prevent `stack too deep` errors
        {
            uint256 gasUsed = gasleft();
            // If the gasPrice is 0 we assume that nearly all available gas can be used (it is always more than safeTxGas)
            // We only substract 2500 (compared to the 3000 before) to ensure that the amount passed is still higher than safeTxGas
            success = execute(to, value, data, operation, gasPrice == 0 ? (gasleft() - 2500) : safeTxGas);
            gasUsed = gasUsed.sub(gasleft());
            // If no safeTxGas and no gasPrice was set (e.g. both are 0), then the internal tx is required to be successful
            // This makes it possible to use `estimateGas` without issues, as it searches for the minimum gas where the tx doesn't revert
            require(success || safeTxGas != 0 || gasPrice != 0, "GS013");
            // We transfer the calculated tx costs to the tx.origin to avoid sending it to intermediate contracts that have made calls
            uint256 payment = 0;
            if (gasPrice > 0) {
                payment = handlePayment(gasUsed, baseGas, gasPrice, gasToken, refundReceiver);
            }
            if (success) emit ExecutionSuccess(txHash, payment);
            else emit ExecutionFailure(txHash, payment);
        }
        {
            if (guard != address(0)) {
                Guard(guard).checkAfterExecution(txHash, success);
            }
        }
    }

    function handlePayment(
        uint256 gasUsed,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address payable refundReceiver
    ) private returns (uint256 payment) {
        // solhint-disable-next-line avoid-tx-origin
        address payable receiver = refundReceiver == address(0) ? payable(tx.origin) : refundReceiver;
        if (gasToken == address(0)) {
            // For ETH we will only adjust the gas price to not be higher than the actual used gas price
            payment = gasUsed.add(baseGas).mul(gasPrice < tx.gasprice ? gasPrice : tx.gasprice);
            require(receiver.send(payment), "GS011");
        } else {
            payment = gasUsed.add(baseGas).mul(gasPrice);
            require(transferToken(gasToken, receiver, payment), "GS012");
        }
    }

    /**
     * @dev Checks whether the signature provided is valid for the provided data, hash. Will revert otherwise.
     * @param dataHash Hash of the data (could be either a message hash or transaction hash)
     * @param data That should be signed (this is passed to an external validator contract)
     * @param signatures Signature data that should be verified. Can be ECDSA signature, contract signature (EIP-1271) or approved hash.
     */
    function checkSignatures(
        bytes32 dataHash,
        bytes memory data,
        bytes memory signatures
    ) public view {
        // Load threshold to avoid multiple storage loads
        uint256 _threshold = threshold;
        // Check that a threshold is set
        require(_threshold > 0, "GS001");
        checkNSignatures(dataHash, data, signatures, _threshold);
    }

    /**
     * @dev Checks whether the signature provided is valid for the provided data, hash. Will revert otherwise.
     * @param dataHash Hash of the data (could be either a message hash or transaction hash)
     * @param data That should be signed (this is passed to an external validator contract)
     * @param signatures Signature data that should be verified. Can be ECDSA signature, contract signature (EIP-1271) or approved hash.
     * @param requiredSignatures Amount of required valid signatures.
     */
    function checkNSignatures(
        bytes32 dataHash,
        bytes memory data,
        bytes memory signatures,
        uint256 requiredSignatures
    ) public view {
        // Check that the provided signature data is not too short
        require(signatures.length >= requiredSignatures.mul(65), "GS020");
        // There cannot be an owner with address 0.
        address lastOwner = address(0);
        address currentOwner;
        uint8 v;
        bytes32 r;
        bytes32 s;
        uint256 i;
        for (i = 0; i < requiredSignatures; i++) {
            (v, r, s) = signatureSplit(signatures, i);
            if (v == 0) {
                // If v is 0 then it is a contract signature
                // When handling contract signatures the address of the contract is encoded into r
                currentOwner = address(uint160(uint256(r)));

                // Check that signature data pointer (s) is not pointing inside the static part of the signatures bytes
                // This check is not completely accurate, since it is possible that more signatures than the threshold are send.
                // Here we only check that the pointer is not pointing inside the part that is being processed
                require(uint256(s) >= requiredSignatures.mul(65), "GS021");

                // Check that signature data pointer (s) is in bounds (points to the length of data -> 32 bytes)
                require(uint256(s).add(32) <= signatures.length, "GS022");

                // Check if the contract signature is in bounds: start of data is s + 32 and end is start + signature length
                uint256 contractSignatureLen;
                // solhint-disable-next-line no-inline-assembly
                assembly {
                    contractSignatureLen := mload(add(add(signatures, s), 0x20))
                }
                require(uint256(s).add(32).add(contractSignatureLen) <= signatures.length, "GS023");

                // Check signature
                bytes memory contractSignature;
                // solhint-disable-next-line no-inline-assembly
                assembly {
                    // The signature data for contract signatures is appended to the concatenated signatures and the offset is stored in s
                    contractSignature := add(add(signatures, s), 0x20)
                }
                require(ISignatureValidator(currentOwner).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "GS024");
            } else if (v == 1) {
                // If v is 1 then it is an approved hash
                // When handling approved hashes the address of the approver is encoded into r
                currentOwner = address(uint160(uint256(r)));
                // Hashes are automatically approved by the sender of the message or when they have been pre-approved via a separate transaction
                require(msg.sender == currentOwner || approvedHashes[currentOwner][dataHash] != 0, "GS025");
            } else if (v > 30) {
                // If v > 30 then default va (27,28) has been adjusted for eth_sign flow
                // To support eth_sign and similar we adjust v and hash the messageHash with the Ethereum message prefix before applying ecrecover
                currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);
            } else {
                // Default is the ecrecover flow with the provided data hash
                // Use ecrecover with the messageHash for EOA signatures
                currentOwner = ecrecover(dataHash, v, r, s);
            }
            require(currentOwner > lastOwner && owners[currentOwner] != address(0) && currentOwner != SENTINEL_OWNERS, "GS026");
            lastOwner = currentOwner;
        }
    }

    /// @dev Allows to estimate a Safe transaction.
    ///      This method is only meant for estimation purpose, therefore the call will always revert and encode the result in the revert data.
    ///      Since the `estimateGas` function includes refunds, call this method to get an estimated of the costs that are deducted from the safe with `execTransaction`
    /// @param to Destination address of Safe transaction.
    /// @param value Ether value of Safe transaction.
    /// @param data Data payload of Safe transaction.
    /// @param operation Operation type of Safe transaction.
    /// @return Estimate without refunds and overhead fees (base transaction and payload data gas costs).
    /// @notice Deprecated in favor of common/StorageAccessible.sol and will be removed in next version.
    function requiredTxGas(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation
    ) external returns (uint256) {
        uint256 startGas = gasleft();
        // We don't provide an error message here, as we use it to return the estimate
        require(execute(to, value, data, operation, gasleft()));
        uint256 requiredGas = startGas - gasleft();
        // Convert response to string and return via error message
        revert(string(abi.encodePacked(requiredGas)));
    }

    /**
     * @dev Marks a hash as approved. This can be used to validate a hash that is used by a signature.
     * @param hashToApprove The hash that should be marked as approved for signatures that are verified by this contract.
     */
    function approveHash(bytes32 hashToApprove) external {
        require(owners[msg.sender] != address(0), "GS030");
        approvedHashes[msg.sender][hashToApprove] = 1;
        emit ApproveHash(hashToApprove, msg.sender);
    }

    /// @dev Returns the chain id used by this contract.
    function getChainId() public view returns (uint256) {
        uint256 id;
        // solhint-disable-next-line no-inline-assembly
        assembly {
            id := chainid()
        }
        return id;
    }

    function domainSeparator() public view returns (bytes32) {
        return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, getChainId(), this));
    }

    /// @dev Returns the bytes that are hashed to be signed by owners.
    /// @param to Destination address.
    /// @param value Ether value.
    /// @param data Data payload.
    /// @param operation Operation type.
    /// @param safeTxGas Gas that should be used for the safe transaction.
    /// @param baseGas Gas costs for that are independent of the transaction execution(e.g. base transaction fee, signature check, payment of the refund)
    /// @param gasPrice Maximum gas price that should be used for this transaction.
    /// @param gasToken Token address (or 0 if ETH) that is used for the payment.
    /// @param refundReceiver Address of receiver of gas payment (or 0 if tx.origin).
    /// @param _nonce Transaction nonce.
    /// @return Transaction hash bytes.
    function encodeTransactionData(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address refundReceiver,
        uint256 _nonce
    ) public view returns (bytes memory) {
        bytes32 safeTxHash =
            keccak256(
                abi.encode(
                    SAFE_TX_TYPEHASH,
                    to,
                    value,
                    keccak256(data),
                    operation,
                    safeTxGas,
                    baseGas,
                    gasPrice,
                    gasToken,
                    refundReceiver,
                    _nonce
                )
            );
        return abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash);
    }

    /// @dev Returns hash to be signed by owners.
    /// @param to Destination address.
    /// @param value Ether value.
    /// @param data Data payload.
    /// @param operation Operation type.
    /// @param safeTxGas Fas that should be used for the safe transaction.
    /// @param baseGas Gas costs for data used to trigger the safe transaction.
    /// @param gasPrice Maximum gas price that should be used for this transaction.
    /// @param gasToken Token address (or 0 if ETH) that is used for the payment.
    /// @param refundReceiver Address of receiver of gas payment (or 0 if tx.origin).
    /// @param _nonce Transaction nonce.
    /// @return Transaction hash.
    function getTransactionHash(
        address to,
        uint256 value,
        bytes calldata data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address refundReceiver,
        uint256 _nonce
    ) public view returns (bytes32) {
        return keccak256(encodeTransactionData(to, value, data, operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, _nonce));
    }
}

Взята отсюда

3. Владение и управление доступом на основе ролей

Концепция:

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

Стандартная реализация этого паттерна доступна в библиотеке .


4. Динамическая авторизация с использованием секрета вне сети

Концепция:

В отличие от предварительно авторизованных групп и членов на основе ролей, могут быть ситуации, когда сторона авторизации неизвестна для данной ситуации. Динамическая привязка адреса не выполняется по умолчанию, их необходимо авторизовать при первом развертывании транзакции/контракта, поскольку обычно списки контроля доступа предопределены.
В такой ситуации мы можем сгенерировать секрет вне сети со стороны клиента для выделенной функции. Этот секрет будет хеширован (SHA-256) и зарегистрирован в контракте. Будет выполнена рабочая привязка к секрету, так что любой пользователь, который отправит транзакцию вместе с секретом в качестве параметра, будет авторизован. Эти секреты могут использоваться совместно со стандартным протоколом обмена ключами с предполагаемыми адресами.
Хэшлоки полезны для каналов состояния платежей, условного депонирования атомарных свопов. Кроме того, секреты лучше всего подходят для одной транзакции, так как проверка в цепочке раскроет их.
Это простая секретная регистрация, которую можно использовать вместе с любыми хэшлоками. Секрет передается в контракт для раскрытия и ссылки. Для событий временной блокировки можно сравнить блок раскрытия и блок истечения срока действия.

Код:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
 
 
contract SecretRegistry {
   mapping(bytes32 => uint256) private secretToBlock;
 
 
   event SecretRevealed(bytes32 indexed secrethash, bytes32 secret);
 
   /**
    * @dev Register the secret that's been used for validation
    */
   function registerSecret(bytes32 secret) public returns (bool) {
       bytes32 secrethash = sha256(abi.encodePacked(secret));
       if (secretToBlock[secrethash] > 0) {
           return false;
       }
       secretToBlock[secrethash] = block.number;
       emit SecretRevealed(secrethash, secret);
       return true;
   }
 
 
   function getRevealedSecretBlockHeight(bytes32 secrethash) public view returns (uint256) {
       return secretToBlock[secrethash];
   }
 
}

Авторизация в сети с помощью ролей и управления собственностью стала необходимостью для управления активами на основе контрактов. Кроме того, будьте предельно осторожны с секретами вне сети и тем, как вы ими делитесь. Попробуйте использовать один из этих шаблонов в своем следующем децентрализованном приложении

image
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх