Skip to main content

Kurs Web 3 wprowadzenie

Kurs Web3, jesteś gotów? W jednym z naszych poprzednich artykułów opisaliśmy pojęcie Web3, czyli trzecią generację stron internetowych. Jeśli jeszcze nie zapoznałeś się z tym terminem, sugerujemy przeczytanie powyższego artykułu przed kontynuowaniem.

Zanim pobrudzimy sobie ręce kodem, szybki zarys nad czym będziemy pracować. Aplikacja „Wiadomość” służy do wyświetlania dowolnej wiadomości na naszej stronie internetowej. Problem polega na tym, że wiadomość może zostać dodana przez dowolnego użytkownika, tylko wtedy, gdy zapłaci on więcej (w kryptowalucie Ether), niż autor poprzedniej wiadomości.

Przykład:

  1. Stara wiadomość (niewidoczna na stronie): Hello world, koszt: 1 ETH,
  2. Nowa wiadomość (widoczna): Sprzedam opla, koszt: 5 ETH

Z racji tego, że nie jesteśmy chytrzy, autor wiadomości, która nie jest już wyświetlana na stronie, może zarządać zwrot środków które przeznaczył na jej „zakup” – pomniejszone o prowizję, która zasili nasze konto (na czymś jednak musimy zarobić).

Artykuł został podzielony na dwie części. Aktualną cześć „backendową”, gdzie zajmiemy się pisaniem serca aplikacji, czyli smart kontraktu. Oraz część „frontendową” – stronę internetową prezentującą aktualną wiadomość i dającą możliwość stworzenia nowej.

Web3 i stos technologiczny

  • Ethereum – Sieć blockchain w której będzie żył nasz smart kontrakt
  • Solidity – Język programowania smart kontraktów w sieci Ethereum
  • TypeScript – Język programowania oparty na JavaScript
  • Hardhat – Środowisko programistyczne dla Ethereum
  • Ethers.js – Biblioteka komunikująca się z blockchainem Ethereum
  • Mocha – Biblioteka do pisania testów w JavaScript (nasz smart kontrakt będzie pokryty testami!)
  • Chai – Biblioteka asercji testowych
  • Waffle – Biblioteka asercji do testowania smart kontraktów

Cały kod który będziemy pisać jest dostępny w naszym repozytorium GitHub. Jeśli nie masz ochoty przechodzić krok po kroku przez proces tworzenia aplikacji, wystarczy sklonować repo i przeczytać pliki README.md:

git clone https://github.com/Chainkraft/message.git

Wymagania

Przed przystąpieniem do dalszych kroków upewnij się, że masz poniższe rzeczy.

Node.js

Posiadasz zainstalowane środowisko uruchomieniowe Node.js w wersji co najmniej 16.0. Można to zrobić za pomocą linii poleceń:

node -v

Jeśli nie wiesz co to Node.js – w wielkim skrócie jest to środowisko, które uruchamia programy napisane w języku JavaScript poza przeglądarką internetową.

Portfel Ethereum

Konto Ethereum jest niezbędne, aby wysyłać i otrzymywać transakcje w sieci. Najpopularniejszym wyborem jest skorzystanie z portfela MetaMask, który działa jako dodatek do przeglądarki internetowej. Możesz go zainstalować z oficjalnej strony, a następnie przejść kreator.

Jeśli masz już konto MetaMask, należy przełączyć się na testową sieć Goerli Test Network w prawym górnym rogu. Jeśli nie widzisz sieci testowych, należy wejść do ustawień MetaMask i włączyć opcję „Show test networks” w zakładce „Advanced”.

Przykładowe konto Ethereum z aktywną siecią testową Goerli

Testowe tokeny

Aby opublikować nasz smart kontrakt do sieci Goerli będziemy potrzebowali tokenów testowych GTH (nie mylić z tokenami ETH). Możemy je otrzymać z tzw. faucets. Przykładowy faucet, gdzie po podaniu adresu naszego portfela otrzymamy testowe tokeny z sieci Goerli. Po chwili przesłane tokeny powinny być widoczne w portfelu MetaMask.

Konto deweloperskie Alchemy

