‘이더리움 댑 개발’ 세미나 8. 6장. The Fundraiser Application 2/2
이번 세미나는 6장. The Fundraiser Application의 Making Donations절 부터 시작합니다.
세미나 진행을 원활히 하기 위해 일부 순서를 변경했습니다. 앞 부분은 소스 코드 내용을 인용했지만 뒤로 갈 수록 본문 내용을 참조하는 것으로 인용 양을 줄여 나갔습니다. 본문 내용을 참조하라고는 했지만 본문 내용을 보지 않고도 또는 살짝 훑어보는 수준에서 작성할 수 있도록 합니다.
Making Donations
기부에 대한 요구사항을 기준으로 테스트 케이스를 작성합니다.
- 기부에 대한 화면은 그림 6.5와 같습니다.
- 이더로 기부를 할 수 있어야 합니다.
- 달러로 기부액을 작성하면 이더로 환산되어 제공되어야 합니다.
- 해당 기금 모금에 대한 전체 모금 금액과 기부 건수가 제공되어야 합니다.
- 사용자의 기부 목록이 제공되어야 합니다.
- 사용자가 새로운 기부를 하면
- 기금 모금 금액이 기부 액 만큼 늘어나야 합니다.
- 기부 건수가 1 증가되어야 합니다.
- 사용자의 기부 목록에 추가되어야 합니다.
- 사용자가의 기부 건수가 추가됩니다.
- 기부 목록의 기부 항목에 대해 영수증 발급이 가능해야 합니다.
- 이더로 기부를 할 수 있어야 합니다.
- 테스트 스위트는 “donate()”를 추가합니다. 테스트 케이스들에서 기부자(donor)를 accounts[2]로, 기부 금액은 0.0289 웨이로 사용할 수 있도록 선언합니다.
-
123describe("making donations", () => {const value = web3.utils.toWei('0.0289');const donor = accounts[2];
- “it increases myDonationsCount” 테스트 케이스를 추가합니다.
- donate() 함수를 실행하기 전에는 myDonationsCount가 0이고, 실행 후에는 1이어야 합니다. 즉 차이가 1이어야 합니다.
- “it includes donation in myDonations” 테스트 케이스를 추가합니다.
- donate() 함수가 실행되고 나면 myDonations에 포함되어 있어야 합니다.
-
12345678910111213it("increases myDonationsCount", async () => {const currentDonationsCount = await fundraiser.myDonationsCount({from: donor});await fundraiser.donate({from: donor, value});const newDonationsCount = await fundraiser.myDonationsCount({from: donor});assert.equal(1, newDonationsCount - currentDonationsCount, "myDonationsCount should increment by 1");})it("includes donation in myDonations", async () => {await fundraiser.donate({from: donor, value});const {values, dates} = await fundraiser.myDonations({from: donor});assert.equal(value, values[0], "values should match");assert(dates[0], "date should be present");});
- 기부한다는 것은 해당 기금 모금 컨트랙트 계정에 이더를 전송하는 것과 같습니다.
-
테스트를 통과하기 위해 Fundraiser 컨트랙트에 donate() 함수와 myDonationsCount() 함수와 myDonations() 함수를 추가합니다.
- 기부는 ‘누가 언제 얼마를’이 하나의 개념처럼 사용됩니다. 기부는 여러 번 할 수 있음으로 ‘누가’를 기준으로 ‘언제 얼마를’가 여러 번 연결될 수 있어야 합니다.
- 솔리디티는 다수의 속성을 하나의 그룹으로 묶어서 다룰 수 있도록 구조체를 제공합니다.
- 솔리디티는 하나의 값을 키로 다수의 값을 연결할 수 있는 다른 프로그래밍 언어에서의 딕셔너리와 같은 매핑 타입을 제공합니다.
- ‘언제 얼마를’ Donation 구조체로 작성합니다.
- 금액은 wei로 uint256으로 작성합니다.
- date는 Unix 타임으로 uint256으로 작성합니다. 블록 timestamp를 사용합니다.
-
1234struct Donation {uint256 value;uint256 date;}
- 구조체를 초기화하는 방법에는 두 가지가 있습니다. 하나는 위에서 처럼 딕셔너리를 사용하는 것이고, 다른 하나는 아래 처럼 속성 정의 순서를 사용하는 것입니다.
-
1Donation memory donation = Donation(100, block.timestamp);
-
- ‘누가’와 ‘언제 얼마를’ 연결하기 위해 매핑 타입의 상태변수를 선언합니다. ‘언제 얼마를’은 여러 개 있을 수 있음으로 배열로 작성합니다.
-
1mapping(address => Donation[]) private _donations;
-
- ‘언제 얼마를’ Donation 구조체로 작성합니다.
- donate() 함수를 작성합니다.
- 컨트랙트 계정에 이더를 전송하는 것으로 추가적인 매개변수는 필요 없습니다.
- 이더를 전송 받으려면 payable이 되어야 합니다. payable 함수로 이더를 전송하는 경우, 추가적인 코드 없이 컨트랙트 계정 잔고가 자동적으로 전송한 이더만큼 증가합니다.
- function donate() public payable
- 트랜잭션 호출 시에 설정한 from이 msg.sender로, value가 msg.value로 전달됩니다.
- msg.value를 value로 block.timestamp를 date로 Donation 인스턴스를 생성합니다.
- msg.sender를 키로, 생성한 Donation 인스턴스를 값으로 해서 _donations에 추가합니다.
-
123456Donation memory donation = Donation({value: msg.value,date: block.timestamp});_donations[msg.sender].push(donation);
- Donation은 구조체로 참조 타입이기 때문에 변수 위치를 명시해야 합니다. Donation은 메모리 상에 임시적으로 생성된 후 _donations에 추가될 때 저장소로 복사되면 됩니다.
- 배열에 값을 추가할 때는 push() 함수를 사용합니다.
-
- 이더를 전송 받으려면 payable이 되어야 합니다. payable 함수로 이더를 전송하는 경우, 추가적인 코드 없이 컨트랙트 계정 잔고가 자동적으로 전송한 이더만큼 증가합니다.
- 컨트랙트 계정에 이더를 전송하는 것으로 추가적인 매개변수는 필요 없습니다.
- myDonationsCount() 함수를 작성합니다.
- _donations[msg.sender].length를 리턴합니다.
- 기부자 계정 주소는 msg.sender로 전달됩니다.
- 상태 변수 값을 쓰지는 않지만 읽기는 해야 하니 view가 되어야 합니다. 이 함수도 여기까지는 내부에서 참조하는 것이 없으므로 external이 더 적당하다.
-
123function myDonationsCount() public view returns(uint256) {return _donations[msg.sender].length;}
-
- _donations[msg.sender].length를 리턴합니다.
- myDonations() 함수를 작성합니다.
-
- ABI의 한계로 public이나 external 함수에서 구조체 배열을 리턴할 수 없습니다.
- 구조체 속성 값 별로 배열을 작성해서 다수의 배열을 리턴하는 방법을 사용해야 합니다. 여기에서 myDonationCount()를 호출함으로 myDonationCount()를 public으로 바꿉니다.
- ABI의 한계로 public이나 external 함수에서 구조체 배열을 리턴할 수 없습니다.
123456789101112function myDonations() public view returns(uint256[] memory values, uint256[] memory dates) {uint256 count = myDonationsCount();values = new uint256[](count);dates = new uint256[](count);for (uint256 i = 0; i < count; i++) {Donation storage donation = _donations[msg.sender][i];values[i] = donation.value;dates[i] = donation.date;}return (values, dates);}-
- 다수의 값을 리턴할 때는 타입과 이름(식별자)을 명시하는 것이 좋습니다.
- 매핑은 열거할 수 없기 때문에 반복문에서 키값쌍을 직접 열거하면서 처리할 수 없습니다.
- myDonationsCount()을 사용해서 count를 구해와서 for문에서 인덱스로 각각의 기부를 구하는 이유입니다.
- for 문의 Donation storage donation = _donations[msg.sender][i]에서 storage로 데이터 위치를 명시한 이유?
- 굳이 복사할 필요가 없고 참조 값을 읽어도 되기 때문에 storage로 데이터 위치를 명시한 것입니다.
-
테스트를 실행합니다. 테스트가 성공적으로 통과할 것입니다.
Fundraiser Totals
모금 총액과 기부 총 건수를 제공해야 합니다.
- “donate()” 테스트 스위트에 “it increases the totalDonations amount”와 “it increases donationsCount” 테스트 케이스를 추가합니다.
- 기부에 따라 기부 금액 만큼 모금 총액이 증가되어야 합니다.
- 기부에 따라 기부 총 건수가 1증가 되어야 합니다.
-
1234567891011121314it("increases the totalDonations amount", async () => {const currentTotalDonations = await fundraiser.totalDonations();await fundraiser.donate({from: donor, value});const newTotalDonations = await fundraiser.totalDonations();const diff = newTotalDonations - currentTotalDonations;assert.equal(diff, value, "difference should match the donation value");});it("increases donationsCount", async () => {const currentDonationsCount = await fundraiser.donationsCount();await fundraiser.donate({from: donor, value});const newDonationsCount = await fundraiser.donationsCount();assert.equal(1, newDonationsCount - currentDonationsCount, "donationsCount should increment by 1");});
- 테스트를 통과할 수 있도록 Fundraiser 컨트랙트에 totalDonations() 함수와 donationsCount() 함수를 작성합니다.
- _totalDonations와 _donationsCount 상태 변수를 추가합니다.
- 테스트를 통과할 수 있도록 donate() 함수에서 _totalDonations 값을 value만큼 증가시키고, _donationsCount를 1 증가 시킵니다.
- 오버플로어 이슈를 없애기 위해 오픈제플린의 SafeMath를 사용합니다.
- 오픈제플린 SafeMath 라이브러리를 임포트 합니다.
- import “@openzeppelin/contracts/math/SafeMath.sol”;
- SafeMath 라이브러리를 uint256에 attach합니다.
- using SafeMath for uint256;
- totalDonations에 기부 금액을 더하기 위해 SafeMath의 add 함수를 사용합니다.
- totalDonations = totalDonations.add(msg.value);
- donationsCount는 1씩 증가시키는 것이고 uint256을 사용하는데 오버플로어가 날 가능성이 거의 없기 때문에 증감연산자를 직접 사용합니다.
- donationsCount++;
- 오픈제플린 SafeMath 라이브러리를 임포트 합니다.
- 오버플로어 이슈를 없애기 위해 오픈제플린의 SafeMath를 사용합니다.
테스트를 실행합니다. 성공적으로 테스트가 통과합니다.
기부에 대한 이벤트 처리를 합니다.
- “donate()” 테스트 스위트에 “it emits the DonationReceived event” 테스트 케이스를 추가합니다.
-
1234567it("emits the DonationReceived event", async () => {const tx = await fundraiser.donate({from: donor, value});const expectedEvent = "DonationReceived";const actualEvent = tx.logs[0].event;assert.equal(actualEvent, expectedEvent, "events should match");});
-
- 테스트가 통과할 수 있도록 Fundraiser 컨트랙트에 이벤트를 추가하고, donate() 함수를 실행하고 이벤트를 발생(emit)시킵니다.
- 이벤트는 일반적으로 과거형을 사용해서 작성합니다.
- DonationReceived
- 기부자 계정 주소와 기부 금액이 얼마인지를 로깅하면 됩니다.
- address indexed donor, uint256 value
- indexed는 이름 그대로 이 속성 값을 인덱스로 사용해서 이벤트 구독자가 이벤트를 좀 더 쉽게 필터링할 수 있도록 합니다. 최대 세 개까지 indexed 속성으로 정의할 수 있습니다.
- 이벤트는 일반적으로 과거형을 사용해서 작성합니다.
- 테스트를 실행합니다. 테스트가 성공적으로 통과 됨을 볼 수 있습니다.
이벤트에 대한 본문 내용을 다시 한 번 주의 깊게 읽어 봅니다.
- Events are essentially a way to write to the Ethereum logs. These logs send out notifications when new entries are made and have been set up in a way that you can subscribe or watch for new entries. Since the logs are considered part of the blockchain, writing to them is not allowed in view or pure functions.
- You can test for events by looking at the transaction receipt.
Withdrawing Funds
모금 기금이 완료되면 수혜자에게 이더를 인출해 전송해 주어야 합니다.
- “withdraw()”로 테스트 스위트를 작성합니다.
- 모든 테스트 케이스가 accounts[2]를 from으로 0.1 이더를 value로 해서 donate() 함수를 실행하고 실행되도록 합니다.
-
12345describe("withdrawing funds", () => {beforeEach(async () => {await fundraiser.donate({from: accounts[2], value: web3.utils.toWei('0.1')});});...
- “it throws an error when called from a non-owner account”와 “it permits the owner to call the function” 테스트 케이스를 작성합니다.
- 관리자만 인출이 가능해야 합니다.
-
12345678910111213141516171819it("throws and error when called from a non-owner account", async () => {try {await fundraiser.withdraw({from: accounts[3]});assert.fail("withdraw was not restricted to owners")} catch(err) {const expectedError = "Ownable: caller is not the owner"const actualError = err.reason;assert.equal(actualError, expectedError, "should not be permitted")}});it("permits the owner to call the function", async () => {try {await fundraiser.withdraw({from: custodian});assert(true, "no errors were thrown");} catch(err) {assert.fail("should not have thrown an error");}});
- 테스트를 통과할 수 있도록 Fundraiser 컨트랙트에 withdraw() 함수를 추가합니다.
- function withdraw() external onlyOwner {}
- “it transfers balance to beneficiary”로 테스트 케이스를 작성합니다.
- 수혜자의 잔고는 컨트랙트 잔고 만큼 증가하고, 컨트랙트 잔고는 0이 되어야 합니다.
-
1234567891011it("transfers balance to beneficiary", async () => {const currentContractBalance = await web3.eth.getBalance(fundraiser.address);const currentBeneficiaryBalance = await web3.eth.getBalance(beneficiary);await fundraiser.withdraw({from: custodian});const newContractBalance = await web3.eth.getBalance(fundraiser.address);const newBeneficiaryBalance = await web3.eth.getBalance(beneficiary);const beneficiaryDifference = newBeneficiaryBalance - currentBeneficiaryBalance;assert.equal(newContractBalance, 0, "contract should have a 0 balance");assert.equal(beneficiaryDifference, currentContractBalance, "beneficiary should receive all the funds");});
- 이더 잔고를 구하기 위해 web3.eth.getBalance() 함수를 사용합니다.
- 테스트가 통과될 수 있도록 Fundraiser 컨트랙트의 withdraw() 함수를 작성합니다.
- 컨트랙트의 이더 잔고를 구합니다.
- 계약을 주소 타입으로 형변환 합니다.
- address(this)
- 이더 잔고를 구합니다.
- balance = address(this).balance
- 계약을 주소 타입으로 형변환 합니다.
- 컨트랙트의 잔고를 수혜자에게 전송합니다.
- _beneficiary.transfer(balance)
- 컨트랙트의 이더 잔고를 구합니다.
- 테스트를 실행하고, 테스트가 통과됨을 확인합니다.
- “it emits Withdraw event” 테스트 케이스를 작성합니다.
- 테스트를 통과시키기 위해서 Fundraiser 컨트랙트 withdraw() 함수에서 이벤트를 발생시킵니다.
- Withdraw 이벤트를 작성합니다.
- event Withdraw(uint256 amount);
- 이벤트를 발생시킵니다.
- emit Withdraw(balance);
- Withdraw 이벤트를 작성합니다.
- 테스트를 실행하고, 테스트가 통과됨을 확인합니다.
Fallback Functions
익명 기부를 지원해야 합니다.
- “fallback()”으로 테스트 스위트를 작성합니다.
- 기부 금액으로 0.0289 이더 값을 사용합니다.
-
123describe("fallback function", () => {const value = web3.utils.toWei('0.0289');...
- “it increases the totalDonations amount” 테스트 케이스를 작성합니다.
- 기부 금액 만큼 totalDonations가 증가되어야 합니다.
-
1234567it("increases the totalDonations amount", async () => {const currentTotalDonations = await fundraiser.totalDonations();await web3.eth.sendTransaction({to: fundraiser.address, from: accounts[9], value});const newTotalDonations = await fundraiser.totalDonations();const diff = newTotalDonations - currentTotalDonations;assert.equal(diff, value, "difference should match the donation value")});
- 트랜잭션 전송을 위해 web3.eth.sendTransaction() 함수를 사용합니다. Fundraiser 컨트랙트를 수신자로 accounts[9]에서 value 만큼 보냅니다.
- “it increases donationsCount” 테스트 케이스를 작성합니다.
- donationsCount를 1 증가 시킵니다.
-
1234567it("increases donationsCount", async () => {const currentDonationsCount = await fundraiser.donationsCount();await web3.eth.sendTransaction({to: fundraiser.address, from: accounts[9], value});const newDonationsCount = await fundraiser.donationsCount();assert.equal(1, newDonationsCount - currentDonationsCount, "donationsCount should increment by 1");});
- 테스트를 통과시키기 위해서 Fundraiser 컨트랙트에 fallback 함수를 작성합니다.
- Fallback functions are functions that are unnamed and will supply the default behavior in the event that the contract receives ether through a plain transaction or if the contract is called with a method signature that does not match any of the defined functions. When defining this function, it cannot accept any parameters and must be marked external. Though it is not required, it is a good idea to go ahead and make it payable as well.
-
1234fallback() external payable{_totalDonations = _totalDonations.add(msg.value);_donationsCount++;}
- 솔리디티 0.6.0 이상에서는 기존의 폴백 함수를 receive 함수와 fallback 함수로 구분하고 있습니다.
- 명확히 이더를 받는 용도로 사용할 때는 receive 함수를 사용하도록 하고 있습니다. 그 외에는 fallback 함수를 사용합니다.
- receive 함수는 receive를 fallback 함수는 fallback 키워드를 명시적으로 사용해야 합니다.
- 테스트를 실행하고 테스트가 통과됨을 확인합니다.