refactor: rename SolaceScanScout to Solace and update related configurations
- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
This commit is contained in:
170
frontend/src/services/api/access.test.ts
Normal file
170
frontend/src/services/api/access.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { accessApi } from './access'
|
||||
|
||||
describe('accessApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
const store = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key)
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: globalThis.localStorage,
|
||||
location: { origin: 'https://explorer.example.org' },
|
||||
dispatchEvent: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('stores the session token on login', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'ops@example.org',
|
||||
username: 'ops',
|
||||
},
|
||||
token: 'jwt-token',
|
||||
expires_at: '2026-04-16T00:00:00Z',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await accessApi.login('ops@example.org', 'secret-password')
|
||||
|
||||
expect(result.token).toBe('jwt-token')
|
||||
expect(accessApi.getStoredAccessToken()).toBe('jwt-token')
|
||||
})
|
||||
|
||||
it('sends scope, expiry, and quota fields when creating an API key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
api_key: 'ek_test',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
window.localStorage.setItem('explorer_access_token', 'jwt-token')
|
||||
|
||||
await accessApi.createAPIKey({
|
||||
name: 'Thirdweb key',
|
||||
tier: 'pro',
|
||||
productSlug: 'thirdweb-rpc',
|
||||
expiresDays: 30,
|
||||
monthlyQuota: 250000,
|
||||
scopes: ['rpc:read', 'rpc:write'],
|
||||
})
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]
|
||||
expect(init?.method).toBe('POST')
|
||||
expect(init?.headers).toBeTruthy()
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
name: 'Thirdweb key',
|
||||
tier: 'pro',
|
||||
product_slug: 'thirdweb-rpc',
|
||||
expires_days: 30,
|
||||
monthly_quota: 250000,
|
||||
scopes: ['rpc:read', 'rpc:write'],
|
||||
})
|
||||
})
|
||||
|
||||
it('requests admin audit with limit and product filters', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
entries: [],
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
window.localStorage.setItem('explorer_access_token', 'jwt-token')
|
||||
|
||||
await accessApi.listAdminAudit(50, 'thirdweb-rpc')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toContain('/explorer-api/v1/access/admin/audit?limit=50&product=thirdweb-rpc')
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
|
||||
})
|
||||
|
||||
it('requests user audit with the selected entry limit', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
entries: [],
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
window.localStorage.setItem('explorer_access_token', 'jwt-token')
|
||||
|
||||
await accessApi.listAudit(10)
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toContain('/explorer-api/v1/access/audit?limit=10')
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
|
||||
})
|
||||
|
||||
it('requests admin subscriptions with the selected status filter', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
subscriptions: [],
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
window.localStorage.setItem('explorer_access_token', 'jwt-token')
|
||||
|
||||
await accessApi.listAdminSubscriptions('suspended')
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(String(url)).toContain('/explorer-api/v1/access/admin/subscriptions?status=suspended')
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer jwt-token')
|
||||
})
|
||||
|
||||
it('creates a wallet nonce and stores the returned wallet session', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nonce: 'nonce-123',
|
||||
address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
token: 'wallet-jwt',
|
||||
expires_at: '2026-04-16T00:00:00Z',
|
||||
track: 'wallet',
|
||||
permissions: ['access'],
|
||||
}),
|
||||
})
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const nonceResponse = await accessApi.createWalletNonce('0x4A666F96fC8764181194447A7dFdb7d471b301C8')
|
||||
const session = await accessApi.authenticateWallet(
|
||||
'0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
'0xsigned',
|
||||
nonceResponse.nonce,
|
||||
)
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain('/explorer-api/v1/auth/nonce')
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('/explorer-api/v1/auth/wallet')
|
||||
expect(session.token).toBe('wallet-jwt')
|
||||
expect(accessApi.getStoredWalletSession()?.address).toBe('0x4A666F96fC8764181194447A7dFdb7d471b301C8')
|
||||
expect(accessApi.getStoredAccessToken()).toBe('wallet-jwt')
|
||||
})
|
||||
})
|
||||
321
frontend/src/services/api/access.ts
Normal file
321
frontend/src/services/api/access.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { getExplorerApiBase } from './blockscout'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ethereum?: {
|
||||
request: (args: { method: string; params?: unknown[] | object }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccessUser {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
is_admin?: boolean
|
||||
}
|
||||
|
||||
export interface AccessSession {
|
||||
user: AccessUser
|
||||
token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export interface WalletAccessSession {
|
||||
token: string
|
||||
expiresAt: string
|
||||
track: string
|
||||
permissions: string[]
|
||||
address: string
|
||||
}
|
||||
|
||||
export interface AccessProduct {
|
||||
slug: string
|
||||
name: string
|
||||
provider: string
|
||||
vmid: number
|
||||
http_url: string
|
||||
ws_url?: string
|
||||
default_tier: string
|
||||
requires_approval: boolean
|
||||
billing_model: string
|
||||
description: string
|
||||
use_cases: string[]
|
||||
management_features: string[]
|
||||
}
|
||||
|
||||
export interface AccessAPIKeyRecord {
|
||||
id: string
|
||||
name: string
|
||||
tier: string
|
||||
productSlug: string
|
||||
scopes: string[]
|
||||
monthlyQuota: number
|
||||
requestsUsed: number
|
||||
approved: boolean
|
||||
approvedAt?: string | null
|
||||
rateLimitPerSecond: number
|
||||
rateLimitPerMinute: number
|
||||
lastUsedAt?: string | null
|
||||
expiresAt?: string | null
|
||||
revoked: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateAccessAPIKeyRequest {
|
||||
name: string
|
||||
tier: string
|
||||
productSlug: string
|
||||
expiresDays?: number
|
||||
monthlyQuota?: number
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export interface AccessSubscription {
|
||||
id: string
|
||||
productSlug: string
|
||||
tier: string
|
||||
status: string
|
||||
monthlyQuota: number
|
||||
requestsUsed: number
|
||||
requiresApproval: boolean
|
||||
approvedAt?: string | null
|
||||
approvedBy?: string | null
|
||||
notes?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AccessUsageSummary {
|
||||
product_slug: string
|
||||
active_keys: number
|
||||
requests_used: number
|
||||
monthly_quota: number
|
||||
}
|
||||
|
||||
export interface AccessAuditEntry {
|
||||
id: number
|
||||
apiKeyId: string
|
||||
keyName: string
|
||||
productSlug: string
|
||||
methodName: string
|
||||
requestCount: number
|
||||
lastIp?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'explorer_access_token'
|
||||
const WALLET_SESSION_STORAGE_KEY = 'explorer_wallet_session'
|
||||
const ACCESS_SESSION_EVENT = 'explorer-access-session-changed'
|
||||
const ACCESS_API_PREFIX = '/explorer-api/v1'
|
||||
|
||||
function getStoredAccessToken(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY) || getStoredWalletSession()?.token || ''
|
||||
}
|
||||
|
||||
function setStoredAccessToken(token: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (token) {
|
||||
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, token)
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY)
|
||||
}
|
||||
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
|
||||
}
|
||||
|
||||
function getStoredWalletSession(): WalletAccessSession | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const raw = window.localStorage.getItem(WALLET_SESSION_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as WalletAccessSession
|
||||
} catch {
|
||||
window.localStorage.removeItem(WALLET_SESSION_STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setStoredWalletSession(session: WalletAccessSession | null) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (session) {
|
||||
window.localStorage.setItem(WALLET_SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||
} else {
|
||||
window.localStorage.removeItem(WALLET_SESSION_STORAGE_KEY)
|
||||
}
|
||||
window.dispatchEvent(new Event(ACCESS_SESSION_EVENT))
|
||||
}
|
||||
|
||||
function buildWalletMessage(nonce: string) {
|
||||
return `Sign this message to authenticate with SolaceScan.\n\nNonce: ${nonce}`
|
||||
}
|
||||
|
||||
async function fetchWalletJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers || {})
|
||||
headers.set('Content-Type', 'application/json')
|
||||
const response = await fetch(`${getExplorerApiBase()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit, includeAuth = false): Promise<T> {
|
||||
const headers = new Headers(init?.headers || {})
|
||||
headers.set('Content-Type', 'application/json')
|
||||
if (includeAuth) {
|
||||
const token = getStoredAccessToken()
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${getExplorerApiBase()}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error?.message || `HTTP ${response.status}`)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export const accessApi = {
|
||||
getStoredAccessToken,
|
||||
getStoredWalletSession,
|
||||
clearSession() {
|
||||
setStoredAccessToken('')
|
||||
},
|
||||
clearWalletSession() {
|
||||
setStoredWalletSession(null)
|
||||
},
|
||||
async register(email: string, username: string, password: string): Promise<AccessSession> {
|
||||
const response = await fetchJson<AccessSession>(`${ACCESS_API_PREFIX}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, username, password }),
|
||||
})
|
||||
setStoredAccessToken(response.token)
|
||||
return response
|
||||
},
|
||||
async login(email: string, password: string): Promise<AccessSession> {
|
||||
const response = await fetchJson<AccessSession>(`${ACCESS_API_PREFIX}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
setStoredAccessToken(response.token)
|
||||
return response
|
||||
},
|
||||
async createWalletNonce(address: string): Promise<{ nonce: string; address: string }> {
|
||||
return fetchWalletJson<{ nonce: string; address: string }>(`${ACCESS_API_PREFIX}/auth/nonce`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address }),
|
||||
})
|
||||
},
|
||||
async authenticateWallet(address: string, signature: string, nonce: string): Promise<WalletAccessSession> {
|
||||
const response = await fetchWalletJson<{
|
||||
token: string
|
||||
expires_at: string
|
||||
track: string
|
||||
permissions: string[]
|
||||
}>(`${ACCESS_API_PREFIX}/auth/wallet`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address, signature, nonce }),
|
||||
})
|
||||
const session: WalletAccessSession = {
|
||||
token: response.token,
|
||||
expiresAt: response.expires_at,
|
||||
track: response.track,
|
||||
permissions: response.permissions || [],
|
||||
address,
|
||||
}
|
||||
setStoredWalletSession(session)
|
||||
return session
|
||||
},
|
||||
async connectWalletSession(): Promise<WalletAccessSession> {
|
||||
if (typeof window === 'undefined' || typeof window.ethereum === 'undefined') {
|
||||
throw new Error('No EOA wallet detected. Please open the explorer with a browser wallet installed.')
|
||||
}
|
||||
|
||||
const accounts = (await window.ethereum.request({
|
||||
method: 'eth_requestAccounts',
|
||||
})) as string[]
|
||||
const address = accounts?.[0]
|
||||
if (!address) {
|
||||
throw new Error('Wallet connection was cancelled.')
|
||||
}
|
||||
|
||||
const nonceResponse = await accessApi.createWalletNonce(address)
|
||||
const message = buildWalletMessage(nonceResponse.nonce)
|
||||
const signature = (await window.ethereum.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, address],
|
||||
})) as string
|
||||
|
||||
return accessApi.authenticateWallet(address, signature, nonceResponse.nonce)
|
||||
},
|
||||
async getMe(): Promise<{ user: AccessUser; subscriptions?: AccessSubscription[] }> {
|
||||
return fetchJson<{ user: AccessUser; subscriptions?: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/me`, undefined, true)
|
||||
},
|
||||
async listProducts(): Promise<{ products: AccessProduct[]; note?: string }> {
|
||||
return fetchJson<{ products: AccessProduct[]; note?: string }>(`${ACCESS_API_PREFIX}/access/products`)
|
||||
},
|
||||
async listAPIKeys(): Promise<{ api_keys: AccessAPIKeyRecord[] }> {
|
||||
return fetchJson<{ api_keys: AccessAPIKeyRecord[] }>(`${ACCESS_API_PREFIX}/access/api-keys`, undefined, true)
|
||||
},
|
||||
async createAPIKey(request: CreateAccessAPIKeyRequest): Promise<{ api_key: string; record?: AccessAPIKeyRecord }> {
|
||||
return fetchJson<{ api_key: string; record?: AccessAPIKeyRecord }>(`${ACCESS_API_PREFIX}/access/api-keys`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: request.name,
|
||||
tier: request.tier,
|
||||
product_slug: request.productSlug,
|
||||
expires_days: request.expiresDays,
|
||||
monthly_quota: request.monthlyQuota,
|
||||
scopes: request.scopes,
|
||||
}),
|
||||
}, true)
|
||||
},
|
||||
async revokeAPIKey(id: string): Promise<{ revoked: boolean; api_key_id: string }> {
|
||||
return fetchJson<{ revoked: boolean; api_key_id: string }>(`${ACCESS_API_PREFIX}/access/api-keys/${id}`, {
|
||||
method: 'POST',
|
||||
}, true)
|
||||
},
|
||||
async listSubscriptions(): Promise<{ subscriptions: AccessSubscription[] }> {
|
||||
return fetchJson<{ subscriptions: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/subscriptions`, undefined, true)
|
||||
},
|
||||
async requestSubscription(productSlug: string, tier: string): Promise<{ subscription: AccessSubscription }> {
|
||||
return fetchJson<{ subscription: AccessSubscription }>(`${ACCESS_API_PREFIX}/access/subscriptions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ product_slug: productSlug, tier }),
|
||||
}, true)
|
||||
},
|
||||
async getUsage(): Promise<{ usage: AccessUsageSummary[] }> {
|
||||
return fetchJson<{ usage: AccessUsageSummary[] }>(`${ACCESS_API_PREFIX}/access/usage`, undefined, true)
|
||||
},
|
||||
async listAudit(limit = 20): Promise<{ entries: AccessAuditEntry[] }> {
|
||||
return fetchJson<{ entries: AccessAuditEntry[] }>(`${ACCESS_API_PREFIX}/access/audit?limit=${encodeURIComponent(limit)}`, undefined, true)
|
||||
},
|
||||
async listAdminSubscriptions(status = 'pending'): Promise<{ subscriptions: AccessSubscription[] }> {
|
||||
const suffix = status ? `?status=${encodeURIComponent(status)}` : ''
|
||||
return fetchJson<{ subscriptions: AccessSubscription[] }>(`${ACCESS_API_PREFIX}/access/admin/subscriptions${suffix}`, undefined, true)
|
||||
},
|
||||
async listAdminAudit(limit = 50, productSlug = ''): Promise<{ entries: AccessAuditEntry[] }> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', String(limit))
|
||||
if (productSlug) params.set('product', productSlug)
|
||||
return fetchJson<{ entries: AccessAuditEntry[] }>(`${ACCESS_API_PREFIX}/access/admin/audit?${params.toString()}`, undefined, true)
|
||||
},
|
||||
async updateAdminSubscription(subscriptionId: string, status: string, notes = ''): Promise<{ subscription: AccessSubscription }> {
|
||||
return fetchJson<{ subscription: AccessSubscription }>(`${ACCESS_API_PREFIX}/access/admin/subscriptions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
subscription_id: subscriptionId,
|
||||
status,
|
||||
notes,
|
||||
}),
|
||||
}, true)
|
||||
},
|
||||
}
|
||||
@@ -1,14 +1,38 @@
|
||||
import { ApiResponse } from './client'
|
||||
import { fetchBlockscoutJson, normalizeTransactionSummary } from './blockscout'
|
||||
import {
|
||||
type BlockscoutTokenRef,
|
||||
fetchBlockscoutJson,
|
||||
normalizeAddressInfo,
|
||||
normalizeAddressTokenBalance,
|
||||
normalizeAddressTokenTransfer,
|
||||
normalizeTransactionSummary,
|
||||
} from './blockscout'
|
||||
|
||||
export interface AddressInfo {
|
||||
address: string
|
||||
chain_id: number
|
||||
transaction_count: number
|
||||
token_count: number
|
||||
token_transfer_count?: number
|
||||
internal_transaction_count?: number
|
||||
logs_count?: number
|
||||
is_contract: boolean
|
||||
is_verified: boolean
|
||||
has_token_transfers: boolean
|
||||
has_tokens: boolean
|
||||
balance?: string
|
||||
creation_transaction_hash?: string
|
||||
label?: string
|
||||
tags: string[]
|
||||
token_contract?: {
|
||||
address: string
|
||||
symbol?: string
|
||||
name?: string
|
||||
decimals?: number
|
||||
type?: string
|
||||
total_supply?: string
|
||||
holders?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddressTransactionsParams {
|
||||
@@ -27,14 +51,54 @@ export interface TransactionSummary {
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface AddressTokenBalance {
|
||||
token_address: string
|
||||
token_name?: string
|
||||
token_symbol?: string
|
||||
token_type?: string
|
||||
token_decimals: number
|
||||
value: string
|
||||
holder_count?: number
|
||||
total_supply?: string
|
||||
}
|
||||
|
||||
export interface AddressTokenTransfer {
|
||||
transaction_hash: string
|
||||
block_number: number
|
||||
timestamp?: string
|
||||
from_address: string
|
||||
from_label?: string
|
||||
to_address: string
|
||||
to_label?: string
|
||||
token_address: string
|
||||
token_name?: string
|
||||
token_symbol?: string
|
||||
token_decimals: number
|
||||
value: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export const addressesApi = {
|
||||
get: async (chainId: number, address: string): Promise<ApiResponse<AddressInfo>> => {
|
||||
const [raw, counters] = await Promise.all([
|
||||
fetchBlockscoutJson<{
|
||||
hash: string
|
||||
coin_balance?: string | null
|
||||
is_contract: boolean
|
||||
is_verified?: boolean
|
||||
has_token_transfers?: boolean
|
||||
has_tokens?: boolean
|
||||
creation_transaction_hash?: string | null
|
||||
name?: string | null
|
||||
token?: { symbol?: string | null } | null
|
||||
token?: {
|
||||
address?: string | null
|
||||
symbol?: string | null
|
||||
name?: string | null
|
||||
decimals?: string | number | null
|
||||
type?: string | null
|
||||
total_supply?: string | null
|
||||
holders?: string | number | null
|
||||
} | null
|
||||
public_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
|
||||
private_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
|
||||
watchlist_names?: string[]
|
||||
@@ -42,31 +106,12 @@ export const addressesApi = {
|
||||
fetchBlockscoutJson<{
|
||||
transactions_count?: number
|
||||
token_balances_count?: number
|
||||
token_transfers_count?: number
|
||||
internal_transactions_count?: number
|
||||
logs_count?: number
|
||||
}>(`/api/v2/addresses/${address}/tabs-counters`),
|
||||
])
|
||||
|
||||
const tags = [
|
||||
...(raw.public_tags || []),
|
||||
...(raw.private_tags || []),
|
||||
...(raw.watchlist_names || []),
|
||||
]
|
||||
.map((tag) => {
|
||||
if (typeof tag === 'string') return tag
|
||||
return tag.display_name || tag.label || tag.name || ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
data: {
|
||||
address: raw.hash,
|
||||
chain_id: chainId,
|
||||
transaction_count: Number(counters.transactions_count || 0),
|
||||
token_count: Number(counters.token_balances_count || 0),
|
||||
is_contract: !!raw.is_contract,
|
||||
label: raw.name || raw.token?.symbol || undefined,
|
||||
tags,
|
||||
},
|
||||
}
|
||||
return { data: normalizeAddressInfo(raw, counters, chainId) }
|
||||
},
|
||||
/** Use when you need to check response.ok before setting state (avoids treating 4xx/5xx body as data). */
|
||||
getSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: AddressInfo | null }> => {
|
||||
@@ -110,4 +155,38 @@ export const addressesApi = {
|
||||
const data = Array.isArray(raw?.items) ? raw.items.map((item) => normalizeTransactionSummary(item as never)) : []
|
||||
return { data }
|
||||
},
|
||||
getTokenBalancesSafe: async (address: string): Promise<{ ok: boolean; data: AddressTokenBalance[] }> => {
|
||||
try {
|
||||
const raw = await fetchBlockscoutJson<Array<{ token?: BlockscoutTokenRef | null; value?: string | null }>>(
|
||||
`/api/v2/addresses/${address}/token-balances`
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
data: Array.isArray(raw) ? raw.map((item) => normalizeAddressTokenBalance(item)) : [],
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
getTokenTransfersSafe: async (
|
||||
address: string,
|
||||
page = 1,
|
||||
pageSize = 10
|
||||
): Promise<{ ok: boolean; data: AddressTokenTransfer[] }> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
items_count: pageSize.toString(),
|
||||
})
|
||||
const raw = await fetchBlockscoutJson<{ items?: unknown[] }>(
|
||||
`/api/v2/addresses/${address}/token-transfers?${params.toString()}`
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
data: Array.isArray(raw?.items) ? raw.items.map((item) => normalizeAddressTokenTransfer(item as never)) : [],
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
131
frontend/src/services/api/blockscout.test.ts
Normal file
131
frontend/src/services/api/blockscout.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizeAddressInfo,
|
||||
normalizeAddressTokenBalance,
|
||||
normalizeAddressTokenTransfer,
|
||||
normalizeTransaction,
|
||||
} from './blockscout'
|
||||
|
||||
describe('blockscout normalization helpers', () => {
|
||||
it('normalizes richer transaction details including decoded input and token transfers', () => {
|
||||
const transaction = normalizeTransaction({
|
||||
hash: '0xabc',
|
||||
block_number: 10,
|
||||
from: { hash: '0xfrom' },
|
||||
to: { hash: '0xto' },
|
||||
value: '1000000000000000000',
|
||||
gas_limit: 21000,
|
||||
gas_used: 21000,
|
||||
gas_price: 123,
|
||||
status: 'ok',
|
||||
timestamp: '2026-04-09T00:00:00.000000Z',
|
||||
method: '0xa9059cbb',
|
||||
revert_reason: null,
|
||||
transaction_tag: 'Transfer',
|
||||
fee: { value: '21000' },
|
||||
decoded_input: {
|
||||
method_call: 'transfer(address,uint256)',
|
||||
method_id: '0xa9059cbb',
|
||||
parameters: [{ name: 'to', type: 'address', value: '0xto' }],
|
||||
},
|
||||
token_transfers: [
|
||||
{
|
||||
from: { hash: '0xfrom' },
|
||||
to: { hash: '0xto' },
|
||||
token: {
|
||||
address: '0xtoken',
|
||||
symbol: 'TKN',
|
||||
name: 'Token',
|
||||
decimals: '6',
|
||||
},
|
||||
total: {
|
||||
decimals: '6',
|
||||
value: '5000000',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, 138)
|
||||
|
||||
expect(transaction.method).toBe('0xa9059cbb')
|
||||
expect(transaction.transaction_tag).toBe('Transfer')
|
||||
expect(transaction.decoded_input?.method_call).toBe('transfer(address,uint256)')
|
||||
expect(transaction.token_transfers).toHaveLength(1)
|
||||
expect(transaction.token_transfers?.[0].token_symbol).toBe('TKN')
|
||||
})
|
||||
|
||||
it('normalizes address balances and trust signals', () => {
|
||||
const info = normalizeAddressInfo({
|
||||
hash: '0xaddr',
|
||||
coin_balance: '123',
|
||||
is_contract: true,
|
||||
is_verified: true,
|
||||
has_token_transfers: true,
|
||||
has_tokens: true,
|
||||
creation_transaction_hash: '0xcreate',
|
||||
name: 'Treasury',
|
||||
token: {
|
||||
address: '0xtoken',
|
||||
symbol: 'cUSDT',
|
||||
name: 'Tether USD (Compliant)',
|
||||
decimals: '6',
|
||||
type: 'ERC-20',
|
||||
total_supply: '1000',
|
||||
holders: '10',
|
||||
},
|
||||
public_tags: [{ label: 'Core' }],
|
||||
private_tags: [],
|
||||
watchlist_names: ['Ops'],
|
||||
}, {
|
||||
transactions_count: '4',
|
||||
token_balances_count: '2',
|
||||
token_transfers_count: '8',
|
||||
internal_transactions_count: '6',
|
||||
logs_count: '9',
|
||||
}, 138)
|
||||
|
||||
expect(info.balance).toBe('123')
|
||||
expect(info.is_verified).toBe(true)
|
||||
expect(info.tags).toEqual(['Core', 'Ops'])
|
||||
expect(info.creation_transaction_hash).toBe('0xcreate')
|
||||
expect(info.token_contract?.symbol).toBe('cUSDT')
|
||||
expect(info.internal_transaction_count).toBe(6)
|
||||
})
|
||||
|
||||
it('normalizes address token balances and transfers', () => {
|
||||
const balance = normalizeAddressTokenBalance({
|
||||
token: {
|
||||
address: '0xtoken',
|
||||
name: 'Stable',
|
||||
symbol: 'STBL',
|
||||
decimals: '6',
|
||||
holders: '11',
|
||||
total_supply: '1000000',
|
||||
},
|
||||
value: '1000',
|
||||
})
|
||||
const transfer = normalizeAddressTokenTransfer({
|
||||
transaction_hash: '0xtx',
|
||||
block_number: 9,
|
||||
from: { hash: '0xfrom', name: 'Sender' },
|
||||
to: { hash: '0xto', name: 'Receiver' },
|
||||
token: {
|
||||
address: '0xtoken',
|
||||
symbol: 'STBL',
|
||||
name: 'Stable',
|
||||
decimals: '6',
|
||||
},
|
||||
total: {
|
||||
decimals: '6',
|
||||
value: '1000',
|
||||
},
|
||||
timestamp: '2026-04-09T00:00:00.000000Z',
|
||||
})
|
||||
|
||||
expect(balance.holder_count).toBe(11)
|
||||
expect(balance.token_symbol).toBe('STBL')
|
||||
expect(transfer.from_label).toBe('Sender')
|
||||
expect(transfer.to_label).toBe('Receiver')
|
||||
expect(transfer.value).toBe('1000')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import type { Block } from './blocks'
|
||||
import type { Transaction } from './transactions'
|
||||
import type { TransactionSummary } from './addresses'
|
||||
import type {
|
||||
AddressInfo,
|
||||
AddressTokenBalance,
|
||||
AddressTokenTransfer,
|
||||
TransactionSummary,
|
||||
} from './addresses'
|
||||
|
||||
export function getExplorerApiBase() {
|
||||
return resolveExplorerApiBase()
|
||||
@@ -16,18 +21,94 @@ export async function fetchBlockscoutJson<T>(path: string): Promise<T> {
|
||||
}
|
||||
|
||||
type HashLike = string | { hash?: string | null } | null | undefined
|
||||
type StringLike = string | number | null | undefined
|
||||
|
||||
export function extractHash(value: HashLike): string {
|
||||
if (!value) return ''
|
||||
return typeof value === 'string' ? value : value.hash || ''
|
||||
}
|
||||
|
||||
export function extractLabel(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
const candidate = value as {
|
||||
name?: string | null
|
||||
label?: string | null
|
||||
display_name?: string | null
|
||||
symbol?: string | null
|
||||
}
|
||||
return candidate.name || candidate.label || candidate.display_name || candidate.symbol || ''
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string' && value.trim() !== '') return Number(value)
|
||||
return 0
|
||||
}
|
||||
|
||||
function toNullableNumber(value: unknown): number | undefined {
|
||||
if (value == null) return undefined
|
||||
const numeric = toNumber(value)
|
||||
return Number.isFinite(numeric) ? numeric : undefined
|
||||
}
|
||||
|
||||
export interface BlockscoutAddressRef {
|
||||
hash?: string | null
|
||||
name?: string | null
|
||||
label?: string | null
|
||||
is_contract?: boolean
|
||||
is_verified?: boolean
|
||||
}
|
||||
|
||||
export interface BlockscoutTokenRef {
|
||||
address?: string | null
|
||||
name?: string | null
|
||||
symbol?: string | null
|
||||
decimals?: StringLike
|
||||
type?: string | null
|
||||
total_supply?: string | null
|
||||
holders?: StringLike
|
||||
}
|
||||
|
||||
export interface BlockscoutTokenTransfer {
|
||||
block_hash?: string
|
||||
block_number?: StringLike
|
||||
from?: BlockscoutAddressRef | null
|
||||
to?: BlockscoutAddressRef | null
|
||||
log_index?: StringLike
|
||||
method?: string | null
|
||||
timestamp?: string | null
|
||||
token?: BlockscoutTokenRef | null
|
||||
total?: {
|
||||
decimals?: StringLike
|
||||
value?: string | null
|
||||
} | null
|
||||
transaction_hash?: string
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
export interface BlockscoutDecodedInput {
|
||||
method_call?: string | null
|
||||
method_id?: string | null
|
||||
parameters?: Array<{
|
||||
name?: string | null
|
||||
type?: string | null
|
||||
value?: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export interface BlockscoutInternalTransaction {
|
||||
from?: BlockscoutAddressRef | null
|
||||
to?: BlockscoutAddressRef | null
|
||||
created_contract?: BlockscoutAddressRef | null
|
||||
success?: boolean | null
|
||||
error?: string | null
|
||||
result?: string | null
|
||||
timestamp?: string | null
|
||||
transaction_hash?: string | null
|
||||
type?: string | null
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
interface BlockscoutBlock {
|
||||
hash: string
|
||||
height: number | string
|
||||
@@ -54,6 +135,13 @@ interface BlockscoutTransaction {
|
||||
raw_input?: string | null
|
||||
timestamp: string
|
||||
created_contract?: HashLike
|
||||
fee?: { value?: string | null } | string | null
|
||||
method?: string | null
|
||||
revert_reason?: string | null
|
||||
transaction_tag?: string | null
|
||||
decoded_input?: BlockscoutDecodedInput | null
|
||||
token_transfers?: BlockscoutTokenTransfer[] | null
|
||||
actions?: unknown[] | null
|
||||
}
|
||||
|
||||
function normalizeStatus(raw: BlockscoutTransaction): number {
|
||||
@@ -95,6 +183,39 @@ export function normalizeTransaction(raw: BlockscoutTransaction, chainId: number
|
||||
input_data: raw.raw_input || undefined,
|
||||
contract_address: extractHash(raw.created_contract) || undefined,
|
||||
created_at: raw.timestamp,
|
||||
fee: typeof raw.fee === 'string' ? raw.fee : raw.fee?.value || undefined,
|
||||
method: raw.method || undefined,
|
||||
revert_reason: raw.revert_reason || undefined,
|
||||
transaction_tag: raw.transaction_tag || undefined,
|
||||
decoded_input: raw.decoded_input
|
||||
? {
|
||||
method_call: raw.decoded_input.method_call || undefined,
|
||||
method_id: raw.decoded_input.method_id || undefined,
|
||||
parameters: Array.isArray(raw.decoded_input.parameters)
|
||||
? raw.decoded_input.parameters.map((parameter) => ({
|
||||
name: parameter.name || undefined,
|
||||
type: parameter.type || undefined,
|
||||
value: parameter.value,
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
: undefined,
|
||||
token_transfers: Array.isArray(raw.token_transfers)
|
||||
? raw.token_transfers.map((transfer) => ({
|
||||
block_number: toNullableNumber(transfer.block_number),
|
||||
from_address: extractHash(transfer.from),
|
||||
from_label: extractLabel(transfer.from),
|
||||
to_address: extractHash(transfer.to),
|
||||
to_label: extractLabel(transfer.to),
|
||||
token_address: transfer.token?.address || '',
|
||||
token_name: transfer.token?.name || undefined,
|
||||
token_symbol: transfer.token?.symbol || undefined,
|
||||
token_decimals: toNullableNumber(transfer.token?.decimals) ?? toNullableNumber(transfer.total?.decimals) ?? 18,
|
||||
amount: transfer.total?.value || '0',
|
||||
type: transfer.type || undefined,
|
||||
timestamp: transfer.timestamp || undefined,
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,3 +229,104 @@ export function normalizeTransactionSummary(raw: BlockscoutTransaction): Transac
|
||||
status: normalizeStatus(raw),
|
||||
}
|
||||
}
|
||||
|
||||
interface BlockscoutAddress {
|
||||
hash: string
|
||||
coin_balance?: string | null
|
||||
is_contract?: boolean
|
||||
is_verified?: boolean
|
||||
name?: string | null
|
||||
public_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
|
||||
private_tags?: Array<{ label?: string; display_name?: string; name?: string } | string>
|
||||
watchlist_names?: string[]
|
||||
has_token_transfers?: boolean
|
||||
has_tokens?: boolean
|
||||
creation_transaction_hash?: string | null
|
||||
token?: BlockscoutTokenRef | null
|
||||
}
|
||||
|
||||
export function normalizeAddressInfo(
|
||||
raw: BlockscoutAddress,
|
||||
counters: {
|
||||
transactions_count?: number | string
|
||||
token_balances_count?: number | string
|
||||
token_transfers_count?: number | string
|
||||
internal_transactions_count?: number | string
|
||||
logs_count?: number | string
|
||||
},
|
||||
chainId: number,
|
||||
): AddressInfo {
|
||||
const tags = [
|
||||
...(raw.public_tags || []),
|
||||
...(raw.private_tags || []),
|
||||
...(raw.watchlist_names || []),
|
||||
]
|
||||
.map((tag) => {
|
||||
if (typeof tag === 'string') return tag
|
||||
return tag.display_name || tag.label || tag.name || ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
return {
|
||||
address: raw.hash,
|
||||
chain_id: chainId,
|
||||
transaction_count: Number(counters.transactions_count || 0),
|
||||
token_count: Number(counters.token_balances_count || 0),
|
||||
token_transfer_count: Number(counters.token_transfers_count || 0),
|
||||
internal_transaction_count: Number(counters.internal_transactions_count || 0),
|
||||
logs_count: Number(counters.logs_count || 0),
|
||||
is_contract: !!raw.is_contract,
|
||||
is_verified: !!raw.is_verified,
|
||||
has_token_transfers: !!raw.has_token_transfers,
|
||||
has_tokens: !!raw.has_tokens,
|
||||
balance: raw.coin_balance || undefined,
|
||||
creation_transaction_hash: raw.creation_transaction_hash || undefined,
|
||||
label: raw.name || raw.token?.symbol || undefined,
|
||||
tags,
|
||||
token_contract: raw.token?.address
|
||||
? {
|
||||
address: raw.token.address,
|
||||
symbol: raw.token.symbol || undefined,
|
||||
name: raw.token.name || undefined,
|
||||
decimals: toNullableNumber(raw.token.decimals),
|
||||
type: raw.token.type || undefined,
|
||||
total_supply: raw.token.total_supply || undefined,
|
||||
holders: toNullableNumber(raw.token.holders),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAddressTokenBalance(raw: {
|
||||
token?: BlockscoutTokenRef | null
|
||||
value?: string | null
|
||||
}): AddressTokenBalance {
|
||||
return {
|
||||
token_address: raw.token?.address || '',
|
||||
token_name: raw.token?.name || undefined,
|
||||
token_symbol: raw.token?.symbol || undefined,
|
||||
token_type: raw.token?.type || undefined,
|
||||
token_decimals: toNullableNumber(raw.token?.decimals) ?? 18,
|
||||
value: raw.value || '0',
|
||||
holder_count: raw.token?.holders != null ? toNumber(raw.token.holders) : undefined,
|
||||
total_supply: raw.token?.total_supply || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAddressTokenTransfer(raw: BlockscoutTokenTransfer): AddressTokenTransfer {
|
||||
return {
|
||||
transaction_hash: raw.transaction_hash || '',
|
||||
block_number: toNullableNumber(raw.block_number) ?? 0,
|
||||
timestamp: raw.timestamp || undefined,
|
||||
from_address: extractHash(raw.from),
|
||||
from_label: extractLabel(raw.from),
|
||||
to_address: extractHash(raw.to),
|
||||
to_label: extractLabel(raw.to),
|
||||
token_address: raw.token?.address || '',
|
||||
token_name: raw.token?.name || undefined,
|
||||
token_symbol: raw.token?.symbol || undefined,
|
||||
token_decimals: toNullableNumber(raw.token?.decimals) ?? toNullableNumber(raw.total?.decimals) ?? 18,
|
||||
value: raw.total?.value || '0',
|
||||
type: raw.type || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface TokenListToken {
|
||||
name?: string
|
||||
decimals?: number
|
||||
logoURI?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface TokenListResponse {
|
||||
|
||||
98
frontend/src/services/api/contracts.test.ts
Normal file
98
frontend/src/services/api/contracts.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { callSimpleReadMethod, contractsApi } from './contracts'
|
||||
|
||||
describe('contractsApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes contract profile metadata safely', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
has_custom_methods_read: true,
|
||||
has_custom_methods_write: false,
|
||||
proxy_type: 'eip1967',
|
||||
is_self_destructed: false,
|
||||
implementations: [{ address: '0ximpl1' }, '0ximpl2'],
|
||||
creation_bytecode: '0x' + 'a'.repeat(120),
|
||||
deployed_bytecode: '0x' + 'b'.repeat(120),
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: '1',
|
||||
message: 'OK',
|
||||
result: '[{"type":"function","name":"symbol","stateMutability":"view","inputs":[],"outputs":[{"type":"string"}]}]',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: '1',
|
||||
message: 'OK',
|
||||
result: [
|
||||
{
|
||||
ContractName: 'MockToken',
|
||||
CompilerVersion: 'v0.8.24+commit',
|
||||
OptimizationUsed: '1',
|
||||
Runs: '200',
|
||||
EVMVersion: 'paris',
|
||||
LicenseType: 'MIT',
|
||||
ConstructorArguments: '0x' + 'c'.repeat(120),
|
||||
SourceCode: 'contract MockToken {}',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await contractsApi.getProfileSafe('0xcontract')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.data?.has_custom_methods_read).toBe(true)
|
||||
expect(result.data?.proxy_type).toBe('eip1967')
|
||||
expect(result.data?.implementations).toEqual(['0ximpl1', '0ximpl2'])
|
||||
expect(result.data?.creation_bytecode?.endsWith('...')).toBe(true)
|
||||
expect(result.data?.source_verified).toBe(true)
|
||||
expect(result.data?.abi_available).toBe(true)
|
||||
expect(result.data?.contract_name).toBe('MockToken')
|
||||
expect(result.data?.optimization_enabled).toBe(true)
|
||||
expect(result.data?.optimization_runs).toBe(200)
|
||||
expect(result.data?.constructor_arguments?.endsWith('...')).toBe(true)
|
||||
expect(result.data?.abi).toContain('"symbol"')
|
||||
expect(result.data?.read_methods.map((method) => method.signature)).toContain('symbol()')
|
||||
})
|
||||
|
||||
it('calls a simple zero-arg read method through public RPC', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result:
|
||||
'0x' +
|
||||
'0000000000000000000000000000000000000000000000000000000000000020' +
|
||||
'0000000000000000000000000000000000000000000000000000000000000004' +
|
||||
'5445535400000000000000000000000000000000000000000000000000000000',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const value = await callSimpleReadMethod('0xcontract', {
|
||||
name: 'name',
|
||||
signature: 'name()',
|
||||
stateMutability: 'view',
|
||||
inputs: [],
|
||||
outputs: [{ name: '', type: 'string' }],
|
||||
})
|
||||
|
||||
expect(value).toBe('TEST')
|
||||
})
|
||||
})
|
||||
406
frontend/src/services/api/contracts.ts
Normal file
406
frontend/src/services/api/contracts.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { getExplorerApiBase, fetchBlockscoutJson } from './blockscout'
|
||||
import { keccak_256 } from 'js-sha3'
|
||||
|
||||
export interface ContractMethodParam {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface ContractMethod {
|
||||
name: string
|
||||
signature: string
|
||||
stateMutability: string
|
||||
inputs: ContractMethodParam[]
|
||||
outputs: ContractMethodParam[]
|
||||
}
|
||||
|
||||
export interface ContractMethodExecutionResult {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ContractProfile {
|
||||
has_custom_methods_read: boolean
|
||||
has_custom_methods_write: boolean
|
||||
proxy_type?: string
|
||||
is_self_destructed?: boolean
|
||||
implementations: string[]
|
||||
creation_bytecode?: string
|
||||
deployed_bytecode?: string
|
||||
source_verified: boolean
|
||||
abi_available: boolean
|
||||
contract_name?: string
|
||||
compiler_version?: string
|
||||
optimization_enabled?: boolean
|
||||
optimization_runs?: number
|
||||
evm_version?: string
|
||||
license_type?: string
|
||||
constructor_arguments?: string
|
||||
abi?: string
|
||||
source_code_preview?: string
|
||||
source_status_text?: string
|
||||
read_methods: ContractMethod[]
|
||||
write_methods: ContractMethod[]
|
||||
}
|
||||
|
||||
function truncateHex(value?: string | null, maxLength = 66): string | undefined {
|
||||
if (!value) return undefined
|
||||
if (value.length <= maxLength) return value
|
||||
return `${value.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
function truncateText(value?: string | null, maxLength = 400): string | undefined {
|
||||
if (!value) return undefined
|
||||
if (value.length <= maxLength) return value
|
||||
return `${value.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
interface ContractCompatibilityAbiResponse {
|
||||
status?: string | null
|
||||
message?: string | null
|
||||
result?: string | null
|
||||
}
|
||||
|
||||
interface ContractCompatibilitySourceRecord {
|
||||
Address?: string
|
||||
ContractName?: string
|
||||
CompilerVersion?: string
|
||||
OptimizationUsed?: string | number
|
||||
Runs?: string | number
|
||||
EVMVersion?: string
|
||||
LicenseType?: string
|
||||
ConstructorArguments?: string
|
||||
SourceCode?: string
|
||||
ABI?: string
|
||||
}
|
||||
|
||||
interface ContractCompatibilitySourceResponse {
|
||||
status?: string | null
|
||||
message?: string | null
|
||||
result?: ContractCompatibilitySourceRecord[] | null
|
||||
}
|
||||
|
||||
interface ABIEntry {
|
||||
type?: string
|
||||
name?: string
|
||||
stateMutability?: string
|
||||
constant?: boolean
|
||||
inputs?: Array<{ name?: string; type?: string }>
|
||||
outputs?: Array<{ name?: string; type?: string }>
|
||||
}
|
||||
|
||||
async function fetchCompatJson<T>(params: URLSearchParams): Promise<T> {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function normalizeBooleanFlag(value: string | number | null | undefined): boolean | undefined {
|
||||
if (value == null || value === '') return undefined
|
||||
if (typeof value === 'number') return value === 1
|
||||
return value === '1' || value.toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function normalizeNumber(value: string | number | null | undefined): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseABI(abiString?: string): ContractMethod[] {
|
||||
if (!abiString) return []
|
||||
try {
|
||||
const parsed = JSON.parse(abiString) as ABIEntry[]
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter((entry) => entry.type === 'function' && entry.name)
|
||||
.map((entry) => {
|
||||
const inputs = Array.isArray(entry.inputs)
|
||||
? entry.inputs.map((input) => ({
|
||||
name: input.name || '',
|
||||
type: input.type || 'unknown',
|
||||
}))
|
||||
: []
|
||||
const outputs = Array.isArray(entry.outputs)
|
||||
? entry.outputs.map((output) => ({
|
||||
name: output.name || '',
|
||||
type: output.type || 'unknown',
|
||||
}))
|
||||
: []
|
||||
return {
|
||||
name: entry.name || 'unknown',
|
||||
signature: `${entry.name || 'unknown'}(${inputs.map((input) => input.type).join(',')})`,
|
||||
stateMutability:
|
||||
entry.stateMutability ||
|
||||
(entry.constant || outputs.length > 0 ? 'view' : 'nonpayable'),
|
||||
inputs,
|
||||
outputs,
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function isReadMethod(method: ContractMethod): boolean {
|
||||
return method.stateMutability === 'view' || method.stateMutability === 'pure'
|
||||
}
|
||||
|
||||
function isSupportedInputType(type: string): boolean {
|
||||
return ['address', 'bool', 'string', 'uint256', 'uint8', 'bytes32'].includes(type)
|
||||
}
|
||||
|
||||
function isSupportedOutputType(type: string): boolean {
|
||||
return ['address', 'bool', 'string', 'uint256', 'uint8', 'bytes32', 'bytes'].includes(type)
|
||||
}
|
||||
|
||||
function supportsSimpleReadCall(method: ContractMethod): boolean {
|
||||
return (
|
||||
method.outputs.length === 1 &&
|
||||
method.inputs.every((input) => isSupportedInputType(input.type)) &&
|
||||
method.outputs.every((output) => isSupportedOutputType(output.type))
|
||||
)
|
||||
}
|
||||
|
||||
function supportsSimpleWriteCall(method: ContractMethod): boolean {
|
||||
return !isReadMethod(method) && method.inputs.every((input) => isSupportedInputType(input.type))
|
||||
}
|
||||
|
||||
function getPublicRpcUrl(): string {
|
||||
return process.env.NEXT_PUBLIC_RPC_URL_138 || 'https://rpc-http-pub.d-bis.org'
|
||||
}
|
||||
|
||||
function decodeDynamicString(wordData: string, offset: number): string {
|
||||
const lengthHex = wordData.slice(offset, offset + 64)
|
||||
const length = parseInt(lengthHex || '0', 16)
|
||||
const start = offset + 64
|
||||
const end = start + length * 2
|
||||
const contentHex = wordData.slice(start, end)
|
||||
if (!contentHex) return ''
|
||||
const bytes = contentHex.match(/.{1,2}/g) || []
|
||||
return bytes
|
||||
.map((byte) => String.fromCharCode(parseInt(byte, 16)))
|
||||
.join('')
|
||||
.replace(/\u0000+$/g, '')
|
||||
}
|
||||
|
||||
function validateAndEncodeInput(type: string, value: string): { head: string; tail?: string } {
|
||||
const trimmed = value.trim()
|
||||
switch (type) {
|
||||
case 'address': {
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(trimmed)) {
|
||||
throw new Error('Address inputs must be full 0x-prefixed addresses.')
|
||||
}
|
||||
return { head: trimmed.slice(2).toLowerCase().padStart(64, '0') }
|
||||
}
|
||||
case 'bool':
|
||||
if (!['true', 'false', '1', '0'].includes(trimmed.toLowerCase())) {
|
||||
throw new Error('Boolean inputs must be true/false or 1/0.')
|
||||
}
|
||||
return { head: (trimmed === 'true' || trimmed === '1' ? '1' : '0').padStart(64, '0') }
|
||||
case 'uint256':
|
||||
case 'uint8': {
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
throw new Error('Unsigned integer inputs must be non-negative decimal numbers.')
|
||||
}
|
||||
return { head: BigInt(trimmed).toString(16).padStart(64, '0') }
|
||||
}
|
||||
case 'bytes32': {
|
||||
if (!/^0x[a-fA-F0-9]{64}$/.test(trimmed)) {
|
||||
throw new Error('bytes32 inputs must be 32-byte 0x-prefixed hex values.')
|
||||
}
|
||||
return { head: trimmed.slice(2).toLowerCase() }
|
||||
}
|
||||
case 'string': {
|
||||
const contentHex = Array.from(new TextEncoder().encode(trimmed))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const paddedContent = contentHex.padEnd(Math.ceil(contentHex.length / 64) * 64 || 64, '0')
|
||||
const lengthHex = contentHex.length / 2
|
||||
return {
|
||||
head: '',
|
||||
tail:
|
||||
BigInt(lengthHex).toString(16).padStart(64, '0') +
|
||||
paddedContent,
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported input type ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeMethodCalldata(method: ContractMethod, values: string[]): string {
|
||||
if (values.length !== method.inputs.length) {
|
||||
throw new Error('Method input count does not match the provided values.')
|
||||
}
|
||||
|
||||
const selector = keccak_256(method.signature).slice(0, 8)
|
||||
const encodedInputs = method.inputs.map((input, index) => validateAndEncodeInput(input.type, values[index] || ''))
|
||||
let dynamicOffsetWords = method.inputs.length * 32
|
||||
const heads = encodedInputs.map((encoded) => {
|
||||
if (encoded.tail != null) {
|
||||
const head = BigInt(dynamicOffsetWords).toString(16).padStart(64, '0')
|
||||
dynamicOffsetWords += encoded.tail.length / 2
|
||||
return head
|
||||
}
|
||||
return encoded.head
|
||||
})
|
||||
const tails = encodedInputs
|
||||
.filter((encoded) => encoded.tail != null)
|
||||
.map((encoded) => encoded.tail || '')
|
||||
.join('')
|
||||
|
||||
return `0x${selector}${heads.join('')}${tails}`
|
||||
}
|
||||
|
||||
function decodeSimpleOutput(outputType: string, data: string): string {
|
||||
const normalized = data.replace(/^0x/i, '')
|
||||
if (!normalized) return 'No data returned'
|
||||
|
||||
switch (outputType) {
|
||||
case 'address':
|
||||
return `0x${normalized.slice(24, 64)}`
|
||||
case 'bool':
|
||||
return BigInt(`0x${normalized.slice(0, 64)}`) === 0n ? 'false' : 'true'
|
||||
case 'string': {
|
||||
const offset = Number(BigInt(`0x${normalized.slice(0, 64)}`) * 2n)
|
||||
return decodeDynamicString(normalized, offset)
|
||||
}
|
||||
case 'bytes32':
|
||||
return `0x${normalized.slice(0, 64)}`
|
||||
case 'bytes': {
|
||||
const offset = Number(BigInt(`0x${normalized.slice(0, 64)}`) * 2n)
|
||||
const length = parseInt(normalized.slice(offset + 64, offset + 128) || '0', 16)
|
||||
const start = offset + 128
|
||||
return `0x${normalized.slice(start, start + length * 2)}`
|
||||
}
|
||||
default:
|
||||
if (outputType.startsWith('uint') || outputType.startsWith('int')) {
|
||||
return BigInt(`0x${normalized.slice(0, 64)}`).toString()
|
||||
}
|
||||
return `0x${normalized}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function callSimpleReadMethod(address: string, method: ContractMethod, values: string[] = []): Promise<string> {
|
||||
if (!supportsSimpleReadCall(method)) {
|
||||
throw new Error('Only simple read methods with supported input and output types are supported in this explorer surface.')
|
||||
}
|
||||
const data = encodeMethodCalldata(method, values)
|
||||
const response = await fetch(getPublicRpcUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'eth_call',
|
||||
params: [
|
||||
{
|
||||
to: address,
|
||||
data,
|
||||
},
|
||||
'latest',
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`RPC HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { result?: string; error?: { message?: string } }
|
||||
if (payload.error?.message) {
|
||||
throw new Error(payload.error.message)
|
||||
}
|
||||
const result = payload.result || '0x'
|
||||
return decodeSimpleOutput(method.outputs[0]?.type || 'bytes', result)
|
||||
}
|
||||
|
||||
export const contractsApi = {
|
||||
getProfileSafe: async (address: string): Promise<{ ok: boolean; data: ContractProfile | null }> => {
|
||||
try {
|
||||
const [raw, abiResponse, sourceResponse] = await Promise.all([
|
||||
fetchBlockscoutJson<{
|
||||
has_custom_methods_read?: boolean
|
||||
has_custom_methods_write?: boolean
|
||||
proxy_type?: string | null
|
||||
is_self_destructed?: boolean | null
|
||||
implementations?: Array<{ address?: string | null } | string>
|
||||
creation_bytecode?: string | null
|
||||
deployed_bytecode?: string | null
|
||||
}>(`/api/v2/smart-contracts/${address}`),
|
||||
fetchCompatJson<ContractCompatibilityAbiResponse>(
|
||||
new URLSearchParams({
|
||||
module: 'contract',
|
||||
action: 'getabi',
|
||||
address,
|
||||
}),
|
||||
).catch(() => null),
|
||||
fetchCompatJson<ContractCompatibilitySourceResponse>(
|
||||
new URLSearchParams({
|
||||
module: 'contract',
|
||||
action: 'getsourcecode',
|
||||
address,
|
||||
}),
|
||||
).catch(() => null),
|
||||
])
|
||||
|
||||
const sourceRecord = Array.isArray(sourceResponse?.result) ? sourceResponse?.result[0] : undefined
|
||||
const abiString =
|
||||
abiResponse?.status === '1' && abiResponse.result && abiResponse.result !== 'Contract source code not verified'
|
||||
? abiResponse.result
|
||||
: sourceRecord?.ABI && sourceRecord.ABI !== 'Contract source code not verified'
|
||||
? sourceRecord.ABI
|
||||
: undefined
|
||||
const sourceCode = sourceRecord?.SourceCode
|
||||
const parsedMethods = parseABI(abiString)
|
||||
const sourceVerified = Boolean(
|
||||
abiString ||
|
||||
(sourceCode && sourceCode.trim().length > 0) ||
|
||||
(sourceRecord?.ContractName && sourceRecord.ContractName.trim().length > 0),
|
||||
)
|
||||
const sourceStatusText = abiResponse?.message || sourceResponse?.message || (sourceVerified ? 'Verified source available' : 'Contract source code not verified')
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
has_custom_methods_read: !!raw.has_custom_methods_read,
|
||||
has_custom_methods_write: !!raw.has_custom_methods_write,
|
||||
proxy_type: raw.proxy_type || undefined,
|
||||
is_self_destructed: raw.is_self_destructed ?? undefined,
|
||||
implementations: Array.isArray(raw.implementations)
|
||||
? raw.implementations
|
||||
.map((entry) => typeof entry === 'string' ? entry : entry.address || '')
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
creation_bytecode: truncateHex(raw.creation_bytecode),
|
||||
deployed_bytecode: truncateHex(raw.deployed_bytecode),
|
||||
source_verified: sourceVerified,
|
||||
abi_available: Boolean(abiString),
|
||||
contract_name: sourceRecord?.ContractName || undefined,
|
||||
compiler_version: sourceRecord?.CompilerVersion || undefined,
|
||||
optimization_enabled: normalizeBooleanFlag(sourceRecord?.OptimizationUsed),
|
||||
optimization_runs: normalizeNumber(sourceRecord?.Runs),
|
||||
evm_version: sourceRecord?.EVMVersion || undefined,
|
||||
license_type: sourceRecord?.LicenseType || undefined,
|
||||
constructor_arguments: truncateHex(sourceRecord?.ConstructorArguments, 90),
|
||||
abi: truncateText(abiString, 1200),
|
||||
source_code_preview: truncateText(sourceCode, 1200),
|
||||
source_status_text: sourceStatusText || undefined,
|
||||
read_methods: parsedMethods.filter(isReadMethod),
|
||||
write_methods: parsedMethods.filter((method) => !isReadMethod(method)),
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
},
|
||||
supportsSimpleReadCall,
|
||||
supportsSimpleWriteCall,
|
||||
}
|
||||
216
frontend/src/services/api/gru.ts
Normal file
216
frontend/src/services/api/gru.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type { ContractMethod, ContractProfile } from './contracts'
|
||||
import { callSimpleReadMethod } from './contracts'
|
||||
import { getGruCatalogPosture } from './gruCatalog'
|
||||
|
||||
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
|
||||
const ZERO_BYTES32 = '0x0000000000000000000000000000000000000000000000000000000000000000'
|
||||
|
||||
export interface GruStandardStatus {
|
||||
id: string
|
||||
required: boolean
|
||||
detected: boolean
|
||||
}
|
||||
|
||||
export interface GruMetadataField {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface GruStandardsProfile {
|
||||
isGruSurface: boolean
|
||||
wrappedTransport: boolean
|
||||
forwardCanonical: boolean | null
|
||||
legacyAliasSupport: boolean
|
||||
x402Ready: boolean
|
||||
minimumUpgradeNoticePeriodSeconds: number | null
|
||||
activeVersion?: string
|
||||
forwardVersion?: string
|
||||
profileId: string
|
||||
standards: GruStandardStatus[]
|
||||
metadata: GruMetadataField[]
|
||||
}
|
||||
|
||||
const GRU_PROFILE_ID = 'gru-c-star-v2-transport-and-payment'
|
||||
|
||||
const STANDARD_DEFINITIONS = [
|
||||
{ id: 'ERC-20', required: true },
|
||||
{ id: 'AccessControl', required: true },
|
||||
{ id: 'Pausable', required: true },
|
||||
{ id: 'EIP-712', required: true },
|
||||
{ id: 'ERC-2612', required: true },
|
||||
{ id: 'ERC-3009', required: true },
|
||||
{ id: 'ERC-5267', required: true },
|
||||
{ id: 'IeMoneyToken', required: true },
|
||||
{ id: 'DeterministicStorageNamespace', required: true },
|
||||
{ id: 'JurisdictionAndSupervisionMetadata', required: true },
|
||||
] as const
|
||||
|
||||
function method(signature: string, outputType: string, inputTypes: string[] = []): ContractMethod {
|
||||
const name = signature.split('(')[0]
|
||||
return {
|
||||
name,
|
||||
signature,
|
||||
stateMutability: 'view',
|
||||
inputs: inputTypes.map((type, index) => ({ name: `arg${index + 1}`, type })),
|
||||
outputs: [{ name: '', type: outputType }],
|
||||
}
|
||||
}
|
||||
|
||||
function hasMethod(profile: ContractProfile | null | undefined, name: string): boolean {
|
||||
const allMethods = [...(profile?.read_methods || []), ...(profile?.write_methods || [])]
|
||||
return allMethods.some((entry) => entry.name === name)
|
||||
}
|
||||
|
||||
async function readOptional(address: string, contractMethod: ContractMethod, values: string[] = []): Promise<string | null> {
|
||||
try {
|
||||
const value = await callSimpleReadMethod(address, contractMethod, values)
|
||||
return value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeGruToken(symbol?: string | null, tags?: string[] | null): boolean {
|
||||
const normalizedSymbol = (symbol || '').toUpperCase()
|
||||
if (normalizedSymbol.startsWith('CW') || normalizedSymbol.startsWith('C')) return true
|
||||
const normalizedTags = (tags || []).map((tag) => tag.toLowerCase())
|
||||
return normalizedTags.includes('compliant') || normalizedTags.includes('wrapped') || normalizedTags.includes('bridge')
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string | null): number | null {
|
||||
if (!value || !/^\d+$/.test(value)) return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export async function getGruStandardsProfileSafe(input: {
|
||||
address: string
|
||||
symbol?: string | null
|
||||
tags?: string[] | null
|
||||
contractProfile?: ContractProfile | null
|
||||
}): Promise<{ ok: boolean; data: GruStandardsProfile | null }> {
|
||||
const { address, symbol, tags, contractProfile } = input
|
||||
|
||||
const [
|
||||
currencyCode,
|
||||
versionTag,
|
||||
assetId,
|
||||
assetVersionId,
|
||||
governanceProfileId,
|
||||
supervisionProfileId,
|
||||
storageNamespace,
|
||||
primaryJurisdiction,
|
||||
regulatoryDisclosureURI,
|
||||
reportingURI,
|
||||
minimumUpgradeNoticePeriod,
|
||||
wrappedTransport,
|
||||
forwardCanonical,
|
||||
paused,
|
||||
domainSeparator,
|
||||
nonces,
|
||||
authorizationState,
|
||||
defaultAdminRole,
|
||||
] = await Promise.all([
|
||||
readOptional(address, method('currencyCode()(string)', 'string')),
|
||||
readOptional(address, method('versionTag()(string)', 'string')),
|
||||
readOptional(address, method('assetId()(bytes32)', 'bytes32')),
|
||||
readOptional(address, method('assetVersionId()(bytes32)', 'bytes32')),
|
||||
readOptional(address, method('governanceProfileId()(bytes32)', 'bytes32')),
|
||||
readOptional(address, method('supervisionProfileId()(bytes32)', 'bytes32')),
|
||||
readOptional(address, method('storageNamespace()(bytes32)', 'bytes32')),
|
||||
readOptional(address, method('primaryJurisdiction()(string)', 'string')),
|
||||
readOptional(address, method('regulatoryDisclosureURI()(string)', 'string')),
|
||||
readOptional(address, method('reportingURI()(string)', 'string')),
|
||||
readOptional(address, method('minimumUpgradeNoticePeriod()(uint256)', 'uint256')),
|
||||
readOptional(address, method('wrappedTransport()(bool)', 'bool')),
|
||||
readOptional(address, method('forwardCanonical()(bool)', 'bool')),
|
||||
readOptional(address, method('paused()(bool)', 'bool')),
|
||||
readOptional(address, method('DOMAIN_SEPARATOR()(bytes32)', 'bytes32')),
|
||||
readOptional(address, method('nonces(address)(uint256)', 'uint256', ['address']), [ZERO_ADDRESS]),
|
||||
readOptional(address, method('authorizationState(address,bytes32)(bool)', 'bool', ['address', 'bytes32']), [ZERO_ADDRESS, ZERO_BYTES32]),
|
||||
readOptional(address, method('DEFAULT_ADMIN_ROLE()(bytes32)', 'bytes32')),
|
||||
])
|
||||
|
||||
const hasErc20Shape =
|
||||
Boolean(symbol) ||
|
||||
hasMethod(contractProfile, 'name') ||
|
||||
hasMethod(contractProfile, 'symbol') ||
|
||||
hasMethod(contractProfile, 'decimals') ||
|
||||
hasMethod(contractProfile, 'totalSupply')
|
||||
|
||||
const detectedMap: Record<string, boolean> = {
|
||||
'ERC-20': hasErc20Shape,
|
||||
AccessControl: defaultAdminRole != null || hasMethod(contractProfile, 'grantRole') || hasMethod(contractProfile, 'hasRole'),
|
||||
Pausable: paused != null || hasMethod(contractProfile, 'paused'),
|
||||
'EIP-712': domainSeparator != null || hasMethod(contractProfile, 'DOMAIN_SEPARATOR'),
|
||||
'ERC-2612': nonces != null || hasMethod(contractProfile, 'permit') || hasMethod(contractProfile, 'nonces'),
|
||||
'ERC-3009': authorizationState != null || hasMethod(contractProfile, 'authorizationState'),
|
||||
'ERC-5267': hasMethod(contractProfile, 'eip712Domain'),
|
||||
IeMoneyToken: currencyCode != null || versionTag != null,
|
||||
DeterministicStorageNamespace: storageNamespace != null,
|
||||
JurisdictionAndSupervisionMetadata:
|
||||
governanceProfileId != null ||
|
||||
supervisionProfileId != null ||
|
||||
primaryJurisdiction != null ||
|
||||
regulatoryDisclosureURI != null ||
|
||||
reportingURI != null ||
|
||||
minimumUpgradeNoticePeriod != null ||
|
||||
wrappedTransport != null,
|
||||
}
|
||||
|
||||
const isGruSurface =
|
||||
looksLikeGruToken(symbol, tags) ||
|
||||
Boolean(currencyCode) ||
|
||||
Boolean(versionTag) ||
|
||||
Boolean(assetId) ||
|
||||
Boolean(governanceProfileId) ||
|
||||
Boolean(storageNamespace)
|
||||
|
||||
if (!isGruSurface) {
|
||||
return { ok: true, data: null }
|
||||
}
|
||||
|
||||
const x402Ready = Boolean(detectedMap['EIP-712'] && detectedMap['ERC-5267'] && (detectedMap['ERC-2612'] || detectedMap['ERC-3009']))
|
||||
const minimumUpgradeNoticePeriodSeconds = parseOptionalNumber(minimumUpgradeNoticePeriod)
|
||||
const legacyAliasSupport = hasMethod(contractProfile, 'legacyAliases')
|
||||
const catalogPosture = getGruCatalogPosture({ symbol, address, tags })
|
||||
|
||||
const metadata: GruMetadataField[] = [
|
||||
currencyCode ? { label: 'Currency Code', value: currencyCode } : null,
|
||||
versionTag ? { label: 'Version Tag', value: versionTag } : null,
|
||||
assetId ? { label: 'Asset ID', value: assetId } : null,
|
||||
assetVersionId ? { label: 'Asset Version ID', value: assetVersionId } : null,
|
||||
governanceProfileId ? { label: 'Governance Profile', value: governanceProfileId } : null,
|
||||
supervisionProfileId ? { label: 'Supervision Profile', value: supervisionProfileId } : null,
|
||||
storageNamespace ? { label: 'Storage Namespace', value: storageNamespace } : null,
|
||||
primaryJurisdiction ? { label: 'Primary Jurisdiction', value: primaryJurisdiction } : null,
|
||||
regulatoryDisclosureURI ? { label: 'Disclosure URI', value: regulatoryDisclosureURI } : null,
|
||||
reportingURI ? { label: 'Reporting URI', value: reportingURI } : null,
|
||||
minimumUpgradeNoticePeriod ? { label: 'Upgrade Notice Period', value: `${minimumUpgradeNoticePeriod} seconds` } : null,
|
||||
wrappedTransport != null ? { label: 'Wrapped Transport', value: wrappedTransport } : null,
|
||||
forwardCanonical != null ? { label: 'Forward Canonical', value: forwardCanonical } : null,
|
||||
legacyAliasSupport ? { label: 'Legacy Alias Support', value: 'true' } : null,
|
||||
{ label: 'x402 Readiness', value: x402Ready ? 'true' : 'false' },
|
||||
].filter(Boolean) as GruMetadataField[]
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
isGruSurface: true,
|
||||
wrappedTransport: wrappedTransport === 'true',
|
||||
forwardCanonical: forwardCanonical === 'true' ? true : forwardCanonical === 'false' ? false : null,
|
||||
legacyAliasSupport,
|
||||
x402Ready,
|
||||
minimumUpgradeNoticePeriodSeconds,
|
||||
activeVersion: catalogPosture?.activeVersion,
|
||||
forwardVersion: catalogPosture?.forwardVersion,
|
||||
profileId: GRU_PROFILE_ID,
|
||||
standards: STANDARD_DEFINITIONS.map((entry) => ({
|
||||
id: entry.id,
|
||||
required: entry.required,
|
||||
detected: Boolean(detectedMap[entry.id]),
|
||||
})),
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
116
frontend/src/services/api/gruCatalog.ts
Normal file
116
frontend/src/services/api/gruCatalog.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export interface GruCatalogPosture {
|
||||
isGru: boolean
|
||||
isWrappedTransport: boolean
|
||||
isX402Ready: boolean
|
||||
isForwardCanonical: boolean
|
||||
currencyCode?: string
|
||||
activeVersion?: string
|
||||
forwardVersion?: string
|
||||
}
|
||||
|
||||
interface GruCatalogEntry extends GruCatalogPosture {
|
||||
symbol: string
|
||||
addresses?: string[]
|
||||
}
|
||||
|
||||
const GRU_X402_READY_SYMBOLS = new Set([
|
||||
'CAUDC',
|
||||
'CCADC',
|
||||
'CCHFC',
|
||||
'CEURC',
|
||||
'CEURT',
|
||||
'CGBPC',
|
||||
'CGBPT',
|
||||
'CJPYC',
|
||||
'CUSDC',
|
||||
'CUSDT',
|
||||
'CXAUC',
|
||||
'CXAUT',
|
||||
])
|
||||
|
||||
const GRU_CATALOG: GruCatalogEntry[] = [
|
||||
{
|
||||
symbol: 'cUSDC',
|
||||
currencyCode: 'USD',
|
||||
isGru: true,
|
||||
isWrappedTransport: false,
|
||||
isX402Ready: true,
|
||||
isForwardCanonical: true,
|
||||
activeVersion: 'v1',
|
||||
forwardVersion: 'v2',
|
||||
addresses: [
|
||||
'0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
'0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d',
|
||||
],
|
||||
},
|
||||
{
|
||||
symbol: 'cUSDT',
|
||||
currencyCode: 'USD',
|
||||
isGru: true,
|
||||
isWrappedTransport: false,
|
||||
isX402Ready: true,
|
||||
isForwardCanonical: true,
|
||||
activeVersion: 'v1',
|
||||
forwardVersion: 'v2',
|
||||
addresses: [
|
||||
'0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
|
||||
'0x9fbfab33882efe0038daa608185718b772ee5660',
|
||||
],
|
||||
},
|
||||
...['cAUDC', 'cCADC', 'cCHFC', 'cEURC', 'cEURT', 'cGBPC', 'cGBPT', 'cJPYC', 'cXAUC', 'cXAUT'].map((symbol) => ({
|
||||
symbol,
|
||||
currencyCode: symbol.slice(1, 4).replace('XAU', 'XAU'),
|
||||
isGru: true,
|
||||
isWrappedTransport: false,
|
||||
isX402Ready: true,
|
||||
isForwardCanonical: true,
|
||||
forwardVersion: 'v2',
|
||||
})),
|
||||
]
|
||||
|
||||
function normalizeSymbol(symbol?: string | null): string {
|
||||
return (symbol || '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
function normalizeAddress(address?: string | null): string {
|
||||
return (address || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function getGruCatalogPosture(input: {
|
||||
symbol?: string | null
|
||||
address?: string | null
|
||||
tags?: string[] | null
|
||||
}): GruCatalogPosture | null {
|
||||
const symbol = normalizeSymbol(input.symbol)
|
||||
const address = normalizeAddress(input.address)
|
||||
const tags = (input.tags || []).map((tag) => tag.toLowerCase())
|
||||
|
||||
const matched = GRU_CATALOG.find((entry) => {
|
||||
if (symbol && normalizeSymbol(entry.symbol) === symbol) return true
|
||||
if (address && entry.addresses?.includes(address)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
isGru: true,
|
||||
isWrappedTransport: matched.isWrappedTransport,
|
||||
isX402Ready: matched.isX402Ready,
|
||||
isForwardCanonical: matched.isForwardCanonical,
|
||||
currencyCode: matched.currencyCode,
|
||||
activeVersion: matched.activeVersion,
|
||||
forwardVersion: matched.forwardVersion,
|
||||
}
|
||||
}
|
||||
|
||||
const looksWrapped = symbol.startsWith('CW')
|
||||
const looksGru = looksWrapped || symbol.startsWith('C') || tags.includes('compliant') || tags.includes('wrapped') || tags.includes('bridge')
|
||||
if (!looksGru) return null
|
||||
|
||||
return {
|
||||
isGru: true,
|
||||
isWrappedTransport: looksWrapped || tags.includes('wrapped') || tags.includes('bridge'),
|
||||
isX402Ready: GRU_X402_READY_SYMBOLS.has(symbol),
|
||||
isForwardCanonical: GRU_X402_READY_SYMBOLS.has(symbol) && !looksWrapped,
|
||||
}
|
||||
}
|
||||
183
frontend/src/services/api/gruExplorerData.ts
Normal file
183
frontend/src/services/api/gruExplorerData.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
export interface GruNetworkLink {
|
||||
chainId: number
|
||||
chainName: string
|
||||
symbol: string
|
||||
address: string
|
||||
notes?: string
|
||||
explorerUrl?: string
|
||||
}
|
||||
|
||||
export interface GruExplorerMetadata {
|
||||
currencyCode?: string
|
||||
iso20022Ready: boolean
|
||||
x402Ready: boolean
|
||||
activeVersion?: string
|
||||
transportActiveVersion?: string
|
||||
x402PreferredVersion?: string
|
||||
canonicalForwardVersion?: string
|
||||
canonicalForwardAddress?: string
|
||||
otherNetworks: GruNetworkLink[]
|
||||
}
|
||||
|
||||
interface GruExplorerEntry extends GruExplorerMetadata {
|
||||
symbol: string
|
||||
addresses: string[]
|
||||
}
|
||||
|
||||
function chainExplorerUrl(chainId: number, address: string): string | undefined {
|
||||
switch (chainId) {
|
||||
case 1:
|
||||
return `https://etherscan.io/address/${address}`
|
||||
case 651940:
|
||||
return `https://alltra.global/address/${address}`
|
||||
case 56:
|
||||
return `https://bscscan.com/address/${address}`
|
||||
case 100:
|
||||
return `https://gnosisscan.io/address/${address}`
|
||||
case 137:
|
||||
return `https://polygonscan.com/address/${address}`
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function networkLink(chainId: number, chainName: string, symbol: string, address: string, notes?: string): GruNetworkLink {
|
||||
return {
|
||||
chainId,
|
||||
chainName,
|
||||
symbol,
|
||||
address,
|
||||
notes,
|
||||
explorerUrl: chainExplorerUrl(chainId, address),
|
||||
}
|
||||
}
|
||||
|
||||
const GRU_EXPLORER_ENTRIES: GruExplorerEntry[] = [
|
||||
{
|
||||
symbol: 'cUSDC',
|
||||
addresses: [
|
||||
'0xf22258f57794cc8e06237084b353ab30fffa640b',
|
||||
'0x219522c60e83dee01fc5b0329d6fa8fd84b9d13d',
|
||||
],
|
||||
currencyCode: 'USD',
|
||||
iso20022Ready: true,
|
||||
x402Ready: true,
|
||||
activeVersion: 'v1',
|
||||
transportActiveVersion: 'v1',
|
||||
x402PreferredVersion: 'v2',
|
||||
canonicalForwardVersion: 'v2',
|
||||
canonicalForwardAddress: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
|
||||
otherNetworks: [
|
||||
networkLink(651940, 'ALL Mainnet (Alltra)', 'AUSDC', '0xa95EeD79f84E6A0151eaEb9d441F9Ffd50e8e881', 'Primary Alltra-native origin counterpart.'),
|
||||
networkLink(1, 'Ethereum Mainnet', 'USDC', '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 'Native Ethereum settlement counterpart.'),
|
||||
networkLink(1, 'Ethereum Mainnet', 'cWUSDC', '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', 'GRU wrapped transport representation on Ethereum.'),
|
||||
networkLink(56, 'BNB Chain', 'USDC', '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', 'Native BNB Chain settlement counterpart.'),
|
||||
networkLink(56, 'BNB Chain', 'cWUSDC', '0x5355148C4740fcc3D7a96F05EdD89AB14851206b', 'GRU wrapped transport representation on BNB Chain.'),
|
||||
networkLink(137, 'Polygon', 'USDC', '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', 'Native Polygon settlement counterpart.'),
|
||||
networkLink(137, 'Polygon', 'cWUSDC', '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4', 'GRU wrapped transport representation on Polygon.'),
|
||||
networkLink(100, 'Gnosis', 'USDC', '0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83', 'Native Gnosis settlement counterpart.'),
|
||||
networkLink(100, 'Gnosis', 'cWUSDC', '0xd6969bC19b53f866C64f2148aE271B2Dae0C58E4', 'GRU wrapped transport representation on Gnosis.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
symbol: 'cUSDT',
|
||||
addresses: [
|
||||
'0x93e66202a11b1772e55407b32b44e5cd8eda7f22',
|
||||
'0x9fbfab33882efe0038daa608185718b772ee5660',
|
||||
],
|
||||
currencyCode: 'USD',
|
||||
iso20022Ready: true,
|
||||
x402Ready: true,
|
||||
activeVersion: 'v1',
|
||||
transportActiveVersion: 'v1',
|
||||
x402PreferredVersion: 'v2',
|
||||
canonicalForwardVersion: 'v2',
|
||||
canonicalForwardAddress: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
|
||||
otherNetworks: [
|
||||
networkLink(651940, 'ALL Mainnet (Alltra)', 'AUSDT', '0x015B1897Ed5279930bC2Be46F661894d219292A6', 'Primary Alltra-native origin counterpart.'),
|
||||
networkLink(1, 'Ethereum Mainnet', 'USDT', '0xdAC17F958D2ee523a2206206994597C13D831ec7', 'Native Ethereum settlement counterpart.'),
|
||||
networkLink(1, 'Ethereum Mainnet', 'cWUSDT', '0xaF5017d0163ecb99D9B5D94e3b4D7b09Af44D8AE', 'GRU wrapped transport representation on Ethereum.'),
|
||||
networkLink(56, 'BNB Chain', 'USDT', '0x55d398326f99059fF775485246999027B3197955', 'Native BNB Chain settlement counterpart.'),
|
||||
networkLink(56, 'BNB Chain', 'cWUSDT', '0x9a1D0dBEE997929ED02fD19E0E199704d20914dB', 'GRU wrapped transport representation on BNB Chain.'),
|
||||
networkLink(137, 'Polygon', 'USDT', '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', 'Native Polygon settlement counterpart.'),
|
||||
networkLink(137, 'Polygon', 'cWUSDT', '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', 'GRU wrapped transport representation on Polygon.'),
|
||||
networkLink(100, 'Gnosis', 'USDT', '0x4ECaBa5870353805a9F068101A40E0f32ed605C6', 'Native Gnosis settlement counterpart.'),
|
||||
networkLink(100, 'Gnosis', 'cWUSDT', '0x0cb0192C056aa425C557BdeAD8E56C7eEabf7acF', 'GRU wrapped transport representation on Gnosis.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
symbol: 'cEURC',
|
||||
addresses: ['0x8085961f9cf02b4d800a3c6d386d31da4b34266a', '0x243e6581dc8a98d98b92265858b322b193555c81'],
|
||||
currencyCode: 'EUR',
|
||||
iso20022Ready: true,
|
||||
x402Ready: true,
|
||||
activeVersion: 'v2',
|
||||
x402PreferredVersion: 'v2',
|
||||
canonicalForwardVersion: 'v2',
|
||||
canonicalForwardAddress: '0x243e6581Dc8a98d98B92265858b322b193555C81',
|
||||
otherNetworks: [
|
||||
networkLink(56, 'BNB Chain', 'cWEURC', '0x50b073d0D1D2f002745cb9FC28a057d5be84911c', 'GRU wrapped transport representation on BNB Chain.'),
|
||||
networkLink(137, 'Polygon', 'cWEURC', '0x3CD9ee18db7ad13616FCC1c83bC6098e03968E66', 'GRU wrapped transport representation on Polygon.'),
|
||||
networkLink(100, 'Gnosis', 'cWEURC', '0x25603ae4bff0b71d637b3573d1b6657f5f6d17ef', 'GRU wrapped transport representation on Gnosis.'),
|
||||
],
|
||||
},
|
||||
{
|
||||
symbol: 'cEURT',
|
||||
addresses: ['0xdf4b71c61e5912712c1bdd451416b9ac26949d72', '0x2bafa83d8ff8bae9505511998987d0659791605b'],
|
||||
currencyCode: 'EUR',
|
||||
iso20022Ready: true,
|
||||
x402Ready: true,
|
||||
activeVersion: 'v2',
|
||||
x402PreferredVersion: 'v2',
|
||||
canonicalForwardVersion: 'v2',
|
||||
canonicalForwardAddress: '0x2bAFA83d8fF8BaE9505511998987D0659791605B',
|
||||
otherNetworks: [
|
||||
networkLink(56, 'BNB Chain', 'cWEURT', '0x1ED9E491A5eCd53BeF21962A5FCE24880264F63f', 'GRU wrapped transport representation on BNB Chain.'),
|
||||
networkLink(137, 'Polygon', 'cWEURT', '0xBeF5A0Bcc0E77740c910f197138cdD90F98d2427', 'GRU wrapped transport representation on Polygon.'),
|
||||
networkLink(100, 'Gnosis', 'cWEURT', '0x8e54c52d34a684e22865ac9f2d7c27c30561a7b9', 'GRU wrapped transport representation on Gnosis.'),
|
||||
],
|
||||
},
|
||||
...[
|
||||
['cGBPC', 'GBP', '0x003960f16d9d34f2e98d62723b6721fb92074ad2', '0x707508D223103f5D2d9EFBc656302c9d48878b29'],
|
||||
['cGBPT', 'GBP', '0x350f54e4d23795f86a9c03988c7135357ccad97c', '0xee17c18E10E55ce23F7457D018aAa2Fb1E64B281'],
|
||||
['cAUDC', 'AUD', '0xd51482e567c03899eece3cae8a058161fd56069d', '0xfb37aFd415B70C5cEDc9bA58a72D517207b769Bb'],
|
||||
['cJPYC', 'JPY', '0xee269e1226a334182aace90056ee4ee5cc8a6770', '0x2c751bBE4f299b989b3A8c333E0A966cdcA6Fd98'],
|
||||
['cCHFC', 'CHF', '0x873990849dda5117d7c644f0af24370797c03885', '0x60B7FB8e0DD0Be8595AD12Fe80AE832861Be747c'],
|
||||
['cCADC', 'CAD', '0x54dbd40cf05e15906a2c21f600937e96787f5679', '0xe799033c87fE0CE316DAECcefBE3134CC74b76a9'],
|
||||
['cXAUC', 'XAU', '0x290e52a8819a4fbd0714e517225429aa2b70ec6b', '0xF0F0F81bE3D033D8586bAfd2293e37eE2f615647'],
|
||||
['cXAUT', 'XAU', '0x94e408e26c6fd8f4ee00b54df19082fda07dc96e', '0x89477E982847023aaB5C3492082cd1bB4b1b9Ef1'],
|
||||
].map(([symbol, currencyCode, v1Address, v2Address]) => ({
|
||||
symbol,
|
||||
addresses: [v1Address.toLowerCase(), v2Address.toLowerCase()],
|
||||
currencyCode,
|
||||
iso20022Ready: true,
|
||||
x402Ready: true,
|
||||
activeVersion: 'v2',
|
||||
x402PreferredVersion: 'v2',
|
||||
canonicalForwardVersion: 'v2',
|
||||
canonicalForwardAddress: v2Address,
|
||||
otherNetworks: [],
|
||||
})),
|
||||
]
|
||||
|
||||
function normalizeAddress(address?: string | null): string {
|
||||
return (address || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeSymbol(symbol?: string | null): string {
|
||||
return (symbol || '').trim().toUpperCase()
|
||||
}
|
||||
|
||||
export function getGruExplorerMetadata(input: {
|
||||
address?: string | null
|
||||
symbol?: string | null
|
||||
}): GruExplorerMetadata | null {
|
||||
const address = normalizeAddress(input.address)
|
||||
const symbol = normalizeSymbol(input.symbol)
|
||||
const matched = GRU_EXPLORER_ENTRIES.find((entry) => {
|
||||
if (address && entry.addresses.includes(address)) return true
|
||||
if (symbol && entry.symbol.toUpperCase() === symbol) return true
|
||||
return false
|
||||
})
|
||||
return matched || null
|
||||
}
|
||||
60
frontend/src/services/api/missionControl.test.ts
Normal file
60
frontend/src/services/api/missionControl.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { missionControlApi } from './missionControl'
|
||||
|
||||
class FakeEventSource {
|
||||
static instances: FakeEventSource[] = []
|
||||
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
listeners = new Map<string, Set<(event: MessageEvent<string>) => void>>()
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
FakeEventSource.instances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(event: string, handler: (event: MessageEvent<string>) => void) {
|
||||
const existing = this.listeners.get(event) || new Set()
|
||||
existing.add(handler)
|
||||
this.listeners.set(event, existing)
|
||||
}
|
||||
|
||||
removeEventListener(event: string, handler: (event: MessageEvent<string>) => void) {
|
||||
this.listeners.get(event)?.delete(handler)
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
emit(event: string, data: unknown) {
|
||||
const payload = { data: JSON.stringify(data) } as MessageEvent<string>
|
||||
for (const handler of this.listeners.get(event) || []) {
|
||||
handler(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('missionControlApi.subscribeBridgeStatus', () => {
|
||||
it('subscribes to the named mission-control SSE event', () => {
|
||||
const originalWindow = globalThis.window
|
||||
const fakeWindow = {
|
||||
EventSource: FakeEventSource as unknown as typeof EventSource,
|
||||
location: {
|
||||
origin: 'https://explorer.example.org',
|
||||
},
|
||||
} as Window & typeof globalThis
|
||||
// @ts-expect-error test shim
|
||||
globalThis.window = fakeWindow
|
||||
|
||||
const onStatus = vi.fn()
|
||||
const unsubscribe = missionControlApi.subscribeBridgeStatus(onStatus)
|
||||
const instance = FakeEventSource.instances.at(-1)
|
||||
|
||||
expect(instance).toBeTruthy()
|
||||
|
||||
instance?.emit('mission-control', { data: { status: 'operational' } })
|
||||
|
||||
expect(onStatus).toHaveBeenCalledWith({ data: { status: 'operational' } })
|
||||
|
||||
unsubscribe()
|
||||
globalThis.window = originalWindow
|
||||
})
|
||||
})
|
||||
@@ -220,7 +220,7 @@ export const missionControlApi = {
|
||||
|
||||
const eventSource = new window.EventSource(getMissionControlStreamUrl())
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as MissionControlBridgeStatusResponse
|
||||
onStatus(payload)
|
||||
@@ -229,11 +229,15 @@ export const missionControlApi = {
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.addEventListener('mission-control', handleMessage)
|
||||
eventSource.onmessage = handleMessage
|
||||
|
||||
eventSource.onerror = () => {
|
||||
onError?.(new Error('Mission-control live stream connection lost'))
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener('mission-control', handleMessage)
|
||||
eventSource.close()
|
||||
}
|
||||
},
|
||||
|
||||
35
frontend/src/services/api/routes.test.ts
Normal file
35
frontend/src/services/api/routes.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeMissionControlLiquidityPools } from './routes'
|
||||
|
||||
describe('normalizeMissionControlLiquidityPools', () => {
|
||||
it('accepts the nested backend proxy shape', () => {
|
||||
expect(
|
||||
normalizeMissionControlLiquidityPools({
|
||||
data: {
|
||||
count: 2,
|
||||
pools: [
|
||||
{ address: '0x1', dex: 'DODO' },
|
||||
{ address: '0x2', dex: 'Uniswap' },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
count: 2,
|
||||
pools: [
|
||||
{ address: '0x1', dex: 'DODO' },
|
||||
{ address: '0x2', dex: 'Uniswap' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps working with a flat legacy shape', () => {
|
||||
expect(
|
||||
normalizeMissionControlLiquidityPools({
|
||||
pools: [{ address: '0xabc', dex: 'DODO' }],
|
||||
})
|
||||
).toEqual({
|
||||
count: 1,
|
||||
pools: [{ address: '0xabc', dex: 'DODO' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -67,9 +67,42 @@ export interface MissionControlLiquidityPool {
|
||||
}
|
||||
|
||||
export interface MissionControlLiquidityPoolsResponse {
|
||||
count?: number
|
||||
pools?: MissionControlLiquidityPool[]
|
||||
}
|
||||
|
||||
interface RawMissionControlLiquidityPoolsResponse {
|
||||
count?: number
|
||||
pools?: MissionControlLiquidityPool[]
|
||||
data?: {
|
||||
count?: number
|
||||
pools?: MissionControlLiquidityPool[]
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMissionControlLiquidityPools(
|
||||
raw: RawMissionControlLiquidityPoolsResponse | null | undefined
|
||||
): MissionControlLiquidityPoolsResponse {
|
||||
if (!raw) {
|
||||
return { count: 0, pools: [] }
|
||||
}
|
||||
|
||||
const nested = raw.data
|
||||
const pools = Array.isArray(raw.pools)
|
||||
? raw.pools
|
||||
: Array.isArray(nested?.pools)
|
||||
? nested.pools
|
||||
: []
|
||||
|
||||
const count = typeof raw.count === 'number'
|
||||
? raw.count
|
||||
: typeof nested?.count === 'number'
|
||||
? nested.count
|
||||
: pools.length
|
||||
|
||||
return { count, pools }
|
||||
}
|
||||
|
||||
const tokenAggregationBase = `${getExplorerApiBase()}/token-aggregation/api/v1`
|
||||
const missionControlBase = `${getExplorerApiBase()}/explorer-api/v1/mission-control`
|
||||
|
||||
@@ -89,7 +122,9 @@ export const routesApi = {
|
||||
fetchJson<RouteMatrixResponse>(`${tokenAggregationBase}/routes/matrix?includeNonLive=true`),
|
||||
|
||||
getTokenPools: async (tokenAddress: string): Promise<MissionControlLiquidityPoolsResponse> =>
|
||||
fetchJson<MissionControlLiquidityPoolsResponse>(
|
||||
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
|
||||
normalizeMissionControlLiquidityPools(
|
||||
await fetchJson<RawMissionControlLiquidityPoolsResponse>(
|
||||
`${missionControlBase}/liquidity/token/${tokenAddress}/pools`
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeExplorerStats } from './stats'
|
||||
import {
|
||||
normalizeExplorerStats,
|
||||
normalizeTransactionTrend,
|
||||
summarizeRecentTransactions,
|
||||
} from './stats'
|
||||
|
||||
describe('normalizeExplorerStats', () => {
|
||||
it('normalizes the local explorer stats shape', () => {
|
||||
@@ -32,4 +36,49 @@ describe('normalizeExplorerStats', () => {
|
||||
latest_block: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes transaction trend chart data', () => {
|
||||
expect(
|
||||
normalizeTransactionTrend({
|
||||
chart_data: [
|
||||
{ date: '2026-04-08', transaction_count: '2' },
|
||||
{ date: '2026-04-07', transaction_count: 101 },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ date: '2026-04-08', transaction_count: 2 },
|
||||
{ date: '2026-04-07', transaction_count: 101 },
|
||||
])
|
||||
})
|
||||
|
||||
it('summarizes recent activity metrics from main-page transactions', () => {
|
||||
expect(
|
||||
summarizeRecentTransactions([
|
||||
{
|
||||
status: 'ok',
|
||||
transaction_types: ['contract_call', 'token_transfer'],
|
||||
gas_used: '100',
|
||||
fee: { value: '200' },
|
||||
},
|
||||
{
|
||||
status: 'error',
|
||||
transaction_types: ['contract_creation'],
|
||||
gas_used: '300',
|
||||
fee: { value: '400' },
|
||||
},
|
||||
]),
|
||||
).toEqual({
|
||||
success_rate: 0.5,
|
||||
failure_rate: 0.5,
|
||||
average_gas_used: 200,
|
||||
average_fee_wei: 300,
|
||||
contract_creations: 1,
|
||||
token_transfer_txs: 1,
|
||||
contract_calls: 1,
|
||||
contract_creation_share: 0.5,
|
||||
token_transfer_share: 0.5,
|
||||
contract_call_share: 0.5,
|
||||
sample_size: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,25 @@ export interface ExplorerStats {
|
||||
latest_block: number | null
|
||||
}
|
||||
|
||||
export interface ExplorerTransactionTrendPoint {
|
||||
date: string
|
||||
transaction_count: number
|
||||
}
|
||||
|
||||
export interface ExplorerRecentActivitySnapshot {
|
||||
success_rate: number
|
||||
failure_rate: number
|
||||
average_gas_used: number
|
||||
average_fee_wei: number
|
||||
contract_creations: number
|
||||
token_transfer_txs: number
|
||||
contract_calls: number
|
||||
contract_creation_share: number
|
||||
token_transfer_share: number
|
||||
contract_call_share: number
|
||||
sample_size: number
|
||||
}
|
||||
|
||||
interface RawExplorerStats {
|
||||
total_blocks?: number | string | null
|
||||
total_transactions?: number | string | null
|
||||
@@ -34,6 +53,67 @@ export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTransactionTrend(raw: {
|
||||
chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }>
|
||||
}): ExplorerTransactionTrendPoint[] {
|
||||
return Array.isArray(raw.chart_data)
|
||||
? raw.chart_data.map((entry) => ({
|
||||
date: entry.date || '',
|
||||
transaction_count: toNumber(entry.transaction_count),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
|
||||
export function summarizeRecentTransactions(
|
||||
raw: Array<{
|
||||
status?: string | null
|
||||
transaction_types?: string[] | null
|
||||
gas_used?: number | string | null
|
||||
fee?: { value?: string | number | null } | string | null
|
||||
}>,
|
||||
): ExplorerRecentActivitySnapshot {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return {
|
||||
success_rate: 0,
|
||||
failure_rate: 0,
|
||||
average_gas_used: 0,
|
||||
average_fee_wei: 0,
|
||||
contract_creations: 0,
|
||||
token_transfer_txs: 0,
|
||||
contract_calls: 0,
|
||||
contract_creation_share: 0,
|
||||
token_transfer_share: 0,
|
||||
contract_call_share: 0,
|
||||
sample_size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const sampleSize = raw.length
|
||||
const successes = raw.filter((transaction) => ['ok', 'success', '1'].includes((transaction.status || '').toLowerCase())).length
|
||||
const totalGasUsed = raw.reduce((sum, transaction) => sum + toNumber(transaction.gas_used), 0)
|
||||
const totalFeeWei = raw.reduce((sum, transaction) => {
|
||||
const feeValue = typeof transaction.fee === 'string' ? transaction.fee : transaction.fee?.value
|
||||
return sum + toNumber(feeValue)
|
||||
}, 0)
|
||||
const contractCreations = raw.filter((transaction) => transaction.transaction_types?.includes('contract_creation')).length
|
||||
const tokenTransferTxs = raw.filter((transaction) => transaction.transaction_types?.includes('token_transfer')).length
|
||||
const contractCalls = raw.filter((transaction) => transaction.transaction_types?.includes('contract_call')).length
|
||||
|
||||
return {
|
||||
success_rate: successes / sampleSize,
|
||||
failure_rate: (sampleSize - successes) / sampleSize,
|
||||
average_gas_used: totalGasUsed / sampleSize,
|
||||
average_fee_wei: totalFeeWei / sampleSize,
|
||||
contract_creations: contractCreations,
|
||||
token_transfer_txs: tokenTransferTxs,
|
||||
contract_calls: contractCalls,
|
||||
contract_creation_share: contractCreations / sampleSize,
|
||||
token_transfer_share: tokenTransferTxs / sampleSize,
|
||||
contract_call_share: contractCalls / sampleSize,
|
||||
sample_size: sampleSize,
|
||||
}
|
||||
}
|
||||
|
||||
export const statsApi = {
|
||||
get: async (): Promise<ExplorerStats> => {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats`)
|
||||
@@ -43,4 +123,25 @@ export const statsApi = {
|
||||
const json = (await response.json()) as RawExplorerStats
|
||||
return normalizeExplorerStats(json)
|
||||
},
|
||||
getTransactionTrend: async (): Promise<ExplorerTransactionTrendPoint[]> => {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v2/stats/charts/transactions`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const json = (await response.json()) as { chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }
|
||||
return normalizeTransactionTrend(json)
|
||||
},
|
||||
getRecentActivitySnapshot: async (): Promise<ExplorerRecentActivitySnapshot> => {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api/v2/main-page/transactions`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const json = (await response.json()) as Array<{
|
||||
status?: string | null
|
||||
transaction_types?: string[] | null
|
||||
gas_used?: number | string | null
|
||||
fee?: { value?: string | number | null } | string | null
|
||||
}>
|
||||
return summarizeRecentTransactions(json)
|
||||
},
|
||||
}
|
||||
|
||||
115
frontend/src/services/api/tokens.test.ts
Normal file
115
frontend/src/services/api/tokens.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { tokensApi } from './tokens'
|
||||
|
||||
describe('tokensApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes a token profile, holders, and transfers safely', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
address: '0xtoken',
|
||||
symbol: 'cUSDT',
|
||||
name: 'Tether USD (Compliant)',
|
||||
decimals: '6',
|
||||
holders: '37',
|
||||
total_supply: '1000',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{
|
||||
address: { hash: '0xholder', name: 'Treasury' },
|
||||
value: '500',
|
||||
token: { decimals: '6' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{
|
||||
transaction_hash: '0xtx',
|
||||
block_number: 1,
|
||||
from: { hash: '0xfrom' },
|
||||
to: { hash: '0xto' },
|
||||
token: {
|
||||
address: '0xtoken',
|
||||
symbol: 'cUSDT',
|
||||
name: 'Tether USD (Compliant)',
|
||||
decimals: '6',
|
||||
},
|
||||
total: { decimals: '6', value: '100' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const token = await tokensApi.getSafe('0xtoken')
|
||||
const holders = await tokensApi.getHoldersSafe('0xtoken')
|
||||
const transfers = await tokensApi.getTransfersSafe('0xtoken')
|
||||
|
||||
expect(token.ok).toBe(true)
|
||||
expect(token.data?.symbol).toBe('cUSDT')
|
||||
expect(holders.data[0].label).toBe('Treasury')
|
||||
expect(transfers.data[0].token_symbol).toBe('cUSDT')
|
||||
})
|
||||
|
||||
it('builds provenance and curated token lists from the token list config', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
tokens: [
|
||||
{
|
||||
chainId: 138,
|
||||
address: '0xlisted',
|
||||
symbol: 'cUSDT',
|
||||
name: 'Tether USD (Compliant)',
|
||||
tags: ['compliant', 'bridge'],
|
||||
},
|
||||
{
|
||||
chainId: 1,
|
||||
address: '0xother',
|
||||
symbol: 'OTHER',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
tokens: [
|
||||
{
|
||||
chainId: 138,
|
||||
address: '0xlisted',
|
||||
symbol: 'cUSDT',
|
||||
name: 'Tether USD (Compliant)',
|
||||
tags: ['compliant', 'bridge'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const provenance = await tokensApi.getProvenanceSafe('0xlisted')
|
||||
const curated = await tokensApi.listCuratedSafe(138)
|
||||
|
||||
expect(provenance.ok).toBe(true)
|
||||
expect(provenance.data?.listed).toBe(true)
|
||||
expect(provenance.data?.tags).toEqual(['compliant', 'bridge'])
|
||||
expect(curated.data).toHaveLength(1)
|
||||
expect(curated.data[0].symbol).toBe('cUSDT')
|
||||
})
|
||||
})
|
||||
205
frontend/src/services/api/tokens.ts
Normal file
205
frontend/src/services/api/tokens.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { fetchBlockscoutJson, normalizeAddressTokenTransfer, type BlockscoutTokenTransfer } from './blockscout'
|
||||
import { configApi, type TokenListToken } from './config'
|
||||
import { routesApi, type MissionControlLiquidityPool } from './routes'
|
||||
import type { AddressTokenTransfer } from './addresses'
|
||||
|
||||
export interface TokenProfile {
|
||||
address: string
|
||||
name?: string
|
||||
symbol?: string
|
||||
decimals: number
|
||||
type?: string
|
||||
total_supply?: string
|
||||
holders?: number
|
||||
exchange_rate?: string | number | null
|
||||
icon_url?: string | null
|
||||
circulating_market_cap?: string | number | null
|
||||
volume_24h?: string | number | null
|
||||
}
|
||||
|
||||
export interface TokenHolder {
|
||||
address: string
|
||||
label?: string
|
||||
value: string
|
||||
token_decimals: number
|
||||
}
|
||||
|
||||
export interface TokenProvenance {
|
||||
listed: boolean
|
||||
chainId?: number
|
||||
name?: string
|
||||
symbol?: string
|
||||
logoURI?: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
function normalizeTokenProfile(raw: {
|
||||
address: string
|
||||
name?: string | null
|
||||
symbol?: string | null
|
||||
decimals?: string | number | null
|
||||
type?: string | null
|
||||
total_supply?: string | null
|
||||
holders?: string | number | null
|
||||
exchange_rate?: string | number | null
|
||||
icon_url?: string | null
|
||||
circulating_market_cap?: string | number | null
|
||||
volume_24h?: string | number | null
|
||||
}): TokenProfile {
|
||||
return {
|
||||
address: raw.address,
|
||||
name: raw.name || undefined,
|
||||
symbol: raw.symbol || undefined,
|
||||
decimals: Number(raw.decimals || 0),
|
||||
type: raw.type || undefined,
|
||||
total_supply: raw.total_supply || undefined,
|
||||
holders: raw.holders != null ? Number(raw.holders) : undefined,
|
||||
exchange_rate: raw.exchange_rate ?? null,
|
||||
icon_url: raw.icon_url ?? null,
|
||||
circulating_market_cap: raw.circulating_market_cap ?? null,
|
||||
volume_24h: raw.volume_24h ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTokenHolder(raw: {
|
||||
address?: {
|
||||
hash?: string | null
|
||||
name?: string | null
|
||||
label?: string | null
|
||||
} | null
|
||||
value?: string | null
|
||||
token?: {
|
||||
decimals?: string | number | null
|
||||
} | null
|
||||
}): TokenHolder {
|
||||
return {
|
||||
address: raw.address?.hash || '',
|
||||
label: raw.address?.name || raw.address?.label || undefined,
|
||||
value: raw.value || '0',
|
||||
token_decimals: Number(raw.token?.decimals || 0),
|
||||
}
|
||||
}
|
||||
|
||||
async function getTokenListLookup(): Promise<Map<string, TokenListToken>> {
|
||||
const response = await configApi.getTokenList()
|
||||
const lookup = new Map<string, TokenListToken>()
|
||||
for (const token of response.tokens || []) {
|
||||
if (token.address) {
|
||||
lookup.set(token.address.toLowerCase(), token)
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
export const tokensApi = {
|
||||
getSafe: async (address: string): Promise<{ ok: boolean; data: TokenProfile | null }> => {
|
||||
try {
|
||||
const raw = await fetchBlockscoutJson<{
|
||||
address: string
|
||||
name?: string | null
|
||||
symbol?: string | null
|
||||
decimals?: string | number | null
|
||||
type?: string | null
|
||||
total_supply?: string | null
|
||||
holders?: string | number | null
|
||||
exchange_rate?: string | number | null
|
||||
icon_url?: string | null
|
||||
circulating_market_cap?: string | number | null
|
||||
volume_24h?: string | number | null
|
||||
}>(`/api/v2/tokens/${address}`)
|
||||
return { ok: true, data: normalizeTokenProfile(raw) }
|
||||
} catch {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
},
|
||||
|
||||
getTransfersSafe: async (
|
||||
address: string,
|
||||
page = 1,
|
||||
pageSize = 10
|
||||
): Promise<{ ok: boolean; data: AddressTokenTransfer[] }> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
items_count: pageSize.toString(),
|
||||
})
|
||||
const raw = await fetchBlockscoutJson<{ items?: BlockscoutTokenTransfer[] }>(
|
||||
`/api/v2/tokens/${address}/transfers?${params.toString()}`
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
data: Array.isArray(raw.items) ? raw.items.map((item) => normalizeAddressTokenTransfer(item)) : [],
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
|
||||
getHoldersSafe: async (
|
||||
address: string,
|
||||
page = 1,
|
||||
pageSize = 10
|
||||
): Promise<{ ok: boolean; data: TokenHolder[] }> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
items_count: pageSize.toString(),
|
||||
})
|
||||
const raw = await fetchBlockscoutJson<{ items?: Array<{
|
||||
address?: { hash?: string | null; name?: string | null; label?: string | null } | null
|
||||
value?: string | null
|
||||
token?: { decimals?: string | number | null } | null
|
||||
}> }>(`/api/v2/tokens/${address}/holders?${params.toString()}`)
|
||||
return {
|
||||
ok: true,
|
||||
data: Array.isArray(raw.items) ? raw.items.map((item) => normalizeTokenHolder(item)) : [],
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
|
||||
getProvenanceSafe: async (address: string): Promise<{ ok: boolean; data: TokenProvenance | null }> => {
|
||||
try {
|
||||
const lookup = await getTokenListLookup()
|
||||
const token = lookup.get(address.toLowerCase())
|
||||
if (!token) {
|
||||
return { ok: true, data: { listed: false, tags: [] } }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
listed: true,
|
||||
chainId: token.chainId,
|
||||
name: token.name,
|
||||
symbol: token.symbol,
|
||||
logoURI: token.logoURI,
|
||||
tags: token.tags || [],
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
},
|
||||
|
||||
listCuratedSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
|
||||
try {
|
||||
const response = await configApi.getTokenList()
|
||||
const data = (response.tokens || [])
|
||||
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
|
||||
return { ok: true, data }
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
|
||||
getRelatedPoolsSafe: async (address: string): Promise<{ ok: boolean; data: MissionControlLiquidityPool[] }> => {
|
||||
try {
|
||||
const response = await routesApi.getTokenPools(address)
|
||||
return { ok: true, data: response.pools || [] }
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
75
frontend/src/services/api/transactions.test.ts
Normal file
75
frontend/src/services/api/transactions.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { transactionsApi } from './transactions'
|
||||
|
||||
describe('transactionsApi.diagnoseMissing', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('distinguishes missing explorer and missing rpc results', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ message: 'Not found' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: null }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: null }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: '0x39fb85' }),
|
||||
})
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const diagnostic = await transactionsApi.diagnoseMissing(
|
||||
138,
|
||||
'0x14d7259e6e75bbcd8979dc9e19f3001b0f328bbcdb730937f8cc2e6a52f26da6'
|
||||
)
|
||||
|
||||
expect(diagnostic.checked_hash).toBe('0x14d7259e6e75bbcd8979dc9e19f3001b0f328bbcdb730937f8cc2e6a52f26da6')
|
||||
expect(diagnostic.chain_id).toBe(138)
|
||||
expect(diagnostic.explorer_indexed).toBe(false)
|
||||
expect(diagnostic.rpc_transaction_found).toBe(false)
|
||||
expect(diagnostic.rpc_receipt_found).toBe(false)
|
||||
expect(diagnostic.latest_block_number).toBeTypeOf('number')
|
||||
expect(diagnostic.rpc_url).toBe('https://rpc-http-pub.d-bis.org')
|
||||
})
|
||||
|
||||
it('reports when rpc can still see a transaction the explorer has not indexed', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ message: 'Not found' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: { hash: '0xabc' } }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: null }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: '0x10' }),
|
||||
})
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const diagnostic = await transactionsApi.diagnoseMissing(138, '0xabc')
|
||||
|
||||
expect(diagnostic.explorer_indexed).toBe(false)
|
||||
expect(diagnostic.rpc_transaction_found).toBe(true)
|
||||
expect(diagnostic.rpc_receipt_found).toBe(false)
|
||||
expect(diagnostic.latest_block_number).toBe(16)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiClient, ApiResponse } from './client'
|
||||
import { fetchBlockscoutJson, normalizeTransaction } from './blockscout'
|
||||
import { ApiResponse } from './client'
|
||||
import { fetchBlockscoutJson, normalizeTransaction, type BlockscoutInternalTransaction } from './blockscout'
|
||||
import { resolveExplorerApiBase } from '../../../libs/frontend-api-client/api-base'
|
||||
|
||||
export interface Transaction {
|
||||
chain_id: number
|
||||
@@ -19,6 +20,165 @@ export interface Transaction {
|
||||
input_data?: string
|
||||
contract_address?: string
|
||||
created_at: string
|
||||
fee?: string
|
||||
method?: string
|
||||
revert_reason?: string
|
||||
transaction_tag?: string
|
||||
decoded_input?: {
|
||||
method_call?: string
|
||||
method_id?: string
|
||||
parameters: Array<{
|
||||
name?: string
|
||||
type?: string
|
||||
value?: unknown
|
||||
}>
|
||||
}
|
||||
token_transfers?: TransactionTokenTransfer[]
|
||||
}
|
||||
|
||||
export interface TransactionTokenTransfer {
|
||||
block_number?: number
|
||||
from_address: string
|
||||
from_label?: string
|
||||
to_address: string
|
||||
to_label?: string
|
||||
token_address: string
|
||||
token_name?: string
|
||||
token_symbol?: string
|
||||
token_decimals: number
|
||||
amount: string
|
||||
type?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface TransactionInternalCall {
|
||||
from_address: string
|
||||
from_label?: string
|
||||
to_address?: string
|
||||
to_label?: string
|
||||
contract_address?: string
|
||||
contract_label?: string
|
||||
type?: string
|
||||
value: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
result?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface TransactionLookupDiagnostic {
|
||||
checked_hash: string
|
||||
chain_id: number
|
||||
explorer_indexed: boolean
|
||||
rpc_transaction_found: boolean
|
||||
rpc_receipt_found: boolean
|
||||
latest_block_number?: number
|
||||
rpc_url?: string
|
||||
}
|
||||
|
||||
const CHAIN_138_PUBLIC_RPC_URL = 'https://rpc-http-pub.d-bis.org'
|
||||
|
||||
function resolvePublicRpcUrl(chainId: number): string | null {
|
||||
if (chainId !== 138) {
|
||||
return null
|
||||
}
|
||||
|
||||
const envValue = (process.env.NEXT_PUBLIC_CHAIN_138_RPC_URL || '').trim()
|
||||
return envValue || CHAIN_138_PUBLIC_RPC_URL
|
||||
}
|
||||
|
||||
async function fetchJsonWithStatus<T>(input: RequestInfo | URL, init?: RequestInit): Promise<{ ok: boolean; status: number; data: T | null }> {
|
||||
const response = await fetch(input, init)
|
||||
let data: T | null = null
|
||||
try {
|
||||
data = (await response.json()) as T
|
||||
} catch {
|
||||
data = null
|
||||
}
|
||||
return { ok: response.ok, status: response.status, data }
|
||||
}
|
||||
|
||||
async function fetchRpcResult<T>(rpcUrl: string, method: string, params: unknown[]): Promise<T | null> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||
try {
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const payload = (await response.json()) as { result?: T | null }
|
||||
return payload.result ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function diagnoseMissingTransaction(chainId: number, hash: string): Promise<TransactionLookupDiagnostic> {
|
||||
const diagnostic: TransactionLookupDiagnostic = {
|
||||
checked_hash: hash,
|
||||
chain_id: chainId,
|
||||
explorer_indexed: false,
|
||||
rpc_transaction_found: false,
|
||||
rpc_receipt_found: false,
|
||||
}
|
||||
|
||||
const explorerLookup = await fetchJsonWithStatus<unknown>(`${resolveExplorerApiBase()}/api/v2/transactions/${hash}`)
|
||||
diagnostic.explorer_indexed = explorerLookup.ok
|
||||
|
||||
const rpcUrl = resolvePublicRpcUrl(chainId)
|
||||
if (!rpcUrl) {
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
diagnostic.rpc_url = rpcUrl
|
||||
|
||||
const [transactionResult, receiptResult, latestBlockHex] = await Promise.all([
|
||||
fetchRpcResult<string | Record<string, unknown>>(rpcUrl, 'eth_getTransactionByHash', [hash]),
|
||||
fetchRpcResult<string | Record<string, unknown>>(rpcUrl, 'eth_getTransactionReceipt', [hash]),
|
||||
fetchRpcResult<string>(rpcUrl, 'eth_blockNumber', []),
|
||||
])
|
||||
|
||||
diagnostic.rpc_transaction_found = transactionResult != null
|
||||
diagnostic.rpc_receipt_found = receiptResult != null
|
||||
|
||||
if (typeof latestBlockHex === 'string' && latestBlockHex.startsWith('0x')) {
|
||||
diagnostic.latest_block_number = parseInt(latestBlockHex, 16)
|
||||
}
|
||||
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
function normalizeInternalTransactions(items: BlockscoutInternalTransaction[] | null | undefined): TransactionInternalCall[] {
|
||||
if (!Array.isArray(items)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return items.map((item) => ({
|
||||
from_address: item.from?.hash || '',
|
||||
from_label: item.from?.name || item.from?.label || undefined,
|
||||
to_address: item.to?.hash || undefined,
|
||||
to_label: item.to?.name || item.to?.label || undefined,
|
||||
contract_address: item.created_contract?.hash || undefined,
|
||||
contract_label: item.created_contract?.name || item.created_contract?.label || undefined,
|
||||
type: item.type || undefined,
|
||||
value: item.value || '0',
|
||||
success: item.success ?? undefined,
|
||||
error: item.error || undefined,
|
||||
result: item.result || undefined,
|
||||
timestamp: item.timestamp || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export const transactionsApi = {
|
||||
@@ -35,6 +195,20 @@ export const transactionsApi = {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
},
|
||||
diagnoseMissing: async (chainId: number, hash: string): Promise<TransactionLookupDiagnostic> => {
|
||||
return diagnoseMissingTransaction(chainId, hash)
|
||||
},
|
||||
getInternalTransactionsSafe: async (hash: string): Promise<{ ok: boolean; data: TransactionInternalCall[] }> => {
|
||||
try {
|
||||
const raw = await fetchBlockscoutJson<{ items?: BlockscoutInternalTransaction[] } | BlockscoutInternalTransaction[]>(
|
||||
`/api/v2/transactions/${hash}/internal-transactions`
|
||||
)
|
||||
const items = Array.isArray(raw) ? raw : raw.items
|
||||
return { ok: true, data: normalizeInternalTransactions(items) }
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
list: async (chainId: number, page: number, pageSize: number): Promise<ApiResponse<Transaction[]>> => {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
|
||||
Reference in New Issue
Block a user