Привет, незнакомец!

Похоже, вы здесь новенький. Чтобы принять участие, нажмите одну из кнопок ниже!

Смарт-контракт на Solidity: Частина 10 — ICO, вихідники на Etherscan, типи емісіїї

отредактировано December 2017 Раздел: Смарт Контракты

Серія уроків була взята з сайту inaword.ru

У попередній статті ми навчилися додавати бонуси та закінчили писати контракти для проведення ICO. У цій статті ми зачепимо певні тонкощі реалізації та роботи зі смарт-контрактами ICO. А саме:
1. Дізнаємося як додати вихідні дані на etherscan
2. Ознайомимося з типами емісій токенів і BurnableToken
3. Ознайомимося з архітектурою з безліччю етапів розпродажу

Як додати вихідні дані на Etherscan

Якщо ви подивитеся на інші контракти в etherscan, то можна помітити, що у багатьох наявний вихідний код і вкладка «Read Smart Contract». На цій вкладці зручно представлений інтерфейс.

Якщо ж ви подивитеся на свій щойно залитий контракт, то побачите лише дві вкладки.

При цьому на вкладці «Contract code» нічого окрім байткоду не буде. Давайте навчимося робити інтерфейс для нашого контракту в etherscan.

  1. Відкрийте etherscan та знайдіть за адресою свій контракт
  2. Натисніть на вкладку «Contract code»


3. У поле «Contract Name» впишіть ім’я вашого контракту. В поле Compiler версію, якою компілювали контракт під час заливки в блокчейн (саме версію компілятора, а не ту версію, яка у вас зверху контракту «pragma solidity 0.4….». ). А в полі Optimization оберіть, з оптимізацією компілювали чи ні. Якщо не пам’ятаєте, то за-замовчуванням оптимізація вимкнена в Parity і remix. В Mist оптимізація включена. Версія компілятора в remix зазвичай остання, як і в parity (на момент написання статті). Mist на момент написання статті підтримував не останню версію 0.4.13.


4. У поле «Enter the Solidity Contract Code» вставте весь код смарт-контракту, з усіма залежностями. Та натисніть кнопку знизу «Verify And Publish» .

5. Якщо версії компілятора і оптимізація зазначені правильно, то отримаєте повідомлення про виконання. В якому натисніть посилання біля зеленого напису. Якщо отримаєте повідомлення про помилку, то найімовірніше не правильно зазначений компілятор.

Тепер на вкладці «Contract code» відображається код нашого контракту і заодно ABI інтерфейс.

А на вкладці «Read Smart Contract» відображається інтерфейс для читання смарт-контракту.

У цьому випадку я залив контракт нашого токену. Однак, як ви бачите, токен відображається як звичайний смарт-контракт. Для того, щоб відобразити наш контракт саме як токен ERC20 потрібно в URL в браузері змінити «address» на «token». В моєму випадку був URL:

https://ropsten.etherscan.io/address/0x275f215a3bfc4699e7278f4e521c0ed43d6011aa#readContract

Після заміни отримуємо:

https://ropsten.etherscan.io/token/0x275f215a3bfc4699e7278f4e521c0ed43d6011aa#readContract

У вашому випадку слово ropsten в URL може бути відсутнім. Ropsten — це просто тестова мережа, але про це пізніше. Після заміни ми побачимо, що etherscan відобразить наш контракт як токен ERC20.

Тепер з’явилася вкладка «Token holders». На ній будуть відображатися адреси власників наших токенів і процент володіння від загальної суми токенів. Також з’явилась «Token transaction», на якій будуть відображатися всі події Transfer.

Типи емісії токенів і BurnableToken

У наших попередніх контрактах емісія токенів здійснювалася в момент, коли інвестор вкладав гроші. Однак, є також інший тип емісії. Коли всі токени випускають обмеженою кількістю під час створення контракту. А в наслідок переміщуються на рахунок інвестора тоді, коли відбувається оплата.

