first commit

This commit is contained in:
2025-08-27 16:05:28 +08:00
commit a9e7a1c201
11 changed files with 3525 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

29
README.md Normal file
View File

@ -0,0 +1,29 @@
# web3-test
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

2944
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "web3-test",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"ethers": "^6.13.2",
"vue": "^3.5.18"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

453
src/App.vue Normal file
View File

@ -0,0 +1,453 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ethers } from 'ethers'
const ownerAddress = ref('')
const connectedAddress = ref('')
const isLoading = ref(false)
const errorMessage = ref('')
const safes = ref([])
const progressText = ref('')
const hasInjectedProvider = ref(false)
const latestBlock = ref(null)
const selectedPreset = ref(50000)
const allSafes = ref([])
// BSC Testnet RPC 与 Safe 工厂地址SafeProxyFactory 1.3.xCREATE2 地址)
const RPC_HTTP_URL = 'https://bsc-testnet-rpc.publicnode.com'
const RPC_WS_URL = 'wss://bsc-testnet-rpc.publicnode.com'
const SAFE_PROXY_FACTORY = '0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2'
// 可选:自定义扫描区块范围
const fromBlockInput = ref('')
const toBlockInput = ref('')
const SAFE_ABI = [
'function getOwners() view returns (address[])',
]
function isValidAddress(address) {
return /^0x[a-fA-F0-9]{40}$/.test(address)
}
async function fetchSafes() {
errorMessage.value = ''
safes.value = []
progressText.value = ''
const targetOwner = ownerAddress.value.trim()
if (!isValidAddress(targetOwner)) {
errorMessage.value = '请输入有效的 0x 开头的以太坊地址'
return
}
isLoading.value = true
try {
const provider = new ethers.JsonRpcProvider(RPC_HTTP_URL, 97)
const latestBlock = await provider.getBlockNumber()
const fromBlock = fromBlockInput.value
? Number(fromBlockInput.value)
: Math.max(1, latestBlock - 300000)
const toBlock = toBlockInput.value ? Number(toBlockInput.value) : latestBlock
if (Number.isNaN(fromBlock) || Number.isNaN(toBlock) || fromBlock > toBlock) {
throw new Error('区块范围不合法')
}
const factoryTopic = ethers.id('ProxyCreation(address,address)')
const logs = await getFactoryLogsChunked(provider, SAFE_PROXY_FACTORY, factoryTopic, fromBlock, toBlock, 50000, (processedTo) => {
progressText.value = `正在扫描日志... 当前进度: 至区块 ${processedTo}`
})
progressText.value = `扫描到 ${logs.length} 个 ProxyCreation 事件,正在筛选...`
const iface = new ethers.Interface(['event ProxyCreation(address proxy,address singleton)'])
const candidates = new Set()
for (const log of logs) {
try {
const decoded = iface.decodeEventLog('ProxyCreation', log.data, log.topics)
const proxy = decoded?.proxy || decoded?.[0]
if (proxy) candidates.add(ethers.getAddress(proxy))
} catch (_) {
// 忽略无法解码的日志
}
}
const uniqueProxies = Array.from(candidates)
if (uniqueProxies.length === 0) {
errorMessage.value = '未找到任何 Safe 创建事件,请调大区间或确认网络'
return
}
const safeIface = new ethers.Interface(SAFE_ABI)
const matched = []
let checked = 0
for (const proxy of uniqueProxies) {
try {
const data = safeIface.encodeFunctionData('getOwners', [])
const result = await provider.call({ to: proxy, data })
const owners = safeIface.decodeFunctionResult('getOwners', result)[0]
if (owners.map((o) => ethers.getAddress(o)).includes(ethers.getAddress(targetOwner))) {
matched.push(proxy)
}
} catch (_) {
// 非 Safe 合约或未初始化,忽略
} finally {
checked += 1
if (checked % 25 === 0) {
progressText.value = `已检查 ${checked}/${uniqueProxies.length} 个候选...`
}
}
}
safes.value = matched
if (safes.value.length === 0) {
errorMessage.value = '未找到关联的 Safe 多签地址。'
}
} catch (err) {
errorMessage.value = err?.message || '查询失败,请稍后重试'
} finally {
isLoading.value = false
progressText.value = ''
}
}
// 一键自动填充区块范围from = latest - 预设跨度to = latest
async function fillBlockRange() {
try {
const provider = new ethers.JsonRpcProvider(RPC_HTTP_URL, 97)
const latest = await provider.getBlockNumber()
latestBlock.value = latest
const span = Number(selectedPreset.value) || 50000
const from = Math.max(1, latest - span)
fromBlockInput.value = String(from)
toBlockInput.value = String(latest)
} catch (e) {
errorMessage.value = e?.message || '获取最新区块失败'
}
}
// 分段获取工厂事件,避免 RPC getLogs 区块跨度限制
async function getFactoryLogsChunked(provider, address, topic0, fromBlock, toBlock, maxSpan, onProgress) {
const all = []
let span = Math.max(1000, Math.min(maxSpan || 50000, toBlock - fromBlock))
let start = fromBlock
while (start <= toBlock) {
let end = Math.min(start + span, toBlock)
try {
const logs = await provider.getLogs({ address, topics: [topic0], fromBlock: start, toBlock: end })
all.push(...logs)
if (onProgress) onProgress(end)
start = end + 1
} catch (e) {
const msg = String(e?.message || '')
// 自适应减小跨度
if (span <= 1000 || msg.includes('exceed maximum block range')) {
if (span <= 1000) throw e
span = Math.max(1000, Math.floor(span / 2))
} else {
// 其他错误,稍微减半重试
span = Math.max(1000, Math.floor(span / 2))
}
}
}
return all
}
// 通过 WS provider 扫描历史所有 Safe 地址
async function fetchAllSafesViaWs() {
errorMessage.value = ''
progressText.value = ''
isLoading.value = true
try {
const provider = new ethers.WebSocketProvider(RPC_WS_URL, 97)
const latest = await provider.getBlockNumber()
if (!fromBlockInput.value) {
const span = Number(selectedPreset.value) || 50000
fromBlockInput.value = String(Math.max(1, latest - span))
}
if (!toBlockInput.value) {
toBlockInput.value = String(latest)
}
const fromBlock = Number(fromBlockInput.value)
const toBlock = Number(toBlockInput.value)
const factoryTopic = ethers.id('ProxyCreation(address,address)')
const logs = await getFactoryLogsChunked(provider, SAFE_PROXY_FACTORY, factoryTopic, fromBlock, toBlock, 50000, (processedTo) => {
progressText.value = `通过 WS 扫描... 已至区块 ${processedTo}`
})
const iface = new ethers.Interface(['event ProxyCreation(address proxy,address singleton)'])
const set = new Set(allSafes.value)
for (const log of logs) {
try {
const decoded = iface.decodeEventLog('ProxyCreation', log.data, log.topics)
const proxy = decoded?.proxy || decoded?.[0]
if (proxy) set.add(ethers.getAddress(proxy))
} catch (_) {}
}
allSafes.value = Array.from(set)
} catch (e) {
errorMessage.value = e?.message || '通过 WS 获取失败'
} finally {
isLoading.value = false
progressText.value = ''
}
}
// 连接浏览器钱包MetaMask 等)
async function connectWallet() {
try {
if (!window.ethereum) {
errorMessage.value = '未检测到浏览器钱包(如 MetaMask'
return
}
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' })
connectedAddress.value = accounts[0] ? ethers.getAddress(accounts[0]) : ''
if (!ownerAddress.value && connectedAddress.value) ownerAddress.value = connectedAddress.value
// 监听账户/网络变化
window.ethereum.on?.('accountsChanged', (accs) => {
connectedAddress.value = accs[0] ? ethers.getAddress(accs[0]) : ''
if (!ownerAddress.value && connectedAddress.value) ownerAddress.value = connectedAddress.value
})
window.ethereum.on?.('chainChanged', () => {
// 可根据需要刷新
})
} catch (e) {
errorMessage.value = e?.message || '连接钱包失败'
}
}
// WS 订阅 Safe 工厂 ProxyCreation
let wsProvider = null
let wsListener = null
function startWsSubscription() {
try {
if (wsProvider) return
wsProvider = new ethers.WebSocketProvider(RPC_WS_URL, 97)
const factoryTopic = ethers.id('ProxyCreation(address,address)')
wsListener = async (log) => {
try {
if (!log?.topics || log.topics[0] !== factoryTopic || log.address.toLowerCase() !== SAFE_PROXY_FACTORY.toLowerCase()) return
const iface = new ethers.Interface(['event ProxyCreation(address proxy,address singleton)'])
const decoded = iface.decodeEventLog('ProxyCreation', log.data, log.topics)
const proxy = decoded?.proxy || decoded?.[0]
if (!proxy) return
// 记录所有 Safe实时新建
const normalized = ethers.getAddress(proxy)
if (!allSafes.value.includes(normalized)) allSafes.value = [normalized, ...allSafes.value]
const provider = new ethers.JsonRpcProvider(RPC_HTTP_URL, 97)
const safeIface = new ethers.Interface(SAFE_ABI)
const data = safeIface.encodeFunctionData('getOwners', [])
const result = await provider.call({ to: proxy, data })
const owners = safeIface.decodeFunctionResult('getOwners', result)[0]
const target = ownerAddress.value || connectedAddress.value
if (target && owners.map((o) => ethers.getAddress(o)).includes(ethers.getAddress(target))) {
const addr = ethers.getAddress(proxy)
if (!safes.value.includes(addr)) safes.value = [addr, ...safes.value]
}
} catch (_) {
// 忽略单条失败
}
}
wsProvider.on({ address: SAFE_PROXY_FACTORY, topics: [factoryTopic] }, wsListener)
} catch (e) {
errorMessage.value = e?.message || 'WS 订阅失败'
}
}
function stopWsSubscription() {
try {
if (wsProvider && wsListener) {
const factoryTopic = ethers.id('ProxyCreation(address,address)')
wsProvider.off({ address: SAFE_PROXY_FACTORY, topics: [factoryTopic] }, wsListener)
}
wsListener = null
wsProvider?.destroy?.()
wsProvider = null
} catch (_) {}
}
onMounted(() => {
try { hasInjectedProvider.value = typeof window !== 'undefined' && !!window.ethereum } catch (_) { hasInjectedProvider.value = false }
startWsSubscription()
})
onBeforeUnmount(() => {
stopWsSubscription()
})
</script>
<template>
<div class="container">
<h1>查询多签 Safe 地址BSC Testnet, chain=97</h1>
<div class="form">
<button @click="connectWallet" :disabled="!hasInjectedProvider">{{ connectedAddress ? '已连接' : '连接钱包' }}</button>
<span v-if="connectedAddress" class="mono">{{ connectedAddress }}</span>
</div>
<div class="form">
<input
v-model="ownerAddress"
type="text"
placeholder="输入钱包地址0x..."
spellcheck="false"
/>
<button :disabled="isLoading" @click="fetchSafes">{{ isLoading ? '查询中...' : '查询' }}</button>
</div>
<div class="form small">
<input v-model="fromBlockInput" type="number" placeholder="起始区块(可选)" />
<input v-model="toBlockInput" type="number" placeholder="结束区块(可选)" />
<div>
<select v-model="selectedPreset">
<option :value="10000">最近 1 万块</option>
<option :value="50000">最近 5 万块</option>
<option :value="100000">最近 10 万块</option>
<option :value="300000">最近 30 万块</option>
<option :value="1000000">最近 100 万块</option>
</select>
<button @click="fillBlockRange">自动填充</button>
</div>
<small v-if="latestBlock !== null" class="hint">最新区块: {{ latestBlock }}</small>
</div>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<p v-if="progressText && isLoading" class="progress">{{ progressText }}</p>
<ul v-if="safes.length > 0" class="result">
<li v-for="safe in safes" :key="safe">
<span class="mono">{{ safe }}</span>
<a
class="link"
:href="`https://testnet.bscscan.com/address/${safe}`"
target="_blank"
rel="noopener"
>查看区块浏览器</a>
</li>
</ul>
<div class="form">
<button :disabled="isLoading" @click="fetchAllSafesViaWs">通过 WS 获取所有 Safe</button>
<span v-if="allSafes.length"> {{ allSafes.length }} </span>
</div>
<ul v-if="allSafes.length > 0" class="result">
<li v-for="safe in allSafes" :key="safe">
<span class="mono">{{ safe }}</span>
<a
class="link"
:href="`https://testnet.bscscan.com/address/${safe}`"
target="_blank"
rel="noopener"
>查看</a>
</li>
</ul>
</div>
</template>
<style scoped>
.container {
max-width: 720px;
margin: 48px auto;
padding: 0 16px;
}
h1 {
font-size: 20px;
margin-bottom: 16px;
}
.form {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.form.small {
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
input[type="text"] {
flex: 1;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
button {
padding: 10px 16px;
border: 1px solid #333;
background: #111;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #d93025;
margin: 8px 0 16px;
}
.progress {
color: #6b7280;
margin: 0 0 12px;
}
.form.small input[type="number"] {
max-width: 220px;
}
select {
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
margin-right: 8px;
}
.hint {
color: #6b7280;
}
.result {
list-style: none;
padding: 0;
margin: 0;
}
.result li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #eee;
border-radius: 8px;
margin-bottom: 8px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
.link {
color: #2563eb;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
</style>

4
src/main.js Normal file
View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

18
vite.config.js Normal file
View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})