Note

Các ghi chú bên dưới sử dụng VRF phiên bản 2.5 và sử dụng mô hình subscription.

Contract ví dụ: Remix - Ethereum IDE

Sau đây là các bước cần có để triển khai một consuming contract (hay consumer) nhằm lấy các số ngẫu nhiên từ các VRF node.

Dependencies

Trước tiên, ta cần import 2 contract sau:

import {VRFConsumerBaseV2Plus} from "@chainlink/contracts@1.4.0/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts@1.4.0/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";

Coordination Parameters

Sau đó, ta kế thừa VRFConsumerBaseV2Plus và thêm vào các khai báo kiểu dữ liệu cũng như là biến như sau:

contract SubscriptionConsumer is VRFConsumerBaseV2Plus {
	event RequestSent(uint256 requestId, uint32 numWords);
	event RequestFulfilled(uint256 requestId, uint256[] randomWords);
	
	struct RequestStatus {
		bool fulfilled; // whether the request has been successfully fulfilled
		bool exists; // whether a requestId exists
		uint256[] randomWords;
	}
	mapping(uint256 => RequestStatus)
		public s_requests; /* requestId --> requestStatus */
	
	// Your subscription ID.
	uint256 public s_subscriptionId;
	
	// Past request IDs.
	uint256[] public requestIds;
	uint256 public lastRequestId;
	
	// The gas lane to use, which specifies the maximum gas price to bump to.
	// For a list of available gas lanes on each network,
	// see https://docs.chain.link/vrf/v2-5/supported-networks
	bytes32 public keyHash =
		0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae;
	
	// Depends on the number of requested values that you want sent to the
	// fulfillRandomWords() function. Storing each word costs about 20,000 gas,
	// so 100,000 is a safe default for this example contract. Test and adjust
	// this limit based on the network that you select, the size of the request,
	// and the processing of the callback request in the fulfillRandomWords()
	// function.
	uint32 public callbackGasLimit = 100000;
	
	// The default is 3, but you can set this higher.
	uint16 public requestConfirmations = 3;
	
	// For this example, retrieve 2 random values in one request.
	// Cannot exceed VRFCoordinatorV2_5.MAX_NUM_WORDS.
	uint32 public numWords = 2;
}

Chúng ta sử dụng cấu trúc RequestStatus để thể hiện trạng thái của một request lấy số ngẫu nhiên. Mapping s_requests sẽ được dùng để theo dõi các request đã được gửi.

Ngoài ra, do không thể dùng vòng lặp cho mapping nên ta sử dụng mảng requestIds để lưu lại danh sách các request ID và lưu lastRequestId để truy xuất hiệu quả hơn.

Việc lưu lại s_subscriptionId là để validate chắc chắn rằng subscription ID là đúng và đủ fund trước khi request.

Giá trị keyHash là để đại diện cho gas lane, là maximum gas price mà ta sẽ trả cho một request với đơn vị gwei. Nó hoạt động như là ID của off-chain VRF job nhằm trả về response cho request.

Giá trị callbackGasLimit cho biết lượng gas tối đa có thể có dựa vào số lượng random word mà ta request cũng như là logic trong callback function của fulfillRandomWords().

Giá trị requestConfirmations là số lượng block cần confirm trước khi một VRF node trả về random word. Điều này là để tránh việc chain bị tái tổ chức (xảy ra khi network tìm ra một chain dài hơn). Giá trị này càng cao thì độ an toàn càng cao.

Coordinator

Coordinator là smart contract dùng để verify random word nhận từ VRF node.

Chúng ta cần phải gọi constructor của base contract VRFConsumerBaseV2Plus bằng cách truyền vào address của coordinator tương ứng với network mà ta muốn deploy contract. Địa chỉ của coordinator có thể được tìm thấy ở đây. Ví dụ, coordinator address của Sepolia là 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B

/**
 * HARDCODED FOR SEPOLIA
 * COORDINATOR: 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B
 */
constructor(
	uint256 subscriptionId
) VRFConsumerBaseV2Plus(0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B) {
	s_subscriptionId = subscriptionId;
}

Functions

Để lấy random words, ta cần gọi hàm requestRandomWords thông qua thuộc tính s_vrfCoordinator của base contract VRFConsumerBaseV2Plus nhằm lấy requestId:

requestId = s_vrfCoordinator.requestRandomWords(
	VRFV2PlusClient.RandomWordsRequest({
		keyHash: keyHash,
		subId: s_subscriptionId,
		requestConfirmations: requestConfirmations,
		callbackGasLimit: callbackGasLimit,
		numWords: numWords,
		extraArgs: VRFV2PlusClient._argsToBytes(
			VRFV2PlusClient.ExtraArgsV1({
				nativePayment: enableNativePayment
			})
		)
	})
);

Sau đó, ta cần cập nhật các state variable cần thiết:

s_requests[requestId] = RequestStatus({
	randomWords: new uint256[](0),
	exists: true,
	fulfilled: false
});
requestIds.push(requestId);
lastRequestId = requestId;
emit RequestSent(requestId, numWords);

Chúng ta cũng cần một hàm fulfillRandomWords để Chainlink node gọi sau khi nó tạo ra random words và validate random words:

function fulfillRandomWords(
	uint256 _requestId,
	uint256[] calldata _randomWords
) internal override {
	require(s_requests[_requestId].exists, "request not found");
	s_requests[_requestId].fulfilled = true;
	s_requests[_requestId].randomWords = _randomWords;
	emit RequestFulfilled(_requestId, _randomWords);
}

Note

Implementation trên chỉ là ví dụ mẫu, ta có thể thực hiện các logic bất kỳ nếu muốn với các đối số truyền vào của hàm.

Đây là một hàm bắt buộc do VRFConsumerBaseV2Plus mà ta kế thừa là một abstract contract và nó có hàm fulfillRandomWords được declare với keyword virtual:

/**
* @notice fulfillRandomness handles the VRF response. Your contract must
* @notice implement it. See "SECURITY CONSIDERATIONS" above for important
* @notice principles to keep in mind when implementing your fulfillRandomness
* @notice method.
*
* @dev VRFConsumerBaseV2Plus expects its subcontracts to have a method with this
* @dev signature, and will call it once it has verified the proof
* @dev associated with the randomness. (It is triggered via a call to
* @dev rawFulfillRandomness, below.)
*
* @param requestId The Id initially returned by requestRandomness
* @param randomWords the VRF output expanded to the requested number of words
*/
// solhint-disable-next-line chainlink-solidity/prefix-internal-functions-with-underscore
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) internal virtual;

Về bản chất, Chainlink node sẽ gọi hàm rawFulfillRandomWords trong VRFConsumerBaseV2Plus để verify random words rồi nó mới gọi hàm fulfillRandomWords:

// rawFulfillRandomness is called by VRFCoordinator when it receives a valid VRF
// proof. rawFulfillRandomness then calls fulfillRandomness, after validating
// the origin of the call
function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external {
	if (msg.sender != address(s_vrfCoordinator)) {
	  revert OnlyCoordinatorCanFulfill(msg.sender, address(s_vrfCoordinator));
	}
	fulfillRandomWords(requestId, randomWords);
}

Security

Trang VRF Security Considerations | Chainlink Documentation (trang này có nhiều consideration rất hay về security) có một vài lưu ý về bảo mật. Một trong số đó là:

Cite

Don’t accept bids/bets/inputs after you have made a randomness request.

Điều này có nghĩa là, nếu như outcome của contract phụ thuộc vào user input và random words thì trong quá trình random words đang được tạo ra, user input phải không được thay đổi. Chúng ta hiện thực điều này bằng một state variable lưu trạng thái của contract:

State private s_state;

Với State là một enum:

enum State {
    OPEN,           // 0
    CALCULATING     // 1
}

Testing

Để tạo ra mock contract phục vụ cho việc testing với Foundry, ta có thể dùng contract VRFCoordinatorV2_5Mock.sol có tại đường dẫn @chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol.

Deploy mock contract ở local chain:

vm.startBroadcast();
VRFCoordinatorV2_5Mock vrfCoordinatorMock = new VRFCoordinatorV2_5Mock(
		MOCK_BASE_FEE,
		MOCK_GAS_PRICE_LINK,
		MOCK_WEI_PER_UNIT_LINK
	);
vm.stopBroadcast();

Giá trị vrfCoordinatorMock là địa chỉ của mock coordinator và ta cần lưu nó vào state variable của script contract1.

Để coordinator này có thể hoạt động được, ta cần làm các bước sau:

  1. Tạo subscription
  2. Fund subscription
  3. Thêm consumer

Contract VRFCoordinatorV2_5Mock kế thừa SubscriptionAPISubscriptionAPI có hàm createSubscription với signature như sau:

function createSubscription() external override nonReentrant returns (uint256 subId)

Gọi sử dụng như sau:

function createSubscription(
	address vrfCoordinator
) public returns (uint256) {
	vm.startBroadcast();
	uint256 subId = VRFCoordinatorV2_5Mock(vrfCoordinator).createSubscription();
	vm.stopBroadcast();
	return subId;
}

Có thể thấy, ta bọc thao tác tạo subscription trong một transaction và ép kiểu địa chỉ về dạng contract để có thể gọi hàm.

Tiếp đến, hàm dùng để fund subscription của mock contract có signature như sau:

function fundSubscription(uint256 _subId, uint256 _amount) public

Trước khi gọi sử dụng, ta cần phải deploy một mock LINK contract (ERC-20 token của Chainlink) giống với cách mà ta đã deploy mock coordinator. Source code của mock LINK contract có thể được tìm thấy ở đây. Để sử dụng source code này, ta cần install package transmission11/solmate để có thể import ERC20.sol:

forge install transmissions11/solmate

Deploy như sau:

vm.startBroadcast();
LinkToken linkToken = new LinkToken();
vm.stopBroadcast();

Với LinkToken sẽ là source code mà ta vừa thêm vào:

import {LinkToken} from "../test/mock/LinkToken.sol";

Gọi sử dụng hàm fundSubscription với subscription ID và amount như sau:

function fundSubscription(
	address vrfCoordinator,
	uint256 subscriptionId,
	address linkToken
) public {
	console.log("Funding subscription: ", subscriptionId);
	console.log("Using vrfCoordinator: ", vrfCoordinator);
	console.log("On chainId: ", block.chainid);
 
	if (block.chainid == LOCAL_CHAIN_ID) {
		vm.startBroadcast();
		VRFCoordinatorV2_5Mock(vrfCoordinator).fundSubscription(
			subscriptionId,
			FUND_AMOUNT
		);
		vm.stopBroadcast();
	} else {
		console.log(LinkToken(linkToken).balanceOf(msg.sender));
		console.log(msg.sender);
		console.log(LinkToken(linkToken).balanceOf(address(this)));
		console.log(address(this));
		vm.startBroadcast();
		LinkToken(linkToken).transferAndCall(
			vrfCoordinator,
			FUND_AMOUNT,
			abi.encode(subscriptionId)
		);
		vm.stopBroadcast();
	}
}

Bước tiếp theo là add consumer vào subscription.

Với bước này, ta chỉ cần gọi hàm addConsumer của mock coordinator contract rồi truyền vào subscription ID cũng như là address mà ta muốn add vào subscription:

function addConsumer(
	address raffle,
	address vrfCoordinator,
	uint256 subscriptionId
) public {
	console.log("Adding consumer contract: ", raffle);
	console.log("Using VRFCoordinator: ", vrfCoordinator);
	console.log("On chain id: ", block.chainid);
 
	vm.startBroadcast();
	VRFCoordinatorV2_5Mock(vrfCoordinator).addConsumer(
		subscriptionId,
		raffle
	);
	vm.stopBroadcast();
}

Gọi sử dụng tất cả các hàm trên trong deploy script như sau:

function run() external returns (Raffle, HelperConfig) {
	HelperConfig helperConfig = new HelperConfig();
	(
		uint256 entranceFee,
		uint256 interval,
		address vrfCoordinator,
		bytes32 gasLane,
		uint256 subscriptionId,
		uint32 callbackGasLimit,
		address linkToken
	) = helperConfig.activeNetworkConfig();
 
	if (subscriptionId == 0) {
		CreateSubscription createSubscription = new CreateSubscription();
		subscriptionId = createSubscription.createSubscription(
			vrfCoordinator
		);
 
		FundSubscription fundSubscription = new FundSubscription();
		fundSubscription.fundSubscription(
			vrfCoordinator,
			subscriptionId,
			linkToken
		);
	}
 
	vm.startBroadcast();
	Raffle raffle = new Raffle(
		entranceFee,
		interval,
		vrfCoordinator,
		gasLane,
		subscriptionId,
		callbackGasLimit
	);
	vm.stopBroadcast();
	
	AddConsumer addConsumer = new AddConsumer();
	addConsumer.addConsumer(
		address(raffle),
		vrfCoordinator,
		subscriptionId
	);
 
	return (raffle, helperConfig);
}

Resources

Footnotes

  1. Xem thêm Foundry - Scripting.