Wchodząc w interakcję z siecią blockchain potrzebujemy API, które umożliwi komunikację. Jednym z wygodniejszych sposobów jest skorzystanie z platformy deweloperskiej dla Ethereum Alchemy.com. Platforma utrzymuje za nas nody blockchaina i wystawia API do komunikacji z nimi. Dodatkowo otrzymujemy narzędzie do monitorowania oraz analityki.

Na stronie Alchemy.com zakładamy darmowe konto i tworzymy nowy projekt. Ważne, żeby podczas tworzenia projektu wybrać sieć testową Goerli.

Po przejściu do projektu klikamy przycisk VIEW KEY. Będziemy potrzebować dwóch pierwszych wartości.

Po stworzniu aplikacji mamy dostęp do API KEY oraz API URL (HTTP), który będzie nam potrzebny

Konfiguracja środowiska

Tworzenie środowiska rozpoczniemy od stworzenia folderu projektu message oraz inicjalizacji pliku package.json.

mkdir message
cd message
npm init --yes

dotenv

Dotenv to biblioteka, które wczytuje zmienne środowiskowe z lokalnego pliku .env i ładuje je do process.env (miejsce w którym Node.js przechowuje zmienne środowiskowe).

npm install dotenv --save

Komenda npm (Node Package Manager) instaluje wybrane zależności z repozytorium kodu NPM (zawiera ponad milion bibliotek) do lokalnego folderu node_modules i dodaje wpis do pliku package.json.

Tworzymy plik .env przechowujący nasze zmienne środowiskowe i uzupełniamy wartości odpowiednio:

  • GOERLI_API_KEY: Klucz API z aplikacji na platformie Alchemy (patrz screen z sekcji Konto deweloperskie Alchemy).
  • GOERLI_API_URL: Adres HTTP wraz z kluczem z aplikacji na platformie Alchemy.
  • PRIVATE_KEY: Kluczy prywatny naszego portfela z aplikacji MetaMask (instrukcja).
  • CONTRACT_ADDRESS: Po publikacji smart kontraktu do sieci Goerli, wpiszemy tutaj jego adres.
GOERLI_API_KEY = "<secret>"
GOERLI_API_URL = "<secret>"
PRIVATE_KEY = "<secret>"
CONTRACT_ADDRESS = "<secret>"

TypeScript

Moglibyśmy pominąć ten krok i używać wyłącznie czystego JavaScriptu. Jednak dzięki wykorzystaniu języka programowania TypeScript zachowamy spójność z kodem aplikacji frontendowej pisanej w React. Dodatkowym benefitem będzie wykorzystanie statycznego typowania. Oprócz wsparcia dla TypeScript dodajemy również pliki z definicjami dla już zainstalowanych bibliotek.

npm install --save-dev ts-node typescript \
typechain @typechain/hardhat @typechain/ethers-v5

Tworzymy plik konfiguracyjny:

touch tsconfig.json

Dodajemy prostą konfigurację do utworzonego pliku tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "CommonJS",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist"
  },
  "include": ["./scripts", "./test", "./typechain-types"],
  "files": ["./hardhat.config.ts"]
}

Opis poszczególnych pól dostępny jest w dokumentacji.

Ethers.js, Waffle, Chai

Krótki opis bibliotek oraz linki znajdziesz wyżej w sekcji stos technologiczny.

npm install --save-dev @nomiclabs/hardhat-ethers ethers \
@nomiclabs/hardhat-waffle ethereum-waffle chai \
@types/node @types/mocha @types/chai

Hardhat

Hardhat to środowisko deweloperskie które kompiluje, publikuje, testuje oraz debuguje smart kontrakty w sieci Ethereum. W skrócie – ułatwi nam życie.

npm install --save-dev hardhat

Rozpoczynamy konfigurację Hardhat:

npx hardhat

Komenda npx (Node Package Execute) w przeciwieństwie do komendy npm, nie instaluje zależności, tylko odpala program prosto ze zdalnego repozytorium. Po wykonaniu komendy zobaczymy menu z opcjami wyboru:

Welcome to Hardhat v2.9.6

? What do you want to do? … 
  Create a basic sample project
  Create an advanced sample project
  Create an advanced sample project that uses TypeScript
▸ Create an empty hardhat.config.js

Wybieramy ostatnią opcję Create an empty hardhat.config.js, który utworzy nam w głównym folderze plik konfiguracyjny hardhat.config.js.

