블록체인은 체인 바깥의 정보를 실시간으로 업데이트하기가 어렵다. 예를 들어, 월드컵에서 실시간으로 바뀌는 우리나라 순위를 블록체인 위에 기록한다고 해보자. 매 경기마다 순위는 계속해서 바뀌기 때문에 외부에서 누군가가 체인에 정보를 넣어줘야 한다. 이 때 또 다른 문제가 발생한다. 첫 번째는 누가 정보를 넣어줄 것이며, 두 번째는 넣어준 정보가 정확한 정보인지 어떻게 아느냐다.
체인링크에서는 이러한 문제들을 해결하기 위해 탈중앙화된 노드
를 사용한다. 노드를 통해 체인 바깥의 정보를 서로 검증하고 컨트랙트에 데이터를 제공한다. 모든 요청 및 응답은 온체인에 기록되며 이는 네트워크의 신뢰도 및 정확도를 판단하는 데 사용된다. 이렇게 온체인과 오프체인을 연결해주는 것을 오라클
이라 한다. 현재 다양한 곳에서 사용되며, 주로 디파이에서 가격 데이터를 제공하는데 사용된다.
먼저 몇 가지 준비물이 필요하다.
https://faucets.chain.link/rinkeby 에서 테스트넷에서 사용할 LINK 토큰과 ETH를 받는다.
가격에 맞춰서 변할 NFT 사진을 filebase를 통해 저장하고, ipfs url를 다른 곳에 잘 보관해놓는다. ipfs url 만드는 방법은 Alchemy에서 올려준 영상에 잘 나와있다.
예제 코드를 보기에 앞서 알아두면 좋은 것들이 있다. 첫 번째는 Trigger다. 체인링크를 통해 우리의 컨트랙트가 동작하려면 Trigger가 필요하다. Trigger의 종류에는 두 가지가 있다.
1) Time-based trigger
2) Custom logic trigger
Time-based trigger는 일정 시간마다 trigger가 발동한다. 알람을 설정해서 일정 시간이 되면 컨트랙트의 함수가 동작하는 경우다. Custom logic trigger는 원하는 조건을 만족할 경우 함수가 돌아가도록 할 수 있다. 이 때 Custom logic trigger는 Keepers-compatible
해야한다.
체인링크를 통해 외부에서 데이터를 가져오는 과정은 다음과 같다.
1) 사용자는 스마트컨트랙트를 만든 후 trigger가 담긴 Upkeep job
을 만들어 Chainlink Keeper App에 등록한다.(LINK 토큰 필요)
2) 이후 조건을 충족한 trigger가 발생하면 요청을 받은 체인링크의 노드로 부터 데이터를 가져온다.
3) 가져온 데이터를 통해 컨트랙트의 함수가 실행되고 트랜잭션이 블록에 포함된다.
먼저 Openzeppelin의 Contracts Wizard를 통해 간단한 ERC721 컨트랙트를 만든다. 이후 Custom logic trigger를 사용할 수 있도록 컨트랙트를 Keepers-compatible
로 만들어줘야 한다.
KeeperCompatible
과 AggregatorV3Interface
를 임포트 해오고 KeeperCompatibleInterface
를 컨트랙트에 상속한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts@4.7.3/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts@4.7.3/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts@4.7.3/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts@4.7.3/access/Ownable.sol";
import "@openzeppelin/contracts@4.7.3/utils/Counters.sol";
import "@chainlink/contracts/src/v0.8/KeeperCompatible.sol";
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract BullBear is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable, KeeperCompatibleInterface {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("Bull&Bear", "BBTK") {}
function safeMint(address to, string memory uri) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
//...
//변수 선언
uint public interval;
uint public lastTimeStamp;
//interface 타입 변수 선언
AggregatorV3Interface public priceFeed;
int256 public currentPrice;
//디플로이 할 때 업데이트 주기와 기준이 될 종목 설정
constructor(uint updateInterval, address _priceFeed) ERC721("Bull&Bear", "BBTK") {
interval = updateInterval;
lastTimeStamp = block.timestamp;
//priiceFeed에 BTC/USD Feed 컨트랙트 주소를 넣어주면 가격 트래킹이 가능하다.
priceFeed = AggregatorV3Interface(_priceFeed);
currentPrice = getLatestPrice();
}
자신이 트래킹하고 싶은 토큰을 나중에 디플로이 할 때 설정이 가능하다. 토큰별 컨트랙트 주소는 Ethereum Data Feeds에 잘 나와있다. 이더리움 뿐만 아니라 다른 체인들도 트래킹 가능하다.
//각 NFT URI
string[] bullUrisIpfs = [
"https://ipfs.io/ipfs/QmcEd1R1Qq7M6G7NnHxDXYdiYtJAn9A2FGjg94Ku75HgUD?filename=gamer_bull.png",
"https://ipfs.io/ipfs/QmUveY3PgfLD9TahrAEH2WieCG1PfU9Ybdq9WbZe8wXwvQ?filename=party_bull.png",
"https://ipfs.io/ipfs/QmXou1mty7AqhKzxrxC5C7Xqx8yVjVi8ZEqjacUpQQ89hD?filename=simple_bull.png"
];
string[] bearUrisIpfs = [
"https://ipfs.io/ipfs/QmSHyHr3kHKza3YRMfNz5HFatotSvLSa17YHJPzXJow1gU?filename=beanie_bear.png",
"https://ipfs.io/ipfs/QmVqKAMsQRbNcVffMUwyzVHhcx37sx46qnFX6nnYqu1rb9?filename=coolio_bear.png",
"https://ipfs.io/ipfs/QmNeo34iQGsPLagnw1E6LjzfgmqVNLYftCymHKv11q9QsS?filename=simple_bear.png"
];
function safeMint(address to, string memory uri) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
//uri 디폴드로 설정
string memory defaultUri = bullUrisIpfs[0];
_setTokenURI(tokenId, defaultUri);
}
bull, bear용 배열을 만들어서 가격이 변할 때마다 바뀔 NFT url을 넣어준다. 앞서 만들었던 url을 활용한다. 현재는 민팅할 때 bull NFT를 디폴트로 설정했다. 나중에 performUpkeep
함수를 통해 가격이 변하면 url을 수정하도록 하면 된다.
//event 변수 선언
event TokenUpdated(string marketTrend);
//bear 또는 bull 상황에 맞춰서 tokenURI 업데이트
function updateAlltokenUris(string memory trend) internal {
if(compareStrings("bear", trend)) {
for (uint i=0; i<_tokenIdCounter.current(); i++) {
_setTokenURI(i, bearUrisIpfs[0]);
}
} else {
for (uint i=0; i<_tokenIdCounter.current(); i++) {
_setTokenURI(i, bullUrisIpfs[0]);
}
}
emit TokenUpdated(trend);
}
//글자 비교 함수
function compareStrings(string memory a, string memory b) internal pure returns (bool) {
return (keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b)));
}
for
문을 사용해서 지금까지 발행된 모든 NFT의 메타데이터를 바꾼다. 현재 trend가 bear라면 그에 맞게 bearUrisIpfs
배열에 있는 url을 적용시킨다. 반대 상황이라면 bullUrisIpfs
배열을 사용한다. 마지막에 변경 사항을 알 수 있도록 emit
을 통해 event를 남겨놓는다.
추가적으로 Keepers-compatible contracts
가 되기 위해선 checkUpkeep
함수와 performUpkeep
함수가 컨트랙트 내에 있어야 한다.
checkUpkeep
함수는 체인 외부에서 performUpkeep
함수가 실행되어야 하는지 매 블록마다 체크한다. checkUpkeep
함수가 true를 리턴하면 체인 내부에 있는 performUpkeep
함수에 작성된 트리거가 발동한다. 코드는 다음과 같다.
//리턴한 performData를 performUpkeep 함수에 넘긴다.
function checkUpkeep(bytes calldata /* checkData */) external view override returns (bool upkeepNeeded, bytes memory /* performData */) {
//현재 시각과 최근 업데이트된 시각을 뺀 시간(그 사이의 텀)이 내가 컨트랙트를 시작할 때 설정한 업데이트 주기보다 길다면 업데이트 필요(true 리턴)
//checkData는 Upkeep이 등록되면 정의된다.
upkeepNeeded = (block.timestamp - lastTimeStamp) > interval;
}
function performUpkeep(bytes calldata /* performData */) external override {
//checkUpkeep 함수에서 업데이트를 확인 했더라도 performUpkeep에서 한 번 더 확인하는 것이 좋다.
if ((block.timestamp - lastTimeStamp) > interval ) {
//시간을 업데이트 하고
lastTimeStamp = block.timestamp;
//최신가격을 가져온다.
int latestPrice = getLatestPrice();
if(latestPrice == currentPrice) {
return;
}
//가격에 맞춰서 토큰Uri 수정
if(latestPrice < currentPrice) {
updateAlltokenUris("bear");
} else {
updateAlltokenUris("bull");
}
//가격 업데이트
currentPrice = latestPrice;
}
}
최신 가격을 가져올 때 latestRoundData
가 사용된다. latestRoundData
는 AggregatorV3Interface
에 포함된 함수다. 여러 정보를 가져올 수 있지만 destruct해서 가격만 리턴시킨다.
//최신가격 가져오기
function getLatestPrice() public view returns (int256) {
(
/*uint80 roundID*/,
int256 price,
/*uint startedAt*/,
/*uint timeStamp*
/*uint80 answeredInRound*/
) = priceFeed.latestRoundData();
return price;
}
AggregatorV3Interface public priceFeed;
priceFeed = AggregatorV3Interface(_priceFeed);
AggregatorV3Interface
는 priceFeed 변수를 interface type으로 선언할 때 사용됐다. 그런데 파라미터를 받아서 가격을 가져와 변수에 할당할 때도 사용된다. interface type은 파라미터를 받을 수 있는건가? 이해가 잘 안 가서 소스코드를 살펴봤다.
그런데 파라미터를 받을 수 있는 곳은 없었다(???) 하지만 나와 같은 궁금증을 가진 사람이 또 있었다. ethereum stackexchange에서 의문을 해소할 수 있었다.
interface는 일종의 정해진 규격이라고 할 수 있다. interface로 선언된 변수는 interface에 포함된 함수를 호출할 수 있다. solidity에서는 interface와 address를 같이 사용하면 interface 내부의 함수를 호출할 수 있는 객체를 받는다.
위 사진에서 설명한 것처럼 컨트랙트 내부의 함수를 사용하고 싶다고 해서 컨트랙트 주소를 객체처럼 사용할 수는 없다. 메소드를 사용하려면 내부 함수를 정의한 interface에 컨트랙트 주소를 넣어서 메소드를 호출할 수 있는 객체로 만들어줘야 한다.
interface
에서 주의할 점
external
로 선언해야 한다.constructor
는 선언할 수 없다. 가격에 따라 NFT의 메타데이터를 바꿀 때 일괄적으로 배열의 첫 번째 항목을 적용시켰다. 발행된 모든 NFT가 한 가지로만 바뀌면 재미없으니 배열에 있는 항목들 중에서 랜덤하게 바뀌도록 변경하면 더 괜찮을 것 같다. 다음편에서는 체인링크의 VRF
를 사용해서 검증된 랜덤 숫자에 따라 NFT를 바꿔보자.
전체코드는 여기서 볼 수 있다.