본 글은 freeCodeCamp.Org의 Youtube 영상 'Solidity, Blockchain, and Smart Contract Course – Beginner to Expert Python Tutorial'와 관련 코드인 SmartContract의 Github 코드를 기초로 작성되었다.
Youtube 영상 링크: https://www.youtube.com/watch?v=M576WGiDBdQ&t=10336s
Github 코드 링크: https://github.com/smartcontractkit/full-blockchain-solidity-course-py
오늘의 코드: https://github.com/PatrickAlphaC/brownie_fund_me
이번 포스팅은 유튜브 영상 05:06:34~06:11:38에 해당하는 내용이다.
이번 시간에는 Python으로 3. Solidity에서 구현한 'FundMe' 계약을 실행, 확장해보자.
mkdir brownie_fund_me
cd brownie_fund_me
brownie init
demos 폴더에 brownie_fund_me라는 새로운 폴더를 만들고 brownie 실행을 위한 준비를 갖추자.(initialize brownie.)
다음으로 생성된 contracts에 FundMe.sol 파일을 만들고 3. Solidity에서 작성한 코드를 그대로 붙여넣자.
코드에서 변경할 사항이 몇 가지가 있다.
변경 전 코드 주소: https://github.com/PatrickAlphaC/fund_me/blob/main/FundMe.sol
변경 후 코드 주소: https://github.com/PatrickAlphaC/brownie_fund_me/blob/main/contracts/FundMe.sol
pragma solidity ^0.6.6;
먼저 버전을 위와 같이 수정해주자.
AggregatorV3Interface public priceFeed;
위 코드를 'address public owner;'아래에 붙여넣자.
constructor(address _priceFeed) public {
priceFeed = AggregatorV3Interface(_priceFeed);
owner = msg.sender;
}
생성자(constructor)를 위와 같이 변경해주자.
function getVersion() public view returns (uint256){
return priceFeed.version();
}
function getPrice() public view returns(uint256){
(,int256 answer,,,) = priceFeed.latestRoundData();
// ETH/USD rate in 18 digit
return uint256(answer * 10000000000);
}
AggregatorV3Interface priceFeed = AggregatorV3Interface(0x8A753747A1Fa494EC906cE90E9f37563A8AF630e);
AggregatorV3Interface priceFeed = AggregatorV3Interface(0x8A753747A1Fa494EC906cE90E9f37563A8AF630e);
원래 코드에서 이 코드를 지우고 나머지만 남기자.
생성자 변경과 위 코드 삭제를 진행한 이유는 가격 정보(화폐 간 교환 비율 등)를 담은 인터페이스를 유동적으로 전환하기 위해서이다. 기존 코드에서는 ETH/USD 비율을 담은 주소가 인터페이스 입력값에 고정적으로 들어가있었다. 변경된 코드에서는 가격 정보와 연결된 주소를 자유롭게 입력받아 priceFeed에 해당 인터페이스를 저장하는 형식을 취하고 있다.
import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";
import "@chainlink/contracts/src/v0.6/vendor/SafeMathChainlink.sol";
이 코드는 Remix에서는 동작했지만 brownie에서는 동작하지 않는다. Remix에서는 @chainlink/contracts가 그로부터 dependency(라이브러리나 패키지와 같이 import 대상인 소프트웨어)를 가져올 수 있는 npm 패키지임을 인식한다. 그러나 brownie는 이를 인식하지 못한다. 대신 brownie는 github로부터 dependency를 가져올 수 있다. dependency는 brownie-config 파일에 명시해주면 된다.
dependencies:
- smartcontractkit/chainlink-brownie-contracts@1.1.1
brownie-config.yaml 파일을 추가하여 위 코드를 추가해주자. 추가 형식은 '- <조직/저장소>@<버전>'이다. 위 예시에서는 조직: smartcontractkit, 저장소: chainlink-brownie-contracts, 버전: 1.1.1이다. 아래 사진을 보자. 조직과 저장소는 좌측 상단에서, 버전은 우측 하단의 'Releases'를 클릭하여 확인할 수 있다.
다음으로 FundMe.sol의 @chainlink가 smartcontractkit/chainlink-brownie-contracts@1.1.1임을 알려주어야 한다. 다음 코드를 brownie-config.yaml에 입력해주자.
compiler:
solc:
remappings:
- '@chainlink=smartcontractkit/chainlink-brownie-contracts@1.1.1'
위 코드로 인해 solc로 컴파일 시(즉 Python 스크립트로 solidity 파일을 컴파일 시) @chainlink 대신 smartcontractkit/chainlink-brownie-contracts@1.1.1로 연결된다.
networks:
default: development
rinkeby:
eth_usd_price_feed: '0x8A753747A1Fa494EC906cE90E9f37563A8AF630e'
verify: True
mainnet-fork-dev:
eth_usd_price_feed: '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419'
verify: False
development:
verify: False
ganache-local:
verify: False
brownie-config.yaml에 다음 코드를 추가해주자. 이는 스크립트의 함수를 짤 때 네트워크를 고정시키지 않고 호출을 통해 원하는 네트워크를 불러오기 위한 코드이다. 디폴트(default), 즉 네트워크를 지정하지 않았을 때는 development 네트워크(ganache-cli)가 호출된다.
rinkeby와 mainnet-fork-dev의 ETH-USD 가격 비율을 담은 주소를 각각 '해당 네트워크: eht_usd_pricefeed: '주소'' 형식으로 작성해주었다.
네트워크 중 처음 다루는 mainnet-fork-dev와 ganache-local에 대해서는 추후 설명하겠다.
etherscan에서 코드 검증(verification)이 이루어지는 네트워크는 rinkeby밖에 없기 때문에 rinkeby만 'verify: true'로, 나머지는 'verify: false'로 설정하였다. etherscan의 코드 검증이 무엇인지는 곧바로 살펴보겠다.
etherscan에서 코드를 검증하고 공개하는 방법을 설명하겠다. 먼저 etherscan 회원가입을 진행하자.
https://etherscan.io
https://etherscan.io/verifyContract
위 주소로 들어가면 작성한 계약 코드를 검증하고(verify) 공개(publish)할 수 있다. 그러나 FundMe의 @chainlink를 etherscan에서는 가져오지 못한다. 따라서 이를 원본 코드로 변경해야 하는데, 그 변경 과정을 flattening이라고 한다.
직접 flattening하지 않고도 Python 상에서 etherscan과 상호작용할 수 있는 방법이 있다. 이 방법을 활용하면 별다른 코드 변경 없이 계약 검증과 공개가 가능하다. 아래 링크로 들어가 +Add 버튼을 누르고 API Key를 만들어 복사하자.
https://etherscan.io/myapikey
export PRIVATE_KEY = 0xb5e857091a491a306f8a13ac49ed53655f51c2d780db0e9faf0305878b3f8fe5
export WEB3_INFURA_PROJECT_ID=a9c5bc0ec75c4a83a3e48086df81acfe
export ETHERSCAN_TOKEN=BEF1HMKFS34JWYP6RQACG8S3MX6G4FN4N4
전 시간과 동일하게 .env파일을 추가 및 구성하되, 마지막 줄에 'export ETHERSCAN_TOKEN=복사한 API Key'를 추가해주자.
이후 코드 작성을 전부 마쳤을 때 deploy.py를 rinkeby network에 대해 실행하면 다음과 같이 rinkeby etherscan에서 계약이 검증 & 공개되었음을 확인할 수 있다.(계약 실행 주소로 rinkeby etherscan에서 검색) 나아가 계약을 읽고, 작성하는 것까지도 가능하다.
from brownie import FundMe, MockV3Aggregator, network, config
from scripts.helpful_scripts import (
get_account,
deploy_mocks,
LOCAL_BLOCKCHAIN_ENVIRONMENTS,
)
이번 시간에 스크립트가 여러 개 나온다. scripts에 '__init__.py'라는 빈 스크립트의 파일을 만들어주자. 이 파일이 있어야 scripts 안의 파일들이 하나의 패키지로 인식되어 상호 import가 가능해진다.
메인 스크립트인 deploy.py부터 보자.
FundMe와 MockV3Aggregator은 solidity로 구성되어 있다. MockV3Aggregator는 Mocking을 위해 필요한 인터페이스이다. 이때 Mocking은 모의 코드 테스트이다.
contracts에 test 폴더를 만들고 그 안에 MockV3Aggregator.sol을 추가해주자. 스크립트는 chainlinkmix나 오늘의 코드에서 쉽게 찾을 수 있다. 찾아서 복사-붙여넣기 해주자. FundMe.sol과 MockV3Aggregator.sol을 모두 채웠다면 저장 및 컴파일을 해주자.(brownie compile)
컴파일이 완료된 두 파일을 import를 이용해 가져오고 있다.
network와 config를 지난 시간처럼 가져오고 있다.
다음으로 scripts.helpful_scripts에서 get_account, deploy_mocks, LOCAL_BLOCKCHAIN_ENVIRONMENTS를 가져오고 있는데, scripts 폴더에 helpful_scripts.py 파일을 만들고 이를 채워보자.
from brownie import network, config, accounts, MockV3Aggregator
from web3 import Web3
FORKED_LOCAL_ENVIRONMENTS = ["mainnet-fork", "mainnet-fork-dev"]
LOCAL_BLOCKCHAIN_ENVIRONMENTS = ["development", "ganache-local"]
DECIMALS = 8
STARTING_PRICE = 200000000000
def get_account():
if (
network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS
or network.show_active() in FORKED_LOCAL_ENVIRONMENTS
):
return accounts[0]
else:
return accounts.add(config["wallets"]["from_key"])
def deploy_mocks():
print(f"The active network is {network.show_active()}")
print("Deploying Mocks...")
if len(MockV3Aggregator) <= 0:
MockV3Aggregator.deploy(DECIMALS, STARTING_PRICE, {"from": get_account()})
print("Mocks Deployed!")
이후의 코드 진행을 위해 network, config, accounts, MockV3Aggregator, Web3를 가져오고 있다.
다음으로 FORKED_LOCAL_ENVIRONMENTS라는 배열을 선언하여 mainnet-fork, mainnet-fork-dev라는 문자열을 배열 요소로 담고 있다. 또한 LOCAL_BLOCKCHAIN_ENVIRONMENTS라는 배열을 선언하고 development와 ganache-local이라는 문자열을 배열 요소로 담고 있다. '배열 이름=[요소1, 요소2, ...]'형식으로 배열 선언 및 요소 저장이 가능하다. 문자열은 큰따옴표("")나 작은 따옴표('') 안에 담아 문자열임을 나타낸다.
Ethereum Mainnet에 transaction을 생성할 때 화폐 가치를 갖는 이더리움이 소모된다. 한 번 체인 상에 기록된 거래는 취소할 수 없으므로 transaction 생성에 신중해야 하고, 오류나 실수가 없는지 확인해봐야 한다. 이를 위해 테스트를 진행하는데, 테스트의 방법에는 다음과 같이 3가지가 있다.
Transaction 테스트
1. ganache나 ganache-cli 등 단일 노드로 구성된 개발 환경에서 진행하는 Mocking
2. Mainnet의 chain을 분기하여 진행하는 Forking
3. rinkeby, kovan 등 이더리움 테스트 네트워크 상의 테스트
위 2가지 배열의 정의는 Mocking과 Forking 진행을 위한 정의이다.
DECIMALS를 8, STARTING_PRICE를 200000000000로 저장하고 있다. 이는 MockV2Aggregator를 deploy할 때 constructor의 입력값이 된다. 200000000000에 10^DECIMALS, 즉 10^8을 곱하면 2000*10^18이 된다. 이 값이 어떤 의미를 갖는지는 곧 확인하겠다.
다음으로 getaccount 함수가 정의되고 있다. in 문법과 or 문법이 새롭게 등장하였다.
'a in 배열A'에서 a가 배열A의 요소이면 True(참)가, 요소가 아니면 False(거짓)가 반환된다.
'X or Y'는 X와 Y 중 적어도 하나가 True이면 True를 반환하고, 둘 다 False인 경우에만 False를 반환한다.
따라서 get_account 함수는 LOCAL_BLOCKCHAIN_ENVIRONMENTS나 FORKED_LOCAL_ENVIRONMENTS에 속한 네트워크가 활성화되었을 때는 Local계좌, 즉 단일 노드 계좌 중 하나인 accounts[0]를 반환한다. 이때 account[0]가 mainnet-fork, mainnet-fork-dev, development, ganache-local 중 어디에 해당되는지는 brownie가 알아서 찾아주기 때문에 걱정하지 않아도 된다.
이때, brownie의 기본 forking 메커니즘에서는 계좌를 제공하지 않기에 mainnet-fork는 활성화해도 계좌를 얻을 수 없다. 따라서 forking 기반 계좌를 얻기 위해서는 fork된 네트워크를 직접 추가해주어야 한다.
brownie networks add development mainnet-fork-dev cmd=ganache-cli host=https://127.0.0.1 fork='https://mainnet.infura.io/v3/$WEB3_INFURA_PROJECT_ID' accounts=10 mnemonic=brownie port=8545
터미널에 위 코드를 입력하면 mainnet-fork-dev라는 이름의 ethereum mainnet에서 fork된 로컬 네트워크가 추가된다. 그러나 영상에서는 infura를 이용한 fork가 오류를 일으키는 경우도 있어서 alchemy라는 사이트를 활용하기를 추천한다. 필자가 infura를 이용해봤는데 큰 문제는 없었다. 곧 alchemy를 이용법을 설명할 테니 둘 중에 원하는 방법을 선택하라.
alchemy 링크: https://dashboard.alchemyapi.io/
회원가입 및 로그인을 진행한 후 '+CREATE APP' 버튼을 클릭하여 아래와 같이 설정한 후 'CREATE APP'을 눌러주면 된다.
VIEW KEY에서 HTTPS를 복사해준 후 mainnet-fork-dev라는 이름의 development network를 추가해주는 아까의 코드에서 infura link 대신 복사한 링크를 넣어주면 된다.
brownie networks add development mainnet-fork-dev cmd=ganache-cli host=https://127.0.0.1 fork=https://eth-mainnet.g.alchemy.com/v2/akNSguIN9K-iGqsQjZ3bR4Ai8_p-tPQL accounts=10 mnemonic=brownie port=8545
터미널에 위 코드를 입력해주면 된다.
ganache 또한 네트워크에 추가할 수 있다.(ganache-local이라는 이름을 붙여주자) 아래 사진을 예시로 들면 다음과 같이 터미널에 입력해주면 된다. host와 chainid에 각각 본인의 RPC SERVER와 NETWORK ID를 입력해주면 된다.
brownie networks add Ethereum ganache-local host=http://127.0.0.1:7545 chainid=5777
brownie networks list
위 코드를 입력하여 기존에 있는 network와 추가된 network를 한 번 확인해보라.
다른 경우(rinkeby network, ethereum mainnet 등이 활성화되었을 때)에는 ${PRIVATE_KEY}에 해당하는 주소를 accounts반환한다. 'accounts.add(비밀키)'는 해당 비밀키의 주소를 생성하여 반환한다.
deploy_mocks 함수를 살펴보자. 함수 이름 그대로 deploy를 mocking하는 함수이다. print 함수를 보면 Mocking의 진행 과정을 출력해주고 있음을 알 수 있다. 이때 print(f"{변수}")를 하면 변수에 해당하는 문자열이 출력된다. 예컨대 현재 활성화된 network가 development이면 "The active network is development"가 출력된다.
'if len(MockV3Aggregator) <= 0:'을 보자. MockV3Aggregator는 deploy된 MockV3Aggregator를 저장하는 배열이다. 따라서 길이가 0일 때(<= 대신 == 써도 상관 없다. 길이는 0이상의 정수이므로 <=나 ==나 같다.), 즉 deploy된 MockV3Aggregator가 없을 때 다음 문장을 실행하라는 코드이다.
MockV3Aggregator가 deploy될 때 constructor가 호출되는데 그때 입력값으로 DECIMALS와 STARTING_PRICE가 들어가 STARTING_PRICE*10^DECIMALS가 1이더의 초기 달러가치가 된다. 이때 달러에 WEI단위를 곱하여 적용하기 위해 달러에 10^18을 곱해주어야 하는데, 이미 10^10이 곱해져있으므로 DECIMALS에 8을 입력하였다.
def deploy_fund_me():
account = get_account()
if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
price_feed_address = config["networks"][network.show_active()][
"eth_usd_price_feed"
]
else:
deploy_mocks()
price_feed_address = MockV3Aggregator[-1].address
fund_me = FundMe.deploy(
price_feed_address,
{"from": account},
publish_source=config["networks"][network.show_active()].get("verify"),
)
print(f"Contract deployed to {fund_me.address}")
return fund_me
def main():
deploy_fund_me()
다시 deploy.py로 돌아오자. deploy_fund_me 함수를 보자. get_account 함수를 호출하여 account에 반환값을 저장하고 있다.
if절을 보자. 활성화된 네트워크가 LOCAL_BLOCKCHAIN_ENVIRONMENTS 배열 안에 없다면, 즉 활성화된 네트워크가 development나 ganache-local이 아니라면 그 다음 줄이 실행된다. 그 다음 줄에서는 활성화되어 있는 네트워크의 ETH/USD 가격 피드 주소를 price_feed_address에 저장한다..
else절을 보자. 이 경우는 활성화된 네트워크가 LOCAL_BLOCKCHAIN_ENVIRONMENTS 배열 안에 있는 경우이다. mocking을 한 후 가장 최근([-1]의 의미이다.)에 deploy된 MockV3Aggregator의 주소를 price_feed_address에 저장하고 있다.
다음으로 우리가 실행하고 싶은 FundMe가 deploy된다. price_feed_address와 deploy를 실행하는 계좌(account), 그리고 publish 유무를 담아 deploy를 진행한다.
그 후 deploy된 FundMe 계약을 fund_me에 담아 반환하고 있다.
메인 함수에서는 deploy_fund_me를 실행한다.
from brownie import (
MockV3Aggregator,
network,
)
from scripts.helpful_scripts import (
get_account,
)
DECIMALS = 8
# This is 2,000
INITIAL_VALUE = 200000000000
def deploy_mocks():
"""
Use this script if you want to deploy mocks to a testnet
"""
print(f"The active network is {network.show_active()}")
print("Deploying Mocks...")
account = get_account()
MockV3Aggregator.deploy(DECIMALS, INITIAL_VALUE, {"from": account})
print("Mocks Deployed!")
def main():
deploy_mocks()
deploy_mocks.py를 scripts에 추가해주고 위 코드를 붙여넣자. 파일명 그대로 mocking을 deploy하기 위한 스크립트이다. 코드가 앞서 살펴보았던 helpful_scripts의 일부와 거의 동일하므로 코드 설명은 생략하겠다.
from brownie import FundMe
from scripts.helpful_scripts import get_account
def fund():
fund_me = FundMe[-1]
account = get_account()
entrance_fee = fund_me.getEntranceFee()
print(entrance_fee)
print(f"The current entry fee is {entrance_fee}")
print("Funding")
fund_me.fund({"from": account, "value": entrance_fee})
def withdraw():
fund_me = FundMe[-1]
account = get_account()
fund_me.withdraw({"from": account})
def main():
fund()
withdraw()
fund_and_withdraw.py를 scripts에 만들어 위 코드를 넣어주자. 위 코드로 fund와 withdraw를 할 수 있다.
fund 함수를 보자. 가장 최근 deploy된 FundMe와 계좌 하나를 호출 및 저장하고 있다.
다음으로 fund_me의 getEntranceFee를 호출하여 그 반환값을 entrance_fee에 저장하고 있다. entrance_fee는 최소 펀딩 금액(FundMe에서는 $50로 지정됨)를 ETH로 나타낸 값이 된다.(원리가 궁금하다면 FundMe.sol을 다시 한 번 살펴보라!)
이 entrance fee만큼을 불러온 계좌에서 펀딩해준다.
withdraw함수를 보자. fund 함수와 마찬가지로 가장 최근 deploy된 FundMe와 계좌 하나를 호출 및 저장한다.
다음으로 fund_me의 withdraw함수를 불러온 계좌로 실행한다.
main함수에서는 fund와 withdraw 함수를 각각 실행한다.
from scripts.helpful_scripts import get_account, LOCAL_BLOCKCHAIN_ENVIRONMENTS
from scripts.deploy import deploy_fund_me
from brownie import network, accounts, exceptions
import pytest
def test_can_fund_and_withdraw():
account = get_account()
fund_me = deploy_fund_me()
entrance_fee = fund_me.getEntranceFee() + 100
tx = fund_me.fund({"from": account, "value": entrance_fee})
tx.wait(1)
assert fund_me.addressToAmountFunded(account.address) == entrance_fee
tx2 = fund_me.withdraw({"from": account})
tx2.wait(1)
assert fund_me.addressToAmountFunded(account.address) == 0
def test_only_owner_can_withdraw():
if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
pytest.skip("only for local testing")
fund_me = deploy_fund_me()
bad_actor = accounts.add()
with pytest.raises(exceptions.VirtualMachineError):
fund_me.withdraw({"from": bad_actor})
마지막으로 tests 폴더에 test_fund_me.py를 추가해서 위 코드를 입력해주자.
brownie에서 exceptions가 새로이 import되었다. 이에 대해서는 추후 설명하겠다.
이후 코드 전개에서 활용되는 pytest를 import하기 위해 터미널에 다음 코드를 입력해주자.
pip install pytest
test_can_fund_and_withdraw부터 보자. 이 함수는 말 그대로 fund와 withdraw가 제대로 진행되는지를 test하는 함수이다.
핵심 코드 2줄만 살펴보자. 나머지 코드는 앞에서 설명한 내용으로 쉽게 이해되리라 생각한다. 핵심 코드 2줄은 assert로 시작하는 2줄이다.
먼저 나오는 assert를 보자. 가져온 계좌를 입력값으로 해서 addressToAmountFunded를 호출하여 entrance_fee가 제대로 펀딩되었는지 확인하는 코드이다.(코드를 쭉 읽어보면 앞서 entrance_fee가 펀딩되었음을 알 수 있다.)
두 번째로 나오는 assert에서는 addressToAmountFunded를 호출하여 그 값이 0인지, 즉 withdraw가 제대로 이루어졌는지 확인한다.
test_only_owner_can_withdraw에서는 활성화된 네트워크가 로컬 체인이 아닌 경우 pytest.skip이라는 함수를 실행하고 있다. 이 함수는 상위 함수(위 코드에서는 test_only_owner_can_withdraw)의 나머지 부분을 생략하고 괄호 안의 문자열을 출력한다. 즉 위 코드에서는 조건문이 만족되면 뒤이어 나오는 코드를 생략하고 only for local testing을 출력한다. 이 if절의 목적은 test를 로컬 체인에서만 실행하는 데 있다.
활성화된 네트워크가 로컬 체인인 경우에 FundMe를 deploy하고 계좌를 새로 만들어 bad_actor에 저장하고 있다.
FundMe.sol의 modifier를 확인하면 알겠지만 owner만이 withdraw가 가능하다. 따라서 bad_actor로 withdraw를 호출하면 error가 발생한다. 이 에러를 확인하는 게 pytest.raises(exceptions.예외 대상)이다. 원래 error가 발생했을 때 error창이 뜬다. 그러나 error를 코드 실행 오류의 예외 대상으로 삼으면서 error 발생 없이 코드가 통과된다. 따라서 owner가 아닌 bad_actor로 withdraw를 실행하면 해당 코드가 통과되어 withdraw가 해당자가 아닌 이에 의해 실행되고 있음이 확인된다. 정리하자면 문제 상황을 error 발생이 아니라 코드 통과를 통해 검증할 수 있도록 pytest.raises(exceptions.예외 대상)이 사용된다.
길고 긴 포스팅이었다. 갈수록 영상에서 많은 내용을 압축적으로 설명하다보니 글이 길어진다. 제한된 시간 속에서 글을 작성하기에 글이 점점 불친절해지고 있음을 느낀다. 그러나 이 방대한 내용을 친절하게 써줄 시간과 역량이 아직은 부족하다. 나중에 포스팅을 완료한 후 수정할 때 독자가 보다 편히 이해할 수 있도록 글을 써보겠다.
내일은 오전부터 하루종일 사지방에서 다음 포스팅을 진행할 계획이다. 오늘 글이 영상 1시간이 조금 넘는 분량이었는데 내일 분량은 영상 2시간 10분 가량이다. 집중해서 열심히 한 번 해보겠다.
8월이다, 이제. 오지 않을 것 같던 8월이 결국 왔다. 인내라는 씨앗이 뿌리를 내려 아름다운 열매를 맺기 위해서는 시간이라는 영양분이 필요하다. 곧 사회에 나가는데 군대에서 얻은 소중한 자산들을 꽃피울 수 있으면 좋겠다. 8월은 나에게 시작하는 달이다. 의미 있는 시작이 되었으면 좋겠고, 그 끝이 아름다웠으면 한다. 하나님, 나, 그리고 내 주위 소중한 사람들을 믿고 한 걸음씩 내딛겠다. 시작은 미약할지 모르나 그 끝은 창대하리라.
MIT License
Copyright (c) 2021 SmartContract
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.