Життєвий цикл контракту такого токену:
1. Під час створення контракту: на адресу власника контракту записуються всі токени.
2. Розпродаж токенів: з адреси власника контракту списуються токени і записуються на адресу інвесторів. Під час кожної покупки виникає подія Transfer.
3. Розподіл токенів засновникам і на баунті: Ну тут все зрозуміло.
4. Спалювання нерозпроданих токенів: токени, які не були розпродані підлягають знищенню або «спалюванню». Під час спалювання токенів генерується подія Burn.

Навіщо спалювати токени? Річ у тому, що за решту токенів ніхто не заплатив. Є куплені інвесторами токени, є процент токенів на баунті, є процент засновникам. І є токени, які ніхто не купив. Якщо ми їх залишаємо на балансі власника контракту, то фактично залишаємо можливість випуску цих токенів на біржу. Тим самим ми розмиваємо ціну. А це не дуже приємно для інвестора. Дуже важливо вказувати у WhitePaper той факт, що ви зобов’язані нерозпродані токени спалити! А також вкажіть коли саме ви це зробите! Наприклад, у чаті проекту Polybius користувачі часто цікавились моментом спалювання нерозпроданих токенів.

Що потрібно, щоб зробити токени з обмеженою початковою емісією? Для цього досить зробити дві речі:

1.В контракті токену ініціалізувати баланс власника контракту усіма токенами і не забути прописати відповідне число токенів в totalSupply. Можна зробити так:

`contract SimpleCoinToken() is ... {
            ...
            uint256 public INITIAL_SUPPLY = 100000000 * 1 ether;
            ...
            function SimpleCointToken() {
              totalSupply = INITIAL_SUPPLY;
              balances[msg.sender] = INITIAL_SUPPLY;
            }
            ...
}`

Пам’ятаємо, що кількість токенів вказується з урахуванням знаків після коми. Наприклад, в нашому токені 18 знаків після коми. Тому ми множимо на 1 ether (1 ether = 1000000000000000000 wei).

  1. Зробити можливість знищувати токени. Краще скористатися готовим рішенням. Наприклад, від OpenZepplein. І успадковуватися від BurnableToken нам не потрібно, тому його прибираємо. Давайте поглянемо на реалізацію BurnableToken.

     contract BurnableToken is StandardToken {
    
          function burn(uint _value) public {
            require(_value > 0);
            address burner = msg.sender;
            balances[burner] = balances[burner].sub(_value);
            totalSupply = totalSupply.sub(_value);
            Burn(burner, _value);
          }
    
          event Burn(address indexed burner, uint indexed value);
    
     }
    

До контракту токену додається функція Burn. Вона і надає можливість спалювати токени зі свого і тільки свого балансу тому, хто викликає цю функцію. Якщо користувач намагається спалити більше токенів, ніж треба, то бібліотека безпечних математичних операцій, зокрема функція sub, не дозволить це зробити.

Фактично, діаграма успадкування для токену з обмеженою емісією за структурою лишається такою, як і раніше. Тільки замість MintableToken ми успадковуємо від BurnableToken.

Тепер повна реалізація нашого токену виглядає так:

pragma solidity ^0.4.16;

/**
 * @title ERC20Basic
 * @dev Simpler version of ERC20 interface
 * @dev see https://github.com/ethereum/EIPs/issues/179
 */
contract ERC20Basic {
  uint256 public totalSupply;
  function balanceOf(address who) constant returns (uint256);
  function transfer(address to, uint256 value) returns (bool);
  event Transfer(address indexed from, address indexed to, uint256 value);
}

/**
 * @title ERC20 interface
 * @dev see https://github.com/ethereum/EIPs/issues/20
 */
contract ERC20 is ERC20Basic {
  function allowance(address owner, address spender) constant returns (uint256);
  function transferFrom(address from, address to, uint256 value) returns (bool);
  function approve(address spender, uint256 value) returns (bool);
  event Approval(address indexed owner, address indexed spender, uint256 value);
}

/**
 * @title SafeMath
 * @dev Math operations with safety checks that throw on error
 */
library SafeMath {

  function mul(uint256 a, uint256 b) internal constant returns (uint256) {
    uint256 c = a * b;
    assert(a == 0 || c / a == b);
    return c;
  }

  function div(uint256 a, uint256 b) internal constant returns (uint256) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint256 c = a / b;
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }

  function sub(uint256 a, uint256 b) internal constant returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal constant returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }

}