Jeśli wybralibyśmy opcję najbardziej zaawansowaną Create an advanced sample project that uses TypeScript to otrzymamy najbogatszą wersję konfiguracyjną z bibliotekami które dodaliśmy ręcznie oraz kilka dodatkowych bonusów. Nasza opcja jest uboga celowo, aby więcej zrobić i zrozumieć samemu.

Zmieniamy rozszerzenie pliku hardhat.config.js na hardhat.config.ts oraz modyfikujemy zawartość:

// Importuje bibliotekę i dodaje nasze zmienne środowiskowe z pliku .env
import 'dotenv/config'

// Importuje bibliotekę która generuje typowania dla naszego smart kontraktu podczas kompilacji
// Dzięki temu dostajemy wsparcie typów dla kontraktu Solidity
import '@typechain/hardhat'

// Importuje plugin integrujący bibliotekę testową oraz pośrednio ethers.js
import '@nomiclabs/hardhat-waffle'

// Importuje definicje typów dla poniższego obiektu konfiguracyjnego
import {HardhatUserConfig} from "hardhat/types";

const config: HardhatUserConfig = {
    solidity: "0.8.0",
    networks: {
        // Konfiguracja sieci testowej Goerli
        goerli: {
            // Adres API (node) dającego dostęp do sieci Goerli
            url: process.env.GOERLI_API_URL,

            // Klucz prywatny portfela do publikacji smart kontraktu
            accounts: [`${process.env.PRIVATE_KEY}`]
        }
    }
};

export default config;

Nasz smart kontrakt będziemy finalnie publikować do testowej sieci testnet Goerli. Jest to idealnie rozwiązanie dla deweloperów pracujących nad aplikacjami, aby za darmo przetestować kod przed publikacją w mainnet Ethereum.

Tworzenie smart kontraktu

Tworzymy folder contracts, a w nim plik Message.sol zawierający kod Solidity:

/**
 * Informacja z jaką wersją kompilatora Solidity działa nasz kod.
 * W tym przypadku wyłącznie z kompilatorem od wersji 0.8.0, do wersji 0.8.9 (warunek za sprawą ^).
 */
pragma solidity ^0.8.0;

/**
 * Smart kontrakty w Solidity są podobne do klas w językach obiektowych.
 * Mamy w nich zmienne stanowe (state variables), których wartości zapisywane są do blockchain oraz funkcje, które mogą je modyfikować.
 */
