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:
defiQUG
2026-04-10 12:52:17 -07:00
parent bdae5a9f6e
commit f46bd213ba
160 changed files with 13274 additions and 1061 deletions

View 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')
})
})

View 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)
},
}

View File

@@ -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: [] }
}
},
}

View 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')
})
})

View File

@@ -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,
}
}

View File

@@ -18,6 +18,7 @@ export interface TokenListToken {
name?: string
decimals?: number
logoURI?: string
tags?: string[]
}
export interface TokenListResponse {

View 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')
})
})

View 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,
}

View 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,
},
}
}

View 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,
}
}

View 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
}

View 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
})
})

View File

@@ -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()
}
},

View 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' }],
})
})
})

View File

@@ -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`
)
),
}

View File

@@ -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,
})
})
})

View File

@@ -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)
},
}

View 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')
})
})

View 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: [] }
}
},
}

View 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)
})
})

View File

@@ -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(),