/**
 * @title Basic token
 * @dev Basic version of StandardToken, with no allowances. 
 */
contract BasicToken is ERC20Basic {

  using SafeMath for uint256;

  mapping(address => uint256) balances;

  /**
  * @dev transfer token for a specified address
  * @param _to The address to transfer to.
  * @param _value The amount to be transferred.
  */
  function transfer(address _to, uint256 _value) returns (bool) {
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    Transfer(msg.sender, _to, _value);
    return true;
  }

  /**
  * @dev Gets the balance of the specified address.
  * @param _owner The address to query the the balance of. 
  * @return An uint256 representing the amount owned by the passed address.
  */
  function balanceOf(address _owner) constant returns (uint256 balance) {
    return balances[_owner];
  }

}

/**
 * @title Standard ERC20 token
 *
 * @dev Implementation of the basic standard token.
 * @dev https://github.com/ethereum/EIPs/issues/20
 * @dev Based on code by FirstBlood: https://github.com/Firstbloodio/token/blob/master/smart_contract/FirstBloodToken.sol
 */
contract StandardToken is ERC20, BasicToken {

  mapping (address => mapping (address => uint256)) allowed;

  /**
   * @dev Transfer tokens from one address to another
   * @param _from address The address which you want to send tokens from
   * @param _to address The address which you want to transfer to
   * @param _value uint256 the amout of tokens to be transfered
   */
  function transferFrom(address _from, address _to, uint256 _value) returns (bool) {
    var _allowance = allowed[_from][msg.sender];

    // Check is not needed because sub(_allowance, _value) will already throw if this condition is not met
    // require (_value <= _allowance);

    balances[_to] = balances[_to].add(_value);
    balances[_from] = balances[_from].sub(_value);
    allowed[_from][msg.sender] = _allowance.sub(_value);
    Transfer(_from, _to, _value);
    return true;
  }

  /**
   * @dev Aprove the passed address to spend the specified amount of tokens on behalf of msg.sender.
   * @param _spender The address which will spend the funds.
   * @param _value The amount of tokens to be spent.
   */
  function approve(address _spender, uint256 _value) returns (bool) {

    // To change the approve amount you first have to reduce the addresses`
    //  allowance to zero by calling `approve(_spender, 0)` if it is not
    //  already 0 to mitigate the race condition described here:
    //  https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
    require((_value == 0) || (allowed[msg.sender][_spender] == 0));

    allowed[msg.sender][_spender] = _value;
    Approval(msg.sender, _spender, _value);
    return true;
  }

  /**
   * @dev Function to check the amount of tokens that an owner allowed to a spender.
   * @param _owner address The address which owns the funds.
   * @param _spender address The address which will spend the funds.
   * @return A uint256 specifing the amount of tokens still available for the spender.
   */
  function allowance(address _owner, address _spender) constant returns (uint256 remaining) {
    return allowed[_owner][_spender];
  }

}

/**
 * @title Ownable
 * @dev The Ownable contract has an owner address, and provides basic authorization control
 * functions, this simplifies the implementation of "user permissions".
 */
contract Ownable {

  address public owner;

  /**
   * @dev The Ownable constructor sets the original `owner` of the contract to the sender
   * account.
   */
  function Ownable() {
    owner = msg.sender;
  }

  /**
   * @dev Throws if called by any account other than the owner.
   */
  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }

  /**
   * @dev Allows the current owner to transfer control of the contract to a newOwner.
   * @param newOwner The address to transfer ownership to.
   */
  function transferOwnership(address newOwner) onlyOwner {
    require(newOwner != address(0));      
    owner = newOwner;
  }

}

/**
 * @title Burnable Token
 * @dev Token that can be irreversibly burned (destroyed).
 */
contract BurnableToken is StandardToken {

  /**
   * @dev Burns a specific amount of tokens.
   * @param _value The amount of token to be burned.
   */
  function burn(uint _value) public {
    require(_value > 0);
    address burner = msg.sender;
    balances[burner] = balances[burner].sub(_value);
    totalSupply = totalSupply.sub(_value);
    Burn(burner, _value);
  }

  event Burn(address indexed burner, uint indexed value);

}