contract Message {

    /**
     * Zmienna stanowa typu address (20 bajtów) reprezentująca adres portfela.
     * W zmiennej będziemy przechowywać właściciela smart kontraktu, który ma prawo do wypłacania prowizji zarobionych na wiadomościach.
     */
    address public owner;

    /**
      * Modyfikator dostępu public tworzy automatycznie funkcję owner(), która zwraca wartość zmiennej dla wywołań z innych smart kontraktów. 
      * Zmienne publiczne są dziedziczone.
      * Zmienna przechowuje adres który jest właścicielem obecnie wyświetlanej wiadomości.
      */
    address public author;

    /**
      * Typ uint (zamiennie uint256) to 256 bitowa liczba całkowita bez znaku.
      * Zmienna jest oznaczona jako stała i nie można edytować jej wartości po kompilacji.
      * Stałe są dużo tańsze pod względem gas fee niż zmienne stanowe, ponieważ wartość zapisywana jest bezpośrednio do kodu smart kontraktu. 
      * Opłata za wiadomość jest mierzona jako część ze zmiennej feeParts (feeRate/feeParts).
      * Przykład: 20/10000 = 0.2%
      */
    uint public constant feeParts = 10000;

    /**
      * Prowizja za dodanie wiadomości.
      * Razem ze zmienną feeParts jest wykorzystana do obliczania % z wartości transakcji.
      */
    uint public feeRate;

    /*
     * Zmienna przechowuje ilość tokenów Ether, które zostały wysłane razem z aktualnie wyświetlaną wiadomością.
     * Jest to cena jaką zapłacił autor wiadomości i od której obliczymy prowizję.
     */
    uint public price;

    /*
     * Typ string to dynamiczna tablica bajtów która przechowuje znaki zakodowane w formacie UTF-8. 
     * Uwaga, im dłuższa będzie wiadomość tym wyższy będzie koszt zapisania zmiennej do sieci (gas fee).
     * Zmienna przechowuje aktualnie wyświetlaną wiadomość.
     */
    string public message;

    /*
     * Typ mapping możemy porównać do hash mapy. Klucze są typu address, natomiast wartości mają typ uint.
     * Modyfikator dostępu internal jest domyślnie ustawiany. 
     * Zmienna jest dostępna wyłącznie w aktualnym smart kontrakcie oraz kontraktach dziedziczących.
     * Zmienna mapuje adresy portfel na stan ich salda do wypłaty.
     */
    mapping(address => uint) internal funds;

    /* Event to specjalna konstrukcja służąca do logowania zdarzeń.
     * Emitowane zdarzenia mogą być monitorowane przez zewnętrzne aplikacje, które na ich podstawie podejmują działanie.
     * Logi (nazwa zdarzenia oraz parametry) przechowywane są w wydzielonym miejscu w blockchainie, przypisane do adresu smart kontraktu.
     * Poniższe eventy będą monitorowane przez aplikację frontendową, która je przetworzy i wyświetli w odpowiedniej formie.
     */

    /* Poniższy event informuje o zmianie wysokości prowizji. Logowana jest stara stawka oraz nowa. */
    event FeeRateSet(uint oldFeeRate, uint newFeeRate);
    event MessageSet(string message, address indexed author, uint price);
    event OwnershipTransferred(address oldOwner, address newOwner);
    event Withdrawal(address recipient, uint amount, uint fee);

    /**
     * Modifier to konstrukcja, która zmienia działanie innych funkcji. Możemy porównać je do aspektów w języku Java.
     * Poniższy modifier umożliwia wywołanie funkcji która nim zostanie oznaczona wyłącznie przez aktualnego właściciela smart kontraktu.
     */
    modifier onlyOwner() {
        // msg.sender to adres portfela, który aktualnie komunikuje się ze smart kontraktem (poprzez transakcję w sieci)
        // Jeśli warunek nie jest spełniony, następuje przerwanie transakcji
        require(owner == msg.sender, "Only owner");
        _; // wywołuje kod właściwej funkcji
    }

    /**
     * Konstruktor jest wykonywany tylko raz, podczas publikowania kontraktu do sieci
     */
    constructor() {
        owner = msg.sender; // Zapisujemy pierwszego właściciela kontraktu
        author = msg.sender; // Publikując kontrakt stajemy się właścicielem pierwszej wiadomości
        message = "Hello world"; // Pierwsza domyślna wiadomość
    }

    /**
     * external (modyfikator dostępu) - Funkcję możemy wywołać jedynie z innego kontraktu lub poprzez transakcję
     * calldata (lokalizacja pamięci) - Niemodyfikowalne zoptymalizowane pod względem gas fee miejsce przechowywania argumentów funkcji
     * payable - Funkcja oznaczona tym modyfikatorem może otrzymywać tokeny Ether
     * Jest to serce naszego smart kontraktu. Funkcja umożliwia zmianę aktualnie wyświetlanej wiadomości
     */
    function setMessage(string calldata _message) external payable {
        // msg.value - Przechowuje ilość tokenów wysłanych transakcją
        require(msg.value > price, "Not enough ether"); // Jeśli ilość tokenów jest mniejsza od starej ceny przerywamy transakcję

        funds[author] += price; // Dodajemy środki autora zastępowanej wiadomości do stanu jego konta

        price = msg.value; // Ustawiamy nową cenę
        author = msg.sender; // Ustawiamy nowego autora
        message = _message; // Ustawiamy nową wiadomość
        emit MessageSet(message, author, price); // Emitujemy zdarzenie o ustawieniu nowej wiadomości
    }

    /**
     * view - Modyfikator informujący, że funkcja nie zmienia stanu smart kontraktu
     * Funkcja zwraca stan salda środków gotowych do wypłaty dla podanego konta  
     */
    function balanceOf(address _address) public view returns (uint) {
        return funds[_address];
    }

    /**
     * Funkcja zwraca środki już nie wyświetlanych wiadomości pomniejszone o prowizję
     * Zwróć uwagę na miejsce zerowania salda oraz miejsce wywołania funkcji call
     * Zamiana miejscami stworzyłaby błąd umożliwiający wyczyszczenie salda smart kontraktu
     */
    function withdraw() public {
        uint _funds = funds[msg.sender]; // Pobierz stan konta osoby wywołującej funkcję
        uint _fee = calculateFee(_funds); // Obliczamy prowizję
        uint _principle = _funds - _fee; // Pomniejszamy środki do zwrotu o prowizję
        funds[msg.sender] = 0; // Zerujemy stan konta
        if (feeRate > 0) {
            // Jeśli prowizja jest większa od 0, 
            // dodajemy ją do salda aktualnego właściciela kontraktu
            funds[owner] += _fee;
        }

        (bool sent,) = msg.sender.call{value : _principle}(""); // Wysyłamy tokeny Ether do wywołującego funkcję
        require(sent, "Failure! Ether not sent"); // Jeśli transfer się nie udał, przerwij działanie
        emit Withdrawal(msg.sender, _principle, _fee); // Emitujemy zdarzenie wypłaty środków
    }

    /**
     * Funkcja oznaczona jest naszym modyfikatorem onlyOwner
     * Tylko aktualny właściciel może ją wykonać
     */
    function setOwner(address _owner) public onlyOwner {
        address _oldOwner = owner; // Przypisujemy właściciela do zmiennej lokalnej
        owner = _owner; // Ustawiamy nowego właściciela
        emit OwnershipTransferred(_oldOwner, owner); // Emitujemy zdarzenie zmiany właściciela smart kontraktu
    }

    /**
     * Funkcja zmieniająca prowizję. Dostępna tylko dla właściciela smart kontraktu
     */
    function setFeeRate(uint _feeRate) public onlyOwner {
        require(_feeRate <= feeParts, "Fee rate above 100%");

        uint _oldFeeRate = feeRate;
        feeRate = _feeRate;
        emit FeeRateSet(_oldFeeRate, feeRate); // Emitujemy zdarzenie zmiany prowizji
    }

    /**
     * Wewnętrzna funkcja do obliczania prowizji z podanej wartości
     */
    function calculateFee(uint _value) internal view returns (uint) {
        if (feeRate > 0) {
            return _value * feeRate / feeParts;
        }
        return 0;
    }
}

