filter out tokens without logo on tokens list
This commit is contained in:
+217
-135
@@ -7,6 +7,7 @@ import {
|
|||||||
type RPCSchema,
|
type RPCSchema,
|
||||||
Utils,
|
Utils,
|
||||||
} from 'electrobun/bun'
|
} from 'electrobun/bun'
|
||||||
|
import { inspect } from 'util'
|
||||||
|
|
||||||
const DEV_SERVER_PORT = 5173
|
const DEV_SERVER_PORT = 5173
|
||||||
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
|
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
|
||||||
@@ -56,7 +57,18 @@ ApplicationMenu.setApplicationMenu([
|
|||||||
|
|
||||||
const url = await getMainViewUrl()
|
const url = await getMainViewUrl()
|
||||||
|
|
||||||
const ETHERSCAN_API = 'https://api.etherscan.io/v2/api'
|
const ALCHEMY_NETWORKS: Record<string, string> = {
|
||||||
|
'1': 'eth-mainnet',
|
||||||
|
'137': 'polygon-mainnet',
|
||||||
|
'56': 'bnb-mainnet',
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenBalanceResult = {
|
||||||
|
symbol: string
|
||||||
|
balance: string
|
||||||
|
contractAddress: string
|
||||||
|
logo?: string
|
||||||
|
}
|
||||||
|
|
||||||
type TxEntry = {
|
type TxEntry = {
|
||||||
hash: string
|
hash: string
|
||||||
@@ -64,10 +76,7 @@ type TxEntry = {
|
|||||||
from: string
|
from: string
|
||||||
to: string
|
to: string
|
||||||
value: string
|
value: string
|
||||||
input?: string
|
|
||||||
isError?: string
|
|
||||||
tokenSymbol?: string
|
tokenSymbol?: string
|
||||||
tokenName?: string
|
|
||||||
tokenDecimal?: string
|
tokenDecimal?: string
|
||||||
contractAddress?: string
|
contractAddress?: string
|
||||||
pairedValue?: string
|
pairedValue?: string
|
||||||
@@ -97,11 +106,16 @@ type RPC = {
|
|||||||
params: {
|
params: {
|
||||||
address: string
|
address: string
|
||||||
chainid: string
|
chainid: string
|
||||||
page?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
}
|
||||||
response: Promise<TxEntry[]>
|
response: Promise<TxEntry[]>
|
||||||
}
|
}
|
||||||
|
fetchTokenBalances: {
|
||||||
|
params: {
|
||||||
|
address: string
|
||||||
|
chainid: string
|
||||||
|
}
|
||||||
|
response: Promise<TokenBalanceResult[]>
|
||||||
|
}
|
||||||
openExternal: {
|
openExternal: {
|
||||||
params: {
|
params: {
|
||||||
url: string
|
url: string
|
||||||
@@ -117,32 +131,148 @@ type RPC = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchEtherscan(
|
async function fetchAlchemyTransfers(
|
||||||
key: string,
|
key: string,
|
||||||
chainid: string,
|
chainid: string,
|
||||||
action: string,
|
|
||||||
address: string,
|
address: string,
|
||||||
page: number,
|
|
||||||
offset: number,
|
|
||||||
): Promise<any[]> {
|
): Promise<any[]> {
|
||||||
const params = new URLSearchParams({
|
const network = ALCHEMY_NETWORKS[chainid]
|
||||||
module: 'account',
|
if (!network) return []
|
||||||
action,
|
const url = `https://${network}.g.alchemy.com/v2/${key}`
|
||||||
address,
|
|
||||||
page: String(page),
|
const body = (fromAddress?: string, toAddress?: string) => ({
|
||||||
offset: String(offset),
|
jsonrpc: '2.0',
|
||||||
sort: 'desc',
|
id: 0,
|
||||||
apikey: key,
|
method: 'alchemy_getAssetTransfers',
|
||||||
chainid,
|
params: [
|
||||||
|
{
|
||||||
|
...(fromAddress ? { fromAddress } : {}),
|
||||||
|
...(toAddress ? { toAddress } : {}),
|
||||||
|
category: ['external', 'erc20'],
|
||||||
|
withMetadata: true,
|
||||||
|
maxCount: '0x3E8',
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
const res = await fetch(`${ETHERSCAN_API}?${params}`)
|
|
||||||
const data = await res.json()
|
const [outRes, inRes] = await Promise.all([
|
||||||
if (data.status !== '1') return []
|
fetch(url, {
|
||||||
return data.result
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body(address, undefined)),
|
||||||
|
}).then((r) => r.json()),
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body(undefined, address)),
|
||||||
|
}).then((r) => r.json()),
|
||||||
|
])
|
||||||
|
|
||||||
|
const outgoing: any[] = outRes.result?.transfers ?? []
|
||||||
|
const incoming: any[] = inRes.result?.transfers ?? []
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return [...outgoing, ...incoming].filter((t) => {
|
||||||
|
if (seen.has(t.hash)) return false
|
||||||
|
seen.add(t.hash)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnits(balance: string, decimals: number): string {
|
||||||
|
const bal = BigInt(balance)
|
||||||
|
if (bal === 0n) return '0'
|
||||||
|
const divisor = 10n ** BigInt(decimals)
|
||||||
|
const intPart = bal / divisor
|
||||||
|
const fracPart = (bal % divisor)
|
||||||
|
.toString()
|
||||||
|
.padStart(decimals, '0')
|
||||||
|
.replace(/0+$/, '')
|
||||||
|
return fracPart ? `${intPart}.${fracPart}` : `${intPart}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlchemyTokenBalances(
|
||||||
|
key: string,
|
||||||
|
chainid: string,
|
||||||
|
address: string,
|
||||||
|
): Promise<TokenBalanceResult[]> {
|
||||||
|
const network = ALCHEMY_NETWORKS[chainid]
|
||||||
|
if (!network) return []
|
||||||
|
const url = `https://${network}.g.alchemy.com/v2/${key}`
|
||||||
|
|
||||||
|
const post = (body: any) =>
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then((r) => r.json())
|
||||||
|
|
||||||
|
const balRes = await post({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 0,
|
||||||
|
method: 'alchemy_getTokenBalances',
|
||||||
|
params: [address, 'erc20'],
|
||||||
|
})
|
||||||
|
// console.log(inspect(balRes, { depth: null }))
|
||||||
|
|
||||||
|
const tokens: any[] = balRes.result?.tokenBalances ?? []
|
||||||
|
const nonZero = tokens.filter(
|
||||||
|
(t: any) => t.tokenBalance !== '0x0' && !t.error,
|
||||||
|
)
|
||||||
|
|
||||||
|
const metas = await Promise.allSettled(
|
||||||
|
nonZero.map((t: any) =>
|
||||||
|
post({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 0,
|
||||||
|
method: 'alchemy_getTokenMetadata',
|
||||||
|
params: [t.contractAddress],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
console.log(inspect(metas, { depth: null }))
|
||||||
|
|
||||||
|
return nonZero
|
||||||
|
.map((t: any, i: number) => {
|
||||||
|
const meta =
|
||||||
|
metas[i].status === 'fulfilled' ? metas[i].value.result : null
|
||||||
|
return {
|
||||||
|
symbol: meta?.symbol ?? 'Unknown',
|
||||||
|
decimals: meta?.decimals ?? 0,
|
||||||
|
balance: formatUnits(t.tokenBalance, meta?.decimals ?? 18),
|
||||||
|
contractAddress: t.contractAddress,
|
||||||
|
logo: meta?.logo ?? undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((t) => t.logo && t.decimals !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTransfer(t: any): TxEntry {
|
||||||
|
const isExternal = t.category === 'external'
|
||||||
|
return {
|
||||||
|
hash: t.hash,
|
||||||
|
timeStamp: String(
|
||||||
|
Math.floor(
|
||||||
|
new Date(t.metadata.blockTimestamp).getTime() / 1000,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
from: t.from,
|
||||||
|
to: t.to,
|
||||||
|
value: String(t.value),
|
||||||
|
...(isExternal
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
tokenSymbol: t.asset,
|
||||||
|
tokenDecimal: t.rawContract?.decimal
|
||||||
|
? String(parseInt(t.rawContract.decimal, 16))
|
||||||
|
: undefined,
|
||||||
|
contractAddress: t.rawContract?.address ?? undefined,
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpc = BrowserView.defineRPC<RPC>({
|
const rpc = BrowserView.defineRPC<RPC>({
|
||||||
maxRequestTime: 20000,
|
maxRequestTime: 200000,
|
||||||
handlers: {
|
handlers: {
|
||||||
requests: {
|
requests: {
|
||||||
getSecret: async ({ name, service }) => {
|
getSecret: async ({ name, service }) => {
|
||||||
@@ -154,140 +284,78 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
openExternal: async ({ url }) => {
|
openExternal: async ({ url }) => {
|
||||||
Utils.openExternal(url)
|
Utils.openExternal(url)
|
||||||
},
|
},
|
||||||
fetchTxHistory: async ({
|
fetchTxHistory: async ({ address, chainid }) => {
|
||||||
address,
|
|
||||||
chainid,
|
|
||||||
page = 1,
|
|
||||||
offset = 1000,
|
|
||||||
}) => {
|
|
||||||
const key = await Bun.secrets.get({
|
const key = await Bun.secrets.get({
|
||||||
service: 'koins',
|
service: 'koins',
|
||||||
name: 'etherscan_key',
|
name: 'alchemy_key',
|
||||||
})
|
})
|
||||||
if (!key) return []
|
if (!key) return []
|
||||||
|
|
||||||
const [nativeTxs, tokenTxs] = await Promise.all([
|
const all = await fetchAlchemyTransfers(key, chainid, address)
|
||||||
fetchEtherscan(
|
|
||||||
key,
|
|
||||||
chainid,
|
|
||||||
'txlist',
|
|
||||||
address,
|
|
||||||
page,
|
|
||||||
offset,
|
|
||||||
),
|
|
||||||
fetchEtherscan(
|
|
||||||
key,
|
|
||||||
chainid,
|
|
||||||
'tokentx',
|
|
||||||
address,
|
|
||||||
page,
|
|
||||||
offset,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
const addrLower = address.toLowerCase()
|
const addrLower = address.toLowerCase()
|
||||||
const byHash = new Map<
|
const byHash = new Map<string, any[]>()
|
||||||
string,
|
for (const t of all) {
|
||||||
{ native?: any; token: any[] }
|
|
||||||
>()
|
|
||||||
for (const t of nativeTxs) {
|
|
||||||
const h = t.hash
|
const h = t.hash
|
||||||
if (!byHash.has(h)) byHash.set(h, { token: [] })
|
if (!byHash.has(h)) byHash.set(h, [])
|
||||||
byHash.get(h)!.native = t
|
byHash.get(h)!.push(t)
|
||||||
}
|
|
||||||
for (const t of tokenTxs) {
|
|
||||||
const h = t.hash
|
|
||||||
if (!byHash.has(h)) byHash.set(h, { token: [] })
|
|
||||||
byHash.get(h)!.token.push(t)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const combined: TxEntry[] = []
|
const combined: TxEntry[] = []
|
||||||
|
|
||||||
for (const [, group] of byHash) {
|
for (const [, group] of byHash) {
|
||||||
console.log(group)
|
const external = group.filter(
|
||||||
const { native, token } = group
|
(t: any) => t.category === 'external',
|
||||||
const nv = native?.value ?? '0'
|
)
|
||||||
|
const tokens = group.filter(
|
||||||
|
(t: any) => t.category === 'erc20',
|
||||||
|
)
|
||||||
|
|
||||||
const incoming = token.filter(
|
const extOut = external.filter(
|
||||||
|
(t: any) => t.from.toLowerCase() === addrLower,
|
||||||
|
)
|
||||||
|
const extIn = external.filter(
|
||||||
(t: any) => t.to.toLowerCase() === addrLower,
|
(t: any) => t.to.toLowerCase() === addrLower,
|
||||||
)
|
)
|
||||||
const outgoing = token.filter(
|
const tokIn = tokens.filter(
|
||||||
|
(t: any) => t.to.toLowerCase() === addrLower,
|
||||||
|
)
|
||||||
|
const tokOut = tokens.filter(
|
||||||
(t: any) => t.from.toLowerCase() === addrLower,
|
(t: any) => t.from.toLowerCase() === addrLower,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (native && nv !== '0' && incoming.length > 0) {
|
if (extOut.length > 0 && tokIn.length > 0) {
|
||||||
combined.push({
|
combined.push({
|
||||||
hash: native.hash,
|
...mapTransfer(extOut[0]),
|
||||||
timeStamp: native.timeStamp,
|
tokenSymbol: tokIn[0].asset,
|
||||||
from: native.from,
|
tokenDecimal: tokIn[0].rawContract?.decimal
|
||||||
to: native.to,
|
? String(parseInt(tokIn[0].rawContract.decimal, 16))
|
||||||
value: native.value,
|
: undefined,
|
||||||
input: native.input,
|
contractAddress:
|
||||||
isError: native.isError,
|
tokIn[0].rawContract?.address ?? undefined,
|
||||||
tokenSymbol: incoming[0].tokenSymbol,
|
pairedValue: String(tokIn[0].value),
|
||||||
tokenDecimal: incoming[0].tokenDecimal,
|
pairedSymbol: tokIn[0].asset,
|
||||||
contractAddress: incoming[0].contractAddress,
|
pairedDecimals: tokIn[0].rawContract?.decimal
|
||||||
pairedValue: incoming[0].value,
|
? String(parseInt(tokIn[0].rawContract.decimal, 16))
|
||||||
pairedSymbol: incoming[0].tokenSymbol,
|
: undefined,
|
||||||
pairedDecimals: incoming[0].tokenDecimal,
|
|
||||||
})
|
})
|
||||||
} else if (native && nv !== '0') {
|
} else if (extOut.length > 0) {
|
||||||
|
combined.push(mapTransfer(extOut[0]))
|
||||||
|
} else if (extIn.length > 0) {
|
||||||
|
combined.push(mapTransfer(extIn[0]))
|
||||||
|
} else if (tokOut.length > 0 && tokIn.length > 0) {
|
||||||
combined.push({
|
combined.push({
|
||||||
hash: native.hash,
|
...mapTransfer(tokOut[0]),
|
||||||
timeStamp: native.timeStamp,
|
pairedValue: String(tokIn[0].value),
|
||||||
from: native.from,
|
pairedSymbol: tokIn[0].asset,
|
||||||
to: native.to,
|
pairedDecimals: tokIn[0].rawContract?.decimal
|
||||||
value: native.value,
|
? String(parseInt(tokIn[0].rawContract.decimal, 16))
|
||||||
input: native.input,
|
: undefined,
|
||||||
isError: native.isError,
|
|
||||||
})
|
|
||||||
} else if (incoming.length > 0 && outgoing.length > 0) {
|
|
||||||
combined.push({
|
|
||||||
hash: incoming[0].hash,
|
|
||||||
timeStamp: incoming[0].timeStamp,
|
|
||||||
from: incoming[0].from,
|
|
||||||
to: incoming[0].to,
|
|
||||||
value: outgoing[0].value,
|
|
||||||
tokenSymbol: outgoing[0].tokenSymbol,
|
|
||||||
tokenDecimal: outgoing[0].tokenDecimal,
|
|
||||||
contractAddress: outgoing[0].contractAddress,
|
|
||||||
pairedValue: incoming[0].value,
|
|
||||||
pairedSymbol: incoming[0].tokenSymbol,
|
|
||||||
pairedDecimals: incoming[0].tokenDecimal,
|
|
||||||
})
|
|
||||||
} else if (outgoing.length > 0) {
|
|
||||||
combined.push({
|
|
||||||
hash: outgoing[0].hash,
|
|
||||||
timeStamp: outgoing[0].timeStamp,
|
|
||||||
from: outgoing[0].from,
|
|
||||||
to: outgoing[0].to,
|
|
||||||
value: outgoing[0].value,
|
|
||||||
tokenSymbol: outgoing[0].tokenSymbol,
|
|
||||||
tokenDecimal: outgoing[0].tokenDecimal,
|
|
||||||
contractAddress: outgoing[0].contractAddress,
|
|
||||||
isError: '0',
|
|
||||||
})
|
|
||||||
} else if (incoming.length > 0) {
|
|
||||||
combined.push({
|
|
||||||
hash: incoming[0].hash,
|
|
||||||
timeStamp: incoming[0].timeStamp,
|
|
||||||
from: incoming[0].from,
|
|
||||||
to: incoming[0].to,
|
|
||||||
value: incoming[0].value,
|
|
||||||
tokenSymbol: incoming[0].tokenSymbol,
|
|
||||||
tokenDecimal: incoming[0].tokenDecimal,
|
|
||||||
contractAddress: incoming[0].contractAddress,
|
|
||||||
})
|
|
||||||
} else if (native) {
|
|
||||||
combined.push({
|
|
||||||
hash: native.hash,
|
|
||||||
timeStamp: native.timeStamp,
|
|
||||||
from: native.from,
|
|
||||||
to: native.to,
|
|
||||||
value: native.value,
|
|
||||||
input: native.input,
|
|
||||||
isError: native.isError,
|
|
||||||
})
|
})
|
||||||
|
} else if (tokOut.length > 0) {
|
||||||
|
combined.push(mapTransfer(tokOut[0]))
|
||||||
|
} else if (tokIn.length > 0) {
|
||||||
|
combined.push(mapTransfer(tokIn[0]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +365,20 @@ const rpc = BrowserView.defineRPC<RPC>({
|
|||||||
|
|
||||||
return combined
|
return combined
|
||||||
},
|
},
|
||||||
|
fetchTokenBalances: async ({ address, chainid }) => {
|
||||||
|
const key = await Bun.secrets.get({
|
||||||
|
service: 'koins',
|
||||||
|
name: 'alchemy_key',
|
||||||
|
})
|
||||||
|
if (!key) return []
|
||||||
|
const result = await fetchAlchemyTokenBalances(
|
||||||
|
key,
|
||||||
|
chainid,
|
||||||
|
address,
|
||||||
|
)
|
||||||
|
// console.log(inspect(result, { depth: null }))
|
||||||
|
return result
|
||||||
|
},
|
||||||
},
|
},
|
||||||
messages: {},
|
messages: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ export type TxEntry = {
|
|||||||
from: string
|
from: string
|
||||||
to: string
|
to: string
|
||||||
value: string
|
value: string
|
||||||
input?: string
|
|
||||||
isError?: string
|
|
||||||
tokenSymbol?: string
|
tokenSymbol?: string
|
||||||
tokenName?: string
|
|
||||||
tokenDecimal?: string
|
tokenDecimal?: string
|
||||||
contractAddress?: string
|
contractAddress?: string
|
||||||
pairedValue?: string
|
pairedValue?: string
|
||||||
@@ -17,6 +14,13 @@ export type TxEntry = {
|
|||||||
pairedDecimals?: string
|
pairedDecimals?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TokenBalanceResult = {
|
||||||
|
symbol: string
|
||||||
|
balance: string
|
||||||
|
contractAddress: string
|
||||||
|
logo?: string
|
||||||
|
}
|
||||||
|
|
||||||
type RPC = {
|
type RPC = {
|
||||||
bun: {
|
bun: {
|
||||||
requests: {
|
requests: {
|
||||||
@@ -39,11 +43,16 @@ type RPC = {
|
|||||||
params: {
|
params: {
|
||||||
address: string
|
address: string
|
||||||
chainid: string
|
chainid: string
|
||||||
page?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
}
|
||||||
response: Promise<TxEntry[]>
|
response: Promise<TxEntry[]>
|
||||||
}
|
}
|
||||||
|
fetchTokenBalances: {
|
||||||
|
params: {
|
||||||
|
address: string
|
||||||
|
chainid: string
|
||||||
|
}
|
||||||
|
response: Promise<TokenBalanceResult[]>
|
||||||
|
}
|
||||||
openExternal: {
|
openExternal: {
|
||||||
params: {
|
params: {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -2,23 +2,9 @@ import {
|
|||||||
JsonRpcProvider,
|
JsonRpcProvider,
|
||||||
HDNodeWallet,
|
HDNodeWallet,
|
||||||
formatEther,
|
formatEther,
|
||||||
formatUnits,
|
|
||||||
Contract,
|
|
||||||
} from 'ethers'
|
} from 'ethers'
|
||||||
import { electrobun, type TxEntry } from '$lib/electrobun'
|
import { electrobun, type TxEntry } from '$lib/electrobun'
|
||||||
|
|
||||||
const ERC20_ABI = [
|
|
||||||
'function balanceOf(address) view returns (uint256)',
|
|
||||||
'function symbol() view returns (string)',
|
|
||||||
'function decimals() view returns (uint8)',
|
|
||||||
]
|
|
||||||
|
|
||||||
type DiscoveredToken = {
|
|
||||||
address: string
|
|
||||||
symbol: string
|
|
||||||
decimals: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const networks = [
|
export const networks = [
|
||||||
{
|
{
|
||||||
id: 'bsc',
|
id: 'bsc',
|
||||||
@@ -27,18 +13,6 @@ export const networks = [
|
|||||||
symbol: 'BNB',
|
symbol: 'BNB',
|
||||||
chainid: '56',
|
chainid: '56',
|
||||||
explorerUrl: 'https://bscscan.com/tx/',
|
explorerUrl: 'https://bscscan.com/tx/',
|
||||||
tokens: [
|
|
||||||
{
|
|
||||||
symbol: 'USDC',
|
|
||||||
address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
|
|
||||||
decimals: 18,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: 'USDT',
|
|
||||||
address: '0x55d398326f99059fF775485246999027B3197955',
|
|
||||||
decimals: 18,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'polygon',
|
id: 'polygon',
|
||||||
@@ -47,18 +21,6 @@ export const networks = [
|
|||||||
symbol: 'POL',
|
symbol: 'POL',
|
||||||
chainid: '137',
|
chainid: '137',
|
||||||
explorerUrl: 'https://polygonscan.com/tx/',
|
explorerUrl: 'https://polygonscan.com/tx/',
|
||||||
tokens: [
|
|
||||||
{
|
|
||||||
symbol: 'USDC',
|
|
||||||
address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359',
|
|
||||||
decimals: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: 'USDT',
|
|
||||||
address: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F',
|
|
||||||
decimals: 6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'eth',
|
id: 'eth',
|
||||||
@@ -67,23 +29,6 @@ export const networks = [
|
|||||||
symbol: 'ETH',
|
symbol: 'ETH',
|
||||||
chainid: '1',
|
chainid: '1',
|
||||||
explorerUrl: 'https://etherscan.io/tx/',
|
explorerUrl: 'https://etherscan.io/tx/',
|
||||||
tokens: [
|
|
||||||
{
|
|
||||||
symbol: 'USDC',
|
|
||||||
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
|
||||||
decimals: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: 'USDT',
|
|
||||||
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
|
||||||
decimals: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
symbol: 'BAT',
|
|
||||||
address: '0x0D8775F648430679A709E98d2b0Cb6250d2887EF',
|
|
||||||
decimals: 18,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -92,7 +37,8 @@ export type NetworkId = (typeof networks)[number]['id']
|
|||||||
export type TokenBalance = {
|
export type TokenBalance = {
|
||||||
symbol: string
|
symbol: string
|
||||||
balance: string
|
balance: string
|
||||||
address?: string
|
contractAddress: string
|
||||||
|
logo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Wallet = () => {
|
export const Wallet = () => {
|
||||||
@@ -105,9 +51,8 @@ export const Wallet = () => {
|
|||||||
let error = $state('')
|
let error = $state('')
|
||||||
let vaultExists = $state(false)
|
let vaultExists = $state(false)
|
||||||
let ready = $state(false)
|
let ready = $state(false)
|
||||||
let etherscanKey = $state('')
|
let apiKey = $state('')
|
||||||
let transactions = $state<TxEntry[]>([])
|
let transactions = $state<TxEntry[]>([])
|
||||||
let discoveredTokens = $state<DiscoveredToken[]>([])
|
|
||||||
|
|
||||||
const net = () => networks.find((n) => n.id === network)!
|
const net = () => networks.find((n) => n.id === network)!
|
||||||
|
|
||||||
@@ -120,9 +65,9 @@ export const Wallet = () => {
|
|||||||
vaultExists = raw !== null && raw !== undefined
|
vaultExists = raw !== null && raw !== undefined
|
||||||
const key = await electrobun.rpc?.request.getSecret({
|
const key = await electrobun.rpc?.request.getSecret({
|
||||||
service: 'koins',
|
service: 'koins',
|
||||||
name: 'etherscan_key',
|
name: 'alchemy_key',
|
||||||
})
|
})
|
||||||
if (key) etherscanKey = key
|
if (key) apiKey = key
|
||||||
if (raw) {
|
if (raw) {
|
||||||
seed = raw
|
seed = raw
|
||||||
await refresh()
|
await refresh()
|
||||||
@@ -138,7 +83,6 @@ export const Wallet = () => {
|
|||||||
|
|
||||||
const switchNetwork = async (id: NetworkId) => {
|
const switchNetwork = async (id: NetworkId) => {
|
||||||
network = id
|
network = id
|
||||||
discoveredTokens = []
|
|
||||||
if (seed) await refresh()
|
if (seed) await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +113,6 @@ export const Wallet = () => {
|
|||||||
error = ''
|
error = ''
|
||||||
tokenBalances = []
|
tokenBalances = []
|
||||||
transactions = []
|
transactions = []
|
||||||
discoveredTokens = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
@@ -184,39 +127,12 @@ export const Wallet = () => {
|
|||||||
const nativeBal = await provider.getBalance(wallet.address)
|
const nativeBal = await provider.getBalance(wallet.address)
|
||||||
balance = formatEther(nativeBal)
|
balance = formatEther(nativeBal)
|
||||||
|
|
||||||
const [hardcodedBals, discoveredBals] = await Promise.all([
|
const bals = await electrobun.rpc?.request.fetchTokenBalances({
|
||||||
Promise.all(
|
address: wallet.address,
|
||||||
net().tokens.map(async (t) => {
|
chainid: net().chainid,
|
||||||
try {
|
})
|
||||||
const c = new Contract(t.address, ERC20_ABI, provider)
|
tokenBalances = bals ?? []
|
||||||
const bal = await c.balanceOf(wallet.address)
|
|
||||||
return {
|
|
||||||
symbol: t.symbol,
|
|
||||||
balance: formatUnits(bal, t.decimals),
|
|
||||||
address: t.address,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { symbol: t.symbol, balance: '0', address: t.address }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Promise.all(
|
|
||||||
discoveredTokens.map(async (t) => {
|
|
||||||
try {
|
|
||||||
const c = new Contract(t.address, ERC20_ABI, provider)
|
|
||||||
const bal = await c.balanceOf(wallet.address)
|
|
||||||
return {
|
|
||||||
symbol: t.symbol,
|
|
||||||
balance: formatUnits(bal, t.decimals),
|
|
||||||
address: t.address,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { symbol: t.symbol, balance: '0', address: t.address }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
tokenBalances = [...hardcodedBals, ...discoveredBals]
|
|
||||||
fetchTxHistory()
|
fetchTxHistory()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error =
|
error =
|
||||||
@@ -226,18 +142,18 @@ export const Wallet = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveEtherscanKey = async (key: string) => {
|
const saveApiKey = async (key: string) => {
|
||||||
await electrobun.rpc?.request.setSecret({
|
await electrobun.rpc?.request.setSecret({
|
||||||
service: 'koins',
|
service: 'koins',
|
||||||
name: 'etherscan_key',
|
name: 'alchemy_key',
|
||||||
value: key,
|
value: key,
|
||||||
})
|
})
|
||||||
etherscanKey = key
|
apiKey = key
|
||||||
if (address) await fetchTxHistory()
|
if (address) await fetchTxHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchTxHistory = async () => {
|
const fetchTxHistory = async () => {
|
||||||
if (!address || !etherscanKey) {
|
if (!address || !apiKey) {
|
||||||
transactions = []
|
transactions = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -246,69 +162,6 @@ export const Wallet = () => {
|
|||||||
chainid: net().chainid,
|
chainid: net().chainid,
|
||||||
})
|
})
|
||||||
transactions = txs ?? []
|
transactions = txs ?? []
|
||||||
|
|
||||||
const seen = new Set(
|
|
||||||
net().tokens.map((t) => t.address.toLowerCase()),
|
|
||||||
)
|
|
||||||
discoveredTokens.forEach((d) => seen.add(d.address.toLowerCase()))
|
|
||||||
|
|
||||||
const newAddrs = [
|
|
||||||
...new Set(
|
|
||||||
(txs ?? [])
|
|
||||||
.filter(
|
|
||||||
(t) =>
|
|
||||||
t.contractAddress &&
|
|
||||||
!seen.has(t.contractAddress.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((t) => t.contractAddress!.toLowerCase()),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
if (newAddrs.length === 0) return
|
|
||||||
|
|
||||||
const provider = new JsonRpcProvider(net().rpc)
|
|
||||||
const newTokens: DiscoveredToken[] = []
|
|
||||||
|
|
||||||
for (const addr of newAddrs) {
|
|
||||||
try {
|
|
||||||
const c = new Contract(addr, ERC20_ABI, provider)
|
|
||||||
const [symbol, decimals] = await Promise.all([
|
|
||||||
c.symbol(),
|
|
||||||
c.decimals(),
|
|
||||||
])
|
|
||||||
if (
|
|
||||||
typeof symbol !== 'string' ||
|
|
||||||
symbol.length > 12 ||
|
|
||||||
/[\s.:/]/.test(symbol)
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newTokens.push({ address: addr, symbol, decimals })
|
|
||||||
} catch {
|
|
||||||
// not ERC-20, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(newTokens)
|
|
||||||
|
|
||||||
if (newTokens.length > 0) {
|
|
||||||
discoveredTokens = [...discoveredTokens, ...newTokens]
|
|
||||||
const newBals = await Promise.all(
|
|
||||||
newTokens.map(async (t) => {
|
|
||||||
try {
|
|
||||||
const c = new Contract(t.address, ERC20_ABI, provider)
|
|
||||||
const bal = await c.balanceOf(address)
|
|
||||||
return {
|
|
||||||
symbol: t.symbol,
|
|
||||||
balance: formatUnits(bal, t.decimals),
|
|
||||||
address: t.address,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { symbol: t.symbol, balance: '0', address: t.address }
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
tokenBalances = [...tokenBalances, ...newBals]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -342,9 +195,6 @@ export const Wallet = () => {
|
|||||||
get explorerUrl() {
|
get explorerUrl() {
|
||||||
return net().explorerUrl
|
return net().explorerUrl
|
||||||
},
|
},
|
||||||
get tokens() {
|
|
||||||
return net().tokens
|
|
||||||
},
|
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading
|
return loading
|
||||||
},
|
},
|
||||||
@@ -360,21 +210,18 @@ export const Wallet = () => {
|
|||||||
get networks() {
|
get networks() {
|
||||||
return networks
|
return networks
|
||||||
},
|
},
|
||||||
get etherscanKey() {
|
get apiKey() {
|
||||||
return etherscanKey
|
return apiKey
|
||||||
},
|
},
|
||||||
get transactions() {
|
get transactions() {
|
||||||
return transactions
|
return transactions
|
||||||
},
|
},
|
||||||
get discoveredTokens() {
|
|
||||||
return discoveredTokens
|
|
||||||
},
|
|
||||||
refresh,
|
refresh,
|
||||||
init,
|
init,
|
||||||
lock,
|
lock,
|
||||||
saveVault,
|
saveVault,
|
||||||
switchNetwork,
|
switchNetwork,
|
||||||
saveEtherscanKey,
|
saveApiKey,
|
||||||
fetchTxHistory,
|
fetchTxHistory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '$lib/components/ui/card/index.js'
|
} from '$lib/components/ui/card/index.js'
|
||||||
import { formatEther, formatUnits } from 'ethers'
|
|
||||||
|
|
||||||
const CDN = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains'
|
const CDN =
|
||||||
|
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains'
|
||||||
|
|
||||||
const chainDir: Record<string, string> = {
|
const chainDir: Record<string, string> = {
|
||||||
eth: 'ethereum',
|
eth: 'ethereum',
|
||||||
@@ -29,26 +29,14 @@
|
|||||||
return `${CDN}/${chainDir[id]}/assets/${address}/logo.png`
|
return `${CDN}/${chainDir[id]}/assets/${address}/logo.png`
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECTORS: Record<string, string> = {
|
|
||||||
'0x': 'Send',
|
|
||||||
'0xa9059cbb': 'Transfer',
|
|
||||||
'0x095ea7b3': 'Approve',
|
|
||||||
'0x23b872dd': 'TransferFrom',
|
|
||||||
'0x38ed1739': 'Swap',
|
|
||||||
'0x7ff36ab5': 'Swap',
|
|
||||||
'0x18cbafe5': 'Swap',
|
|
||||||
'0x022c0d9f': 'Swap',
|
|
||||||
'0x3593564c': 'Swap',
|
|
||||||
'0x49404b7c': 'Swap',
|
|
||||||
'0x414bf389': 'Swap',
|
|
||||||
}
|
|
||||||
|
|
||||||
function txAction(tx: TxEntry, address: string): string {
|
function txAction(tx: TxEntry, address: string): string {
|
||||||
if (tx.pairedValue) return 'Swap'
|
if (tx.pairedValue) return 'Swap'
|
||||||
const dir = tx.from.toLowerCase() === address.toLowerCase() ? 'Sent' : 'Received'
|
const dir =
|
||||||
|
tx.from.toLowerCase() === address.toLowerCase()
|
||||||
|
? 'Sent'
|
||||||
|
: 'Received'
|
||||||
if (tx.tokenSymbol) return `${dir} ${tx.tokenSymbol}`
|
if (tx.tokenSymbol) return `${dir} ${tx.tokenSymbol}`
|
||||||
const sel = tx.input?.slice(0, 10) ?? '0x'
|
return dir
|
||||||
return SELECTORS[sel] ?? 'Contract Interaction'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let w = Wallet()
|
let w = Wallet()
|
||||||
@@ -132,19 +120,25 @@
|
|||||||
src={nativeIcon(w.network)}
|
src={nativeIcon(w.network)}
|
||||||
alt=""
|
alt=""
|
||||||
class="size-4 rounded-full"
|
class="size-4 rounded-full"
|
||||||
onerror={(e) => (e.target as HTMLElement).style.display = 'none'} />
|
onerror={(e) =>
|
||||||
{w.balance} {w.symbol}
|
((e.target as HTMLElement).style.display =
|
||||||
|
'none')} />
|
||||||
|
{w.balance}
|
||||||
|
{w.symbol}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4 space-y-1">
|
<div class="mb-4 space-y-1">
|
||||||
<p class="font-medium text-xs">Tokens</p>
|
<p class="font-medium text-xs">Tokens</p>
|
||||||
{#each w.tokenBalances as t}
|
{#each w.tokenBalances as t}
|
||||||
<p class="flex items-center gap-1.5 font-mono text-sm">
|
<p
|
||||||
|
class="flex items-center gap-1.5 font-mono text-sm">
|
||||||
<img
|
<img
|
||||||
src={tokenIcon(w.network, t.address)}
|
src={t.logo}
|
||||||
alt=""
|
alt=""
|
||||||
class="size-4 rounded-full"
|
class="size-4 rounded-full"
|
||||||
onerror={(e) => (e.target as HTMLElement).style.display = 'none'} />
|
onerror={(e) =>
|
||||||
|
((e.target as HTMLElement).style.display =
|
||||||
|
'none')} />
|
||||||
{Number(t.balance) < 0.0001 && Number(t.balance) > 0
|
{Number(t.balance) < 0.0001 && Number(t.balance) > 0
|
||||||
? '<0.0001'
|
? '<0.0001'
|
||||||
: Number(t.balance).toFixed(4)}
|
: Number(t.balance).toFixed(4)}
|
||||||
@@ -174,15 +168,15 @@
|
|||||||
<CardTitle>Transactions</CardTitle>
|
<CardTitle>Transactions</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{#if !w.etherscanKey}
|
{#if !w.apiKey}
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
|
||||||
placeholder="Etherscan API key"
|
placeholder="Alchemy API key"
|
||||||
bind:value={apiKeyInput} />
|
bind:value={apiKeyInput} />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => w.saveEtherscanKey(apiKeyInput)}
|
onclick={() => w.saveApiKey(apiKeyInput)}
|
||||||
disabled={!apiKeyInput.trim()}>
|
disabled={!apiKeyInput.trim()}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
@@ -199,24 +193,62 @@
|
|||||||
<div class="min-w-0 flex-1 space-y-0.5">
|
<div class="min-w-0 flex-1 space-y-0.5">
|
||||||
<p>
|
<p>
|
||||||
<button
|
<button
|
||||||
onclick={() => electrobun.rpc?.request.openExternal({ url: w.explorerUrl + tx.hash })}
|
onclick={() =>
|
||||||
|
electrobun.rpc?.request.openExternal({
|
||||||
|
url: w.explorerUrl + tx.hash,
|
||||||
|
})}
|
||||||
class="hover:underline cursor-pointer font-medium">
|
class="hover:underline cursor-pointer font-medium">
|
||||||
{txAction(tx, w.address)}
|
{txAction(tx, w.address)}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex items-center gap-1 text-muted-foreground font-mono">
|
<p
|
||||||
|
class="flex items-center gap-1 text-muted-foreground font-mono">
|
||||||
{#if tx.pairedValue}
|
{#if tx.pairedValue}
|
||||||
<img src={nativeIcon(w.network)} alt="" class="size-3.5 rounded-full" onerror={(e) => (e.target as HTMLElement).style.display = 'none'} />
|
<img
|
||||||
{Number(formatEther(tx.value)).toFixed(4)} {w.symbol}
|
src={nativeIcon(w.network)}
|
||||||
|
alt=""
|
||||||
|
class="size-3.5 rounded-full"
|
||||||
|
onerror={(e) =>
|
||||||
|
((e.target as HTMLElement).style.display =
|
||||||
|
'none')} />
|
||||||
|
{Number(tx.value).toFixed(4)}
|
||||||
|
{w.symbol}
|
||||||
<span class="mx-0.5">→</span>
|
<span class="mx-0.5">→</span>
|
||||||
<img src={tokenIcon(w.network, tx.contractAddress)} alt="" class="size-3.5 rounded-full" onerror={(e) => (e.target as HTMLElement).style.display = 'none'} />
|
<img
|
||||||
{Number(formatUnits(tx.pairedValue, Number(tx.pairedDecimals ?? 18))).toFixed(4)} {tx.pairedSymbol}
|
src={tokenIcon(
|
||||||
|
w.network,
|
||||||
|
tx.contractAddress,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
class="size-3.5 rounded-full"
|
||||||
|
onerror={(e) =>
|
||||||
|
((e.target as HTMLElement).style.display =
|
||||||
|
'none')} />
|
||||||
|
{Number(tx.pairedValue).toFixed(4)}
|
||||||
|
{tx.pairedSymbol}
|
||||||
{:else if tx.tokenSymbol}
|
{:else if tx.tokenSymbol}
|
||||||
<img src={tokenIcon(w.network, tx.contractAddress)} alt="" class="size-3.5 rounded-full" onerror={(e) => (e.target as HTMLElement).style.display = 'none'} />
|
<img
|
||||||
{Number(formatUnits(tx.value, Number(tx.tokenDecimal ?? 18))).toFixed(4)} {tx.tokenSymbol}
|
src={tokenIcon(
|
||||||
|
w.network,
|
||||||
|
tx.contractAddress,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
class="size-3.5 rounded-full"
|
||||||
|
onerror={(e) =>
|
||||||
|
((e.target as HTMLElement).style.display =
|
||||||
|
'none')} />
|
||||||
|
{Number(tx.value).toFixed(4)}
|
||||||
|
{tx.tokenSymbol}
|
||||||
{:else}
|
{:else}
|
||||||
<img src={nativeIcon(w.network)} alt="" class="size-3.5 rounded-full" onerror={(e) => (e.target as HTMLElement).style.display = 'none'} />
|
<img
|
||||||
{Number(formatEther(tx.value)).toFixed(4)} {w.symbol}
|
src={nativeIcon(w.network)}
|
||||||
|
alt=""
|
||||||
|
class="size-3.5 rounded-full"
|
||||||
|
onerror={(e) =>
|
||||||
|
((e.target as HTMLElement).style.display =
|
||||||
|
'none')} />
|
||||||
|
{Number(tx.value).toFixed(4)}
|
||||||
|
{w.symbol}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
@@ -230,20 +262,18 @@
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground/50 truncate font-mono">
|
<p
|
||||||
|
class="text-muted-foreground/50 truncate font-mono">
|
||||||
<button
|
<button
|
||||||
onclick={() => electrobun.rpc?.request.openExternal({ url: w.explorerUrl + tx.hash })}
|
onclick={() =>
|
||||||
|
electrobun.rpc?.request.openExternal({
|
||||||
|
url: w.explorerUrl + tx.hash,
|
||||||
|
})}
|
||||||
class="hover:underline cursor-pointer">
|
class="hover:underline cursor-pointer">
|
||||||
{tx.hash.slice(0, 10)}...
|
{tx.hash.slice(0, 10)}...
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if !tx.tokenSymbol || tx.pairedValue}
|
|
||||||
<span
|
|
||||||
class="ml-2 shrink-0 {tx.isError === '0' ? 'text-green-500' : 'text-red-500'}">
|
|
||||||
{tx.isError === '0' ? '✓' : '✗'}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user