contract SimpleCoinToken is BurnableToken {

  string public constant name = "Simple Coin Token";

  string public constant symbol = "SCT";

  uint32 public constant decimals = 18;

  uint256 public INITIAL_SUPPLY = 100000000 * 1 ether;

  function SimpleCoinToken() {
    totalSupply = INITIAL_SUPPLY;
    balances[msg.sender] = INITIAL_SUPPLY;
  }

}

А тепер виправимо наш контракт Crowdsale. Раніше, коли інвестор купував токени, ми викликали mint у контракту токену. Тепер нам досить викликати transfer і перемістити токени з рахунку власника контракту токену на рахунок інвестора. Для цього у функції createTokens досить замінити останній рядок

token.mint(msg.sender, tokensWithBonus);
на
token.transfer(msg.sender, tokensWithBonus);

Функцію finishMinting так просто виправити не вийде. Раніше вона виконувала два завдання:
1. Рахувала процент токенів для засновників і баунті після закінчення розпродажу. Причому, процент рахувався від totalSupply. Раніше totalSupply — кількість випущених токенів відповідала кількості куплених. Тепер це не так. А рахувати нам треба процент саме від куплених, інакше вийде розмиття ціни токену.
2. Забороняла будь-яку емісію після закінчення розпродажу
token.finishMinting();

Щоб процент токенів засновникам рахувався коректно — ми будемо рахувати його безпосередньо під час купівлі токенів інвесторами і одразу перераховувати на рахунок засновників. Для цього наприкінці createTokens додамо два рядки.

uint restrictedTokens = tokens.mul(restrictedPercent).div(100 - restrictedPercent);
token.transfer(restricted, restrictedTokens);

Оскільки токени у нас випускаються лише один раз і більше можливості випуску немає, то забороняти емісію немає сенсу в кінці розпродажу. Тому функцію finishMinting тепер можна просто прибрати.

Також з контракту розпродажу можна сміливо прибрати обмеження isUnderHardcap. Як тільки всі токени закінчяться, функція покупки перестане працювати і все. Давайте тепер подивимось як виглядає весь код нашого ICO.

pragma solidity ^0.4.16;

/**
 * @title ERC20Basic
 * @dev Simpler version of ERC20 interface
 * @dev see https://github.com/ethereum/EIPs/issues/179
 */
contract ERC20Basic {
  uint256 public totalSupply;
  function balanceOf(address who) constant returns (uint256);
  function transfer(address to, uint256 value) returns (bool);
  event Transfer(address indexed from, address indexed to, uint256 value);
}

/**
 * @title ERC20 interface
 * @dev see https://github.com/ethereum/EIPs/issues/20
 */
contract ERC20 is ERC20Basic {
  function allowance(address owner, address spender) constant returns (uint256);
  function transferFrom(address from, address to, uint256 value) returns (bool);
  function approve(address spender, uint256 value) returns (bool);
  event Approval(address indexed owner, address indexed spender, uint256 value);
}

/**
 * @title SafeMath
 * @dev Math operations with safety checks that throw on error
 */
library SafeMath {

  function mul(uint256 a, uint256 b) internal constant returns (uint256) {
    uint256 c = a * b;
    assert(a == 0 || c / a == b);
    return c;
  }

  function div(uint256 a, uint256 b) internal constant returns (uint256) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint256 c = a / b;
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }

  function sub(uint256 a, uint256 b) internal constant returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal constant returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }

}

/**
 * @title Basic token
 * @dev Basic version of StandardToken, with no allowances. 
 */
contract BasicToken is ERC20Basic {

  using SafeMath for uint256;

  mapping(address => uint256) balances;

  /**
  * @dev transfer token for a specified address
  * @param _to The address to transfer to.
  * @param _value The amount to be transferred.
  */
  function transfer(address _to, uint256 _value) returns (bool) {
    balances[msg.sender] = balances[msg.sender].sub(_value);
    balances[_to] = balances[_to].add(_value);
    Transfer(msg.sender, _to, _value);
    return true;
  }

  /**
  * @dev Gets the balance of the specified address.
  * @param _owner The address to query the the balance of. 
  * @return An uint256 representing the amount owned by the passed address.
  */
  function balanceOf(address _owner) constant returns (uint256 balance) {
    return balances[_owner];
  }

}