Jeśli wszystko wykonaliśmy poprawnie nasz kontrakt powinien zostać skompilowany poleceniem:

npx hardhat compile

Testowanie smart kontraktu

Tworzymy folder test, a w nim plik Message.test.ts zawierający poniższe testy:

import {ethers} from "hardhat";
import {expect} from "chai";
import {SignerWithAddress} from "@nomiclabs/hardhat-ethers/signers";
import {Message, Message__factory} from "../typechain-types";

describe("Message contract", () => {

    let contractFactory: Message__factory;
    let contract: Message;
    let owner: SignerWithAddress;
    let addr1: SignerWithAddress;
    let addr2: SignerWithAddress;

    // before wykona się raz przed wszystkimi testami
    before(async () => {
        // Instancja fabryki będzie tworzyła nasze smart kontrakty
        contractFactory = await ethers.getContractFactory("Message");

        // Zapisujemy 3 portfele Ethereum
        // Hardhat stawiając lokalną sieć tworzy również kilkanaście portfeli testowych
        [owner, addr1, addr2] = await ethers.getSigners();
    });

    describe("Deployment", () => {

        before(async () => {
            // Publikujemy smart kontrakt do sieci i czekamy na potwierdzenie
            contract = await contractFactory.deploy();
        });

        // Sprawdza czy konstruktor ustawił zmienną stanową owner
        it("Should set the owner", async () => {
            expect(await contract.owner()).to.equal(owner.address);
        });

        // Sprawdza czy konstruktor ustawił domyślną wiadomość
        it("Should set initial message", async () => {
            expect(await contract.message()).to.equal("Hello world");
            expect(await contract.author()).to.equal(owner.address);
            expect(await contract.price()).to.equal(0);
        });
    });

    describe("Management", () => {

        // beforeEach wykona się przed każdym testem w aktualnym bloku describe
        beforeEach(async () => {
            contract = await contractFactory.deploy();
        });

        // Sprawdza czy owner może zmienić właściciela smart kontraktu
        it("Should set new owner", async () => {
            await expect(contract.setOwner(addr1.address)).to
                .emit(contract, "OwnershipTransferred")
                .withArgs(owner.address, addr1.address);
            expect(await contract.owner()).to.equal(addr1.address);
        });

        // Sprawdza czy nie owner może zmienić właściciela smart kontraktu
        it("Should revert setting new owner by not owner account", async () => {
            await expect(contract.connect(addr1).setOwner(addr2.address)).to.revertedWith("Only owner");
        });
    });
});

