읽는 데 24분 소요
이제 Quicknode 스트림을 통해 단일 파이프라인에서 여러 대상에 동시에 데이터를 전송할 수 있습니다. 중복 스트림이 발생하지 않으며, 구성 불일치도 없고, 대상당 추가 요금도 부과되지 않습니다. 스트림당 이용 가능한 대상 수는 요금제에 따라 다릅니다. 자세히 알아보기.
개요
DeFi에서 스왑, 스테이킹 등 어떤 거래를 수행하든, 사용자는 ERC-20 토큰에 대한 권한을 스마트 계약에 승인함으로써, 스마트 계약이 사용자를 대신해 토큰을 사용할 수 있도록 허용합니다. 대부분의 경우, 스마트 계약은 향후 가스 비용을 절약하기 위해 해당 거래에 필요한 양보다 훨씬 많은 토큰을 승인하지만, 이는 많은 양의 토큰을 승인하게 됨으로써 보안 위험이 될 수 있습니다. 따라서 사용자나 누구나 특정 지갑 주소에 대한 다양한 토큰의 승인 한도 및 승인 내역을 확인할 수 있는 ‘토큰 승인 한도 확인기’ 앱을 만들어 보겠습니다.
토큰 한도 확인 앱
1단계: Quicknode 스트림 생성하기
다양한 블록에 대한 로그를 필터링하여 할당량 인덱스 이벤트를 생성해야 합니다. 또한 과거 할당량 정보도 확보해야 하므로, 제네시스 블록의 데이터도 가져와야 합니다.
만들기 Quicknode 계정 아직 가지고 계시지 않다면, 다음을 생성하세요. 스트림. 링크를 클릭하면, 로그 데이터셋과 필터 코드가 스트림 구성에 미리 채워집니다.
자, 그럼 스트림 시작 선택 블록 # 그리고 블록 0 또는 1을 입력하세요. 제네시스 블록부터 체인의 인덱스를 지정해야 하므로, 스트림 종료 생성되는 대로 최신 블록 데이터가 필요하기 때문에, 이 과정은 아무런 제약 없이 진행되어야 합니다.
필터 코드는 다음과 같이 보일 것입니다:
function extractAddress(paddedAddress) {
if (typeof paddedAddress !== 'string' || paddedAddress.length < 40) {
console.error('Invalid address format:', paddedAddress);
return null;
}
return '0x' + paddedAddress.slice(-40);
}
function hexToDecimal(hexValue) {
if (typeof hexValue !== 'string') {
console.error('Invalid hex value:', hexValue);
return '0';
}
// Remove '0x' prefix if present
hexValue = hexValue.replace(/^0x/, '');
// If the resulting string is empty, return '0'
if (hexValue === '') {
return '0';
}
// Parse the hex string to a BigInt (to handle large numbers)
try {
return BigInt('0x' + hexValue).toString();
} catch (error) {
console.error('Error converting hex to decimal:', error);
return '0';
}
}
function findAndFilterLogs(data) {
const targetTopic = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925";
let results = [];
let signatureFound = false;
function search(item) {
if (Array.isArray(item)) {
item.forEach(search);
} else if (typeof item === 'object' && item !== null) {
if (item.topics && item.topics[0] === targetTopic) {
signatureFound = true;
try {
const wallet = extractAddress(item.topics[1]);
const spender = extractAddress(item.topics[2]);
if (wallet && spender) {
results.push({
token: item.address,
wallet: wallet,
spender: spender,
amount: hexToDecimal(item.data),
blockNumber: hexToDecimal(item.blockNumber),
transactionHash: item.transactionHash
});
}
} catch (error) {
console.error('Error processing log entry:', error);
}
} else {
Object.values(item).forEach(search);
}
}
}
search(data);
return signatureFound ? results : null;
}
function main(data) {
const filteredData = findAndFilterLogs(data);
console.log(JSON.stringify(filteredData, null, 2));
return filteredData;
}
이 코드 스니펫에서는 특정 토큰 할당/승인 이벤트에 대한 로그를 필터링하고 있으며, 승인 (index_topic_1 주소 소유자, index_topic_2 주소 지출자, uint256 값) 그리고 우리는 로그에서 해당 시그니처에 해당하는 이벤트를 찾아냄으로써 이를 수행하고 있습니다 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925 , 이는 기본적으로 다음의 인코딩/해시 처리된 형식이며, 승인 이벤트. 해당 이벤트를 필터링하는 것 외에도, 주소와 블록 번호를 더 읽기 쉽게 표시하고 다음과 같은 유용한 정보를 풍부하게 제공하고 있습니다. 토큰, 지갑, 낭비꾼, 금액, 블록 번호, 그리고 트랜잭션 해시.
특정 데이터만 필요하고 원치 않는 데이터는 처리할 필요가 없기 때문에, 스트림 데이터를 목적지로 전송하기 전에 필터링합니다. 스트림을 사용하면 스트리밍된 데이터에 대해서만 요금이 부과되므로, 일석이조입니다.
자, 이제 다음을 클릭하세요. 다음 그리고 다음을 선택하세요 PostgreSQL 목적지 유형으로 선택하고 Postgres 인스턴스의 구성 세부 정보를 입력하세요. 표 필드에 테이블 이름을 입력하면, Streams에서 해당 테이블을 생성하고 데이터가 해당 테이블로 스트리밍됩니다.
2단계: pgSQL 함수 생성하기
먼저 열이 포함된 새 테이블을 생성하겠습니다. 지갑, 승인 내역, 최신 승인 내역, 그리고 최종 업데이트.
그런 다음 다음 함수를 사용하여 Streams로 생성된 테이블의 데이터를 더욱 정제할 것입니다.
CREATE OR REPLACE FUNCTION process_wallet_approval_states_latest()
RETURNS void AS $$
DECLARE
-- make sure to add your table's name instead of token_allowances_new
cur CURSOR FOR SELECT * FROM token_allowances_new ORDER BY block_number ASC;
row_data RECORD;
allowance_events JSONB;
allowance_event JSONB;
v_wallet_address VARCHAR(42);
approval_data JSONB;
total_rows INT;
processed_rows INT := 0;
skipped_rows INT := 0;
BEGIN
SELECT COUNT(*) INTO total_rows FROM token_allowances_new;
RAISE NOTICE 'Starting to process % rows', total_rows;
FOR row_data IN cur LOOP
BEGIN
IF jsonb_typeof(row_data.data) = 'array' THEN
allowance_events := row_data.data;
ELSE
-- If it's not an array, wrap it in an array
allowance_events := jsonb_build_array(row_data.data);
END IF;
FOR allowance_event IN SELECT jsonb_array_elements(allowance_events)
LOOP
BEGIN
v_wallet_address := allowance_event->>'wallet';
IF v_wallet_address IS NULL THEN
RAISE EXCEPTION 'Wallet address is null';
END IF;
approval_data := jsonb_build_object(
'token', allowance_event->>'token',
'spender', allowance_event->>'spender',
'amount', allowance_event->>'amount',
'blockNumber', (allowance_event->>'blockNumber')::bigint,
'transactionHash', allowance_event->>'transactionHash'
);
-- Insert or update the wallet_approval_states table
-- make sure to add your table's name instead of wallet_approval_states_latest
INSERT INTO wallet_approval_states_latest (wallet_address, approval_history, latest_approvals)
VALUES (
v_wallet_address,
ARRAY[approval_data],
jsonb_build_object(
concat(approval_data->>'token', ':', approval_data->>'spender'),
approval_data
)
)
ON CONFLICT (wallet_address)
DO UPDATE SET
approval_history = wallet_approval_states_latest.approval_history || ARRAY[approval_data],
latest_approvals =
CASE
WHEN (approval_data->>'blockNumber')::bigint > COALESCE((wallet_approval_states_latest.latest_approvals->>(concat(approval_data->>'token', ':', approval_data->>'spender')))::jsonb->>'blockNumber', '0')::bigint
THEN wallet_approval_states_latest.latest_approvals || jsonb_build_object(concat(approval_data->>'token', ':', approval_data->>'spender'), approval_data)
ELSE wallet_approval_states_latest.latest_approvals
END,
last_updated = CURRENT_TIMESTAMP
WHERE wallet_approval_states_latest.wallet_address = v_wallet_address;
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Error processing event in row % (block %): %', processed_rows, row_data.block_number, SQLERRM;
END;
END LOOP;
processed_rows := processed_rows + 1;
IF processed_rows % 100 = 0 THEN
RAISE NOTICE 'Processed % out of % rows', processed_rows, total_rows;
END IF;
EXCEPTION
WHEN OTHERS THEN
skipped_rows := skipped_rows + 1;
RAISE WARNING 'Error processing row % (block %): %', processed_rows, row_data.block_number, SQLERRM;
END;
END LOOP;
RAISE NOTICE 'Finished processing % rows. Skipped % rows.', processed_rows, skipped_rows;
END;
$$ LANGUAGE plpgsql;
참고: 위 함수에서 Streams를 통해 생성된 테이블은 `token_allowances_new` 이며, 정제된 데이터가 포함된 또 다른 테이블은 `wallet_approval_states_latest`입니다.
3단계: React 앱과 Express 서버
GitHub의 mono 샘플 앱 저장소 내 이 디렉터리에서 React 앱과 Express 서버 코드를 찾아보세요.
여기에는 React 앱이 src 디렉터리, 환경 설정 파일, 그리고 server.js Express 서버용.
이 React 앱에는 WalletApprovalSearch.js라는 컴포넌트가 있는데, 이 컴포넌트는 사용자로부터 지갑 주소를 받아온 뒤, Express 서버에서 승인 데이터를 가져오고, Quicknode RPC를 사용하여 해당 스마트 계약에서 토큰 데이터를 가져온 다음 이를 표시합니다.
import React, { useState } from 'react';
import { ethers } from 'ethers';
import axios from 'axios';
import {
TextField, Button, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, Modal,
Box, Typography, Container, CircularProgress
} from '@mui/material';
const WalletApprovalSearch = () => {
const [walletAddress, setWalletAddress] = useState('');
const [approvalData, setApprovalData] = useState(null);
const [historyData, setHistoryData] = useState(null);
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async () => {
setIsLoading(true);
setError('');
setApprovalData(null);
setHistoryData(null);
try {
const response = await axios.get(`http://localhost:3001/api/wallet-approval-states/${walletAddress}`);
const { latest_approvals, approval_history } = response.data;
if (latest_approvals) {
const processedApprovals = await processApprovals(latest_approvals);
setApprovalData(processedApprovals);
}
if (approval_history) {
const historyObject = typeof approval_history === 'string'
? JSON.parse(approval_history)
: approval_history;
const processedHistory = await processApprovals(historyObject);
setHistoryData(processedHistory);
}
if (!latest_approvals && !approval_history) {
setError('No approval data found for this wallet address.');
}
} catch (error) {
console.error('Error fetching data:', error);
setError('Error fetching data. Please try again. ' + (error.response?.data?.error || error.message));
} finally {
setIsLoading(false);
}
};
const processApprovals = async (approvals) => {
const provider = new ethers.JsonRpcProvider(process.env.REACT_APP_QUICKNODE_ENDPOINT);
return Promise.all(Object.entries(approvals).map(async ([key, approval]) => {
const tokenContract = new ethers.Contract(approval.token, [
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
], provider);
try {
const [name, symbol, decimals] = await Promise.all([
tokenContract.name().catch(() => 'Unknown'),
tokenContract.symbol().catch(() => 'UNK'),
tokenContract.decimals().catch(() => 18),
]);
const normalizedAmount = ethers.formatUnits(approval.amount, decimals);
return {
...approval,
tokenName: name,
tokenSymbol: symbol,
normalizedAmount,
};
} catch (error) {
console.warn(`Error processing token ${approval.token}:`, error);
return {
...approval,
tokenName: 'Unknown',
tokenSymbol: 'UNK',
normalizedAmount: ethers.formatUnits(approval.amount, 18), // assume 18 decimals
};
}
}));
};
return (
<Container maxWidth="lg">
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Wallet Approval Search
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<TextField
fullWidth
label="Wallet Address"
variant="outlined"
value={walletAddress}
onChange={(e) => setWalletAddress(e.target.value)}
/>
<Button variant="contained" onClick={handleSearch} disabled={isLoading}>
{isLoading ? <CircularProgress size={24} /> : 'Search'}
</Button>
</Box>
{error && (
<Typography color="error" sx={{ mb: 2 }}>
{error}
</Typography>
)}
{isLoading && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 2 }}>
<CircularProgress />
</Box>
)}
{!isLoading && approvalData && approvalData.length > 0 && (
<>
<Typography variant="h6" gutterBottom>
Latest Approvals
</Typography>
{approvalData.some(approval => approval.tokenName === 'Unknown') && (
<Typography color="warning" sx={{ mb: 2 }}>
Warning: Some token information could not be retrieved. These tokens are marked as 'Unknown'.
</Typography>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Token Name</TableCell>
<TableCell>Token Symbol</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Spender</TableCell>
<TableCell>Block Number</TableCell>
</TableRow>
</TableHead>
<TableBody>
{approvalData.map((approval, index) => (
<TableRow key={index}>
<TableCell>{approval.tokenName}</TableCell>
<TableCell>{approval.tokenSymbol}</TableCell>
<TableCell>{approval.normalizedAmount}</TableCell>
<TableCell>{approval.spender}</TableCell>
<TableCell>{approval.blockNumber}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{historyData && historyData.length > 0 && (
<Button onClick={() => setIsHistoryModalOpen(true)} sx={{ mt: 2 }}>
View History
</Button>
)}
</>
)}
{!isLoading && approvalData !== null && approvalData.length === 0 && (
<Typography>No records found</Typography>
)}
<Modal
open={isHistoryModalOpen}
onClose={() => setIsHistoryModalOpen(false)}
>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80%',
maxWidth: 800,
bgcolor: 'background.paper',
boxShadow: 24,
p: 4,
maxHeight: '90vh',
overflow: 'auto',
}}>
<Typography variant="h6" gutterBottom>Approval History</Typography>
{historyData && historyData.some(approval => approval.tokenName === 'Unknown') && (
<Typography color="warning" sx={{ mb: 2 }}>
Warning: Some historical token information could not be retrieved. These tokens are marked as 'Unknown'.
</Typography>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Token Name</TableCell>
<TableCell>Token Symbol</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Spender</TableCell>
<TableCell>Block Number</TableCell>
</TableRow>
</TableHead>
<TableBody>
{historyData && historyData.map((approval, index) => (
<TableRow key={index}>
<TableCell>{approval.tokenName}</TableCell>
<TableCell>{approval.tokenSymbol}</TableCell>
<TableCell>{approval.normalizedAmount}</TableCell>
<TableCell>{approval.spender}</TableCell>
<TableCell>{approval.blockNumber}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Button onClick={() => setIsHistoryModalOpen(false)} sx={{ mt: 2 }}>
Close
</Button>
</Box>
</Modal>
</Box>
</Container>
);
};
export default WalletApprovalSearch;
환경 변수 파일에는 Quicknode URL과 PostgreSQL 인스턴스의 구성 정보가 포함되어 있습니다.
REACT_APP_QUICKNODE_ENDPOINT=
# 데이터베이스 구성
DB_USER=
DB_HOST=
DB_NAME=
DB_PASSWORD=
DB_PORT=
server.js는 PostgreSQL 인스턴스에 연결하여 3001번 포트에서 서비스를 제공하는 Express 서버입니다.
require('dotenv').config();
const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
ssl: {
rejectUnauthorized: false
}
});
// Test the database connection
pool.connect((err, client, release) => {
if (err) {
return console.error('Error acquiring client', err.stack);
}
client.query('SELECT NOW()', (err, result) => {
release();
if (err) {
return console.error('Error executing query', err.stack);
}
console.log('Connected to database successfully');
});
});
const app = express();
app.use(cors());
app.get('/api/wallet-approval-states/:walletAddress', async (req, res) => {
try {
const { walletAddress } = req.params;
const result = await pool.query(
'SELECT latest_approvals, approval_history FROM wallet_approval_states WHERE wallet_address = $1', //replace wallet_approval_states with your table name
[walletAddress]
);
if (result.rows.length > 0) {
res.json(result.rows[0]);
} else {
res.status(404).json({ error: 'Wallet address not found' });
}
} catch (error) {
console.error('Database error:', error);
res.status(500).json({ error: 'Internal server error', details: error.message });
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
여러분의 피드백을 ❤️ 환영합니다!
의견이나 다루었으면 하는 새로운 주제가 있다면 알려주세요. 여러분의 의견을 기다리고 있습니다.