/**
 * @title Standard ERC20 token
 *
 * @dev Implementation of the basic standard token.
 * @dev https://github.com/ethereum/EIPs/issues/20
 * @dev Based on code by FirstBlood: https://github.com/Firstbloodio/token/blob/master/smart_contract/FirstBloodToken.sol
 */
contract StandardToken is ERC20, BasicToken {

  mapping (address => mapping (address => uint256)) allowed;

  /**
   * @dev Transfer tokens from one address to another
   * @param _from address The address which you want to send tokens from
   * @param _to address The address which you want to transfer to
   * @param _value uint256 the amout of tokens to be transfered
   */
  function transferFrom(address _from, address _to, uint256 _value) returns (bool) {
    var _allowance = allowed[_from][msg.sender];

    // Check is not needed because sub(_allowance, _value) will already throw if this condition is not met
    // require (_value <= _allowance);

    balances[_to] = balances[_to].add(_value);
    balances[_from] = balances[_from].sub(_value);
    allowed[_from][msg.sender] = _allowance.sub(_value);
    Transfer(_from, _to, _value);
    return true;
  }

  /**
   * @dev Aprove the passed address to spend the specified amount of tokens on behalf of msg.sender.
   * @param _spender The address which will spend the funds.
   * @param _value The amount of tokens to be spent.
   */
  function approve(address _spender, uint256 _value) returns (bool) {

    // To change the approve amount you first have to reduce the addresses`
    //  allowance to zero by calling `approve(_spender, 0)` if it is not
    //  already 0 to mitigate the race condition described here:
    //  https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
    require((_value == 0) || (allowed[msg.sender][_spender] == 0));

    allowed[msg.sender][_spender] = _value;
    Approval(msg.sender, _spender, _value);
    return true;
  }

  /**
   * @dev Function to check the amount of tokens that an owner allowed to a spender.
   * @param _owner address The address which owns the funds.
   * @param _spender address The address which will spend the funds.
   * @return A uint256 specifing the amount of tokens still available for the spender.
   */
  function allowance(address _owner, address _spender) constant returns (uint256 remaining) {
    return allowed[_owner][_spender];
  }

}

/**
 * @title Ownable
 * @dev The Ownable contract has an owner address, and provides basic authorization control
 * functions, this simplifies the implementation of "user permissions".
 */
contract Ownable {

  address public owner;

  /**
   * @dev The Ownable constructor sets the original `owner` of the contract to the sender
   * account.
   */
  function Ownable() {
    owner = msg.sender;
  }

  /**
   * @dev Throws if called by any account other than the owner.
   */
  modifier onlyOwner() {
    require(msg.sender == owner);
    _;
  }

  /**
   * @dev Allows the current owner to transfer control of the contract to a newOwner.
   * @param newOwner The address to transfer ownership to.
   */
  function transferOwnership(address newOwner) onlyOwner {
    require(newOwner != address(0));      
    owner = newOwner;
  }

}

/**
 * @title Burnable Token
 * @dev Token that can be irreversibly burned (destroyed).
 */
contract BurnableToken is StandardToken {

  /**
   * @dev Burns a specific amount of tokens.
   * @param _value The amount of token to be burned.
   */
  function burn(uint _value) public {
    require(_value > 0);
    address burner = msg.sender;
    balances[burner] = balances[burner].sub(_value);
    totalSupply = totalSupply.sub(_value);
    Burn(burner, _value);
  }

  event Burn(address indexed burner, uint indexed value);

}

contract SimpleCoinToken is BurnableToken {

  string public constant name = "Simple Coin Token";

  string public constant symbol = "SCT";

  uint32 public constant decimals = 18;

  uint256 public INITIAL_SUPPLY = 100000000 * 1 ether;

  function SimpleCoinToken() {
    totalSupply = INITIAL_SUPPLY;
    balances[msg.sender] = INITIAL_SUPPLY;
  }

}