Ograniczyliśmy przypadki testowe w tym artykule do minimum, aby nie przedłużać (jeśli dotarłeś do tego miejsca – szacun!). Jeśli chcecie zobaczyć więcej przypadków testowych zapraszamy do repozytorium. Szczegółowy opis użytych matcherów znajdziecie w oficjalnej dokumentacji Waffle.

Na tym etapie jestesmy w stanie przetestować nasz smart kontrakt. Hardhat automatycznie postawi lokalną sieć blockchain na czas testów, do którego metody beforeEach oraz before będą publikować kontrakt. Komendę odpalamy z głównego folderu projektu.

npx hardhat test

Mniej więcej tak powinien wyglądać raport po przeprowadzonych testach:

Message contract
  Deployment
    ✔ Should set the owner
    ✔ Should set initial message
  Management
    ✔ Should set new owner
    ✔ Should revert setting new owner by not owner account

4 passing (692ms)

Web3 a publikowanie smart kontraktu

Na tym etapie jesteśmy gotowi, aby pokazać naszą aplikację światu. Do tej pory odpalaliśmy smart kontrakt na lokalnej sieci Hardhat.

Do tej pory nasz smart kontrakt był publikowany do lokalnej sieci Hardhat podczas testów. Jeśli chcemy, aby inni użytkownicy mogli z niego korzystać, musimy opublikować go do jednej z sieci Ethereum.

Do wyboru mamy oficjalną sieć tzw. mainnet Ethereum, który wykorzystuje do funkcjonowania prawdziwe pieniądze (tokeny Ether) lub jedną z kilku sieci testowych testnet (Goerli, Rinkeby, Ropsten, Kovan) , które naśladują działanie głównego łańcucha, ale wykorzystują fake tokeny.

W naszym przypadku smart kontrakt będzie publikowany do sieci testowej Goerli. Zasada działania będzie analogiczna do tej z testów (wykorzystujemy ContractFactory oraz Signer).

Tworzymy folder scripts, a w nim plik deploy.ts. Następnie kopiujemy do pliku skrypt:

import "@nomiclabs/hardhat-ethers";
import {ethers} from "hardhat";

async function deployMessage() {
    const [account] = await ethers.getSigners();

    console.log("Deployer account:", account.address);
    console.log("Account balance:", ethers.utils.formatEther(await account.getBalance()));

    const messageFactory = await ethers.getContractFactory("Message");

    console.log('Deploying Message...');
    const message = await messageFactory.deploy();
    await message.deployed();
    console.log('Message deployed to:', message.address);
}

deployMessage()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error);
        process.exit(1);
    });

Aby opublikować smart kontrakt za pomocą skryptu wykonujemy komendę:

npx hardhat run scripts/deploy.ts --network goerli

Parametr network wskazuje do której sieci opublikować kontrakt. Dzięki wcześniejszej konfiguracji pliku hardhat.config.ts oraz .env biblioteka ethers.js wie z jakiego Noda oraz konta Ethereum skorzystać. Jeśli pominiemy parametr network to smart kontrakt zostanie opublikowany do lokalnej sieci Hardhat.

Skrypt powinien zwrócić adres smart kontraktu w sieci Goerli, który możemy sprawdzić w narzędziu goerli.etherscan.io (lub na platformie Alchemy).

Podsumowanie mini Kursu Web3

W tym artykule nie będziemy wchodzić w interakcję z kontraktem (oprócz testów). Jeśli chcesz spróbować swoich sił, możesz sprawdzić nasz przykładowy skrypt wyświetlający aktualną wiadomość, jego cenę oraz autora.

W następnym artykule zbudujemy aplikację frontendową, która umożliwi użytkownikom interakcję ze smart kontraktem za pomocą interfejsu graficznego.

Jeśli artykuł był dla Ciebie wartościowy i nie jesteś jeszcze naszym subskrybentem – zapraszamy do dołączenia poniżej. Jest to dla nas najwyższa forma uznania i motywacja do pracy.