Nếu bạn là một Dev làm về blockchain và lập trình contract thì chắc bạn có biết đến hợp đồng Multicall? Vậy hợp đồng Multicall là gì? Tại sao lại sử dụng hợp đồng Multicall?
Mục lục
Hợp đồng Multicall là gì?
Hợp đồng Multicall là hợp đồng cho phép thực hiện đồng thời nhiều lệnh gọi tới các smart contract khác nhau trong 1 lần gọi duy nhất. Điều này sẽ làm giảm số lượng request tới các node và đảm bảo các dữ liệu trả về đều từ cùng một khối (block). Hiện tại phiên bản mới nhất là Multicall3.
Nói đơn giản, Multicall tổng hợp tất cả các truy vấn đối với các hợp đồng khác nhau trong một cuộc gọi, vì vậy nó đảm bảo dữ liệu của bạn được truy xuất từ cùng một khối.
Bạn tham khảo một số mã nguồn hợp đồng Multicall:
- Multicall của MarketDao: Phiên bản cũ của Multicall3.
- Multicall3: Chi tiết ABI, tài liệu cũng như địa chỉ triển khai contract này trên các nền tảng blockchain hãy xem tại https://www.multicall3.com
Bạn có thể tương tác với Multicall thông qua các thư viện:
- Đơn giản bạn dùng thư viện web3.js and ethers.js
- Dùng thư viện Multicall.js
- Thư viện ethereum-multicall
Tại sao lại sử dụng hợp đồng Multicall?
Như trong định nghĩa của hợp đồng Multicall, 2 lợi ích lớn nhất:
- Giảm số lượng request tới node => Điều này khá quan trọng vì một số node chặn lượng tối đa request đến, có node tính phí theo số lượng request => Làm khả năng bị chặn thấp hơn và chi phí thấp hơn.
- Đảm bảo các dữ liệu trong cùng 1 block => Dữ liệu trong 1 block rất trọng khi truy vấn dữ liệu, rất nhiều nghiệp vụ thực tế yêu cầu điều này.
- Ví dụ bạn cần lấy thông tin (reserve0, reserve1) của 50 pairs trên Uniswap V2 để tìm giá tốt nhất khi đi qua 1 trong nhiều pair này. Trong trường hợp này, để có kết quả chính xác bạn phải đảm bảo rằng dữ liệu phải trong blockchain mới nhất.
- Bình thường bạn sẽ phải gọi 51 request đồng thời trong đó 1 request để lấy blockTime và 50 request để lấy thông tin reserve =>Thời gian trả về lúc này phụ thuộc và request chậm nhất, các dữ liệu này không đảm bảo là dữ liệu cùng 1 block => Việc tính toán của bạn sẽ sai
- Việc này sẽ giải quyết đơn giản với Multicall.
- …
- Ví dụ bạn cần lấy thông tin (reserve0, reserve1) của 50 pairs trên Uniswap V2 để tìm giá tốt nhất khi đi qua 1 trong nhiều pair này. Trong trường hợp này, để có kết quả chính xác bạn phải đảm bảo rằng dữ liệu phải trong blockchain mới nhất.
- Cho phép trả về số khối (Block number) hoặc dấu thời gian (Block timestamp) cùng với dữ liệu đã đọc, để giúp phát hiện dữ liệu cũ. Hầu hết các nghiệp vụ đều yêu cầu là dữ liệu mới nhất.
Thực tế, bạn có thể tự viết các hợp đồng riêng bạn để thu thập dữ liệu theo mong muốn của bạn. Nhưng như thế mỗi nhóm dữ liệu khác nhau bạn lại phải triển khai các Contract khác nhau sẽ rất là tốn kém. Trong đó Multicall rất linh động, dùng được cho hầu hết các trường hợp trong thực tế, đặc biệt là cho tác vụ thu thập dữ liệu.
Hướng dẫn sử dụng Multicall3
Tài liệu sử dụng Multicall3 bạn có thể xem tại: Multicall3 README. Có nhieuef thư viện mặc định hỗ trợ Multicall3 như ethers-rs, viem, and ape. Để sử dụng các thư viện này, bạn có thể xem một số ví dụ tại: Multicall Examples.
Hiện tại Multicall3 đã được triển khai trên 70 chuỗi:
- Địa chỉ Multicall3: 0xcA11bde05977b3631167028862bE2a173976CA11.
- Danh sách chuỗi: https://multicall3.com/deployments
- ABI: https://multicall3.com/abi
Lấy đồng thời nhiều dữ liệu trên Blockchain (Batch Contract Reads)
Để tương tác với Multicall3, bạn sử dụng phương thức aggregate3() với đầu vào là mảng theo cấu trúc Call3 và nhận về mảng theo cấu trúc Result:
struct Call3 {
// Target contract to call.
address target;
// If false, the entire call will revert if the call fails.
bool allowFailure;
// Data to call on the target contract.
bytes callData;
}
struct Result {
// True if the call succeeded, false otherwise.
bool success;
// Return data if the call succeeded, or revert data if the call reverted.
bytes returnData;
}
/// @notice Aggregate calls, ensuring each returns success if required
/// @param calls An array of Call3 structs
/// @return returnData An array of Result structs
function aggregate3(Call3[] calldata calls) public payable returns (Result[] memory returnData);
Để có dữ liệu block hiện tại (block number) và dấu thời gian (block timestamp) bạn thêm dữ tham số để gọi tới hàm getBlockNumber() và getCurrentBlockTimestamp() của chính hợp đồng Multicall3. Một số phương thức hỗ trợ thêm trong Multicall3:
- getBlockHash(): Trả về hàm băm khối cho số khối đã cho.
- getBlockNumber(): Trả về số của khối hiện tại.
- getCurrentBlockCoinbase(): Trả về coinbase của khối hiện tại.
- getCurrentBlockDifficulty(): Trả về độ khó của khối hiện tại đối với chuỗi Proof-of-Work hoặc giá trị RANDAO mới nhất đối với chuỗi Proof-of-Stake. Xem EIP-4399 để tìm hiểu thêm về điều này.
- getCurrentBlockGasLimit(): Trả về giới hạn gas của khối hiện tại.
- getCurrentBlockTimestamp(): Trả về dấu thời gian của khối hiện tại.
- getEthBalance(): Trả về số dư ETH (hoặc mã thông báo gốc) của địa chỉ đã cho.
- getLastBlockHash(): Trả về hàm băm khối của khối trước đó.
- getBasefee(): Trả về phí cơ bản của khối đã cho. Điều này sẽ hoàn nguyên nếu opcode BASEFEE không được hỗ trợ trên chuỗi đã cho. Xem EIP-1599 để tìm hiểu thêm về điều này.
- getChainId(): Trả về chuỗi ID.
Nếu bạn muốn gửi ít dữ liệu hơn và chấp nhận dữ liệu trả về ít chi tiết hơn, bạn có thể gọi hàm khác:
- aggregate3Value()
- aggregate()
- blockAndAggregate()
- tryAggregate()
- tryBlockAndAggregate()
Các hàm này khác nhau tham số và giá trị trả về, tùy theo mục đích sử dụng của bạn mà chọn hàm nào cho phù hợp nhất.
Thực hiện đồng thời nhiều giao dịch trên blockchain (Batch Contract Writes)
Nếu sử dụng Multicall3 cho mục đích này, hãy lưu ý rằng nó chưa được kiểm tra, do đó bạn tự chịu rủi ro khi sử dụng. Tuy nhiên, vì nó là một hợp đồng phi trạng thái nên nó sẽ an toàn khi được sử dụng đúng cách—nó sẽ không bao giờ giữ tiền của bạn sau khi giao dịch kết thúc và bạn không bao giờ được chấp thuận Multicall3 chi tiêu mã thông báo của mình.
Multicall3 cũng có thể được sử dụng để thực hiện hàng loạt các giao dịch trên chuỗi bằng cách sử dụng các phương pháp được mô tả trong phần trước. Khi sử dụng Multicall3 cho mục đích này, có hai chi tiết quan trọng bạn PHẢI hiểu:
- Cách msg.sender hoạt động khi thực hiện calling / delegatecalling tới một hợp đồng.
- Rủi ro khi sử dụng msg.value trong cuộc gọi multicall.
Trước khi giải thích kỹ hơn về hai vấn đề trên, trước tiên chúng ta hãy tìm hiểu một số thông tin cơ bản về cách thức thức hoạt động của máy ảo Ethereum (EVM).
Có hai loại tài khoản trong Ethereum:
- Tài khoản thuộc sở hữu bên ngoài (EOA) => Được kiểm soát bằng Private Key.
- Tài khoản hợp đồng =>Tài khoản hợp đồng được kiểm soát bằng code.
Khi một EOA gọi một hợp đồng, giá trị msg.sender trong khi thực hiện lệnh gọi sẽ cung cấp địa chỉ của EOA đó. Điều này cũng đúng nếu lệnh gọi được thực hiện theo hợp đồng. Từ “b” ở đây đề cập cụ thể đến mã opcode CALL. Bất cứ khi nào một CALL được thực thi, ngữ cảnh sẽ thay đổi. Ngữ cảnh mới có nghĩa là các hoạt động lưu trữ sẽ được thực hiện trên hợp đồng được gọi, có một giá trị mới (tức là msg.value) và một người gọi mới (tức là msg.sender).
EVM cũng hỗ trợ mã opcode DELEGATECALL, tương tự như CALL, nhưng khác ở một điểm rất quan trọng: nó không thay đổi ngữ cảnh của cuộc gọi. Điều này có nghĩa là hợp đồng được ủy quyền được gọi sẽ thấy cùng một msg.sender, cùng một msg.value và hoạt động trên cùng một bộ lưu trữ như hợp đồng gọi. Điều này rất mạnh mẽ, nhưng cũng có thể nguy hiểm.
Chú ý rằng msg.value không thay đổi với một cuộc gọi được ủy quyền, bạn phải cẩn thận khi dựa vào msg.value trong một cuộc gọi Multicall. Để tìm hiểu thêm về điều này, xem tại đây và tại đây.
Tương tác với Multicall3 bằng NodeJs
Có nhiều công cụ và thư viện đã hỗ trợ Multicall3, bạn có thể xem ở phần trên. Nhưng trong phần này, tôi viết ví dụ nhỏ sử dụng NodeJs để tương tác trực tiếp với hợp đồng Multicall3 thông qua thư viện web3.js. Toàn bộ mã nguồn tôi để trên github tại: nodejs-multicall3
Trong ví dụ này tôi đóng gọi phần tương tác với Multicall trong lớp Multicall3 trong tệp Multicall3.js. Lớp này tôi chỉ cài đặt hàm aggregate3() để tương tác tới blockchain để lấy dữ liệu. Ngoài ra tôi còn viết thêm hàm khác giúp tiện hơn cho việc mã hóa và giải mã dữ liệu.
Multicall3.js
const IMulticall3 = require('./abi/IMulticall3');
const Web3 = require('web3');
class Multicall3 {
constructor(network, rpcNode, contractAddr) {
this.network = network;
this.rpcNode = rpcNode;
this.contractAddr = contractAddr;
if (!this.contractAddr) this.contractAddr = "0xcA11bde05977b3631167028862bE2a173976CA11";
this.web3 = null;
this.contract = null;
}
// args is array of objects: { address target, bool allowFailure, bytes callData }
// Return array of objects { bool success, bytes data }
async aggregate3(args) {
try {
let contract = this._getContract();
let result = await contract.methods.aggregate3(args).call();
if (!result) return null;
return result.map(item => {
return { success: item.success, data: item.returnData };
});
} catch(ex) {
console.error("aggregate3() EXCEPTION", args, ex);
}
return null;
}
getAddress() {
return this.contractAddr;
}
getBlockNumberEncode() {
return this._getContract().methods.getBlockNumber().encodeABI();
}
getBlockHashEncode(blockNumber) {
return this._getContract().methods.getBlockHash(blockNumber).encodeABI();
}
getCurrentBlockCoinbaseEncode() {
return this._getContract().methods.getCurrentBlockCoinbase().encodeABI();
}
getCurrentBlockDifficultyEncode() {
return this._getContract().methods.getCurrentBlockDifficulty().encodeABI();
}
getCurrentBlockGasLimitEncode() {
return this._getContract().methods.getCurrentBlockGasLimit().encodeABI();
}
getCurrentBlockTimestampEncode() {
return this._getContract().methods.getCurrentBlockTimestamp().encodeABI();
}
getEthBalanceEncode(addr) {
return this._getContract().methods.getEthBalance(addr).encodeABI();
}
getLastBlockHashEncode() {
return this._getContract().methods.getLastBlockHash().encodeABI();
}
getBasefeeEncode() {
return this._getContract().methods.getBasefee().encodeABI();
}
getChainIdEncode() {
return this._getContract().methods.getChainId().encodeABI();
}
erc20_balanceOfEncode(addr) {
let web3 = this._getWeb3();
return web3.eth.abi.encodeFunctionCall({
name: "balanceOf",
type: "function",
inputs: [{
type: "address",
name: "account"
}]
}, [ addr ]);
}
ammv2_getReservesEncode() {
let web3 = this._getWeb3();
return web3.eth.abi.encodeFunctionCall({
name: "getReserves",
type: "function",
inputs: []
}, [ ]);
}
ammv3_slot0Encode() {
let web3 = this._getWeb3();
return web3.eth.abi.encodeFunctionCall({
name: "slot0",
type: "function",
inputs: []
}, [ ]);
}
ammv3_liquidityEncode() {
let web3 = this._getWeb3();
return web3.eth.abi.encodeFunctionCall({
name: "liquidity",
type: "function",
inputs: []
}, [ ]);
}
decodeParameter(type, hexString) {
let web3 = this._getWeb3();
return web3.eth.abi.decodeParameter(type, hexString);
}
decodeUint256(hexString) {
return this.decodeParameter("uint256", hexString);
}
decodeParameters(typesArray, hexString) {
let web3 = this._getWeb3();
return web3.eth.abi.decodeParameters(typesArray, hexString);
}
_getContract() {
if (!this.contract) {
let web3 = this._getWeb3();
this.contract = new web3.eth.Contract(IMulticall3, this.contractAddr);
}
return this.contract;
}
_getWeb3() {
if (!this.web3) {
this.web3 = new Web3(this.rpcNode);
}
return this.web3;
}
}
module.exports = Multicall3;
Trong tệp main.js, tôi sử dụng lớp Multicall3 để lấy nhiều dữ liệu cùng lúc, trong đó luôn có 2 dữ liệu là Block Number và Block Timestamp. Đây là 2 dữ liệu quan trọng để bạn biết dữ liệu này có cập nhật mới nhất hay không. Nếu dữ liệu cách quá lâu so với hiện tại, điều đó có nghĩa là RPC Node đang gặp vấn đề.
Trong file main.js mình demo 3 hàm lấy dữ liệu:
- getBalances() : Nhận số dư trên các tài sản khác nhau của một hoặc nhiều ví.
- getReserves() : Lấy thông tin về dự trữ trong pool của Uniswap V2
- getLiquidityAndPrice() : Nhận thông tin về tính thanh khoản, tick, sqrtPriceX96 trong pool của Uniswap V3
main.js
const parseArgs = require('minimist');
const Multicall3 = require('./Multicall3');
let multicallObj = null;
function getMulticall3() {
if (!multicallObj) multicallObj = new Multicall3("ARBITRUM", "https://arb1.arbitrum.io/rpc");
return multicallObj;
}
async function getBalances(addr) {
let multicall = getMulticall3();
let args = [];
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getBlockNumberEncode()
});
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getCurrentBlockTimestampEncode()
});
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getEthBalanceEncode(addr)
});
args.push({
target: "0xff970a61a04b1ca14834a43f5de4533ebddb5cc8", // USDC
allowFailure: true,
callData: multicall.erc20_balanceOfEncode(addr)
});
args.push({
target: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", // USDT
allowFailure: true,
callData: multicall.erc20_balanceOfEncode(addr)
});
let results = await multicall.aggregate3(args);
let output = {
blockNumber: multicall.decodeUint256(results[0].data),
blockTime: multicall.decodeUint256(results[1].data),
balances: {
ETH: multicall.decodeUint256(results[2].data),
USDC: multicall.decodeUint256(results[3].data),
USDT: multicall.decodeUint256(results[4].data),
}
};
console.log("Results:", output);
}
async function getReserves() {
let multicall = getMulticall3();
let args = [];
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getBlockNumberEncode()
});
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getCurrentBlockTimestampEncode()
});
args.push({
target: "0x8b8149dd385955dc1ce77a4be7700ccd6a212e65", // WETH-USDC
allowFailure: true,
callData: multicall.ammv2_getReservesEncode()
});
args.push({
target: "0xb7e50106a5bd3cf21af210a755f9c8740890a8c9", // MAGIC-WETH
allowFailure: true,
callData: multicall.ammv2_getReservesEncode()
});
args.push({
target: "0x87425d8812f44726091831a9a109f4bdc3ea34b4", // GRAIL-USDC
allowFailure: true,
callData: multicall.ammv2_getReservesEncode()
});
let results = await multicall.aggregate3(args);
let output = {
blockNumber: multicall.decodeUint256(results[0].data),
blockTime: multicall.decodeUint256(results[1].data),
reserves: { }
};
let pools = [ "WETH-USDC", "MAGIC-WETH", "GRAIL-USDC" ];
for (let idx=0; idx<pools.length; idx++) {
let poolName =pools[idx];
let tokens = poolName.split("-");
let result = multicall.decodeParameters(["uint256", "uint256"], results[2+idx].data);
output.reserves[poolName] = {};
output.reserves[poolName][tokens[0]] = result[0];
output.reserves[poolName][tokens[1]] = result[1];
}
console.log("Results:", output);
}
async function getLiquidityAndPrice() {
let multicall = getMulticall3();
let args = [];
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getBlockNumberEncode()
});
args.push({
target: multicall.getAddress(),
allowFailure: true,
callData: multicall.getCurrentBlockTimestampEncode()
});
args.push({
target: "0xf9188aff2b5fa1e1e5542995806706fe6a84f3f3", // GHO-WETH-300
allowFailure: true,
callData: multicall.ammv3_slot0Encode()
});
args.push({
target: "0xf9188aff2b5fa1e1e5542995806706fe6a84f3f3", // GHO-WETH-300
allowFailure: true,
callData: multicall.ammv3_liquidityEncode()
});
args.push({
target: "0xc31e54c7a869b9fcbecc14363cf510d1c41fa443", // ETH-USDC-50
allowFailure: true,
callData: multicall.ammv3_slot0Encode()
});
args.push({
target: "0xc31e54c7a869b9fcbecc14363cf510d1c41fa443", // ETH-USDC-50
allowFailure: true,
callData: multicall.ammv3_liquidityEncode()
});
args.push({
target: "0x2f5e87c9312fa29aed5c179e456625d79015299c", // WBTC-ETH-50
allowFailure: true,
callData: multicall.ammv3_slot0Encode()
});
args.push({
target: "0x2f5e87c9312fa29aed5c179e456625d79015299c", // WBTC-ETH-50
allowFailure: true,
callData: multicall.ammv3_liquidityEncode()
});
let results = await multicall.aggregate3(args);
let output = {
blockNumber: multicall.decodeUint256(results[0].data),
blockTime: multicall.decodeUint256(results[1].data),
reserves: { }
};
let pools = [ " GHO-WETH-300", "ETH-USDC-50", "WBTC-ETH-50" ];
for (let idx=0; idx<pools.length; idx++) {
let poolName =pools[idx];
let result = multicall.decodeParameters(["uint160", "int24", "uint16", "uint16", "uint16", "uint8", "bool"], results[2 + 2*idx].data);
output.reserves[poolName] = {
sqrtPriceX96: result[0],
tick: result[1]
};
result = multicall.decodeParameter("uint128", results[2 + 2*idx + 1].data);
output.reserves[poolName].liquidity = result;
}
console.log("Results:", output);
}
function showHelp() {
console.log("Commands:");
console.log("\tnode main.js --type=balance");
console.log("\tnode main.js --type=ammv2");
console.log("\tnode main.js --type=ammv3");
}
async function main() {
var opts = parseArgs(process.argv.slice(2), {
string: [ ]
});
if (!opts.type) {
showHelp();
return;
}
let type = opts.type;
if (type=="balance") {
await getBalances("0xe93685f3bBA03016F02bD1828BaDD6195988D950");
} else if (type=="ammv2") {
await getReserves();
} else if (type=="ammv3") {
await getLiquidityAndPrice();
} else {
showHelp();
}
}
main();
Trước khi chạy ví dụ này, bạn cần cài đặt thư viện cần thiết bằng lệnh:
npm install
Sau đó bạn chạy 1 trong các lệnh sau:
node main.js --type=balance
node main.js --type=ammv2
node main.js --type=ammv3
1 Pingbacks