contract Crowdsale is Ownable {

  using SafeMath for uint;

  address multisig;

  uint restrictedPercent;

  address restricted;

  SimpleCoinToken public token = new SimpleCoinToken();

  uint start;

  uint period;

  uint rate;

  function Crowdsale() {
    multisig = 0xEA15Adb66DC92a4BbCcC8Bf32fd25E2e86a2A770;
    restricted = 0xb3eD172CC64839FB0C0Aa06aa129f402e994e7De;
    restrictedPercent = 40;
    rate = 100000000000000000000;
    start = 1500379200;
    period = 28;
  }

  modifier saleIsOn() {
    require(now > start && now < start + period * 1 days);
    _;
  }

  function createTokens() saleIsOn payable {
    multisig.transfer(msg.value);
    uint tokens = rate.mul(msg.value).div(1 ether);
    uint bonusTokens = 0;
    if(now < start + (period * 1 days).div(4)) {
      bonusTokens = tokens.div(4);
    } else if(now >= start + (period * 1 days).div(4) && now < start + (period * 1 days).div(4).mul(2)) {
      bonusTokens = tokens.div(10);
    } else if(now >= start + (period * 1 days).div(4).mul(2) && now < start + (period * 1 days).div(4).mul(3)) {
      bonusTokens = tokens.div(20);
    }
    uint tokensWithBonus = tokens.add(bonusTokens);
    token.transfer(msg.sender, tokensWithBonus);
    uint restrictedTokens = tokens.mul(restrictedPercent).div(100 - restrictedPercent);
    token.transfer(restricted, restrictedTokens);
  }

  function() external payable {
    createTokens();
  }

}

Під час тестування контракту не забувайте змінювати дату початку розпродажу.

Між токенами з емісією під час розпродажу і з обмеженою емісією спочатку є мала відмінність для відображення в etherscan.

Річ у тому, що etherscan вираховує власників токенів і їх частки лише на основі події Transfer. В контракті токену з початковою емісією всі токени вже випущені. Коли інвестор робить покупку, токени просто перераховуються на рахунок інвестора з допомогою функції transfer. A transfer генерує подію Transfer. Тому частка інвестора вираховується відразу коректно відносно суми всіх випущених токенів. Однак, подія знищення токенів Burn не сприймається etherscan. Тому частки залишаються розрахованими відносно початкового числа токенів.

У тому випадку, коли емісія відбувається під час покупки, виникає подія Mint, а не Transfer. Тому etherscan не зможе вираховувати і відображати частки власників токенів коректно. Так буде до тих пір, поки всі власники не виконають хоча б одне переміщення токенів.

Однак, в токені з емісією під час розпродажу проблему можна розв’язати. Потрібно не одразу випускати токени на рахунок інвестора. Потрібно спочатку робити емісію на рахунок власника контракту . І тільки після цього переміщувати токени з рахунку власника контракту на рахунок інвестора (спробуйте це зробити самостійно). У такому випаду всі частки будуть відображатися в etherscan коректно. У випадку з токеном з початковою емісією, розв’язання цієї проблеми немає.

Часто трапляються ICO, в яких вказується початково випущена кількість токенів з аргументом: «інвестори хочуть знати скільки токенів випущено загалом». Насправді, якщо нерозпродані токени будуть спалюватись (а спалюватись вони повинні, якщо ви не обманюєте інвесторів), то кінцева кількість токенів нікому не відома. Тому я завжди рекомендую обирати реалізацію з випуском токенів під час розпродажу. Але вибір, звісно, за вами.

Архітектура з безліччю етапів розпродажу

Крім основного розпродажу може бути попередній етап продажу токенів. Так званий pre sale. Якщо етапів розпродажу токенів може бути багато, то створюють один контракт токену. А на кожний розпродаж свій роблять окремий контракт. При цьому контракт токену має поле saleAgent. В цьому полі зберігається адреса того контракту розпродажу, який в цей момент має право емітувати або продавати токени. Встановлювати це поле може лише власник контракту токену. А емітувати токени може тільки той контракт, який вказаний в saleAgent.

Продовження читати тут.

Войдите или Зарегистрируйтесь чтобы комментировать.