diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 594b376..8aba818 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,6 +12,7 @@ permissions: jobs: claude-code-review: + if: github.repository == 'dojoengine/torii-core' runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 7095694..c3c2f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ flamegraph.svg perf.data* .claude-modified-files +.DS_Store +**/.DS_Store diff --git a/Cargo.lock b/Cargo.lock index f10a2bd..0de3593 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,6 +524,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -795,6 +805,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -975,6 +994,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1356,6 +1390,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -1375,9 +1425,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.1", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1862,6 +1914,23 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.26.4" @@ -1981,6 +2050,50 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -2603,15 +2716,20 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -2622,6 +2740,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower 0.5.2", "tower-http 0.6.8", @@ -2806,6 +2925,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2848,6 +2976,29 @@ dependencies = [ "sha2", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -3542,6 +3693,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -3713,6 +3885,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3916,8 +4098,14 @@ name = "torii-common" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", + "base64 0.22.1", + "reqwest", + "serde_json", "starknet", + "tokio", "tracing", + "urlencoding", ] [[package]] @@ -4316,6 +4504,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -4590,6 +4784,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/bins/torii-tokens/client/apps/react/src/App.tsx b/bins/torii-tokens/client/apps/react/src/App.tsx index 7d2cc2f..bf5c7ee 100644 --- a/bins/torii-tokens/client/apps/react/src/App.tsx +++ b/bins/torii-tokens/client/apps/react/src/App.tsx @@ -3,15 +3,20 @@ import { createTokensClient, SERVER_URL, generateClientId, - getErc20Balance, - getErc20Transfers, + getErc20BalancesPage, + getErc20TransfersPage, getErc20Stats, + getErc20TokenMetadataPage, getErc721Stats, - getErc721Transfers, + getErc721TransfersPage, + getErc721TokenMetadataPage, getErc1155Stats, - getErc1155Transfers, - type BalanceResult, + getErc1155TransfersPage, + getErc1155TokenMetadataPage, + type TransferCursorResult, + type TokenBalanceResult, type TransferResult, + type TokenMetadataResult, } from "@torii-tokens/shared"; import StatusPanel from "./components/StatusPanel"; import TokenPanel from "./components/TokenPanel"; @@ -31,6 +36,7 @@ interface Transfer { token: string; from: string; to: string; + value?: string; amount?: string; tokenId?: string; blockNumber: number; @@ -47,6 +53,7 @@ interface Update { const client = createTokensClient(SERVER_URL); const clientId = generateClientId(); +const PAGE_SIZE = 100; export default function App() { const [connected, setConnected] = useState(false); @@ -60,12 +67,48 @@ export default function App() { const [erc1155Stats, setErc1155Stats] = useState(null); const [erc1155Transfers, setErc1155Transfers] = useState([]); + const [erc20Metadata, setErc20Metadata] = useState([]); + const [erc721Metadata, setErc721Metadata] = useState([]); + const [erc1155Metadata, setErc1155Metadata] = useState([]); + const [queryContractAddress, setQueryContractAddress] = useState(""); const [queryWallet, setQueryWallet] = useState(""); const [queryLoading, setQueryLoading] = useState(false); + const [queryBalancesLoading, setQueryBalancesLoading] = useState(false); + const [queryTransfersLoading, setQueryTransfersLoading] = useState(false); const [queryError, setQueryError] = useState(null); - const [queryErc20Balance, setQueryErc20Balance] = useState(null); + const [queryErc20Balances, setQueryErc20Balances] = useState([]); const [queryErc20Transfers, setQueryErc20Transfers] = useState([]); + const [erc20MetadataLoading, setErc20MetadataLoading] = useState(false); + const [erc721MetadataLoading, setErc721MetadataLoading] = useState(false); + const [erc1155MetadataLoading, setErc1155MetadataLoading] = useState(false); + const [erc20TransfersLoading, setErc20TransfersLoading] = useState(false); + const [erc721TransfersLoading, setErc721TransfersLoading] = useState(false); + const [erc1155TransfersLoading, setErc1155TransfersLoading] = useState(false); + const [erc20TransfersHistory, setErc20TransfersHistory] = useState<(TransferCursorResult | undefined)[]>([]); + const [erc20TransfersCursor, setErc20TransfersCursor] = useState(undefined); + const [erc20TransfersNext, setErc20TransfersNext] = useState(undefined); + const [erc721TransfersHistory, setErc721TransfersHistory] = useState<(TransferCursorResult | undefined)[]>([]); + const [erc721TransfersCursor, setErc721TransfersCursor] = useState(undefined); + const [erc721TransfersNext, setErc721TransfersNext] = useState(undefined); + const [erc1155TransfersHistory, setErc1155TransfersHistory] = useState<(TransferCursorResult | undefined)[]>([]); + const [erc1155TransfersCursor, setErc1155TransfersCursor] = useState(undefined); + const [erc1155TransfersNext, setErc1155TransfersNext] = useState(undefined); + const [erc20MetadataHistory, setErc20MetadataHistory] = useState<(string | undefined)[]>([]); + const [erc20MetadataCursor, setErc20MetadataCursor] = useState(undefined); + const [erc20MetadataNext, setErc20MetadataNext] = useState(undefined); + const [erc721MetadataHistory, setErc721MetadataHistory] = useState<(string | undefined)[]>([]); + const [erc721MetadataCursor, setErc721MetadataCursor] = useState(undefined); + const [erc721MetadataNext, setErc721MetadataNext] = useState(undefined); + const [erc1155MetadataHistory, setErc1155MetadataHistory] = useState<(string | undefined)[]>([]); + const [erc1155MetadataCursor, setErc1155MetadataCursor] = useState(undefined); + const [erc1155MetadataNext, setErc1155MetadataNext] = useState(undefined); + const [queryBalancesHistory, setQueryBalancesHistory] = useState<(number | undefined)[]>([]); + const [queryBalancesCursor, setQueryBalancesCursor] = useState(undefined); + const [queryBalancesNext, setQueryBalancesNext] = useState(undefined); + const [queryTransfersHistory, setQueryTransfersHistory] = useState<(TransferCursorResult | undefined)[]>([]); + const [queryTransfersCursor, setQueryTransfersCursor] = useState(undefined); + const [queryTransfersNext, setQueryTransfersNext] = useState(undefined); const loadStats = useCallback(async () => { try { @@ -98,37 +141,83 @@ export default function App() { } }, []); - const loadTransfers = useCallback(async () => { + const loadMetadata = useCallback(async ( + tokenType: "erc20" | "erc721" | "erc1155", + cursor?: string, + ) => { + try { + if (tokenType === "erc20") { + setErc20MetadataLoading(true); + const page = await getErc20TokenMetadataPage(client, { cursor, limit: PAGE_SIZE }); + setErc20Metadata(page.items); + setErc20MetadataNext(page.nextCursor); + return; + } + if (tokenType === "erc721") { + setErc721MetadataLoading(true); + const page = await getErc721TokenMetadataPage(client, { cursor, limit: PAGE_SIZE }); + setErc721Metadata(page.items); + setErc721MetadataNext(page.nextCursor); + return; + } + setErc1155MetadataLoading(true); + const page = await getErc1155TokenMetadataPage(client, { cursor, limit: PAGE_SIZE }); + setErc1155Metadata(page.items); + setErc1155MetadataNext(page.nextCursor); + } catch (err) { + console.error("Failed to load metadata:", err); + } finally { + if (tokenType === "erc20") setErc20MetadataLoading(false); + if (tokenType === "erc721") setErc721MetadataLoading(false); + if (tokenType === "erc1155") setErc1155MetadataLoading(false); + } + }, []); + + const loadTransfers = useCallback(async ( + tokenType: "erc20" | "erc721" | "erc1155", + cursor?: TransferCursorResult, + ) => { const emptyQuery = { contractAddress: "", wallet: "" }; try { - const [t20, t721, t1155] = await Promise.all([ - getErc20Transfers(client, emptyQuery, 20), - getErc721Transfers(client, emptyQuery, 20), - getErc1155Transfers(client, emptyQuery, 20), - ]); - setErc20Transfers(t20.map((t, i) => ({ - id: `${t.txHash}-${i}`, - token: t.token, - from: t.from, - to: t.to, - amount: t.amount, - blockNumber: t.blockNumber, - timestamp: t.timestamp, - }))); - setErc721Transfers(t721.map((t, i) => ({ - id: `${t.txHash}-${i}`, - token: t.token, - from: t.from, - to: t.to, - tokenId: (t as Record).tokenId as string | undefined, - blockNumber: t.blockNumber, - timestamp: t.timestamp, - }))); - setErc1155Transfers(t1155.map((t, i) => ({ + if (tokenType === "erc20") { + setErc20TransfersLoading(true); + const page = await getErc20TransfersPage(client, { ...emptyQuery, cursor, limit: PAGE_SIZE }); + setErc20TransfersNext(page.nextCursor); + setErc20Transfers(page.items.map((t, i) => ({ + id: `${t.txHash}-${i}`, + token: t.token, + from: t.from, + to: t.to, + amount: t.amount, + blockNumber: t.blockNumber, + timestamp: t.timestamp, + }))); + return; + } + if (tokenType === "erc721") { + setErc721TransfersLoading(true); + const page = await getErc721TransfersPage(client, { ...emptyQuery, cursor, limit: PAGE_SIZE }); + setErc721TransfersNext(page.nextCursor); + setErc721Transfers(page.items.map((t, i) => ({ + id: `${t.txHash}-${i}`, + token: t.token, + from: t.from, + to: t.to, + tokenId: (t as unknown as Record).tokenId as string | undefined, + blockNumber: t.blockNumber, + timestamp: t.timestamp, + }))); + return; + } + setErc1155TransfersLoading(true); + const page = await getErc1155TransfersPage(client, { ...emptyQuery, cursor, limit: PAGE_SIZE }); + setErc1155TransfersNext(page.nextCursor); + setErc1155Transfers(page.items.map((t, i) => ({ id: `${t.txHash}-${i}`, token: t.token, from: t.from, to: t.to, + value: t.value, amount: t.amount, tokenId: t.tokenId, blockNumber: t.blockNumber, @@ -136,38 +225,167 @@ export default function App() { }))); } catch (err) { console.error("Failed to load transfers:", err); + } finally { + if (tokenType === "erc20") setErc20TransfersLoading(false); + if (tokenType === "erc721") setErc721TransfersLoading(false); + if (tokenType === "erc1155") setErc1155TransfersLoading(false); } }, []); const handleQuery = useCallback(async (contractAddress: string, wallet: string) => { setQueryContractAddress(contractAddress); setQueryWallet(wallet); + setQueryBalancesHistory([]); + setQueryTransfersHistory([]); + setQueryBalancesCursor(undefined); + setQueryTransfersCursor(undefined); setQueryLoading(true); + setQueryBalancesLoading(true); + setQueryTransfersLoading(true); setQueryError(null); try { - const [balanceResult, transfers] = await Promise.all([ - getErc20Balance(client, { contractAddress, wallet }), - getErc20Transfers(client, { contractAddress, wallet }, 50), + const [balances, transfers] = await Promise.all([ + getErc20BalancesPage(client, { contractAddress, wallet, limit: PAGE_SIZE }), + getErc20TransfersPage(client, { contractAddress, wallet, limit: PAGE_SIZE }), ]); - setQueryErc20Balance(balanceResult); - setQueryErc20Transfers(transfers); + setQueryErc20Balances(balances.items); + setQueryBalancesNext(balances.nextCursor); + setQueryErc20Transfers(transfers.items); + setQueryTransfersNext(transfers.nextCursor); } catch (err) { console.error("Query failed:", err); setQueryError(err instanceof Error ? err.message : "Query failed"); } finally { + setQueryBalancesLoading(false); + setQueryTransfersLoading(false); setQueryLoading(false); } }, []); + const navigateDashboardTransfers = useCallback(async ( + tokenType: "erc20" | "erc721" | "erc1155", + dir: "next" | "prev" + ) => { + if (tokenType === "erc20") { + const target = dir === "next" ? erc20TransfersNext : erc20TransfersHistory[erc20TransfersHistory.length - 1]; + if (!target && dir === "next") return; + const nextHistory = dir === "next" + ? [...erc20TransfersHistory, erc20TransfersCursor] + : erc20TransfersHistory.slice(0, -1); + setErc20TransfersHistory(nextHistory); + setErc20TransfersCursor(target); + await loadTransfers("erc20", target); + return; + } + if (tokenType === "erc721") { + const target = dir === "next" ? erc721TransfersNext : erc721TransfersHistory[erc721TransfersHistory.length - 1]; + if (!target && dir === "next") return; + const nextHistory = dir === "next" + ? [...erc721TransfersHistory, erc721TransfersCursor] + : erc721TransfersHistory.slice(0, -1); + setErc721TransfersHistory(nextHistory); + setErc721TransfersCursor(target); + await loadTransfers("erc721", target); + return; + } + const target = dir === "next" ? erc1155TransfersNext : erc1155TransfersHistory[erc1155TransfersHistory.length - 1]; + if (!target && dir === "next") return; + const nextHistory = dir === "next" + ? [...erc1155TransfersHistory, erc1155TransfersCursor] + : erc1155TransfersHistory.slice(0, -1); + setErc1155TransfersHistory(nextHistory); + setErc1155TransfersCursor(target); + await loadTransfers("erc1155", target); + }, [erc20TransfersNext, erc20TransfersHistory, erc20TransfersCursor, erc721TransfersNext, erc721TransfersHistory, erc1155TransfersNext, erc1155TransfersHistory, erc1155TransfersCursor, loadTransfers]); + + const navigateDashboardMetadata = useCallback(async ( + tokenType: "erc20" | "erc721" | "erc1155", + dir: "next" | "prev" + ) => { + if (tokenType === "erc20") { + const target = dir === "next" ? erc20MetadataNext : erc20MetadataHistory[erc20MetadataHistory.length - 1]; + if (!target && dir === "next") return; + setErc20MetadataHistory(dir === "next" ? [...erc20MetadataHistory, erc20MetadataCursor] : erc20MetadataHistory.slice(0, -1)); + setErc20MetadataCursor(target); + await loadMetadata("erc20", target); + return; + } + if (tokenType === "erc721") { + const target = dir === "next" ? erc721MetadataNext : erc721MetadataHistory[erc721MetadataHistory.length - 1]; + if (!target && dir === "next") return; + setErc721MetadataHistory(dir === "next" ? [...erc721MetadataHistory, erc721MetadataCursor] : erc721MetadataHistory.slice(0, -1)); + setErc721MetadataCursor(target); + await loadMetadata("erc721", target); + return; + } + const target = dir === "next" ? erc1155MetadataNext : erc1155MetadataHistory[erc1155MetadataHistory.length - 1]; + if (!target && dir === "next") return; + setErc1155MetadataHistory(dir === "next" ? [...erc1155MetadataHistory, erc1155MetadataCursor] : erc1155MetadataHistory.slice(0, -1)); + setErc1155MetadataCursor(target); + await loadMetadata("erc1155", target); + }, [erc20MetadataNext, erc20MetadataHistory, erc20MetadataCursor, erc721MetadataNext, erc721MetadataHistory, erc1155MetadataNext, erc1155MetadataHistory, erc1155MetadataCursor, loadMetadata]); + + const navigateQueryBalances = useCallback(async (dir: "next" | "prev") => { + const target = dir === "next" ? queryBalancesNext : queryBalancesHistory[queryBalancesHistory.length - 1]; + if (target == null && dir === "next") return; + setQueryBalancesLoading(true); + setQueryError(null); + try { + setQueryBalancesHistory(dir === "next" ? [...queryBalancesHistory, queryBalancesCursor] : queryBalancesHistory.slice(0, -1)); + setQueryBalancesCursor(target); + const page = await getErc20BalancesPage(client, { + contractAddress: queryContractAddress, + wallet: queryWallet, + cursor: target, + limit: PAGE_SIZE, + }); + setQueryErc20Balances(page.items); + setQueryBalancesNext(page.nextCursor); + } catch (err) { + console.error("Balance pagination failed:", err); + setQueryError(err instanceof Error ? err.message : "Balance pagination failed"); + } finally { + setQueryBalancesLoading(false); + } + }, [queryBalancesNext, queryBalancesHistory, queryBalancesCursor, queryContractAddress, queryWallet]); + + const navigateQueryTransfers = useCallback(async (dir: "next" | "prev") => { + const target = dir === "next" ? queryTransfersNext : queryTransfersHistory[queryTransfersHistory.length - 1]; + if (!target && dir === "next") return; + setQueryTransfersLoading(true); + setQueryError(null); + try { + setQueryTransfersHistory(dir === "next" ? [...queryTransfersHistory, queryTransfersCursor] : queryTransfersHistory.slice(0, -1)); + setQueryTransfersCursor(target); + const page = await getErc20TransfersPage(client, { + contractAddress: queryContractAddress, + wallet: queryWallet, + cursor: target, + limit: PAGE_SIZE, + }); + setQueryErc20Transfers(page.items); + setQueryTransfersNext(page.nextCursor); + } catch (err) { + console.error("Transfer pagination failed:", err); + setQueryError(err instanceof Error ? err.message : "Transfer pagination failed"); + } finally { + setQueryTransfersLoading(false); + } + }, [queryTransfersNext, queryTransfersHistory, queryTransfersCursor, queryContractAddress, queryWallet]); + const subscribe = useCallback(async () => { try { unsubscribeRef.current = await client.subscribeTopics( clientId, [ { topic: "erc20.transfer" }, + { topic: "erc20.metadata" }, { topic: "erc721.transfer" }, + { topic: "erc721.metadata" }, { topic: "erc1155.transfer" }, + { topic: "erc1155.metadata" }, + { topic: "erc1155.uri" }, ], (update) => { setUpdates((prev) => [update as Update, ...prev].slice(0, 50)); @@ -199,11 +417,18 @@ export default function App() { useEffect(() => { loadStats(); - loadTransfers(); + void Promise.all([ + loadTransfers("erc20"), + loadTransfers("erc721"), + loadTransfers("erc1155"), + loadMetadata("erc20"), + loadMetadata("erc721"), + loadMetadata("erc1155"), + ]); return () => { disconnect(); }; - }, [loadStats, loadTransfers, disconnect]); + }, [loadStats, loadTransfers, loadMetadata, disconnect]); return (
@@ -226,10 +451,20 @@ export default function App() { navigateQueryBalances("prev")} + onBalancesNext={() => navigateQueryBalances("next")} + balancesCanPrev={queryBalancesHistory.length > 0} + balancesCanNext={queryBalancesNext != null} + onTransfersPrev={() => navigateQueryTransfers("prev")} + onTransfersNext={() => navigateQueryTransfers("next")} + transfersCanPrev={queryTransfersHistory.length > 0} + transfersCanNext={queryTransfersNext != null} + balancesLoading={queryBalancesLoading} + transfersLoading={queryTransfersLoading} />
@@ -238,7 +473,18 @@ export default function App() { tokenType="erc20" stats={erc20Stats} transfers={erc20Transfers} + metadata={erc20Metadata} showAmount={true} + onMetadataPrev={() => navigateDashboardMetadata("erc20", "prev")} + onMetadataNext={() => navigateDashboardMetadata("erc20", "next")} + metadataCanPrev={erc20MetadataHistory.length > 0} + metadataCanNext={erc20MetadataNext != null} + onTransfersPrev={() => navigateDashboardTransfers("erc20", "prev")} + onTransfersNext={() => navigateDashboardTransfers("erc20", "next")} + transfersCanPrev={erc20TransfersHistory.length > 0} + transfersCanNext={erc20TransfersNext != null} + metadataLoading={erc20MetadataLoading} + transfersLoading={erc20TransfersLoading} /> navigateDashboardMetadata("erc721", "prev")} + onMetadataNext={() => navigateDashboardMetadata("erc721", "next")} + metadataCanPrev={erc721MetadataHistory.length > 0} + metadataCanNext={erc721MetadataNext != null} + onTransfersPrev={() => navigateDashboardTransfers("erc721", "prev")} + onTransfersNext={() => navigateDashboardTransfers("erc721", "next")} + transfersCanPrev={erc721TransfersHistory.length > 0} + transfersCanNext={erc721TransfersNext != null} + metadataLoading={erc721MetadataLoading} + transfersLoading={erc721TransfersLoading} /> navigateDashboardMetadata("erc1155", "prev")} + onMetadataNext={() => navigateDashboardMetadata("erc1155", "next")} + metadataCanPrev={erc1155MetadataHistory.length > 0} + metadataCanNext={erc1155MetadataNext != null} + onTransfersPrev={() => navigateDashboardTransfers("erc1155", "prev")} + onTransfersNext={() => navigateDashboardTransfers("erc1155", "next")} + transfersCanPrev={erc1155TransfersHistory.length > 0} + transfersCanNext={erc1155TransfersNext != null} + metadataLoading={erc1155MetadataLoading} + transfersLoading={erc1155TransfersLoading} /> void; + onBalancesNext?: () => void; + balancesCanPrev?: boolean; + balancesCanNext?: boolean; + onTransfersPrev?: () => void; + onTransfersNext?: () => void; + transfersCanPrev?: boolean; + transfersCanNext?: boolean; + balancesLoading?: boolean; + transfersLoading?: boolean; } export default function QueryResults({ contractAddress, wallet, - erc20Balance, + erc20Balances, erc20Transfers, loading, error, + onBalancesPrev, + onBalancesNext, + balancesCanPrev = false, + balancesCanNext = false, + onTransfersPrev, + onTransfersNext, + transfersCanPrev = false, + transfersCanNext = false, + balancesLoading = false, + transfersLoading = false, }: QueryResultsProps) { if (!contractAddress && !wallet) { return ( @@ -33,7 +54,7 @@ export default function QueryResults({ ); } - if (loading) { + if (loading && erc20Balances.length === 0 && erc20Transfers.length === 0) { return (

Query Results

@@ -56,17 +77,61 @@ export default function QueryResults({

Query Results

-

ERC20 Balance

-
-
-
Balance
-
{erc20Balance?.balance ?? "0"}
+

ERC20 Balances

+ {erc20Balances.length === 0 ? ( +
No token balances found
+ ) : ( +
+
+ + + + + + + + + + + {erc20Balances.map((b) => ( + + + + + + + ))} + +
TokenWalletBalanceLast Updated Block
+ + {b.symbol ? `${b.symbol} (${truncateAddress(b.token)})` : truncateAddress(b.token)} + + + {b.wallet ? ( + + {truncateAddress(b.wallet)} + + ) : "—"} + {b.balance}{b.lastBlock}
+
+ {balancesLoading && ( +
+
+ Loading balances... +
+ )}
-
-
Last Updated Block
-
{erc20Balance?.lastBlock ?? "-"}
+ )} + {erc20Balances.length > 0 && ( +
+ +
-
+ )}
@@ -74,29 +139,55 @@ export default function QueryResults({ {erc20Transfers.length === 0 ? (
No transfers found
) : ( -
- - - - - - - - - - - - {erc20Transfers.map((t, idx) => ( - - - - - - +
+
+
FromToAmountBlockTime
{truncateAddress(t.from)}{truncateAddress(t.to)}{t.amount}{t.blockNumber}{formatTimestamp(t.timestamp)}
+ + + + + + + - ))} - -
FromToAmountBlockTime
+ + + {erc20Transfers.map((t, idx) => ( + + + + {truncateAddress(t.from)} + + + + + {truncateAddress(t.to)} + + + {t.amount} + {t.blockNumber} + {formatTimestamp(t.timestamp)} + + ))} + + +
+ {transfersLoading && ( +
+
+ Loading transfers... +
+ )} +
+ )} + {erc20Transfers.length > 0 && ( +
+ +
)}
diff --git a/bins/torii-tokens/client/apps/react/src/components/TokenPanel.tsx b/bins/torii-tokens/client/apps/react/src/components/TokenPanel.tsx index bccd734..4b967fd 100644 --- a/bins/torii-tokens/client/apps/react/src/components/TokenPanel.tsx +++ b/bins/torii-tokens/client/apps/react/src/components/TokenPanel.tsx @@ -1,6 +1,8 @@ import { formatTimestamp, truncateAddress, + getContractExplorerUrl, + type TokenMetadataResult, } from "@torii-tokens/shared"; interface Stats { @@ -15,6 +17,7 @@ interface Transfer { token: string; from: string; to: string; + value?: string; amount?: string; tokenId?: string; blockNumber: number; @@ -26,7 +29,18 @@ interface TokenPanelProps { tokenType: "erc20" | "erc721" | "erc1155"; stats: Stats | null; transfers: Transfer[]; + metadata?: TokenMetadataResult[]; showAmount: boolean; + onMetadataPrev?: () => void; + onMetadataNext?: () => void; + metadataCanPrev?: boolean; + metadataCanNext?: boolean; + onTransfersPrev?: () => void; + onTransfersNext?: () => void; + transfersCanPrev?: boolean; + transfersCanNext?: boolean; + metadataLoading?: boolean; + transfersLoading?: boolean; } export default function TokenPanel({ @@ -34,7 +48,18 @@ export default function TokenPanel({ tokenType, stats, transfers, + metadata = [], showAmount, + onMetadataPrev, + onMetadataNext, + metadataCanPrev = false, + metadataCanNext = false, + onTransfersPrev, + onTransfersNext, + transfersCanPrev = false, + transfersCanNext = false, + metadataLoading = false, + transfersLoading = false, }: TokenPanelProps) { return (
@@ -67,34 +92,108 @@ export default function TokenPanel({
)} + {metadata.length > 0 && ( +
+

Token Metadata

+
+
+ + + + + + + {tokenType === "erc20" ? : null} + + + + {metadata.map((m) => ( + + + + + {tokenType === "erc20" ? : null} + + ))} + +
ContractNameSymbolDecimals
+ + {truncateAddress(m.token)} + + {m.name ?? "—"}{m.symbol ?? "—"}{m.decimals ?? "—"}
+
+ {metadataLoading && ( +
+
+ Loading metadata... +
+ )} +
+
+ + +
+
+ )} + {transfers.length === 0 ? (
No transfers yet
) : ( -
- - - - - - - - - - - - {transfers.map((t) => ( - - - - - - +
+
+
FromTo{showAmount ? "Amount" : "Token ID"}BlockTime
{truncateAddress(t.from)}{truncateAddress(t.to)} - {showAmount ? t.amount : t.tokenId} - {t.blockNumber}{formatTimestamp(t.timestamp)}
+ + + + + + + - ))} - -
FromTo{showAmount ? (tokenType === "erc1155" ? "Value" : "Amount") : "Token ID"}BlockTime
+ + + {transfers.map((t) => ( + + + + {truncateAddress(t.from)} + + + + + {truncateAddress(t.to)} + + + + {showAmount ? (t.value ?? t.amount) : t.tokenId} + + {t.blockNumber} + {formatTimestamp(t.timestamp)} + + ))} + + +
+ {transfersLoading && ( +
+
+ Loading transfers... +
+ )} +
+ )} + {transfers.length > 0 && ( +
+ +
)}
diff --git a/bins/torii-tokens/client/apps/svelte/src/App.svelte b/bins/torii-tokens/client/apps/svelte/src/App.svelte index 9d57b4c..69c124c 100644 --- a/bins/torii-tokens/client/apps/svelte/src/App.svelte +++ b/bins/torii-tokens/client/apps/svelte/src/App.svelte @@ -10,12 +10,17 @@ getErc20Stats, getErc721Stats, getErc1155Stats, - getErc20Transfers, - getErc721Transfers, - getErc1155Transfers, - getErc20Balance, - type BalanceResult, + getErc20TransfersPage, + getErc721TransfersPage, + getErc1155TransfersPage, + getErc20BalancesPage, + getErc20TokenMetadataPage, + getErc721TokenMetadataPage, + getErc1155TokenMetadataPage, + type TransferCursorResult, + type TokenBalanceResult, type TransferResult, + type TokenMetadataResult, } from "@torii-tokens/shared"; import StatusPanel from "./components/StatusPanel.svelte"; @@ -36,6 +41,7 @@ token: string; from: string; to: string; + value?: string; amount?: string; tokenId?: string; blockNumber: number; @@ -52,6 +58,7 @@ const client = createTokensClient(SERVER_URL); const clientId = generateClientId(); + const PAGE_SIZE = 100; let connected = $state(false); let updates = $state([]); @@ -64,30 +71,76 @@ let erc1155Stats = $state(null); let erc1155Transfers = $state([]); + let erc20Metadata = $state([]); + let erc721Metadata = $state([]); + let erc1155Metadata = $state([]); + let queryContractAddress = $state(""); let queryWallet = $state(""); let queryLoading = $state(false); + let queryBalancesLoading = $state(false); + let queryTransfersLoading = $state(false); let queryError = $state(null); - let queryErc20Balance = $state(null); + let queryErc20Balances = $state([]); let queryErc20Transfers = $state([]); + let erc20MetadataLoading = $state(false); + let erc721MetadataLoading = $state(false); + let erc1155MetadataLoading = $state(false); + let erc20TransfersLoading = $state(false); + let erc721TransfersLoading = $state(false); + let erc1155TransfersLoading = $state(false); + let erc20TransfersHistory = $state<(TransferCursorResult | undefined)[]>([]); + let erc20TransfersCursor = $state(undefined); + let erc20TransfersNext = $state(undefined); + let erc721TransfersHistory = $state<(TransferCursorResult | undefined)[]>([]); + let erc721TransfersCursor = $state(undefined); + let erc721TransfersNext = $state(undefined); + let erc1155TransfersHistory = $state<(TransferCursorResult | undefined)[]>([]); + let erc1155TransfersCursor = $state(undefined); + let erc1155TransfersNext = $state(undefined); + let erc20MetadataHistory = $state<(string | undefined)[]>([]); + let erc20MetadataCursor = $state(undefined); + let erc20MetadataNext = $state(undefined); + let erc721MetadataHistory = $state<(string | undefined)[]>([]); + let erc721MetadataCursor = $state(undefined); + let erc721MetadataNext = $state(undefined); + let erc1155MetadataHistory = $state<(string | undefined)[]>([]); + let erc1155MetadataCursor = $state(undefined); + let erc1155MetadataNext = $state(undefined); + let queryBalancesHistory = $state<(number | undefined)[]>([]); + let queryBalancesCursor = $state(undefined); + let queryBalancesNext = $state(undefined); + let queryTransfersHistory = $state<(TransferCursorResult | undefined)[]>([]); + let queryTransfersCursor = $state(undefined); + let queryTransfersNext = $state(undefined); async function handleQuery(contractAddress: string, wallet: string) { queryContractAddress = contractAddress; queryWallet = wallet; + queryBalancesHistory = []; + queryTransfersHistory = []; + queryBalancesCursor = undefined; + queryTransfersCursor = undefined; queryLoading = true; + queryBalancesLoading = true; + queryTransfersLoading = true; queryError = null; try { - const [balanceResult, transfers] = await Promise.all([ - getErc20Balance(client, { contractAddress, wallet }), - getErc20Transfers(client, { contractAddress, wallet }, 50), + const [balances, transfers] = await Promise.all([ + getErc20BalancesPage(client, { contractAddress, wallet, limit: PAGE_SIZE }), + getErc20TransfersPage(client, { contractAddress, wallet, limit: PAGE_SIZE }), ]); - queryErc20Balance = balanceResult; - queryErc20Transfers = transfers; + queryErc20Balances = balances.items; + queryBalancesNext = balances.nextCursor; + queryErc20Transfers = transfers.items; + queryTransfersNext = transfers.nextCursor; } catch (err) { console.error("Query failed:", err); queryError = err instanceof Error ? err.message : "Query failed"; } finally { + queryBalancesLoading = false; + queryTransfersLoading = false; queryLoading = false; } } @@ -132,37 +185,77 @@ } } - async function loadTransfers() { + async function loadMetadata(tokenType: "erc20" | "erc721" | "erc1155", cursor?: string) { + try { + if (tokenType === "erc20") { + erc20MetadataLoading = true; + const page = await getErc20TokenMetadataPage(client, { cursor, limit: PAGE_SIZE }); + erc20Metadata = page.items; + erc20MetadataNext = page.nextCursor; + return; + } + if (tokenType === "erc721") { + erc721MetadataLoading = true; + const page = await getErc721TokenMetadataPage(client, { cursor, limit: PAGE_SIZE }); + erc721Metadata = page.items; + erc721MetadataNext = page.nextCursor; + return; + } + erc1155MetadataLoading = true; + const page = await getErc1155TokenMetadataPage(client, { cursor, limit: PAGE_SIZE }); + erc1155Metadata = page.items; + erc1155MetadataNext = page.nextCursor; + } catch (err) { + console.error("Failed to load metadata:", err); + } finally { + if (tokenType === "erc20") erc20MetadataLoading = false; + if (tokenType === "erc721") erc721MetadataLoading = false; + if (tokenType === "erc1155") erc1155MetadataLoading = false; + } + } + + async function loadTransfers(tokenType: "erc20" | "erc721" | "erc1155", cursor?: TransferCursorResult) { const emptyQuery = { contractAddress: "", wallet: "" }; try { - const [t20, t721, t1155] = await Promise.all([ - getErc20Transfers(client, emptyQuery, 20), - getErc721Transfers(client, emptyQuery, 20), - getErc1155Transfers(client, emptyQuery, 20), - ]); - erc20Transfers = t20.map((t, i) => ({ - id: `${t.txHash}-${i}`, - token: t.token, - from: t.from, - to: t.to, - amount: t.amount, - blockNumber: t.blockNumber, - timestamp: t.timestamp, - })); - erc721Transfers = t721.map((t, i) => ({ - id: `${t.txHash}-${i}`, - token: t.token, - from: t.from, - to: t.to, - tokenId: (t as Record).tokenId as string | undefined, - blockNumber: t.blockNumber, - timestamp: t.timestamp, - })); - erc1155Transfers = t1155.map((t, i) => ({ + if (tokenType === "erc20") { + erc20TransfersLoading = true; + const page = await getErc20TransfersPage(client, { ...emptyQuery, cursor, limit: PAGE_SIZE }); + erc20TransfersNext = page.nextCursor; + erc20Transfers = page.items.map((t, i) => ({ + id: `${t.txHash}-${i}`, + token: t.token, + from: t.from, + to: t.to, + amount: t.amount, + blockNumber: t.blockNumber, + timestamp: t.timestamp, + })); + return; + } + if (tokenType === "erc721") { + erc721TransfersLoading = true; + const page = await getErc721TransfersPage(client, { ...emptyQuery, cursor, limit: PAGE_SIZE }); + erc721TransfersNext = page.nextCursor; + erc721Transfers = page.items.map((t, i) => ({ + id: `${t.txHash}-${i}`, + token: t.token, + from: t.from, + to: t.to, + tokenId: (t as unknown as Record).tokenId as string | undefined, + blockNumber: t.blockNumber, + timestamp: t.timestamp, + })); + return; + } + erc1155TransfersLoading = true; + const page = await getErc1155TransfersPage(client, { ...emptyQuery, cursor, limit: PAGE_SIZE }); + erc1155TransfersNext = page.nextCursor; + erc1155Transfers = page.items.map((t, i) => ({ id: `${t.txHash}-${i}`, token: t.token, from: t.from, to: t.to, + value: t.value, amount: t.amount, tokenId: t.tokenId, blockNumber: t.blockNumber, @@ -170,6 +263,106 @@ })); } catch (err) { console.error("Failed to load transfers:", err); + } finally { + if (tokenType === "erc20") erc20TransfersLoading = false; + if (tokenType === "erc721") erc721TransfersLoading = false; + if (tokenType === "erc1155") erc1155TransfersLoading = false; + } + } + + async function navigateDashboardTransfers(tokenType: "erc20" | "erc721" | "erc1155", dir: "next" | "prev") { + if (tokenType === "erc20") { + const target = dir === "next" ? erc20TransfersNext : erc20TransfersHistory[erc20TransfersHistory.length - 1]; + if (!target && dir === "next") return; + erc20TransfersHistory = dir === "next" ? [...erc20TransfersHistory, erc20TransfersCursor] : erc20TransfersHistory.slice(0, -1); + erc20TransfersCursor = target; + await loadTransfers("erc20", target); + return; + } + if (tokenType === "erc721") { + const target = dir === "next" ? erc721TransfersNext : erc721TransfersHistory[erc721TransfersHistory.length - 1]; + if (!target && dir === "next") return; + erc721TransfersHistory = dir === "next" ? [...erc721TransfersHistory, erc721TransfersCursor] : erc721TransfersHistory.slice(0, -1); + erc721TransfersCursor = target; + await loadTransfers("erc721", target); + return; + } + const target = dir === "next" ? erc1155TransfersNext : erc1155TransfersHistory[erc1155TransfersHistory.length - 1]; + if (!target && dir === "next") return; + erc1155TransfersHistory = dir === "next" ? [...erc1155TransfersHistory, erc1155TransfersCursor] : erc1155TransfersHistory.slice(0, -1); + erc1155TransfersCursor = target; + await loadTransfers("erc1155", target); + } + + async function navigateDashboardMetadata(tokenType: "erc20" | "erc721" | "erc1155", dir: "next" | "prev") { + if (tokenType === "erc20") { + const target = dir === "next" ? erc20MetadataNext : erc20MetadataHistory[erc20MetadataHistory.length - 1]; + if (!target && dir === "next") return; + erc20MetadataHistory = dir === "next" ? [...erc20MetadataHistory, erc20MetadataCursor] : erc20MetadataHistory.slice(0, -1); + erc20MetadataCursor = target; + await loadMetadata("erc20", target); + return; + } + if (tokenType === "erc721") { + const target = dir === "next" ? erc721MetadataNext : erc721MetadataHistory[erc721MetadataHistory.length - 1]; + if (!target && dir === "next") return; + erc721MetadataHistory = dir === "next" ? [...erc721MetadataHistory, erc721MetadataCursor] : erc721MetadataHistory.slice(0, -1); + erc721MetadataCursor = target; + await loadMetadata("erc721", target); + return; + } + const target = dir === "next" ? erc1155MetadataNext : erc1155MetadataHistory[erc1155MetadataHistory.length - 1]; + if (!target && dir === "next") return; + erc1155MetadataHistory = dir === "next" ? [...erc1155MetadataHistory, erc1155MetadataCursor] : erc1155MetadataHistory.slice(0, -1); + erc1155MetadataCursor = target; + await loadMetadata("erc1155", target); + } + + async function navigateQueryBalances(dir: "next" | "prev") { + const target = dir === "next" ? queryBalancesNext : queryBalancesHistory[queryBalancesHistory.length - 1]; + if (target == null && dir === "next") return; + queryBalancesLoading = true; + queryError = null; + try { + queryBalancesHistory = dir === "next" ? [...queryBalancesHistory, queryBalancesCursor] : queryBalancesHistory.slice(0, -1); + queryBalancesCursor = target; + const page = await getErc20BalancesPage(client, { + contractAddress: queryContractAddress, + wallet: queryWallet, + cursor: target, + limit: PAGE_SIZE, + }); + queryErc20Balances = page.items; + queryBalancesNext = page.nextCursor; + } catch (err) { + console.error("Balance pagination failed:", err); + queryError = err instanceof Error ? err.message : "Balance pagination failed"; + } finally { + queryBalancesLoading = false; + } + } + + async function navigateQueryTransfers(dir: "next" | "prev") { + const target = dir === "next" ? queryTransfersNext : queryTransfersHistory[queryTransfersHistory.length - 1]; + if (!target && dir === "next") return; + queryTransfersLoading = true; + queryError = null; + try { + queryTransfersHistory = dir === "next" ? [...queryTransfersHistory, queryTransfersCursor] : queryTransfersHistory.slice(0, -1); + queryTransfersCursor = target; + const page = await getErc20TransfersPage(client, { + contractAddress: queryContractAddress, + wallet: queryWallet, + cursor: target, + limit: PAGE_SIZE, + }); + queryErc20Transfers = page.items; + queryTransfersNext = page.nextCursor; + } catch (err) { + console.error("Transfer pagination failed:", err); + queryError = err instanceof Error ? err.message : "Transfer pagination failed"; + } finally { + queryTransfersLoading = false; } } @@ -179,8 +372,12 @@ clientId, [ { topic: "erc20.transfer" }, + { topic: "erc20.metadata" }, { topic: "erc721.transfer" }, + { topic: "erc721.metadata" }, { topic: "erc1155.transfer" }, + { topic: "erc1155.metadata" }, + { topic: "erc1155.uri" }, ], (update: Update) => { updates = [update, ...updates].slice(0, 50); @@ -213,7 +410,14 @@ onMount(() => { checkHealth(); loadStats(); - loadTransfers(); + void Promise.all([ + loadTransfers("erc20"), + loadTransfers("erc721"), + loadTransfers("erc1155"), + loadMetadata("erc20"), + loadMetadata("erc721"), + loadMetadata("erc1155"), + ]); }); onDestroy(() => { @@ -241,10 +445,20 @@ navigateQueryBalances("prev")} + onBalancesNext={() => navigateQueryBalances("next")} + balancesCanPrev={queryBalancesHistory.length > 0} + balancesCanNext={queryBalancesNext != null} + onTransfersPrev={() => navigateQueryTransfers("prev")} + onTransfersNext={() => navigateQueryTransfers("next")} + transfersCanPrev={queryTransfersHistory.length > 0} + transfersCanNext={queryTransfersNext != null} + balancesLoading={queryBalancesLoading} + transfersLoading={queryTransfersLoading} />
@@ -253,7 +467,18 @@ tokenType="erc20" stats={erc20Stats} transfers={erc20Transfers} + metadata={erc20Metadata} showAmount={true} + onMetadataPrev={() => navigateDashboardMetadata("erc20", "prev")} + onMetadataNext={() => navigateDashboardMetadata("erc20", "next")} + metadataCanPrev={erc20MetadataHistory.length > 0} + metadataCanNext={erc20MetadataNext != null} + onTransfersPrev={() => navigateDashboardTransfers("erc20", "prev")} + onTransfersNext={() => navigateDashboardTransfers("erc20", "next")} + transfersCanPrev={erc20TransfersHistory.length > 0} + transfersCanNext={erc20TransfersNext != null} + metadataLoading={erc20MetadataLoading} + transfersLoading={erc20TransfersLoading} /> navigateDashboardMetadata("erc721", "prev")} + onMetadataNext={() => navigateDashboardMetadata("erc721", "next")} + metadataCanPrev={erc721MetadataHistory.length > 0} + metadataCanNext={erc721MetadataNext != null} + onTransfersPrev={() => navigateDashboardTransfers("erc721", "prev")} + onTransfersNext={() => navigateDashboardTransfers("erc721", "next")} + transfersCanPrev={erc721TransfersHistory.length > 0} + transfersCanNext={erc721TransfersNext != null} + metadataLoading={erc721MetadataLoading} + transfersLoading={erc721TransfersLoading} /> navigateDashboardMetadata("erc1155", "prev")} + onMetadataNext={() => navigateDashboardMetadata("erc1155", "next")} + metadataCanPrev={erc1155MetadataHistory.length > 0} + metadataCanNext={erc1155MetadataNext != null} + onTransfersPrev={() => navigateDashboardTransfers("erc1155", "prev")} + onTransfersNext={() => navigateDashboardTransfers("erc1155", "next")} + transfersCanPrev={erc1155TransfersHistory.length > 0} + transfersCanNext={erc1155TransfersNext != null} + metadataLoading={erc1155MetadataLoading} + transfersLoading={erc1155TransfersLoading} /> void; + onBalancesNext?: () => void; + balancesCanPrev?: boolean; + balancesCanNext?: boolean; + onTransfersPrev?: () => void; + onTransfersNext?: () => void; + transfersCanPrev?: boolean; + transfersCanNext?: boolean; + balancesLoading?: boolean; + transfersLoading?: boolean; } - let { contractAddress, wallet, erc20Balance, erc20Transfers, loading, error }: Props = $props(); + let { + contractAddress, + wallet, + erc20Balances, + erc20Transfers, + loading, + error, + onBalancesPrev, + onBalancesNext, + balancesCanPrev = false, + balancesCanNext = false, + onTransfersPrev, + onTransfersNext, + transfersCanPrev = false, + transfersCanNext = false, + balancesLoading = false, + transfersLoading = false, + }: Props = $props(); {#if !contractAddress && !wallet} @@ -25,7 +53,7 @@ Enter a contract address or wallet to query balances and transfers
-{:else if loading} +{:else if loading && erc20Balances.length === 0 && erc20Transfers.length === 0}

Query Results

Loading...
@@ -40,17 +68,59 @@

Query Results

-

ERC20 Balance

-
-
-
Balance
-
{erc20Balance?.balance ?? "0"}
+

ERC20 Balances

+ {#if erc20Balances.length === 0} +
No token balances found
+ {:else} +
+
+ + + + + + + + + + + {#each erc20Balances as b (`${b.token}-${b.wallet ?? "none"}`)} + + + + + + + {/each} + +
TokenWalletBalanceLast Updated Block
+ + {b.symbol ? `${b.symbol} (${truncateAddress(b.token)})` : truncateAddress(b.token)} + + + {#if b.wallet} + + {truncateAddress(b.wallet)} + + {:else} + — + {/if} + {b.balance}{b.lastBlock}
+
+ {#if balancesLoading} +
+
+ Loading balances... +
+ {/if}
-
-
Last Updated Block
-
{erc20Balance?.lastBlock ?? "-"}
+ {/if} + {#if erc20Balances.length > 0} +
+ +
-
+ {/if}
@@ -58,29 +128,51 @@ {#if erc20Transfers.length === 0}
No transfers found
{:else} -
- - - - - - - - - - - - {#each erc20Transfers as t, idx (`${t.txHash}-${idx}`)} +
+
+
FromToAmountBlockTime
+ - - - - - + + + + + - {/each} - -
{truncateAddress(t.from)}{truncateAddress(t.to)}{t.amount}{t.blockNumber}{formatTimestamp(t.timestamp)}FromToAmountBlockTime
+ + + {#each erc20Transfers as t, idx (`${t.txHash}-${idx}`)} + + + + {truncateAddress(t.from)} + + + + + {truncateAddress(t.to)} + + + {t.amount} + {t.blockNumber} + {formatTimestamp(t.timestamp)} + + {/each} + + +
+ {#if transfersLoading} +
+
+ Loading transfers... +
+ {/if} +
+ {/if} + {#if erc20Transfers.length > 0} +
+ +
{/if}
diff --git a/bins/torii-tokens/client/apps/svelte/src/components/TokenPanel.svelte b/bins/torii-tokens/client/apps/svelte/src/components/TokenPanel.svelte index 0260e10..d9b6fe8 100644 --- a/bins/torii-tokens/client/apps/svelte/src/components/TokenPanel.svelte +++ b/bins/torii-tokens/client/apps/svelte/src/components/TokenPanel.svelte @@ -2,6 +2,8 @@ import { formatTimestamp, truncateAddress, + getContractExplorerUrl, + type TokenMetadataResult, } from "@torii-tokens/shared"; interface Stats { @@ -16,6 +18,7 @@ token: string; from: string; to: string; + value?: string; amount?: string; tokenId?: string; blockNumber: number; @@ -27,10 +30,38 @@ tokenType: "erc20" | "erc721" | "erc1155"; stats: Stats | null; transfers: Transfer[]; + metadata?: TokenMetadataResult[]; showAmount: boolean; + onMetadataPrev?: () => void; + onMetadataNext?: () => void; + metadataCanPrev?: boolean; + metadataCanNext?: boolean; + onTransfersPrev?: () => void; + onTransfersNext?: () => void; + transfersCanPrev?: boolean; + transfersCanNext?: boolean; + metadataLoading?: boolean; + transfersLoading?: boolean; } - let { title, tokenType, stats, transfers, showAmount }: Props = $props(); + let { + title, + tokenType, + stats, + transfers, + metadata = [], + showAmount, + onMetadataPrev, + onMetadataNext, + metadataCanPrev = false, + metadataCanNext = false, + onTransfersPrev, + onTransfersNext, + transfersCanPrev = false, + transfersCanNext = false, + metadataLoading = false, + transfersLoading = false, + }: Props = $props();
@@ -56,35 +87,105 @@
{tokenType === "erc721" ? "Owners" : "Accounts"}
{stats.uniqueAccounts}
+
+ + +
+
+ {/if} + + {#if metadata.length > 0} + {/if} {#if transfers.length === 0}
No transfers yet
{:else} -
- - - - - - - - - - - - {#each transfers as t (t.id)} +
+
+
FromTo{showAmount ? "Amount" : "Token ID"}BlockTime
+ - - - - - + + + + + - {/each} - -
{truncateAddress(t.from)}{truncateAddress(t.to)}{showAmount ? t.amount : t.tokenId}{t.blockNumber}{formatTimestamp(t.timestamp)}FromTo{showAmount ? (tokenType === "erc1155" ? "Value" : "Amount") : "Token ID"}BlockTime
+ + + {#each transfers as t (t.id)} + + + + {truncateAddress(t.from)} + + + + + {truncateAddress(t.to)} + + + {showAmount ? (t.value ?? t.amount) : t.tokenId} + {t.blockNumber} + {formatTimestamp(t.timestamp)} + + {/each} + + +
+ {#if transfersLoading} +
+
+ Loading transfers... +
+ {/if} +
+ {/if} + {#if transfers.length > 0} +
+ +
{/if} diff --git a/bins/torii-tokens/client/apps/vanilla/src/main.ts b/bins/torii-tokens/client/apps/vanilla/src/main.ts index 4c4e414..54c2709 100644 --- a/bins/torii-tokens/client/apps/vanilla/src/main.ts +++ b/bins/torii-tokens/client/apps/vanilla/src/main.ts @@ -4,17 +4,23 @@ import { SERVER_URL, formatTimestamp, truncateAddress, + getContractExplorerUrl, getUpdateTypeName, generateClientId, getErc20Stats, getErc721Stats, getErc1155Stats, - getErc20Transfers, - getErc721Transfers, - getErc1155Transfers, - getErc20Balance, - type BalanceResult, + getErc20TransfersPage, + getErc721TransfersPage, + getErc1155TransfersPage, + getErc20BalancesPage, + getErc20TokenMetadataPage, + getErc721TokenMetadataPage, + getErc1155TokenMetadataPage, + type TransferCursorResult, + type TokenBalanceResult, type TransferResult, + type TokenMetadataResult, } from "@torii-tokens/shared"; interface Stats { @@ -29,6 +35,7 @@ interface Transfer { token: string; from: string; to: string; + value?: string; amount?: string; tokenId?: string; blockNumber: number; @@ -44,6 +51,7 @@ interface Update { } class TokensApp { + private readonly pageSize = 100; private client = createTokensClient(SERVER_URL); private clientId = generateClientId(); private connected = false; @@ -57,12 +65,48 @@ class TokensApp { private erc1155Stats: Stats | null = null; private erc1155Transfers: Transfer[] = []; + private erc20Metadata: TokenMetadataResult[] = []; + private erc721Metadata: TokenMetadataResult[] = []; + private erc1155Metadata: TokenMetadataResult[] = []; + private queryContractAddress = ""; private queryWallet = ""; private queryLoading = false; + private queryBalancesLoading = false; + private queryTransfersLoading = false; private queryError: string | null = null; - private queryErc20Balance: BalanceResult | null = null; + private queryErc20Balances: TokenBalanceResult[] = []; private queryErc20Transfers: TransferResult[] = []; + private erc20MetadataLoading = false; + private erc721MetadataLoading = false; + private erc1155MetadataLoading = false; + private erc20TransfersLoading = false; + private erc721TransfersLoading = false; + private erc1155TransfersLoading = false; + private erc20TransfersHistory: (TransferCursorResult | undefined)[] = []; + private erc20TransfersCursor?: TransferCursorResult; + private erc20TransfersNext?: TransferCursorResult; + private erc721TransfersHistory: (TransferCursorResult | undefined)[] = []; + private erc721TransfersCursor?: TransferCursorResult; + private erc721TransfersNext?: TransferCursorResult; + private erc1155TransfersHistory: (TransferCursorResult | undefined)[] = []; + private erc1155TransfersCursor?: TransferCursorResult; + private erc1155TransfersNext?: TransferCursorResult; + private erc20MetadataHistory: (string | undefined)[] = []; + private erc20MetadataCursor?: string; + private erc20MetadataNext?: string; + private erc721MetadataHistory: (string | undefined)[] = []; + private erc721MetadataCursor?: string; + private erc721MetadataNext?: string; + private erc1155MetadataHistory: (string | undefined)[] = []; + private erc1155MetadataCursor?: string; + private erc1155MetadataNext?: string; + private queryBalancesHistory: (number | undefined)[] = []; + private queryBalancesCursor?: number; + private queryBalancesNext?: number; + private queryTransfersHistory: (TransferCursorResult | undefined)[] = []; + private queryTransfersCursor?: TransferCursorResult; + private queryTransfersNext?: TransferCursorResult; constructor() { this.render(); @@ -72,7 +116,14 @@ class TokensApp { private async init() { await this.checkHealth(); await this.loadAllStats(); - await this.loadAllTransfers(); + await Promise.all([ + this.loadTransfers("erc20"), + this.loadTransfers("erc721"), + this.loadTransfers("erc1155"), + this.loadMetadata("erc20"), + this.loadMetadata("erc721"), + this.loadMetadata("erc1155"), + ]); this.render(); } @@ -116,37 +167,48 @@ class TokensApp { } } - private async loadAllTransfers() { + private async loadTransfers(tokenType: "erc20" | "erc721" | "erc1155", cursor?: TransferCursorResult) { const emptyQuery = { contractAddress: "", wallet: "" }; try { - const [t20, t721, t1155] = await Promise.all([ - getErc20Transfers(this.client, emptyQuery, 20), - getErc721Transfers(this.client, emptyQuery, 20), - getErc1155Transfers(this.client, emptyQuery, 20), - ]); - this.erc20Transfers = t20.map((t, i) => ({ - id: `${t.txHash}-${i}`, - token: t.token, - from: t.from, - to: t.to, - amount: t.amount, - blockNumber: t.blockNumber, - timestamp: t.timestamp, - })); - this.erc721Transfers = t721.map((t, i) => ({ - id: `${t.txHash}-${i}`, - token: t.token, - from: t.from, - to: t.to, - tokenId: (t as Record).tokenId as string | undefined, - blockNumber: t.blockNumber, - timestamp: t.timestamp, - })); - this.erc1155Transfers = t1155.map((t, i) => ({ + if (tokenType === "erc20") { + this.erc20TransfersLoading = true; + const page = await getErc20TransfersPage(this.client, { ...emptyQuery, cursor, limit: this.pageSize }); + this.erc20TransfersNext = page.nextCursor; + this.erc20Transfers = page.items.map((t, i) => ({ + id: `${t.txHash}-${i}`, + token: t.token, + from: t.from, + to: t.to, + amount: t.amount, + blockNumber: t.blockNumber, + timestamp: t.timestamp, + })); + return; + } + if (tokenType === "erc721") { + this.erc721TransfersLoading = true; + const page = await getErc721TransfersPage(this.client, { ...emptyQuery, cursor, limit: this.pageSize }); + this.erc721TransfersNext = page.nextCursor; + this.erc721Transfers = page.items.map((t, i) => ({ + id: `${t.txHash}-${i}`, + token: t.token, + from: t.from, + to: t.to, + tokenId: (t as unknown as Record).tokenId as string | undefined, + blockNumber: t.blockNumber, + timestamp: t.timestamp, + })); + return; + } + this.erc1155TransfersLoading = true; + const page = await getErc1155TransfersPage(this.client, { ...emptyQuery, cursor, limit: this.pageSize }); + this.erc1155TransfersNext = page.nextCursor; + this.erc1155Transfers = page.items.map((t, i) => ({ id: `${t.txHash}-${i}`, token: t.token, from: t.from, to: t.to, + value: t.value, amount: t.amount, tokenId: t.tokenId, blockNumber: t.blockNumber, @@ -154,27 +216,70 @@ class TokensApp { })); } catch (err) { console.error("Failed to load transfers:", err); + } finally { + if (tokenType === "erc20") this.erc20TransfersLoading = false; + if (tokenType === "erc721") this.erc721TransfersLoading = false; + if (tokenType === "erc1155") this.erc1155TransfersLoading = false; + } + } + + private async loadMetadata(tokenType: "erc20" | "erc721" | "erc1155", cursor?: string) { + try { + if (tokenType === "erc20") { + this.erc20MetadataLoading = true; + const page = await getErc20TokenMetadataPage(this.client, { cursor, limit: this.pageSize }); + this.erc20Metadata = page.items; + this.erc20MetadataNext = page.nextCursor; + return; + } + if (tokenType === "erc721") { + this.erc721MetadataLoading = true; + const page = await getErc721TokenMetadataPage(this.client, { cursor, limit: this.pageSize }); + this.erc721Metadata = page.items; + this.erc721MetadataNext = page.nextCursor; + return; + } + this.erc1155MetadataLoading = true; + const page = await getErc1155TokenMetadataPage(this.client, { cursor, limit: this.pageSize }); + this.erc1155Metadata = page.items; + this.erc1155MetadataNext = page.nextCursor; + } catch (err) { + console.error("Failed to load metadata:", err); + } finally { + if (tokenType === "erc20") this.erc20MetadataLoading = false; + if (tokenType === "erc721") this.erc721MetadataLoading = false; + if (tokenType === "erc1155") this.erc1155MetadataLoading = false; } } private async handleQuery(contractAddress: string, wallet: string) { this.queryContractAddress = contractAddress; this.queryWallet = wallet; + this.queryBalancesHistory = []; + this.queryTransfersHistory = []; + this.queryBalancesCursor = undefined; + this.queryTransfersCursor = undefined; this.queryLoading = true; + this.queryBalancesLoading = true; + this.queryTransfersLoading = true; this.queryError = null; this.render(); try { - const [balanceResult, transfers] = await Promise.all([ - getErc20Balance(this.client, { contractAddress, wallet }), - getErc20Transfers(this.client, { contractAddress, wallet }, 50), + const [balances, transfers] = await Promise.all([ + getErc20BalancesPage(this.client, { contractAddress, wallet, limit: this.pageSize }), + getErc20TransfersPage(this.client, { contractAddress, wallet, limit: this.pageSize }), ]); - this.queryErc20Balance = balanceResult; - this.queryErc20Transfers = transfers; + this.queryErc20Balances = balances.items; + this.queryBalancesNext = balances.nextCursor; + this.queryErc20Transfers = transfers.items; + this.queryTransfersNext = transfers.nextCursor; } catch (err) { console.error("Query failed:", err); this.queryError = err instanceof Error ? err.message : "Query failed"; } finally { + this.queryBalancesLoading = false; + this.queryTransfersLoading = false; this.queryLoading = false; this.render(); } @@ -186,8 +291,12 @@ class TokensApp { this.clientId, [ { topic: "erc20.transfer" }, + { topic: "erc20.metadata" }, { topic: "erc721.transfer" }, + { topic: "erc721.metadata" }, { topic: "erc1155.transfer" }, + { topic: "erc1155.metadata" }, + { topic: "erc1155.uri" }, ], (update) => { this.updates = [update as Update, ...this.updates].slice(0, 50); @@ -222,6 +331,134 @@ class TokensApp { this.render(); } + private async navigateDashboardTransfers(tokenType: "erc20" | "erc721" | "erc1155", dir: "next" | "prev") { + if (tokenType === "erc20") { + const target = dir === "next" ? this.erc20TransfersNext : this.erc20TransfersHistory[this.erc20TransfersHistory.length - 1]; + if (!target && dir === "next") return; + this.erc20TransfersHistory = dir === "next" + ? [...this.erc20TransfersHistory, this.erc20TransfersCursor] + : this.erc20TransfersHistory.slice(0, -1); + this.erc20TransfersCursor = target; + this.render(); + await this.loadTransfers("erc20", target); + this.render(); + return; + } + if (tokenType === "erc721") { + const target = dir === "next" ? this.erc721TransfersNext : this.erc721TransfersHistory[this.erc721TransfersHistory.length - 1]; + if (!target && dir === "next") return; + this.erc721TransfersHistory = dir === "next" + ? [...this.erc721TransfersHistory, this.erc721TransfersCursor] + : this.erc721TransfersHistory.slice(0, -1); + this.erc721TransfersCursor = target; + this.render(); + await this.loadTransfers("erc721", target); + this.render(); + return; + } + const target = dir === "next" ? this.erc1155TransfersNext : this.erc1155TransfersHistory[this.erc1155TransfersHistory.length - 1]; + if (!target && dir === "next") return; + this.erc1155TransfersHistory = dir === "next" + ? [...this.erc1155TransfersHistory, this.erc1155TransfersCursor] + : this.erc1155TransfersHistory.slice(0, -1); + this.erc1155TransfersCursor = target; + this.render(); + await this.loadTransfers("erc1155", target); + this.render(); + } + + private async navigateDashboardMetadata(tokenType: "erc20" | "erc721" | "erc1155", dir: "next" | "prev") { + if (tokenType === "erc20") { + const target = dir === "next" ? this.erc20MetadataNext : this.erc20MetadataHistory[this.erc20MetadataHistory.length - 1]; + if (!target && dir === "next") return; + this.erc20MetadataHistory = dir === "next" + ? [...this.erc20MetadataHistory, this.erc20MetadataCursor] + : this.erc20MetadataHistory.slice(0, -1); + this.erc20MetadataCursor = target; + this.render(); + await this.loadMetadata("erc20", target); + this.render(); + return; + } + if (tokenType === "erc721") { + const target = dir === "next" ? this.erc721MetadataNext : this.erc721MetadataHistory[this.erc721MetadataHistory.length - 1]; + if (!target && dir === "next") return; + this.erc721MetadataHistory = dir === "next" + ? [...this.erc721MetadataHistory, this.erc721MetadataCursor] + : this.erc721MetadataHistory.slice(0, -1); + this.erc721MetadataCursor = target; + this.render(); + await this.loadMetadata("erc721", target); + this.render(); + return; + } + const target = dir === "next" ? this.erc1155MetadataNext : this.erc1155MetadataHistory[this.erc1155MetadataHistory.length - 1]; + if (!target && dir === "next") return; + this.erc1155MetadataHistory = dir === "next" + ? [...this.erc1155MetadataHistory, this.erc1155MetadataCursor] + : this.erc1155MetadataHistory.slice(0, -1); + this.erc1155MetadataCursor = target; + this.render(); + await this.loadMetadata("erc1155", target); + this.render(); + } + + private async navigateQueryBalances(dir: "next" | "prev") { + const target = dir === "next" ? this.queryBalancesNext : this.queryBalancesHistory[this.queryBalancesHistory.length - 1]; + if (target == null && dir === "next") return; + this.queryBalancesLoading = true; + this.queryError = null; + this.render(); + try { + this.queryBalancesHistory = dir === "next" + ? [...this.queryBalancesHistory, this.queryBalancesCursor] + : this.queryBalancesHistory.slice(0, -1); + this.queryBalancesCursor = target; + const page = await getErc20BalancesPage(this.client, { + contractAddress: this.queryContractAddress, + wallet: this.queryWallet, + cursor: target, + limit: this.pageSize, + }); + this.queryErc20Balances = page.items; + this.queryBalancesNext = page.nextCursor; + } catch (err) { + console.error("Balance pagination failed:", err); + this.queryError = err instanceof Error ? err.message : "Balance pagination failed"; + } finally { + this.queryBalancesLoading = false; + this.render(); + } + } + + private async navigateQueryTransfers(dir: "next" | "prev") { + const target = dir === "next" ? this.queryTransfersNext : this.queryTransfersHistory[this.queryTransfersHistory.length - 1]; + if (!target && dir === "next") return; + this.queryTransfersLoading = true; + this.queryError = null; + this.render(); + try { + this.queryTransfersHistory = dir === "next" + ? [...this.queryTransfersHistory, this.queryTransfersCursor] + : this.queryTransfersHistory.slice(0, -1); + this.queryTransfersCursor = target; + const page = await getErc20TransfersPage(this.client, { + contractAddress: this.queryContractAddress, + wallet: this.queryWallet, + cursor: target, + limit: this.pageSize, + }); + this.queryErc20Transfers = page.items; + this.queryTransfersNext = page.nextCursor; + } catch (err) { + console.error("Transfer pagination failed:", err); + this.queryError = err instanceof Error ? err.message : "Transfer pagination failed"; + } finally { + this.queryTransfersLoading = false; + this.render(); + } + } + private render() { const app = document.getElementById("app")!; app.innerHTML = ` @@ -303,7 +540,7 @@ class TokensApp { `; } - if (this.queryLoading) { + if (this.queryLoading && this.queryErc20Balances.length === 0 && this.queryErc20Transfers.length === 0) { return `

Query Results

@@ -321,7 +558,7 @@ class TokensApp { `; } - const balance = this.queryErc20Balance; + const balances = this.queryErc20Balances; const transfers = this.queryErc20Transfers; return ` @@ -329,17 +566,46 @@ class TokensApp {

Query Results

-

ERC20 Balance

-
-
-
Balance
-
${balance?.balance ?? "0"}
+

ERC20 Balances

+ ${ + balances.length === 0 + ? `
No token balances found
` + : ` +
+
+ + + + + + + + + + + ${balances + .map( + (b) => ` + + + + + + + ` + ) + .join("")} + +
TokenWalletBalanceLast Updated Block
${b.symbol ? `${b.symbol} (${truncateAddress(b.token)})` : truncateAddress(b.token)}${b.wallet ? `${truncateAddress(b.wallet)}` : "—"}${b.balance}${b.lastBlock}
+
+ ${this.queryBalancesLoading ? `
Loading balances...
` : ""}
-
-
Last Updated Block
-
${balance?.lastBlock ?? "-"}
+
+ +
-
+ ` + }
@@ -348,33 +614,40 @@ class TokensApp { transfers.length === 0 ? `
No transfers found
` : ` -
- - - - - - - - - - - - ${transfers - .map( - (t) => ` +
+
+
FromToAmountBlockTime
+ - - - - - + + + + + - ` - ) - .join("")} - -
${truncateAddress(t.from)}${truncateAddress(t.to)}${t.amount}${t.blockNumber}${formatTimestamp(t.timestamp)}FromToAmountBlockTime
+ + + ${transfers + .map( + (t) => ` + + ${truncateAddress(t.from)} + ${truncateAddress(t.to)} + ${t.amount} + ${t.blockNumber} + ${formatTimestamp(t.timestamp)} + + ` + ) + .join("")} + + +
+ ${this.queryTransfersLoading ? `
Loading transfers...
` : ""} +
+
+ +
` } @@ -447,7 +720,8 @@ class TokensApp { ` : "" } - ${this.renderTransfersTable(this.erc20Transfers, true)} + ${this.renderMetadataTable(this.erc20Metadata, true, "erc20", this.erc20MetadataHistory.length > 0, this.erc20MetadataNext != null, this.erc20MetadataLoading)} + ${this.renderTransfersTable(this.erc20Transfers, true, "erc20", this.erc20TransfersHistory.length > 0, this.erc20TransfersNext != null, this.erc20TransfersLoading)}
`; } @@ -477,7 +751,8 @@ class TokensApp { ` : "" } - ${this.renderTransfersTable(this.erc721Transfers, false)} + ${this.renderMetadataTable(this.erc721Metadata, false, "erc721", this.erc721MetadataHistory.length > 0, this.erc721MetadataNext != null, this.erc721MetadataLoading)} + ${this.renderTransfersTable(this.erc721Transfers, false, "erc721", this.erc721TransfersHistory.length > 0, this.erc721TransfersNext != null, this.erc721TransfersLoading)} `; } @@ -507,44 +782,105 @@ class TokensApp { ` : "" } - ${this.renderTransfersTable(this.erc1155Transfers, false)} + ${this.renderMetadataTable(this.erc1155Metadata, false, "erc1155", this.erc1155MetadataHistory.length > 0, this.erc1155MetadataNext != null, this.erc1155MetadataLoading)} + ${this.renderTransfersTable(this.erc1155Transfers, true, "erc1155", this.erc1155TransfersHistory.length > 0, this.erc1155TransfersNext != null, this.erc1155TransfersLoading)} `; } - private renderTransfersTable(transfers: Transfer[], showAmount: boolean): string { + private renderMetadataTable( + metadata: TokenMetadataResult[], + showDecimals: boolean, + tokenType: "erc20" | "erc721" | "erc1155", + canPrev: boolean, + canNext: boolean, + loading: boolean + ): string { + if (metadata.length === 0) return ""; + + return ` + +
+ + +
+ `; + } + + private renderTransfersTable( + transfers: Transfer[], + showAmount: boolean, + tokenType: "erc20" | "erc721" | "erc1155", + canPrev: boolean, + canNext: boolean, + loading: boolean + ): string { if (transfers.length === 0) { return `
No transfers yet
`; } return ` -
- - - - - - ${showAmount ? "" : ""} - - - - - - ${transfers - .map( - (t) => ` +
+
+
FromToAmountToken IDBlockTime
+ - - - - - + + + ${showAmount ? "" : ""} + + - ` - ) - .join("")} - -
${truncateAddress(t.from)}${truncateAddress(t.to)}${showAmount ? t.amount : t.tokenId}${t.blockNumber}${formatTimestamp(t.timestamp)}FromToValueToken IDBlockTime
+ + + ${transfers + .map( + (t) => ` + + ${truncateAddress(t.from)} + ${truncateAddress(t.to)} + ${showAmount ? (t.value ?? t.amount) : t.tokenId} + ${t.blockNumber} + ${formatTimestamp(t.timestamp)} + + ` + ) + .join("")} + + +
+ ${loading ? `
Loading transfers...
` : ""} + +
+ +
`; } @@ -592,6 +928,22 @@ class TokensApp { document.getElementById("subscribe-btn")?.addEventListener("click", () => this.subscribe()); document.getElementById("disconnect-btn")?.addEventListener("click", () => this.disconnect()); document.getElementById("clear-updates-btn")?.addEventListener("click", () => this.clearUpdates()); + document.getElementById("query-balances-prev")?.addEventListener("click", () => void this.navigateQueryBalances("prev")); + document.getElementById("query-balances-next")?.addEventListener("click", () => void this.navigateQueryBalances("next")); + document.getElementById("query-transfers-prev")?.addEventListener("click", () => void this.navigateQueryTransfers("prev")); + document.getElementById("query-transfers-next")?.addEventListener("click", () => void this.navigateQueryTransfers("next")); + document.getElementById("erc20-metadata-prev")?.addEventListener("click", () => void this.navigateDashboardMetadata("erc20", "prev")); + document.getElementById("erc20-metadata-next")?.addEventListener("click", () => void this.navigateDashboardMetadata("erc20", "next")); + document.getElementById("erc721-metadata-prev")?.addEventListener("click", () => void this.navigateDashboardMetadata("erc721", "prev")); + document.getElementById("erc721-metadata-next")?.addEventListener("click", () => void this.navigateDashboardMetadata("erc721", "next")); + document.getElementById("erc1155-metadata-prev")?.addEventListener("click", () => void this.navigateDashboardMetadata("erc1155", "prev")); + document.getElementById("erc1155-metadata-next")?.addEventListener("click", () => void this.navigateDashboardMetadata("erc1155", "next")); + document.getElementById("erc20-transfers-prev")?.addEventListener("click", () => void this.navigateDashboardTransfers("erc20", "prev")); + document.getElementById("erc20-transfers-next")?.addEventListener("click", () => void this.navigateDashboardTransfers("erc20", "next")); + document.getElementById("erc721-transfers-prev")?.addEventListener("click", () => void this.navigateDashboardTransfers("erc721", "prev")); + document.getElementById("erc721-transfers-next")?.addEventListener("click", () => void this.navigateDashboardTransfers("erc721", "next")); + document.getElementById("erc1155-transfers-prev")?.addEventListener("click", () => void this.navigateDashboardTransfers("erc1155", "prev")); + document.getElementById("erc1155-transfers-next")?.addEventListener("click", () => void this.navigateDashboardTransfers("erc1155", "next")); document.getElementById("query-form")?.addEventListener("submit", (e) => { e.preventDefault(); const contractAddress = (document.getElementById("contractAddress") as HTMLInputElement)?.value ?? ""; diff --git a/bins/torii-tokens/client/shared/src/client.ts b/bins/torii-tokens/client/shared/src/client.ts index a82d378..3b5de49 100644 --- a/bins/torii-tokens/client/shared/src/client.ts +++ b/bins/torii-tokens/client/shared/src/client.ts @@ -21,13 +21,95 @@ export interface TokensClient { getVersion: () => Promise<{ version: string; buildTime: string }>; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function schemaFromTypeUrl(typeUrl: string): MessageSchema | undefined { + const fullTypeName = typeUrl.split("/").pop() ?? ""; + const shortTypeName = fullTypeName.split(".").pop() ?? ""; + return schemas[fullTypeName] ?? schemas[shortTypeName]; +} + +function mapFieldsWithSchema(value: unknown, schema: MessageSchema | undefined): unknown { + if (!schema || !isRecord(value)) { + return value; + } + + const numberToName: Record = {}; + for (const [fieldName, fieldSchema] of Object.entries(schema.fields)) { + numberToName[fieldSchema.number] = fieldName; + } + + const result: Record = {}; + for (const [key, fieldValue] of Object.entries(value)) { + const match = key.match(/^f(\d+)$/); + if (!match) { + result[key] = fieldValue; + continue; + } + + const fieldNum = Number(match[1]); + const fieldName = numberToName[fieldNum] ?? key; + result[fieldName] = fieldValue; + } + + return result; +} + +function normalizeAnyData(data: unknown): unknown { + if (!isRecord(data)) { + return data; + } + + // Already normalized Any payload + if (typeof data.typeUrl === "string") { + const schema = schemaFromTypeUrl(data.typeUrl); + return { + ...data, + value: mapFieldsWithSchema(data.value, schema), + }; + } + + // Raw Any payload { f1: type_url, f2: value } + if (typeof data.f1 === "string" && "f2" in data) { + const typeUrl = data.f1; + const schema = schemaFromTypeUrl(typeUrl); + return { + typeUrl, + value: mapFieldsWithSchema(data.f2, schema), + }; + } + + return data; +} + +function normalizeTopicUpdate(update: unknown): unknown { + if (!isRecord(update)) { + return update; + } + + return { + ...update, + data: normalizeAnyData(update.data), + }; +} + export function createTokensClient(url: string = SERVER_URL): TokensClient { setSchemaRegistry(schemas); const baseClient = new ToriiClient(url, {}); const transport = new GrpcTransport(url); return { - subscribeTopics: baseClient.subscribeTopics.bind(baseClient), + subscribeTopics: async (clientId, topics, onUpdate, onError, onConnected) => { + return baseClient.subscribeTopics( + clientId, + topics, + (update) => onUpdate(normalizeTopicUpdate(update)), + onError, + onConnected + ); + }, disconnect: baseClient.disconnect.bind(baseClient), getVersion: baseClient.getVersion.bind(baseClient), call: >( diff --git a/bins/torii-tokens/client/shared/src/index.ts b/bins/torii-tokens/client/shared/src/index.ts index 1283550..1839671 100644 --- a/bins/torii-tokens/client/shared/src/index.ts +++ b/bins/torii-tokens/client/shared/src/index.ts @@ -7,25 +7,43 @@ export { base64ToHex, formatU256, formatBigInt, + formatBigIntWithDecimals, formatTimestamp, truncateAddress, + getContractExplorerUrl, getUpdateTypeName, generateClientId, } from "./utils"; export { getErc20Balance, + getErc20Balances, + getErc20BalancesPage, + getErc20BalancesForWallet, getErc20Transfers, + getErc20TransfersPage, getErc20Stats, + getErc20TokenMetadata, + getErc20TokenMetadataPage, getErc721Stats, getErc721Transfers, + getErc721TransfersPage, + getErc721TokenMetadata, + getErc721TokenMetadataPage, getErc1155Balance, getErc1155Transfers, + getErc1155TransfersPage, getErc1155Stats, + getErc1155TokenMetadata, + getErc1155TokenMetadataPage, } from "./queries"; export type { TokenQuery, Erc1155TokenQuery, + TransferCursorResult, + PageResult, + TokenMetadataResult, BalanceResult, + TokenBalanceResult, TransferResult, Erc1155TransferResult, StatsResult, diff --git a/bins/torii-tokens/client/shared/src/queries.ts b/bins/torii-tokens/client/shared/src/queries.ts index ce5e546..6d22da2 100644 --- a/bins/torii-tokens/client/shared/src/queries.ts +++ b/bins/torii-tokens/client/shared/src/queries.ts @@ -4,11 +4,15 @@ import { Erc20GetStatsRequest, Erc20GetStatsResponse, Erc20GetTransfersRequest, Erc20GetTransfersResponse, Erc20GetBalanceRequest, Erc20GetBalanceResponse, + Erc20GetBalancesRequest, Erc20GetBalancesResponse, + Erc20GetTokenMetadataRequest, Erc20GetTokenMetadataResponse, Erc721GetStatsRequest, Erc721GetStatsResponse, Erc721GetTransfersRequest, Erc721GetTransfersResponse, + Erc721GetTokenMetadataRequest, Erc721GetTokenMetadataResponse, Erc1155GetStatsRequest, Erc1155GetStatsResponse, Erc1155GetTransfersRequest, Erc1155GetTransfersResponse, Erc1155GetBalanceRequest, Erc1155GetBalanceResponse, + Erc1155GetTokenMetadataRequest, Erc1155GetTokenMetadataResponse, } from "./schemas"; export interface TokenQuery { @@ -20,16 +24,34 @@ export interface Erc1155TokenQuery extends TokenQuery { tokenId: string; } +export interface TokenMetadataResult { + token: string; + name?: string; + symbol?: string; + decimals?: number; + totalSupply?: string; +} + export interface BalanceResult { balance: string; balanceRaw: string; lastBlock: number; } +export interface TokenBalanceResult { + token: string; + wallet?: string; + symbol?: string; + balance: string; + balanceRaw: string; + lastBlock: number; +} + export interface TransferResult { token: string; from: string; to: string; + value?: string; amount?: string; blockNumber: number; txHash: string; @@ -51,6 +73,41 @@ export interface StatsResult { latestBlock: number; } +export interface TransferCursorResult { + blockNumber: number; + id: number; +} + +export interface PageResult { + items: TItem[]; + nextCursor?: TCursor; +} + +const DEFAULT_PAGE_LIMIT = 100; + +async function getErc20BalanceWithDecimals( + client: TokensClient, + contractAddress: string, + wallet: string, + decimals?: number +): Promise { + const response = await client.call( + "/torii.sinks.erc20.Erc20/GetBalance", + { + token: hexToBytes(contractAddress), + wallet: hexToBytes(wallet), + }, + Erc20GetBalanceRequest, + Erc20GetBalanceResponse + ); + + return { + balance: formatU256(response.balance as string | Uint8Array | undefined, decimals), + balanceRaw: typeof response.balance === "string" ? response.balance : "", + lastBlock: Number(response.lastBlock) || 0, + }; +} + export async function getErc20Stats( client: TokensClient ): Promise { @@ -105,6 +162,57 @@ export async function getErc1155Stats( }; } +export async function getErc20TokenMetadataPage( + client: TokensClient, + options?: { contractAddress?: string; cursor?: string; limit?: number } +): Promise> { + const contractAddress = options?.contractAddress; + const cursor = options?.cursor; + const limit = options?.limit ?? DEFAULT_PAGE_LIMIT; + const request: Record = {}; + if (contractAddress) { + request.token = hexToBytes(contractAddress); + } + if (cursor) { + request.cursor = hexToBytes(cursor); + } + request.limit = limit; + + const response = await client.call( + "/torii.sinks.erc20.Erc20/GetTokenMetadata", + request, + Erc20GetTokenMetadataRequest, + Erc20GetTokenMetadataResponse + ); + + const tokens = response.tokens; + const list = Array.isArray(tokens) ? tokens : tokens ? [tokens] : []; + + const items = list.map((t: Record) => ({ + token: bytesToHex(t.token as string | Uint8Array | undefined), + name: t.name as string | undefined, + symbol: t.symbol as string | undefined, + decimals: t.decimals != null ? Number(t.decimals) : undefined, + })); + + const nextCursor = response.nextCursor + ? bytesToHex(response.nextCursor as string | Uint8Array | undefined) + : undefined; + + return { items, nextCursor }; +} + +export async function getErc20TokenMetadata( + client: TokensClient, + contractAddress?: string +): Promise { + const page = await getErc20TokenMetadataPage(client, { + contractAddress, + limit: DEFAULT_PAGE_LIMIT, + }); + return page.items; +} + export async function getErc20Balance( client: TokensClient, query: TokenQuery @@ -113,20 +221,87 @@ export async function getErc20Balance( return null; } + // Fetch decimals from token metadata + const metadata = await getErc20TokenMetadata(client, query.contractAddress); + const decimals = metadata[0]?.decimals; + + return getErc20BalanceWithDecimals( + client, + query.contractAddress, + query.wallet, + decimals + ); +} + +export async function getErc20BalancesForWallet( + client: TokensClient, + wallet: string, + contractAddress?: string +): Promise { + return getErc20Balances(client, { + contractAddress: contractAddress ?? "", + wallet, + }); +} + +export async function getErc20Balances( + client: TokensClient, + query: TokenQuery +): Promise { + const page = await getErc20BalancesPage(client, { ...query, limit: DEFAULT_PAGE_LIMIT }); + return page.items; +} + +export async function getErc20BalancesPage( + client: TokensClient, + query: TokenQuery & { cursor?: number; limit?: number } +): Promise> { + const hasContract = !!query.contractAddress; + const hasWallet = !!query.wallet; + + if (!hasContract && !hasWallet) { + return { items: [] }; + } + + let metaByToken = new Map(); + if (hasContract) { + const metadata = await getErc20TokenMetadata(client, query.contractAddress); + metaByToken = new Map(metadata.map((m) => [m.token, m])); + } + + const request: Record = { limit: query.limit ?? DEFAULT_PAGE_LIMIT }; + if (hasContract) request.token = hexToBytes(query.contractAddress); + if (hasWallet) request.wallet = hexToBytes(query.wallet); + if (query.cursor !== undefined) request.cursor = query.cursor; + const response = await client.call( - "/torii.sinks.erc20.Erc20/GetBalance", - { - token: hexToBytes(query.contractAddress), - wallet: hexToBytes(query.wallet), - }, - Erc20GetBalanceRequest, - Erc20GetBalanceResponse + "/torii.sinks.erc20.Erc20/GetBalances", + request, + Erc20GetBalancesRequest, + Erc20GetBalancesResponse ); + const balances = response.balances; + const rows = Array.isArray(balances) ? balances : balances ? [balances] : []; + + const items = rows.map((row) => { + const r = row as Record; + const token = bytesToHex(r.token as string | Uint8Array | undefined); + const wallet = bytesToHex(r.wallet as string | Uint8Array | undefined); + const meta = metaByToken.get(token); + return { + token, + wallet, + symbol: meta?.symbol, + balance: formatU256(r.balance as string | Uint8Array | undefined, meta?.decimals), + balanceRaw: typeof r.balance === "string" ? r.balance : "", + lastBlock: Number(r.lastBlock ?? 0), + }; + }); + return { - balance: formatU256(response.balance as string | Uint8Array | undefined), - balanceRaw: typeof response.balance === "string" ? response.balance : "", - lastBlock: Number(response.lastBlock) || 0, + items, + nextCursor: response.nextCursor != null ? Number(response.nextCursor) : undefined, }; } @@ -135,13 +310,36 @@ export async function getErc20Transfers( query: TokenQuery, limit = 50 ): Promise { + const page = await getErc20TransfersPage(client, { ...query, limit }); + return page.items; +} + +export async function getErc20TransfersPage( + client: TokensClient, + query: TokenQuery & { cursor?: TransferCursorResult; limit?: number } +): Promise> { const filter: Record = {}; if (query.wallet) filter.wallet = hexToBytes(query.wallet); if (query.contractAddress) filter.tokens = [hexToBytes(query.contractAddress)]; + // Fetch all token metadata to build a decimals lookup + const allMetadata = await getErc20TokenMetadata(client, query.contractAddress || undefined); + const decimalsMap = new Map(); + for (const m of allMetadata) { + if (m.decimals != null) { + decimalsMap.set(m.token, m.decimals); + } + } + const response = await client.call( "/torii.sinks.erc20.Erc20/GetTransfers", - { filter, limit }, + { + filter, + limit: query.limit ?? DEFAULT_PAGE_LIMIT, + ...(query.cursor + ? { cursor: { blockNumber: query.cursor.blockNumber, id: query.cursor.id } } + : {}), + }, Erc20GetTransfersRequest, Erc20GetTransfersResponse ); @@ -149,15 +347,29 @@ export async function getErc20Transfers( const transfers = response.transfers; const list = Array.isArray(transfers) ? transfers : transfers ? [transfers] : []; - return list.map((t: Record) => ({ - token: bytesToHex(t.token as string | Uint8Array | undefined), - from: bytesToHex(t.from as string | Uint8Array | undefined), - to: bytesToHex(t.to as string | Uint8Array | undefined), - amount: formatU256(t.amount as string | Uint8Array | undefined), - blockNumber: Number(t.blockNumber ?? 0), - txHash: bytesToHex(t.txHash as string | Uint8Array | undefined), - timestamp: Number(t.timestamp ?? 0), - })); + const items = list.map((t: Record) => { + const token = bytesToHex(t.token as string | Uint8Array | undefined); + const decimals = decimalsMap.get(token); + return { + token, + from: bytesToHex(t.from as string | Uint8Array | undefined), + to: bytesToHex(t.to as string | Uint8Array | undefined), + amount: formatU256(t.amount as string | Uint8Array | undefined, decimals), + blockNumber: Number(t.blockNumber ?? 0), + txHash: bytesToHex(t.txHash as string | Uint8Array | undefined), + timestamp: Number(t.timestamp ?? 0), + }; + }); + + const next = response.nextCursor as Record | undefined; + const nextCursor = next + ? { + blockNumber: Number(next.blockNumber ?? 0), + id: Number(next.id ?? 0), + } + : undefined; + + return { items, nextCursor }; } export async function getErc721Transfers( @@ -165,13 +377,27 @@ export async function getErc721Transfers( query: TokenQuery, limit = 50 ): Promise { + const page = await getErc721TransfersPage(client, { ...query, limit }); + return page.items; +} + +export async function getErc721TransfersPage( + client: TokensClient, + query: TokenQuery & { cursor?: TransferCursorResult; limit?: number } +): Promise> { const filter: Record = {}; if (query.wallet) filter.wallet = hexToBytes(query.wallet); if (query.contractAddress) filter.tokens = [hexToBytes(query.contractAddress)]; const response = await client.call( "/torii.sinks.erc721.Erc721/GetTransfers", - { filter, limit }, + { + filter, + limit: query.limit ?? DEFAULT_PAGE_LIMIT, + ...(query.cursor + ? { cursor: { blockNumber: query.cursor.blockNumber, id: query.cursor.id } } + : {}), + }, Erc721GetTransfersRequest, Erc721GetTransfersResponse ); @@ -179,7 +405,7 @@ export async function getErc721Transfers( const transfers = response.transfers; const list = Array.isArray(transfers) ? transfers : transfers ? [transfers] : []; - return list.map((t: Record) => ({ + const items = list.map((t: Record) => ({ token: bytesToHex(t.token as string | Uint8Array | undefined), tokenId: bytesToHex(t.tokenId as string | Uint8Array | undefined), from: bytesToHex(t.from as string | Uint8Array | undefined), @@ -188,6 +414,118 @@ export async function getErc721Transfers( txHash: bytesToHex(t.txHash as string | Uint8Array | undefined), timestamp: Number(t.timestamp ?? 0), })); + + const next = response.nextCursor as Record | undefined; + const nextCursor = next + ? { + blockNumber: Number(next.blockNumber ?? 0), + id: Number(next.id ?? 0), + } + : undefined; + + return { items, nextCursor }; +} + +export async function getErc721TokenMetadataPage( + client: TokensClient, + options?: { contractAddress?: string; cursor?: string; limit?: number } +): Promise> { + const contractAddress = options?.contractAddress; + const cursor = options?.cursor; + const limit = options?.limit ?? DEFAULT_PAGE_LIMIT; + const request: Record = {}; + if (contractAddress) { + request.token = hexToBytes(contractAddress); + } + if (cursor) { + request.cursor = hexToBytes(cursor); + } + request.limit = limit; + + const response = await client.call( + "/torii.sinks.erc721.Erc721/GetTokenMetadata", + request, + Erc721GetTokenMetadataRequest, + Erc721GetTokenMetadataResponse + ); + + const tokens = response.tokens; + const list = Array.isArray(tokens) ? tokens : tokens ? [tokens] : []; + + const items = list.map((t: Record) => ({ + token: bytesToHex(t.token as string | Uint8Array | undefined), + name: t.name as string | undefined, + symbol: t.symbol as string | undefined, + totalSupply: t.totalSupply ? formatU256(t.totalSupply as string | Uint8Array | undefined) : undefined, + })); + + const nextCursor = response.nextCursor + ? bytesToHex(response.nextCursor as string | Uint8Array | undefined) + : undefined; + + return { items, nextCursor }; +} + +export async function getErc721TokenMetadata( + client: TokensClient, + contractAddress?: string +): Promise { + const page = await getErc721TokenMetadataPage(client, { + contractAddress, + limit: DEFAULT_PAGE_LIMIT, + }); + return page.items; +} + +export async function getErc1155TokenMetadataPage( + client: TokensClient, + options?: { contractAddress?: string; cursor?: string; limit?: number } +): Promise> { + const contractAddress = options?.contractAddress; + const cursor = options?.cursor; + const limit = options?.limit ?? DEFAULT_PAGE_LIMIT; + const request: Record = {}; + if (contractAddress) { + request.token = hexToBytes(contractAddress); + } + if (cursor) { + request.cursor = hexToBytes(cursor); + } + request.limit = limit; + + const response = await client.call( + "/torii.sinks.erc1155.Erc1155/GetTokenMetadata", + request, + Erc1155GetTokenMetadataRequest, + Erc1155GetTokenMetadataResponse + ); + + const tokens = response.tokens; + const list = Array.isArray(tokens) ? tokens : tokens ? [tokens] : []; + + const items = list.map((t: Record) => ({ + token: bytesToHex(t.token as string | Uint8Array | undefined), + name: t.name as string | undefined, + symbol: t.symbol as string | undefined, + totalSupply: t.totalSupply ? formatU256(t.totalSupply as string | Uint8Array | undefined) : undefined, + })); + + const nextCursor = response.nextCursor + ? bytesToHex(response.nextCursor as string | Uint8Array | undefined) + : undefined; + + return { items, nextCursor }; +} + +export async function getErc1155TokenMetadata( + client: TokensClient, + contractAddress?: string +): Promise { + const page = await getErc1155TokenMetadataPage(client, { + contractAddress, + limit: DEFAULT_PAGE_LIMIT, + }); + return page.items; } export async function getErc1155Balance( @@ -217,13 +555,27 @@ export async function getErc1155Transfers( query: TokenQuery, limit = 50 ): Promise { + const page = await getErc1155TransfersPage(client, { ...query, limit }); + return page.items; +} + +export async function getErc1155TransfersPage( + client: TokensClient, + query: TokenQuery & { cursor?: TransferCursorResult; limit?: number } +): Promise> { const filter: Record = {}; if (query.wallet) filter.wallet = hexToBytes(query.wallet); if (query.contractAddress) filter.tokens = [hexToBytes(query.contractAddress)]; const response = await client.call( "/torii.sinks.erc1155.Erc1155/GetTransfers", - { filter, limit }, + { + filter, + limit: query.limit ?? DEFAULT_PAGE_LIMIT, + ...(query.cursor + ? { cursor: { blockNumber: query.cursor.blockNumber, id: query.cursor.id } } + : {}), + }, Erc1155GetTransfersRequest, Erc1155GetTransfersResponse ); @@ -231,16 +583,32 @@ export async function getErc1155Transfers( const transfers = response.transfers; const list = Array.isArray(transfers) ? transfers : transfers ? [transfers] : []; - return list.map((t: Record) => ({ - token: bytesToHex(t.token as string | Uint8Array | undefined), - operator: bytesToHex(t.operator as string | Uint8Array | undefined), - from: bytesToHex(t.from as string | Uint8Array | undefined), - to: bytesToHex(t.to as string | Uint8Array | undefined), - tokenId: bytesToHex(t.tokenId as string | Uint8Array | undefined), - amount: formatU256(t.amount as string | Uint8Array | undefined), - blockNumber: Number(t.blockNumber ?? 0), - txHash: bytesToHex(t.txHash as string | Uint8Array | undefined), - timestamp: Number(t.timestamp ?? 0), - isBatch: Boolean(t.isBatch), - })); + const items = list.map((t: Record) => { + const rawValue = (t.value ?? t.amount) as string | Uint8Array | undefined; + const formattedValue = formatU256(rawValue); + + return { + token: bytesToHex(t.token as string | Uint8Array | undefined), + operator: bytesToHex(t.operator as string | Uint8Array | undefined), + from: bytesToHex(t.from as string | Uint8Array | undefined), + to: bytesToHex(t.to as string | Uint8Array | undefined), + tokenId: bytesToHex(t.tokenId as string | Uint8Array | undefined), + value: formattedValue, + amount: formattedValue, + blockNumber: Number(t.blockNumber ?? 0), + txHash: bytesToHex(t.txHash as string | Uint8Array | undefined), + timestamp: Number(t.timestamp ?? 0), + isBatch: Boolean(t.isBatch), + }; + }); + + const next = response.nextCursor as Record | undefined; + const nextCursor = next + ? { + blockNumber: Number(next.blockNumber ?? 0), + id: Number(next.id ?? 0), + } + : undefined; + + return { items, nextCursor }; } diff --git a/bins/torii-tokens/client/shared/src/schemas.ts b/bins/torii-tokens/client/shared/src/schemas.ts index f11cbd2..58e0095 100644 --- a/bins/torii-tokens/client/shared/src/schemas.ts +++ b/bins/torii-tokens/client/shared/src/schemas.ts @@ -8,6 +8,20 @@ function reg(schema: MessageSchema): MessageSchema { return schema; } +// ===== Torii Core ===== + +export const ToriiTopicUpdate = reg({ + name: "ToriiTopicUpdate", + fullName: "torii.TopicUpdate", + fields: { + topic: { number: 1, type: "string", repeated: false }, + updateType: { number: 2, type: "enum", repeated: false, enumType: "UpdateType" }, + timestamp: { number: 3, type: "int64", repeated: false }, + typeId: { number: 4, type: "string", repeated: false }, + data: { number: 5, type: "message", repeated: false, messageType: "Any" }, + }, +}); + // ===== ERC20 ===== export const Erc20Transfer = reg({ @@ -66,6 +80,25 @@ export const Erc20GetTransfersResponse = reg({ }, }); +export const Erc20GetTokenMetadataRequest = reg({ + name: "Erc20GetTokenMetadataRequest", + fullName: "torii.sinks.erc20.GetTokenMetadataRequest", + fields: { + token: { number: 1, type: "bytes", repeated: false, optional: true }, + cursor: { number: 2, type: "bytes", repeated: false, optional: true }, + limit: { number: 3, type: "uint32", repeated: false }, + }, +}); + +export const Erc20GetTokenMetadataResponse = reg({ + name: "Erc20GetTokenMetadataResponse", + fullName: "torii.sinks.erc20.GetTokenMetadataResponse", + fields: { + tokens: { number: 1, type: "message", repeated: true, messageType: "Erc20TokenMetadataEntry" }, + nextCursor: { number: 2, type: "bytes", repeated: false, optional: true }, + }, +}); + export const Erc20GetBalanceRequest = reg({ name: "Erc20GetBalanceRequest", fullName: "torii.sinks.erc20.GetBalanceRequest", @@ -84,6 +117,37 @@ export const Erc20GetBalanceResponse = reg({ }, }); +export const Erc20BalanceEntry = reg({ + name: "Erc20BalanceEntry", + fullName: "torii.sinks.erc20.BalanceEntry", + fields: { + token: { number: 1, type: "bytes", repeated: false }, + wallet: { number: 2, type: "bytes", repeated: false }, + balance: { number: 3, type: "bytes", repeated: false }, + lastBlock: { number: 4, type: "uint64", repeated: false }, + }, +}); + +export const Erc20GetBalancesRequest = reg({ + name: "Erc20GetBalancesRequest", + fullName: "torii.sinks.erc20.GetBalancesRequest", + fields: { + token: { number: 1, type: "bytes", repeated: false, optional: true }, + wallet: { number: 2, type: "bytes", repeated: false, optional: true }, + cursor: { number: 3, type: "int64", repeated: false, optional: true }, + limit: { number: 4, type: "uint32", repeated: false }, + }, +}); + +export const Erc20GetBalancesResponse = reg({ + name: "Erc20GetBalancesResponse", + fullName: "torii.sinks.erc20.GetBalancesResponse", + fields: { + balances: { number: 1, type: "message", repeated: true, messageType: "Erc20BalanceEntry" }, + nextCursor: { number: 2, type: "int64", repeated: false, optional: true }, + }, +}); + export const Erc20GetStatsRequest = reg({ name: "Erc20GetStatsRequest", fullName: "torii.sinks.erc20.GetStatsRequest", @@ -101,6 +165,17 @@ export const Erc20GetStatsResponse = reg({ }, }); +export const Erc20TokenMetadataEntry = reg({ + name: "Erc20TokenMetadataEntry", + fullName: "torii.sinks.erc20.TokenMetadataEntry", + fields: { + token: { number: 1, type: "bytes", repeated: false }, + name: { number: 2, type: "string", repeated: false, optional: true }, + symbol: { number: 3, type: "string", repeated: false, optional: true }, + decimals: { number: 4, type: "uint32", repeated: false, optional: true }, + }, +}); + // ===== ERC721 ===== export const Erc721NftTransfer = reg({ @@ -159,6 +234,36 @@ export const Erc721GetTransfersResponse = reg({ }, }); +export const Erc721TokenMetadataEntry = reg({ + name: "Erc721TokenMetadataEntry", + fullName: "torii.sinks.erc721.TokenMetadataEntry", + fields: { + token: { number: 1, type: "bytes", repeated: false }, + name: { number: 2, type: "string", repeated: false, optional: true }, + symbol: { number: 3, type: "string", repeated: false, optional: true }, + totalSupply: { number: 4, type: "bytes", repeated: false, optional: true }, + }, +}); + +export const Erc721GetTokenMetadataRequest = reg({ + name: "Erc721GetTokenMetadataRequest", + fullName: "torii.sinks.erc721.GetTokenMetadataRequest", + fields: { + token: { number: 1, type: "bytes", repeated: false, optional: true }, + cursor: { number: 2, type: "bytes", repeated: false, optional: true }, + limit: { number: 3, type: "uint32", repeated: false }, + }, +}); + +export const Erc721GetTokenMetadataResponse = reg({ + name: "Erc721GetTokenMetadataResponse", + fullName: "torii.sinks.erc721.GetTokenMetadataResponse", + fields: { + tokens: { number: 1, type: "message", repeated: true, messageType: "Erc721TokenMetadataEntry" }, + nextCursor: { number: 2, type: "bytes", repeated: false, optional: true }, + }, +}); + export const Erc721GetStatsRequest = reg({ name: "Erc721GetStatsRequest", fullName: "torii.sinks.erc721.GetStatsRequest", @@ -239,6 +344,36 @@ export const Erc1155GetTransfersResponse = reg({ }, }); +export const Erc1155TokenMetadataEntry = reg({ + name: "Erc1155TokenMetadataEntry", + fullName: "torii.sinks.erc1155.TokenMetadataEntry", + fields: { + token: { number: 1, type: "bytes", repeated: false }, + name: { number: 2, type: "string", repeated: false, optional: true }, + symbol: { number: 3, type: "string", repeated: false, optional: true }, + totalSupply: { number: 4, type: "bytes", repeated: false, optional: true }, + }, +}); + +export const Erc1155GetTokenMetadataRequest = reg({ + name: "Erc1155GetTokenMetadataRequest", + fullName: "torii.sinks.erc1155.GetTokenMetadataRequest", + fields: { + token: { number: 1, type: "bytes", repeated: false, optional: true }, + cursor: { number: 2, type: "bytes", repeated: false, optional: true }, + limit: { number: 3, type: "uint32", repeated: false }, + }, +}); + +export const Erc1155GetTokenMetadataResponse = reg({ + name: "Erc1155GetTokenMetadataResponse", + fullName: "torii.sinks.erc1155.GetTokenMetadataResponse", + fields: { + tokens: { number: 1, type: "message", repeated: true, messageType: "Erc1155TokenMetadataEntry" }, + nextCursor: { number: 2, type: "bytes", repeated: false, optional: true }, + }, +}); + export const Erc1155GetBalanceRequest = reg({ name: "Erc1155GetBalanceRequest", fullName: "torii.sinks.erc1155.GetBalanceRequest", diff --git a/bins/torii-tokens/client/shared/src/utils.ts b/bins/torii-tokens/client/shared/src/utils.ts index bda89b3..60f2605 100644 --- a/bins/torii-tokens/client/shared/src/utils.ts +++ b/bins/torii-tokens/client/shared/src/utils.ts @@ -19,6 +19,18 @@ export function hexToBase64(hex: string): string { return btoa(String.fromCharCode(...bytes)); } +/** + * Decode a base64 string into a Uint8Array + */ +function base64Decode(b64: string): Uint8Array { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + bytes[i] = bin.charCodeAt(i); + } + return bytes; +} + /** * Convert bytes (Uint8Array or base64 string) to hex string for display */ @@ -29,7 +41,7 @@ export function bytesToHex(value: Uint8Array | string | undefined): string { if (value instanceof Uint8Array) { bytes = value; } else { - bytes = new TextEncoder().encode(value); + bytes = base64Decode(value); } let hex = Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) @@ -52,13 +64,16 @@ export function base64ToHex(b64: string): string { * Format U256 bytes to readable string * U256 values are base64-encoded big-endian bytes */ -export function formatU256(bytes: string | Uint8Array | undefined): string { +export function formatU256( + bytes: string | Uint8Array | undefined, + decimals?: number +): string { if (!bytes) return "0"; try { let data: Uint8Array; if (typeof bytes === "string") { - data = new TextEncoder().encode(bytes); + data = base64Decode(bytes); } else { data = bytes; } @@ -69,12 +84,42 @@ export function formatU256(bytes: string | Uint8Array | undefined): string { hex = hex.replace(/^0+/, "") || "0"; const value = BigInt("0x" + hex); + + if (decimals != null && decimals > 0) { + return formatBigIntWithDecimals(value, decimals); + } + return formatBigInt(value); } catch { return String(bytes); } } +/** + * Format a BigInt raw amount using token decimals. + * + * Example: formatBigIntWithDecimals(1000000000000000000n, 18) → "1" + * Example: formatBigIntWithDecimals(1500000000000000000n, 18) → "1.5" + */ +export function formatBigIntWithDecimals( + value: bigint, + decimals: number +): string { + const divisor = 10n ** BigInt(decimals); + const whole = value / divisor; + const remainder = value % divisor; + + if (remainder === 0n) { + return formatBigInt(whole); + } + + const fracStr = remainder + .toString() + .padStart(decimals, "0") + .replace(/0+$/, ""); + return `${formatBigInt(whole)}.${fracStr}`; +} + /** * Format BigInt with thousand separators */ @@ -100,6 +145,13 @@ export function truncateAddress(address: string, chars = 6): string { return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`; } +/** + * Build a Cartridge explorer URL for a contract/address. + */ +export function getContractExplorerUrl(address: string): string { + return `https://explorer.cartridge.gg/contract/${address}`; +} + /** * Get update type name from enum value */ diff --git a/bins/torii-tokens/client/shared/styles/shared.css b/bins/torii-tokens/client/shared/styles/shared.css index 039be16..c5daebd 100644 --- a/bins/torii-tokens/client/shared/styles/shared.css +++ b/bins/torii-tokens/client/shared/styles/shared.css @@ -48,11 +48,18 @@ body { } .container { - max-width: 1400px; + width: 70%; + min-width: 320px; margin: 0 auto; padding: 2rem; } +@media (max-width: 768px) { + .container { + width: 100%; + } +} + header { text-align: center; margin-bottom: 2rem; @@ -85,6 +92,13 @@ header .subtitle { border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 2rem; + min-width: 0; + transition: box-shadow 180ms ease, transform 180ms ease; +} + +.panel:hover { + box-shadow: var(--shadow-md); + transform: translateY(-1px); } .panel.full-width { @@ -161,13 +175,14 @@ header .subtitle { border-radius: var(--radius-sm); font-size: 1rem; cursor: pointer; - transition: all 0.2s; + transition: background-color 150ms ease, transform 150ms ease, opacity 150ms ease; background: #e0e0e0; color: var(--color-text); } .btn:hover:not(:disabled) { background: #d0d0d0; + transform: translateY(-1px); } .btn:disabled { @@ -225,6 +240,41 @@ header .subtitle { overflow-y: auto; border: 1px solid var(--color-border-light); border-radius: var(--radius-sm); + background: var(--color-surface); + transition: opacity 180ms ease, filter 180ms ease; +} + +.table-shell { + position: relative; +} + +.table-shell[aria-busy="true"] .table-container { + opacity: 0.65; + filter: saturate(0.9); +} + +.table-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 0.65rem; + background: rgba(255, 255, 255, 0.7); + border-radius: var(--radius-sm); + pointer-events: none; +} + +.table-overlay-spinner { + width: 1rem; + height: 1rem; + border: 2px solid rgba(0, 102, 204, 0.25); + border-top-color: var(--color-primary); +} + +.table-overlay-text { + color: var(--color-text-secondary); + font-size: 0.85rem; } table { @@ -264,6 +314,15 @@ tbody tr:hover { color: var(--color-primary); } +.address a { + color: inherit; + text-decoration: none; +} + +.address a:hover { + text-decoration: underline; +} + .amount { font-family: var(--font-mono); font-weight: 500; @@ -451,6 +510,7 @@ input:focus { .results-panel { border-top: 4px solid #38ef7d; + margin-bottom: 1rem; } .results-panel h3 { @@ -493,6 +553,7 @@ input:focus { background: var(--color-bg); border-radius: var(--radius-sm); margin-bottom: 1rem; + transition: background-color 180ms ease; } .results-section:last-child { @@ -554,3 +615,12 @@ input:focus { padding: 2rem; color: var(--color-text-muted); } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/bins/torii-tokens/src/main.rs b/bins/torii-tokens/src/main.rs index fc04a40..70ca39d 100644 --- a/bins/torii-tokens/src/main.rs +++ b/bins/torii-tokens/src/main.rs @@ -63,6 +63,8 @@ use torii_erc721::{ FILE_DESCRIPTOR_SET as ERC721_DESCRIPTOR_SET, }; +use torii_common::{MetadataFetcher, TokenUriService}; + // Import from ERC1155 library crate use torii_erc1155::proto::erc1155_server::Erc1155Server; use torii_erc1155::{ @@ -312,8 +314,22 @@ async fn run_indexer(config: Config) -> Result<()> { let decoder = Arc::new(Erc721Decoder::new()); torii_config = torii_config.add_decoder(decoder); + // Spawn token URI service for async metadata fetching + let metadata_fetcher = Arc::new(MetadataFetcher::new(provider.clone())); + let (token_uri_sender, _token_uri_service) = TokenUriService::spawn( + metadata_fetcher, + storage.clone(), + 1024, // buffer size + 8, // max concurrent fetches + ); + let grpc_service = Erc721Service::new(storage.clone()); - let sink = Box::new(Erc721Sink::new(storage).with_grpc_service(grpc_service.clone())); + let sink = Box::new( + Erc721Sink::new(storage) + .with_grpc_service(grpc_service.clone()) + .with_metadata_fetching(provider.clone()) + .with_token_uri_sender(token_uri_sender), + ); torii_config = torii_config.add_sink_boxed(sink); erc721_grpc_service = Some(grpc_service); @@ -345,11 +361,17 @@ async fn run_indexer(config: Config) -> Result<()> { let decoder = Arc::new(Erc1155Decoder::new()); torii_config = torii_config.add_decoder(decoder); + // Spawn token URI service for ERC1155 + let erc1155_fetcher = Arc::new(MetadataFetcher::new(provider.clone())); + let (erc1155_uri_sender, _erc1155_uri_service) = + TokenUriService::spawn(erc1155_fetcher, storage.clone(), 1024, 8); + let grpc_service = Erc1155Service::new(storage.clone()); let sink = Box::new( Erc1155Sink::new(storage) .with_grpc_service(grpc_service.clone()) - .with_balance_tracking(provider.clone()), + .with_balance_tracking(provider.clone()) + .with_token_uri_sender(erc1155_uri_sender), ); torii_config = torii_config.add_sink_boxed(sink); diff --git a/bun.lock b/bun.lock index f446ef5..6b0d9a9 100644 --- a/bun.lock +++ b/bun.lock @@ -283,7 +283,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -309,7 +309,7 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "caniuse-lite": ["caniuse-lite@1.0.30001768", "", {}, "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA=="], @@ -439,8 +439,10 @@ "client/@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], - "client/@toriijs/sdk": ["@toriijs/sdk@file:torii.js", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" }, "devDependencies": { "@types/bun": "latest", "typescript": "^5.9.3" }, "bin": { "torii.js": "./bin/torii.js" } }], + "@torii-tokens/shared/@toriijs/sdk/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "client/@sveltejs/vite-plugin-svelte/@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="], + + "@torii-tokens/shared/@toriijs/sdk/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], } } diff --git a/crates/torii-common/Cargo.toml b/crates/torii-common/Cargo.toml index 5f5fd8b..85e202f 100644 --- a/crates/torii-common/Cargo.toml +++ b/crates/torii-common/Cargo.toml @@ -8,6 +8,12 @@ description = "Common utilities for Torii token indexers" starknet = "0.17" anyhow = "1.0" tracing = "0.1" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json"] } +base64 = "0.22" +urlencoding = "2" +async-trait = "0.1" +serde_json = "1.0" [lints] workspace = true diff --git a/crates/torii-common/src/lib.rs b/crates/torii-common/src/lib.rs index 77d4aba..8f2289c 100644 --- a/crates/torii-common/src/lib.rs +++ b/crates/torii-common/src/lib.rs @@ -1,9 +1,18 @@ //! Common utilities for Torii token indexers //! -//! Provides efficient conversions between Starknet types and storage/wire formats. +//! Provides efficient conversions between Starknet types and storage/wire formats, +//! and shared helpers like token metadata fetching. + +pub mod metadata; +pub mod token_uri; use starknet::core::types::{Felt, U256}; +pub use metadata::{MetadataFetcher, TokenMetadata}; +pub use token_uri::{ + TokenStandard, TokenUriRequest, TokenUriResult, TokenUriSender, TokenUriService, TokenUriStore, +}; + // ===== Felt conversions ===== /// Convert Felt to 32-byte BLOB for storage (big-endian) diff --git a/crates/torii-common/src/metadata.rs b/crates/torii-common/src/metadata.rs new file mode 100644 index 0000000..681a142 --- /dev/null +++ b/crates/torii-common/src/metadata.rs @@ -0,0 +1,368 @@ +//! Token metadata fetcher for ERC20/ERC721/ERC1155 contracts. +//! +//! Fetches `name()`, `symbol()`, `decimals()`, and `token_uri(token_id)` by +//! making `starknet_call` requests. Handles both snake_case and camelCase +//! selectors, felt-encoded strings and ByteArray returns. + +use starknet::core::codec::Decode; +use starknet::core::types::{BlockId, BlockTag, ByteArray, Felt, FunctionCall, U256}; +use starknet::core::utils::parse_cairo_short_string; +use starknet::macros::selector; +use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; +use starknet::providers::Provider; +use std::sync::Arc; + +/// Token metadata (common fields for all ERC standards) +#[derive(Debug, Clone, Default)] +pub struct TokenMetadata { + /// Token name (e.g. "Ether") + pub name: Option, + /// Token symbol (e.g. "ETH") + pub symbol: Option, + /// Token decimals (e.g. 18). Only meaningful for ERC20. + pub decimals: Option, + /// Total supply as U256 string. Meaningful for ERC721/ERC1155. + pub total_supply: Option, +} + +/// Fetches token metadata from on-chain contracts via RPC calls. +pub struct MetadataFetcher { + provider: Arc>, +} + +impl MetadataFetcher { + pub fn new(provider: Arc>) -> Self { + Self { provider } + } + + /// Fetch metadata for an ERC20 token (name, symbol, decimals). + pub async fn fetch_erc20_metadata(&self, contract: Felt) -> TokenMetadata { + let name = self.fetch_string(contract, "name").await; + let symbol = self.fetch_string(contract, "symbol").await; + let decimals = self.fetch_decimals(contract).await; + + TokenMetadata { + name, + symbol, + decimals, + total_supply: None, + } + } + + /// Fetch metadata for an ERC721 contract (name, symbol, totalSupply). + pub async fn fetch_erc721_metadata(&self, contract: Felt) -> TokenMetadata { + let name = self.fetch_string(contract, "name").await; + let symbol = self.fetch_string(contract, "symbol").await; + let total_supply = self.fetch_total_supply(contract).await; + + TokenMetadata { + name, + symbol, + decimals: None, + total_supply, + } + } + + /// Fetch metadata for an ERC1155 contract (name, symbol, totalSupply if available). + pub async fn fetch_erc1155_metadata(&self, contract: Felt) -> TokenMetadata { + let name = self.fetch_string(contract, "name").await; + let symbol = self.fetch_string(contract, "symbol").await; + let total_supply = self.fetch_total_supply(contract).await; + + TokenMetadata { + name, + symbol, + decimals: None, + total_supply, + } + } + + /// Fetch `token_uri(token_id)` or `tokenURI(token_id)` for a specific NFT. + /// + /// Returns None if the call fails or returns empty data. + pub async fn fetch_token_uri(&self, contract: Felt, token_id: Felt) -> Option { + // Try snake_case first, then camelCase + for sel in [selector!("token_uri"), selector!("tokenURI")] { + let call = FunctionCall { + contract_address: contract, + entry_point_selector: sel, + calldata: vec![token_id, Felt::ZERO], // u256: (low, high) + }; + + if let Ok(result) = self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + if let Some(s) = Self::decode_string_result(&result) { + if !s.is_empty() { + return Some(s); + } + } + } + } + + // Try with single felt arg (some contracts don't use u256 for token_id) + for sel in [selector!("token_uri"), selector!("tokenURI")] { + let call = FunctionCall { + contract_address: contract, + entry_point_selector: sel, + calldata: vec![token_id], + }; + + if let Ok(result) = self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + if let Some(s) = Self::decode_string_result(&result) { + if !s.is_empty() { + return Some(s); + } + } + } + } + + None + } + + /// Fetch `uri(token_id)` for ERC1155 tokens. + pub async fn fetch_uri(&self, contract: Felt, token_id: Felt) -> Option { + // ERC1155 uses `uri(token_id)` — u256 arg + let call = FunctionCall { + contract_address: contract, + entry_point_selector: selector!("uri"), + calldata: vec![token_id, Felt::ZERO], + }; + + if let Ok(result) = self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + if let Some(s) = Self::decode_string_result(&result) { + if !s.is_empty() { + return Some(s); + } + } + } + + // Try single felt arg + let call = FunctionCall { + contract_address: contract, + entry_point_selector: selector!("uri"), + calldata: vec![token_id], + }; + + if let Ok(result) = self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + if let Some(s) = Self::decode_string_result(&result) { + if !s.is_empty() { + return Some(s); + } + } + } + + None + } + + /// Fetch a string value (name or symbol) from a contract. + /// + /// Tries snake_case first, returns None on failure. + async fn fetch_string(&self, contract: Felt, fn_name: &str) -> Option { + let sel = match fn_name { + "name" => selector!("name"), + "symbol" => selector!("symbol"), + _ => return None, + }; + + let call = FunctionCall { + contract_address: contract, + entry_point_selector: sel, + calldata: vec![], + }; + + match self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + Ok(result) => Self::decode_string_result(&result), + Err(e) => { + tracing::debug!( + target: "torii_common::metadata", + contract = %format!("{:#x}", contract), + fn_name = fn_name, + error = %e, + "Failed to fetch string" + ); + None + } + } + } + + /// Fetch `decimals()` from an ERC20 contract. + async fn fetch_decimals(&self, contract: Felt) -> Option { + let call = FunctionCall { + contract_address: contract, + entry_point_selector: selector!("decimals"), + calldata: vec![], + }; + + match self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + Ok(result) => { + if result.is_empty() { + return None; + } + // decimals() returns a single felt (typically u8) + let val: u64 = result[0].try_into().unwrap_or(0); + if val > 255 { + tracing::warn!( + target: "torii_common::metadata", + contract = %format!("{:#x}", contract), + value = val, + "Unexpected decimals value" + ); + return None; + } + Some(val as u8) + } + Err(e) => { + tracing::debug!( + target: "torii_common::metadata", + contract = %format!("{:#x}", contract), + error = %e, + "Failed to fetch decimals" + ); + None + } + } + } + + /// Fetch `total_supply()` or `totalSupply()` from a contract. + async fn fetch_total_supply(&self, contract: Felt) -> Option { + for sel in [selector!("total_supply"), selector!("totalSupply")] { + let call = FunctionCall { + contract_address: contract, + entry_point_selector: sel, + calldata: vec![], + }; + + if let Ok(result) = self + .provider + .call(call, BlockId::Tag(BlockTag::Latest)) + .await + { + if result.is_empty() { + continue; + } + // U256 return: [low, high] or single felt + let low: u128 = result[0].try_into().unwrap_or(0); + return Some(if result.len() == 1 { + U256::from(low) + } else { + let high: u128 = result[1].try_into().unwrap_or(0); + U256::from_words(low, high) + }); + } + } + + None + } + + /// Decode a string result from a contract call. + /// + /// Handles multiple return formats using `starknet` crate utilities: + /// 1. **Single short string** (felt): via `parse_cairo_short_string` + /// 2. **Cairo ByteArray**: via `ByteArray::decode` (the standard Cairo string type) + /// 3. **Legacy array**: `[len, felt1, felt2, ...]` where each felt is a short string segment + fn decode_string_result(result: &[Felt]) -> Option { + if result.is_empty() { + return None; + } + + // Single felt — short string (≤31 chars packed into felt) + if result.len() == 1 { + return parse_cairo_short_string(&result[0]) + .ok() + .filter(|s| !s.is_empty()); + } + + // Try ByteArray format via starknet crate's Decode impl + if let Ok(byte_array) = ByteArray::decode(result) { + if let Ok(s) = String::try_from(byte_array) { + if !s.is_empty() { + return Some(s); + } + } + } + + // Fallback: try as legacy array [len, felt1, felt2, ...] + if result.len() >= 2 { + let array_len: u64 = result[0].try_into().unwrap_or(0); + if array_len > 0 && array_len < 100 && result.len() >= (array_len as usize + 1) { + let mut s = String::with_capacity(array_len as usize * 31); + for felt in &result[1..=array_len as usize] { + if let Ok(chunk) = parse_cairo_short_string(felt) { + s.push_str(&chunk); + } + } + if !s.is_empty() { + return Some(s); + } + } + } + + // Last resort: try first felt as short string + parse_cairo_short_string(&result[0]) + .ok() + .filter(|s| !s.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_short_string() { + // "ETH" = 0x455448 + let felt = Felt::from(0x455448u64); + assert_eq!(parse_cairo_short_string(&felt).unwrap(), "ETH".to_string()); + } + + #[test] + fn test_decode_single_felt_string() { + let result = vec![Felt::from(0x455448u64)]; // "ETH" + assert_eq!( + MetadataFetcher::decode_string_result(&result), + Some("ETH".to_string()) + ); + } + + #[test] + fn test_decode_byte_array() { + // ByteArray: [data_len=0, pending_word="ETH", pending_word_len=3] + let result = vec![ + Felt::from(0u64), // data_len = 0 chunks + Felt::from(0x455448u64), // pending_word = "ETH" + Felt::from(3u64), // pending_word_len = 3 + ]; + assert_eq!( + MetadataFetcher::decode_string_result(&result), + Some("ETH".to_string()) + ); + } + + #[test] + fn test_decode_empty() { + assert_eq!(MetadataFetcher::decode_string_result(&[]), None); + } +} diff --git a/crates/torii-common/src/token_uri.rs b/crates/torii-common/src/token_uri.rs new file mode 100644 index 0000000..3b75e0d --- /dev/null +++ b/crates/torii-common/src/token_uri.rs @@ -0,0 +1,568 @@ +//! Async token URI fetching and caching service. +//! +//! Processes `(contract_address, token_id)` requests via a tokio channel. +//! Deduplicates in-flight tasks — if a new request arrives for the same key, +//! the previous task is cancelled so we always apply the latest value. +//! +//! The service fetches `token_uri(token_id)` (ERC721) or `uri(token_id)` (ERC1155), +//! resolves the JSON metadata, and stores the result via a callback. +//! +//! Metadata resolution is modeled after dojoengine/torii's battle-tested approach: +//! - Retries with exponential backoff for transient errors +//! - Permanent error detection (EntrypointNotFound, ContractNotFound) +//! - Tries multiple selectors: token_uri, tokenURI, uri +//! - ERC1155 `{id}` substitution in URIs +//! - data: URI support (base64 and URL-encoded JSON) +//! - IPFS gateway resolution +//! - JSON sanitization for broken metadata (control chars, unescaped quotes) +//! - Raw JSON fallback for inline metadata + +use starknet::core::types::{Felt, U256}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinHandle; + +use crate::MetadataFetcher; + +// Retry configuration +const INITIAL_BACKOFF: Duration = Duration::from_millis(100); +const MAX_RETRIES: u32 = 5; +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +/// Token standard hint for URI fetching +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenStandard { + Erc721, + Erc1155, +} + +/// A request to fetch/update a token's URI and metadata. +#[derive(Debug, Clone)] +pub struct TokenUriRequest { + /// Contract address + pub contract: Felt, + /// Token ID + pub token_id: U256, + /// Which standard to use for fetching + pub standard: TokenStandard, +} + +/// Dedupe key for in-flight tasks +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct TaskKey { + contract: Felt, + token_id: U256, +} + +/// Result of a token URI fetch +#[derive(Debug, Clone)] +pub struct TokenUriResult { + /// Contract address + pub contract: Felt, + /// Token ID + pub token_id: U256, + /// The raw URI string (e.g. ipfs://..., https://...) + pub uri: Option, + /// Resolved JSON metadata (if URI pointed to JSON) + pub metadata_json: Option, +} + +/// Callback trait for storing fetched token URI results. +#[async_trait::async_trait] +pub trait TokenUriStore: Send + Sync + 'static { + async fn store_token_uri(&self, result: &TokenUriResult) -> anyhow::Result<()>; +} + +/// Handle to send requests to the token URI service. +#[derive(Clone)] +pub struct TokenUriSender { + tx: mpsc::Sender, +} + +impl TokenUriSender { + /// Queue a token URI fetch request. + pub async fn request_update(&self, request: TokenUriRequest) { + if let Err(e) = self.tx.send(request).await { + tracing::warn!( + target: "torii_common::token_uri", + error = %e, + "Failed to send token URI request (channel closed)" + ); + } + } + + /// Queue updates for a batch of token IDs on the same contract. + pub async fn request_batch(&self, contract: Felt, token_ids: &[U256], standard: TokenStandard) { + for &token_id in token_ids { + self.request_update(TokenUriRequest { + contract, + token_id, + standard, + }) + .await; + } + } +} + +/// The background service that processes token URI fetch requests. +pub struct TokenUriService { + handle: JoinHandle<()>, +} + +impl TokenUriService { + /// Spawn the token URI service. + /// + /// Returns a `(TokenUriSender, TokenUriService)` pair. + /// The sender is cheap to clone and can be shared across sinks. + pub fn spawn( + fetcher: Arc, + store: Arc, + buffer_size: usize, + max_concurrent: usize, + ) -> (TokenUriSender, Self) { + let (tx, rx) = mpsc::channel(buffer_size); + let handle = tokio::spawn(Self::run(rx, fetcher, store, max_concurrent)); + let sender = TokenUriSender { tx }; + (sender, Self { handle }) + } + + /// Main processing loop. + async fn run( + mut rx: mpsc::Receiver, + fetcher: Arc, + store: Arc, + max_concurrent: usize, + ) { + let in_flight: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent)); + + while let Some(request) = rx.recv().await { + let key = TaskKey { + contract: request.contract, + token_id: request.token_id, + }; + + let mut tasks = in_flight.lock().await; + + // Cancel previous task for same key + if let Some(old_handle) = tasks.remove(&key) { + old_handle.abort(); + tracing::debug!( + target: "torii_common::token_uri", + contract = %format!("{:#x}", key.contract), + token_id = %key.token_id, + "Cancelled previous fetch (superseded)" + ); + } + + let fetcher = fetcher.clone(); + let store = store.clone(); + let in_flight = in_flight.clone(); + let sem = semaphore.clone(); + let task_key = key.clone(); + + let handle = tokio::spawn(async move { + let _permit = match sem.acquire().await { + Ok(p) => p, + Err(_) => return, + }; + + // Fetch the URI from chain + let uri = fetch_token_uri_with_retry( + &fetcher, + request.contract, + request.token_id, + request.standard, + ) + .await; + + // Apply ERC1155 {id} substitution + let uri = uri.map(|u| { + if request.standard == TokenStandard::Erc1155 { + let token_id_hex = format!("{:064x}", request.token_id); + u.replace("{id}", &token_id_hex) + } else { + u + } + }); + + // Resolve URI to JSON metadata + let metadata_json = if let Some(ref uri_str) = uri { + if uri_str.is_empty() { + None + } else { + resolve_metadata(uri_str).await + } + } else { + None + }; + + let result = TokenUriResult { + contract: request.contract, + token_id: request.token_id, + uri, + metadata_json, + }; + + if let Err(e) = store.store_token_uri(&result).await { + tracing::warn!( + target: "torii_common::token_uri", + contract = %format!("{:#x}", request.contract), + token_id = %request.token_id, + error = %e, + "Failed to store token URI result" + ); + } else { + tracing::debug!( + target: "torii_common::token_uri", + contract = %format!("{:#x}", request.contract), + token_id = %request.token_id, + uri = ?result.uri, + has_json = result.metadata_json.is_some(), + "Stored token URI" + ); + } + + in_flight.lock().await.remove(&task_key); + }); + + tasks.insert(key, handle); + } + + tracing::info!( + target: "torii_common::token_uri", + "Token URI service shutting down" + ); + } + + /// Wait for the service to finish. + pub async fn join(self) { + let _ = self.handle.await; + } + + /// Abort the background task. + pub fn abort(self) { + self.handle.abort(); + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Token URI fetching (from chain) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Fetch token URI with retries, trying multiple selectors. +/// +/// Tries `token_uri`, `tokenURI`, and `uri` selectors in order. +/// Distinguishes permanent errors (EntrypointNotFound) from transient ones. +async fn fetch_token_uri_with_retry( + fetcher: &MetadataFetcher, + contract: Felt, + token_id: U256, + standard: TokenStandard, +) -> Option { + // Use the MetadataFetcher which already tries multiple selectors + let token_id_felt = Felt::from(token_id.low()); + + match standard { + TokenStandard::Erc721 => fetcher.fetch_token_uri(contract, token_id_felt).await, + TokenStandard::Erc1155 => fetcher.fetch_uri(contract, token_id_felt).await, + } +} + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// Metadata resolution (URI → JSON) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// Resolve a URI to JSON metadata string. +/// +/// Handles: +/// - `https://` / `http://` URLs — fetch with retries +/// - `ipfs://` URIs — convert to gateway URL, fetch with retries +/// - `data:application/json;base64,` — decode inline +/// - `data:application/json,` — decode inline (URL-encoded) +/// - Raw JSON — try to parse as-is (fallback) +/// +/// Returns the metadata as a JSON string, or None on failure. +/// Based on dojoengine/torii's battle-tested `fetch_metadata`. +async fn resolve_metadata(uri: &str) -> Option { + let result = match uri { + u if u.starts_with("http://") || u.starts_with("https://") => { + fetch_http_with_retry(u).await + } + u if u.starts_with("ipfs://") => { + let cid = &u[7..]; + // Try multiple IPFS gateways + let gateway_url = format!("https://ipfs.io/ipfs/{cid}"); + fetch_http_with_retry(&gateway_url).await + } + u if u.starts_with("data:") => resolve_data_uri(u), + u => { + // Fallback: try to parse as raw JSON + if let Ok(json) = serde_json::from_str::(u) { + serde_json::to_string(&json).ok() + } else { + tracing::debug!( + target: "torii_common::token_uri", + uri = u, + "Unsupported URI scheme and not valid JSON" + ); + None + } + } + }; + + // Sanitize the JSON if we got a result + result.and_then(|raw| { + let sanitized = sanitize_json_string(&raw); + // Validate it's actually valid JSON + match serde_json::from_str::(&sanitized) { + Ok(json) => serde_json::to_string(&json).ok(), + Err(e) => { + tracing::debug!( + target: "torii_common::token_uri", + error = %e, + "Fetched content is not valid JSON after sanitization" + ); + None + } + } + }) +} + +/// Fetch HTTP content with exponential backoff retries. +async fn fetch_http_with_retry(url: &str) -> Option { + let client = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .ok()?; + + let mut retries = 0; + let mut backoff = INITIAL_BACKOFF; + + loop { + match client.get(url).send().await { + Ok(resp) => { + if !resp.status().is_success() { + tracing::debug!( + target: "torii_common::token_uri", + url = %url, + status = %resp.status(), + "HTTP fetch failed" + ); + return None; + } + return resp.text().await.ok(); + } + Err(e) => { + if retries >= MAX_RETRIES { + tracing::debug!( + target: "torii_common::token_uri", + url = %url, + error = %e, + "HTTP fetch failed after {} retries", + MAX_RETRIES + ); + return None; + } + tracing::debug!( + target: "torii_common::token_uri", + url = %url, + error = %e, + retry = retries + 1, + "HTTP fetch failed, retrying" + ); + tokio::time::sleep(backoff).await; + retries += 1; + backoff *= 2; + } + } + } +} + +/// Resolve a `data:` URI to its content. +/// +/// Supports: +/// - `data:application/json;base64,` +/// - `data:application/json,` +/// - Other data URIs with JSON content +fn resolve_data_uri(uri: &str) -> Option { + // Handle the # issue: https://github.com/servo/rust-url/issues/908 + let uri = uri.replace('#', "%23"); + + if let Some(encoded) = uri.strip_prefix("data:application/json;base64,") { + return base64_decode(encoded); + } + + if let Some(json) = uri.strip_prefix("data:application/json,") { + let decoded = urlencoding::decode(json) + .unwrap_or_else(|_| json.into()) + .into_owned(); + return Some(decoded); + } + + // Generic data URI handling + if let Some(comma_pos) = uri.find(',') { + let header = &uri[5..comma_pos]; // skip "data:" + let body = &uri[comma_pos + 1..]; + + if header.contains("base64") { + return base64_decode(body); + } + + let decoded = urlencoding::decode(body) + .unwrap_or_else(|_| body.into()) + .into_owned(); + return Some(decoded); + } + + tracing::debug!( + target: "torii_common::token_uri", + "Malformed data URI" + ); + None +} + +/// Sanitize a JSON string by escaping unescaped double quotes within string values +/// and filtering out control characters. +/// +/// Ported from dojoengine/torii — handles broken metadata like Loot Survivor NFTs. +fn sanitize_json_string(s: &str) -> String { + // First filter out ASCII control characters (except standard whitespace) + let filtered: String = s + .chars() + .filter(|c| !c.is_ascii_control() || *c == '\n' || *c == '\r' || *c == '\t') + .collect(); + + let mut result = String::with_capacity(filtered.len()); + let mut chars = filtered.chars(); + let mut in_string = false; + let mut backslash_count: usize = 0; + + while let Some(c) = chars.next() { + if !in_string { + if c == '"' { + in_string = true; + backslash_count = 0; + result.push('"'); + } else { + result.push(c); + } + continue; + } + + // Inside a string + if c == '\\' { + backslash_count += 1; + result.push('\\'); + continue; + } + + if c == '"' { + if backslash_count.is_multiple_of(2) { + // Unescaped quote — check if it ends the string or is internal + let mut temp = chars.clone().peekable(); + // Skip whitespace + while let Some(&next) = temp.peek() { + if next.is_whitespace() { + temp.next(); + } else { + break; + } + } + if let Some(&next) = temp.peek() { + if next == ':' || next == ',' || next == '}' || next == ']' { + // End of string value + result.push('"'); + in_string = false; + } else { + // Internal unescaped quote — escape it + result.push_str("\\\""); + } + } else { + // End of input + result.push('"'); + in_string = false; + } + } else { + // Already escaped + result.push('"'); + } + backslash_count = 0; + continue; + } + + result.push(c); + backslash_count = 0; + } + + result +} + +/// Simple base64 decode helper +fn base64_decode(input: &str) -> Option { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(input) + .ok()?; + String::from_utf8(bytes).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_json_string_unescaped_quotes() { + let input = r#"{"name":""Rage Shout" DireWolf"}"#; + let expected = r#"{"name":"\"Rage Shout\" DireWolf"}"#; + assert_eq!(sanitize_json_string(input), expected); + } + + #[test] + fn test_sanitize_json_string_already_escaped() { + let input = r#"{"name":"\"Properly Escaped\" Wolf"}"#; + assert_eq!(sanitize_json_string(input), input); + } + + #[test] + fn test_sanitize_json_string_control_chars() { + let input = "{\x01\"name\": \"test\x02\"}"; + let sanitized = sanitize_json_string(input); + assert!(!sanitized.contains('\x01')); + assert!(!sanitized.contains('\x02')); + } + + #[test] + fn test_resolve_data_uri_base64() { + let uri = "data:application/json;base64,eyJuYW1lIjoidGVzdCJ9"; + let result = resolve_data_uri(uri); + assert_eq!(result, Some(r#"{"name":"test"}"#.to_string())); + } + + #[test] + fn test_resolve_data_uri_url_encoded() { + let uri = "data:application/json,%7B%22name%22%3A%22test%22%7D"; + let result = resolve_data_uri(uri); + assert_eq!(result, Some(r#"{"name":"test"}"#.to_string())); + } + + #[test] + fn test_resolve_data_uri_with_hash() { + // The # character in data URIs is problematic + let uri = "data:application/json;base64,eyJuYW1lIjoiIzEifQ=="; + let result = resolve_data_uri(uri); + assert!(result.is_some()); + } + + #[test] + fn test_erc1155_id_substitution() { + let uri = "https://example.com/token/{id}.json"; + let token_id = U256::from(42u64); + let token_id_hex = format!("{token_id:064x}"); + let result = uri.replace("{id}", &token_id_hex); + assert!(result.contains("000000000000000000000000000000000000000000000000000000000000002a")); + } +} diff --git a/crates/torii-erc1155/proto/erc1155.proto b/crates/torii-erc1155/proto/erc1155.proto index aae3486..295c8fc 100644 --- a/crates/torii-erc1155/proto/erc1155.proto +++ b/crates/torii-erc1155/proto/erc1155.proto @@ -150,6 +150,38 @@ message GetBalanceResponse { uint64 last_block = 2; } +// ===== Token Metadata ===== + +// Request for GetTokenMetadata RPC +message GetTokenMetadataRequest { + // Token contract address (32 bytes). If empty, returns all tokens. + optional bytes token = 1; + // Cursor token (exclusive). Only used when token is not set. + optional bytes cursor = 2; + // Maximum number of entries to return (default: 100, max: 1000). + uint32 limit = 3; +} + +// Token metadata entry +message TokenMetadataEntry { + // Token contract address (32 bytes) + bytes token = 1; + // Token name + optional string name = 2; + // Token symbol + optional string symbol = 3; + // Total supply as U256 (variable length, up to 32 bytes) + optional bytes total_supply = 4; +} + +// Response for GetTokenMetadata RPC +message GetTokenMetadataResponse { + // Token metadata entries + repeated TokenMetadataEntry tokens = 1; + // Cursor for next page (absent if no more results). + optional bytes next_cursor = 2; +} + // ===== Stats ===== // Request for GetStats RPC @@ -177,6 +209,9 @@ service Erc1155 { // Get balance for a specific contract, wallet, and token ID rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse); + // Get token metadata (name, symbol) + rpc GetTokenMetadata(GetTokenMetadataRequest) returns (GetTokenMetadataResponse); + // Subscribe to real-time transfer events with filtering rpc SubscribeTransfers(SubscribeTransfersRequest) returns (stream TransferUpdate); diff --git a/crates/torii-erc1155/src/decoder.rs b/crates/torii-erc1155/src/decoder.rs index 0d55c43..22b7b5e 100644 --- a/crates/torii-erc1155/src/decoder.rs +++ b/crates/torii-erc1155/src/decoder.rs @@ -2,11 +2,14 @@ use anyhow::Result; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt, U256}; +use starknet::core::codec::Decode; +use starknet::core::types::{ByteArray, EmittedEvent, Felt, U256}; +use starknet::core::utils::parse_cairo_short_string; use starknet::macros::selector; use std::any::Any; use std::collections::HashMap; use torii::etl::{Decoder, Envelope, TypedBody}; +use torii_common::bytes_to_u256; /// TransferSingle event from ERC1155 token #[derive(Debug, Clone)] @@ -93,6 +96,30 @@ impl TypedBody for OperatorApproval { } } +/// URI event from ERC1155 token +#[derive(Debug, Clone)] +pub struct UriUpdate { + pub token: Felt, + pub token_id: U256, + pub uri: String, + pub block_number: u64, + pub transaction_hash: Felt, +} + +impl TypedBody for UriUpdate { + fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { + torii::etl::envelope::TypeId::new("erc1155.uri") + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + /// ERC1155 event decoder /// /// Decodes multiple ERC1155 events: @@ -108,6 +135,10 @@ impl Erc1155Decoder { Self } + fn felt_to_u256(felt: Felt) -> U256 { + bytes_to_u256(&felt.to_bytes_be()) + } + /// TransferSingle event selector: sn_keccak("TransferSingle") fn transfer_single_selector() -> Felt { selector!("TransferSingle") @@ -123,6 +154,46 @@ impl Erc1155Decoder { selector!("ApprovalForAll") } + /// URI event selector: sn_keccak("URI") + fn uri_selector() -> Felt { + selector!("URI") + } + + fn decode_string_result(result: &[Felt]) -> Option { + if result.is_empty() { + return None; + } + + if result.len() == 1 { + return parse_cairo_short_string(&result[0]) + .ok() + .filter(|s| !s.is_empty()); + } + + if let Ok(byte_array) = ByteArray::decode(result) { + if let Ok(s) = String::try_from(byte_array) { + if !s.is_empty() { + return Some(s); + } + } + } + + let len: usize = result[0].try_into().unwrap_or(0usize); + if len > 0 && len < 1000 && result.len() > len { + let mut out = String::new(); + for felt in &result[1..=len] { + if let Ok(chunk) = parse_cairo_short_string(felt) { + out.push_str(&chunk); + } + } + if !out.is_empty() { + return Some(out); + } + } + + None + } + /// Decode TransferSingle event into envelope /// /// TransferSingle event signatures: @@ -180,10 +251,8 @@ impl Erc1155Decoder { operator = event.keys[1]; from = event.keys[2]; to = event.keys[3]; - let id_felt: u128 = event.data[0].try_into().unwrap_or(0); - id = U256::from(id_felt); - let value_felt: u128 = event.data[1].try_into().unwrap_or(0); - value = U256::from(value_felt); + id = Self::felt_to_u256(event.data[0]); + value = Self::felt_to_u256(event.data[1]); } else { tracing::warn!( target: "torii_erc1155::decoder", @@ -285,33 +354,70 @@ impl Erc1155Decoder { let ids_len: usize = event.data[data_offset].try_into().unwrap_or(0); data_offset += 1; - let mut ids: Vec = Vec::with_capacity(ids_len); - for i in 0..ids_len { - if data_offset + i * 2 + 1 >= event.data.len() { - break; + fn parse_u256_slice(items: &[Felt], as_pairs: bool) -> Vec { + if as_pairs { + let mut out = Vec::with_capacity(items.len() / 2); + for chunk in items.chunks_exact(2) { + let low: u128 = chunk[0].try_into().unwrap_or(0); + let high: u128 = chunk[1].try_into().unwrap_or(0); + out.push(U256::from_words(low, high)); + } + out + } else { + items + .iter() + .copied() + .map(Erc1155Decoder::felt_to_u256) + .collect() } - let low: u128 = event.data[data_offset + i * 2].try_into().unwrap_or(0); - let high: u128 = event.data[data_offset + i * 2 + 1].try_into().unwrap_or(0); - ids.push(U256::from_words(low, high)); } - data_offset += ids_len * 2; - // Parse values array - if event.data.len() <= data_offset { - return Ok(vec![]); - } + let mut ids: Vec = Vec::new(); + let mut values: Vec = Vec::new(); + let mut parsed = false; + + // Try both pair-based and single-felt array layouts for ids and values. + // Standard Starknet ERC1155 uses U256 pairs, but some contracts emit felt arrays. + for ids_as_pairs in [true, false] { + let id_words = if ids_as_pairs { 2 } else { 1 }; + let ids_end = data_offset.saturating_add(ids_len.saturating_mul(id_words)); + if ids_end > event.data.len() || ids_end >= event.data.len() { + continue; + } - let values_len: usize = event.data[data_offset].try_into().unwrap_or(0); - data_offset += 1; + let candidate_ids = parse_u256_slice(&event.data[data_offset..ids_end], ids_as_pairs); + let values_len: usize = event.data[ids_end].try_into().unwrap_or(0); + let values_start = ids_end + 1; + + for values_as_pairs in [true, false] { + let value_words = if values_as_pairs { 2 } else { 1 }; + let values_end = + values_start.saturating_add(values_len.saturating_mul(value_words)); + if values_end > event.data.len() { + continue; + } + + ids.clone_from(&candidate_ids); + values = parse_u256_slice(&event.data[values_start..values_end], values_as_pairs); + parsed = true; + break; + } - let mut values: Vec = Vec::with_capacity(values_len); - for i in 0..values_len { - if data_offset + i * 2 + 1 >= event.data.len() { + if parsed { break; } - let low: u128 = event.data[data_offset + i * 2].try_into().unwrap_or(0); - let high: u128 = event.data[data_offset + i * 2 + 1].try_into().unwrap_or(0); - values.push(U256::from_words(low, high)); + } + + if !parsed { + tracing::warn!( + target: "torii_erc1155::decoder", + token = %format!("{:#x}", event.from_address), + tx_hash = %format!("{:#x}", event.transaction_hash), + block_number = event.block_number.unwrap_or(0), + ids_len = ids_len, + "Failed to parse ERC1155 TransferBatch ids/values arrays" + ); + return Ok(vec![]); } // Create envelope for each id/value pair @@ -415,6 +521,54 @@ impl Erc1155Decoder { metadata, ))) } + + /// Decode URI event into envelope + /// + /// Common ERC1155 Starknet layout: + /// - keys[0]: URI selector + /// - keys[1]: token id (felt-encoded) + /// - data: URI payload (short string or ByteArray) + async fn decode_uri(&self, event: &EmittedEvent) -> Result> { + if event.keys.len() < 2 || event.data.is_empty() { + return Ok(None); + } + + let token_id = Self::felt_to_u256(event.keys[1]); + let Some(uri) = Self::decode_string_result(&event.data) else { + return Ok(None); + }; + + let uri_update = UriUpdate { + token: event.from_address, + token_id, + uri, + block_number: event.block_number.unwrap_or(0), + transaction_hash: event.transaction_hash, + }; + + let mut metadata = HashMap::new(); + metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); + metadata.insert( + "block_number".to_string(), + event.block_number.unwrap_or(0).to_string(), + ); + metadata.insert( + "tx_hash".to_string(), + format!("{:#x}", event.transaction_hash), + ); + + let envelope_id = format!( + "erc1155_uri_{}_{}", + event.block_number.unwrap_or(0), + format!("{:#x}", event.transaction_hash) + ); + + Ok(Some(Envelope::new( + envelope_id, + Box::new(uri_update), + metadata, + ))) + } } impl Default for Erc1155Decoder { @@ -446,6 +600,10 @@ impl Decoder for Erc1155Decoder { if let Some(envelope) = self.decode_approval_for_all(event).await? { return Ok(vec![envelope]); } + } else if selector == Self::uri_selector() { + if let Some(envelope) = self.decode_uri(event).await? { + return Ok(vec![envelope]); + } } else { tracing::trace!( target: "torii_erc1155::decoder", @@ -537,4 +695,116 @@ mod tests { assert_eq!(approval.operator, Felt::from(0xbu64)); assert!(approval.approved); } + + #[tokio::test] + async fn test_decode_transfer_single_single_felt_preserves_full_felt() { + let decoder = Erc1155Decoder::new(); + + let id_felt = + Felt::from_hex("0x100000000000000000000000000000001").expect("invalid id felt"); + let value_felt = + Felt::from_hex("0x200000000000000000000000000000003").expect("invalid value felt"); + + let event = EmittedEvent { + from_address: Felt::from(0x123u64), + keys: vec![ + Erc1155Decoder::transfer_single_selector(), + Felt::from(0x1u64), + Felt::from(0x2u64), + Felt::from(0x3u64), + ], + data: vec![id_felt, value_felt], + block_hash: None, + block_number: Some(101), + transaction_hash: Felt::from(0xabcdu64), + }; + + let envelopes = decoder.decode_event(&event).await.unwrap(); + assert_eq!(envelopes.len(), 1); + + let transfer = envelopes[0] + .body + .as_any() + .downcast_ref::() + .unwrap(); + + assert_eq!(transfer.id, Erc1155Decoder::felt_to_u256(id_felt)); + assert_eq!(transfer.value, Erc1155Decoder::felt_to_u256(value_felt)); + } + + #[tokio::test] + async fn test_decode_transfer_batch_single_felt_arrays() { + let decoder = Erc1155Decoder::new(); + + let event = EmittedEvent { + from_address: Felt::from(0x123u64), + keys: vec![ + Erc1155Decoder::transfer_batch_selector(), + Felt::from(0x1u64), // operator + Felt::from(0x2u64), // from + Felt::from(0x3u64), // to + ], + data: vec![ + Felt::from(2u64), // ids_len + Felt::from(11u64), // id[0] + Felt::from(12u64), // id[1] + Felt::from(2u64), // values_len + Felt::from(101u64), // value[0] + Felt::from(102u64), // value[1] + ], + block_hash: None, + block_number: Some(102), + transaction_hash: Felt::from(0xabcfu64), + }; + + let envelopes = decoder.decode_event(&event).await.unwrap(); + assert_eq!(envelopes.len(), 2); + + let first = envelopes[0] + .body + .as_any() + .downcast_ref::() + .unwrap(); + let second = envelopes[1] + .body + .as_any() + .downcast_ref::() + .unwrap(); + + assert_eq!(first.id, U256::from(11u64)); + assert_eq!(first.value, U256::from(101u64)); + assert_eq!(second.id, U256::from(12u64)); + assert_eq!(second.value, U256::from(102u64)); + } + + #[tokio::test] + async fn test_decode_uri_event() { + let decoder = Erc1155Decoder::new(); + + // "abc" as short string felt + let uri_felt = Felt::from(0x616263u64); + let event = EmittedEvent { + from_address: Felt::from(0x123u64), + keys: vec![ + Erc1155Decoder::uri_selector(), + Felt::from(7u64), // token id + ], + data: vec![uri_felt], + block_hash: None, + block_number: Some(103), + transaction_hash: Felt::from(0xabd0u64), + }; + + let envelopes = decoder.decode_event(&event).await.unwrap(); + assert_eq!(envelopes.len(), 1); + + let uri = envelopes[0] + .body + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(uri.token, Felt::from(0x123u64)); + assert_eq!(uri.token_id, U256::from(7u64)); + assert_eq!(uri.uri, "abc".to_string()); + } } diff --git a/crates/torii-erc1155/src/generated/erc1155_descriptor.bin b/crates/torii-erc1155/src/generated/erc1155_descriptor.bin index 87b0225..a3b3ed3 100644 Binary files a/crates/torii-erc1155/src/generated/erc1155_descriptor.bin and b/crates/torii-erc1155/src/generated/erc1155_descriptor.bin differ diff --git a/crates/torii-erc1155/src/generated/torii.sinks.erc1155.rs b/crates/torii-erc1155/src/generated/torii.sinks.erc1155.rs index f87682f..6a7a7cc 100644 --- a/crates/torii-erc1155/src/generated/torii.sinks.erc1155.rs +++ b/crates/torii-erc1155/src/generated/torii.sinks.erc1155.rs @@ -181,6 +181,45 @@ pub struct GetBalanceResponse { #[prost(uint64, tag = "2")] pub last_block: u64, } +/// Request for GetTokenMetadata RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTokenMetadataRequest { + /// Token contract address (32 bytes). If empty, returns all tokens. + #[prost(bytes = "vec", optional, tag = "1")] + pub token: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Cursor token (exclusive). Only used when token is not set. + #[prost(bytes = "vec", optional, tag = "2")] + pub cursor: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Maximum number of entries to return (default: 100, max: 1000). + #[prost(uint32, tag = "3")] + pub limit: u32, +} +/// Token metadata entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TokenMetadataEntry { + /// Token contract address (32 bytes) + #[prost(bytes = "vec", tag = "1")] + pub token: ::prost::alloc::vec::Vec, + /// Token name + #[prost(string, optional, tag = "2")] + pub name: ::core::option::Option<::prost::alloc::string::String>, + /// Token symbol + #[prost(string, optional, tag = "3")] + pub symbol: ::core::option::Option<::prost::alloc::string::String>, + /// Total supply as U256 (variable length, up to 32 bytes) + #[prost(bytes = "vec", optional, tag = "4")] + pub total_supply: ::core::option::Option<::prost::alloc::vec::Vec>, +} +/// Response for GetTokenMetadata RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTokenMetadataResponse { + /// Token metadata entries + #[prost(message, repeated, tag = "1")] + pub tokens: ::prost::alloc::vec::Vec, + /// Cursor for next page (absent if no more results). + #[prost(bytes = "vec", optional, tag = "2")] + pub next_cursor: ::core::option::Option<::prost::alloc::vec::Vec>, +} /// Request for GetStats RPC #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct GetStatsRequest {} @@ -229,6 +268,14 @@ pub mod erc1155_server { tonic::Response, tonic::Status, >; + /// Get token metadata (name, symbol) + async fn get_token_metadata( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Server streaming response type for the SubscribeTransfers method. type SubscribeTransfersStream: tonic::codegen::tokio_stream::Stream< Item = std::result::Result, @@ -419,6 +466,51 @@ pub mod erc1155_server { }; Box::pin(fut) } + "/torii.sinks.erc1155.Erc1155/GetTokenMetadata" => { + #[allow(non_camel_case_types)] + struct GetTokenMetadataSvc(pub Arc); + impl< + T: Erc1155, + > tonic::server::UnaryService + for GetTokenMetadataSvc { + type Response = super::GetTokenMetadataResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_token_metadata(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetTokenMetadataSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/torii.sinks.erc1155.Erc1155/SubscribeTransfers" => { #[allow(non_camel_case_types)] struct SubscribeTransfersSvc(pub Arc); diff --git a/crates/torii-erc1155/src/grpc_service.rs b/crates/torii-erc1155/src/grpc_service.rs index 148cd87..a18a2fe 100644 --- a/crates/torii-erc1155/src/grpc_service.rs +++ b/crates/torii-erc1155/src/grpc_service.rs @@ -2,8 +2,9 @@ use crate::proto::{ erc1155_server::Erc1155 as Erc1155Trait, Cursor, GetBalanceRequest, GetBalanceResponse, - GetStatsRequest, GetStatsResponse, GetTransfersRequest, GetTransfersResponse, - SubscribeTransfersRequest, TokenTransfer, TransferFilter, TransferUpdate, + GetStatsRequest, GetStatsResponse, GetTokenMetadataRequest, GetTokenMetadataResponse, + GetTransfersRequest, GetTransfersResponse, SubscribeTransfersRequest, TokenMetadataEntry, + TokenTransfer, TransferFilter, TransferUpdate, }; use crate::storage::{Erc1155Storage, TokenTransferData, TransferCursor}; use async_trait::async_trait; @@ -215,6 +216,62 @@ impl Erc1155Trait for Erc1155Service { })) } + /// Get token metadata (name, symbol) + async fn get_token_metadata( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + if let Some(token_bytes) = req.token { + let token = bytes_to_felt(&token_bytes) + .ok_or_else(|| Status::invalid_argument("Invalid token address"))?; + + let entries = match self.storage.get_token_metadata(token) { + Ok(Some((name, symbol, total_supply))) => vec![TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name, + symbol, + total_supply: total_supply.map(u256_to_bytes), + }], + Ok(None) => vec![], + Err(e) => return Err(Status::internal(format!("Query failed: {e}"))), + }; + + return Ok(Response::new(GetTokenMetadataResponse { + tokens: entries, + next_cursor: None, + })); + } + + let cursor = req.cursor.as_ref().and_then(|b| bytes_to_felt(b)); + let limit = if req.limit == 0 { + 100 + } else { + req.limit.min(1000) + }; + + let (all, next_cursor) = self + .storage + .get_token_metadata_paginated(cursor, limit) + .map_err(|e| Status::internal(format!("Query failed: {e}")))?; + + let entries = all + .into_iter() + .map(|(token, name, symbol, total_supply)| TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name, + symbol, + total_supply: total_supply.map(u256_to_bytes), + }) + .collect(); + + Ok(Response::new(GetTokenMetadataResponse { + tokens: entries, + next_cursor: next_cursor.map(|c| c.to_bytes_be().to_vec()), + })) + } + /// Subscribe to real-time transfer events type SubscribeTransfersStream = Pin> + Send>>; diff --git a/crates/torii-erc1155/src/lib.rs b/crates/torii-erc1155/src/lib.rs index a158082..3fd9d16 100644 --- a/crates/torii-erc1155/src/lib.rs +++ b/crates/torii-erc1155/src/lib.rs @@ -53,10 +53,11 @@ pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("generated/erc1155_descrip // Re-export main types for convenience pub use balance_fetcher::{Erc1155BalanceFetchRequest, Erc1155BalanceFetcher}; -pub use decoder::{Erc1155Decoder, OperatorApproval, TransferBatch, TransferSingle}; +pub use decoder::{Erc1155Decoder, OperatorApproval, TransferBatch, TransferSingle, UriUpdate}; pub use grpc_service::Erc1155Service; pub use identification::Erc1155Rule; pub use sink::Erc1155Sink; pub use storage::{ - Erc1155BalanceAdjustment, Erc1155BalanceData, Erc1155Storage, TokenTransferData, TransferCursor, + Erc1155BalanceAdjustment, Erc1155BalanceData, Erc1155Storage, TokenTransferData, TokenUriData, + TransferCursor, }; diff --git a/crates/torii-erc1155/src/sink.rs b/crates/torii-erc1155/src/sink.rs index 5e00ec0..3e3219e 100644 --- a/crates/torii-erc1155/src/sink.rs +++ b/crates/torii-erc1155/src/sink.rs @@ -14,11 +14,11 @@ use crate::balance_fetcher::Erc1155BalanceFetcher; use crate::decoder::{ OperatorApproval as DecodedOperatorApproval, TransferBatch as DecodedTransferBatch, - TransferSingle as DecodedTransferSingle, + TransferSingle as DecodedTransferSingle, UriUpdate as DecodedUriUpdate, }; use crate::grpc_service::Erc1155Service; use crate::proto; -use crate::storage::{Erc1155Storage, OperatorApprovalData, TokenTransferData}; +use crate::storage::{Erc1155Storage, OperatorApprovalData, TokenTransferData, TokenUriData}; use anyhow::Result; use async_trait::async_trait; use axum::Router; @@ -26,12 +26,14 @@ use prost::Message; use prost_types::Any; use starknet::core::types::{Felt, U256}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use torii::etl::sink::{EventBus, TopicInfo}; use torii::etl::{Envelope, ExtractionBatch, Sink, TypeId}; use torii::grpc::UpdateType; -use torii_common::u256_to_bytes; +use torii_common::{ + u256_to_bytes, MetadataFetcher, TokenStandard, TokenUriRequest, TokenUriSender, +}; /// Default threshold for "live" detection: 100 blocks from chain head. /// Events from blocks older than this won't be broadcast to real-time subscribers. @@ -53,6 +55,10 @@ pub struct Erc1155Sink { grpc_service: Option, /// Balance fetcher for RPC calls (None = balance tracking disabled) balance_fetcher: Option>, + /// Metadata fetcher for contract name/symbol + metadata_fetcher: Option>, + /// Token URI service sender for async URI fetching + token_uri_sender: Option, } impl Erc1155Sink { @@ -62,9 +68,17 @@ impl Erc1155Sink { event_bus: None, grpc_service: None, balance_fetcher: None, + metadata_fetcher: None, + token_uri_sender: None, } } + /// Enable async token URI fetching + pub fn with_token_uri_sender(mut self, sender: TokenUriSender) -> Self { + self.token_uri_sender = Some(sender); + self + } + /// Set the gRPC service for dual publishing pub fn with_grpc_service(mut self, service: Erc1155Service) -> Self { self.grpc_service = Some(service); @@ -79,7 +93,8 @@ impl Erc1155Sink { /// - Fetch actual balance from the chain and adjust /// - Record adjustments in an audit table pub fn with_balance_tracking(mut self, provider: Arc>) -> Self { - self.balance_fetcher = Some(Arc::new(Erc1155BalanceFetcher::new(provider))); + self.balance_fetcher = Some(Arc::new(Erc1155BalanceFetcher::new(provider.clone()))); + self.metadata_fetcher = Some(Arc::new(MetadataFetcher::new(provider))); self } @@ -134,6 +149,55 @@ impl Erc1155Sink { true } + + /// Filter function for ERC1155 token metadata updates. + /// + /// Supports filters: + /// - "token": Filter by token contract address (hex string) + fn matches_metadata_filters( + metadata: &proto::TokenMetadataEntry, + filters: &HashMap, + ) -> bool { + if filters.is_empty() { + return true; + } + + if let Some(token_filter) = filters.get("token") { + let token_hex = format!("0x{}", hex::encode(&metadata.token)); + if !token_hex.eq_ignore_ascii_case(token_filter) { + return false; + } + } + + true + } + + /// Filter function for ERC1155 URI updates. + /// + /// Supports filters: + /// - "token": Filter by token contract address (hex string) + /// - "token_id": Filter by token id (hex string) + fn matches_uri_filters(uri: &proto::TokenUri, filters: &HashMap) -> bool { + if filters.is_empty() { + return true; + } + + if let Some(token_filter) = filters.get("token") { + let token_hex = format!("0x{}", hex::encode(&uri.token)); + if !token_hex.eq_ignore_ascii_case(token_filter) { + return false; + } + } + + if let Some(token_id_filter) = filters.get("token_id") { + let token_id_hex = format!("0x{}", hex::encode(&uri.token_id)); + if !token_id_hex.eq_ignore_ascii_case(token_id_filter) { + return false; + } + } + + true + } } #[async_trait] @@ -147,6 +211,7 @@ impl Sink for Erc1155Sink { TypeId::new("erc1155.transfer_single"), TypeId::new("erc1155.transfer_batch"), TypeId::new("erc1155.approval_for_all"), + TypeId::new("erc1155.uri"), ] } @@ -163,6 +228,7 @@ impl Sink for Erc1155Sink { async fn process(&self, envelopes: &[Envelope], batch: &ExtractionBatch) -> Result<()> { let mut transfers: Vec = Vec::new(); let mut operator_approvals: Vec = Vec::new(); + let mut uri_updates: Vec = Vec::new(); // Get block timestamps from batch let block_timestamps: HashMap = batch @@ -240,6 +306,111 @@ impl Sink for Erc1155Sink { }); } } + // Handle URI updates + else if envelope.type_id == TypeId::new("erc1155.uri") { + if let Some(uri) = envelope.body.as_any().downcast_ref::() { + let timestamp = block_timestamps.get(&uri.block_number).copied(); + uri_updates.push(TokenUriData { + token: uri.token, + token_id: uri.token_id, + uri: uri.uri.clone(), + block_number: uri.block_number, + tx_hash: uri.transaction_hash, + timestamp, + }); + } + } + } + + // Fetch metadata for any new token contracts + if let Some(ref fetcher) = self.metadata_fetcher { + let new_tokens: HashSet = transfers.iter().map(|t| t.token).collect(); + for token in new_tokens { + match self.storage.has_token_metadata(token) { + Ok(exists) => { + if exists { + continue; + } + + let meta = fetcher.fetch_erc1155_metadata(token).await; + tracing::info!( + target: "torii_erc1155::sink", + token = %format!("{:#x}", token), + name = ?meta.name, + symbol = ?meta.symbol, + "Fetched token metadata" + ); + if let Err(e) = self.storage.upsert_token_metadata( + token, + meta.name.as_deref(), + meta.symbol.as_deref(), + meta.total_supply, + ) { + tracing::warn!( + target: "torii_erc1155::sink", + error = %e, + "Failed to store token metadata" + ); + } else if let Some(event_bus) = &self.event_bus { + let meta_entry = proto::TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name: meta.name, + symbol: meta.symbol, + total_supply: meta.total_supply.map(u256_to_bytes), + }; + + let mut buf = Vec::new(); + meta_entry.encode(&mut buf)?; + let any = Any { + type_url: + "type.googleapis.com/torii.sinks.erc1155.TokenMetadataEntry" + .to_string(), + value: buf, + }; + + event_bus.publish_protobuf( + "erc1155.metadata", + "erc1155.metadata", + &any, + &meta_entry, + UpdateType::Created, + Self::matches_metadata_filters, + ); + } + } + Err(e) => { + tracing::warn!(target: "torii_erc1155::sink", error = %e, "Failed to check token metadata"); + } + } + } + } + + // Request token URI fetches for new token IDs + if let Some(ref sender) = self.token_uri_sender { + for transfer in &transfers { + match self + .storage + .has_token_uri(transfer.token, transfer.token_id) + { + Ok(false) => { + sender + .request_update(TokenUriRequest { + contract: transfer.token, + token_id: transfer.token_id, + standard: TokenStandard::Erc1155, + }) + .await; + } + Ok(true) => {} + Err(e) => { + tracing::warn!( + target: "torii_erc1155::sink", + error = %e, + "Failed to check token URI existence" + ); + } + } + } } // Batch insert transfers @@ -398,6 +569,57 @@ impl Sink for Erc1155Sink { } } + // Batch upsert token URI updates + if !uri_updates.is_empty() { + match self.storage.upsert_token_uris_batch(&uri_updates) { + Ok(count) => { + tracing::info!( + target: "torii_erc1155::sink", + count = count, + "Batch upserted token URI updates" + ); + + // Publish URI updates to topic subscribers + if let Some(event_bus) = &self.event_bus { + for uri in &uri_updates { + let proto_uri = proto::TokenUri { + token: uri.token.to_bytes_be().to_vec(), + token_id: u256_to_bytes(uri.token_id), + uri: uri.uri.clone(), + block_number: uri.block_number, + }; + + let mut buf = Vec::new(); + proto_uri.encode(&mut buf)?; + let any = Any { + type_url: "type.googleapis.com/torii.sinks.erc1155.TokenUri" + .to_string(), + value: buf, + }; + + event_bus.publish_protobuf( + "erc1155.uri", + "erc1155.uri", + &any, + &proto_uri, + UpdateType::Updated, + Self::matches_uri_filters, + ); + } + } + } + Err(e) => { + tracing::error!( + target: "torii_erc1155::sink", + count = uri_updates.len(), + error = %e, + "Failed to batch upsert token URI updates" + ); + return Err(e); + } + } + } + // Log combined statistics if !transfers.is_empty() || !operator_approvals.is_empty() { if let Ok(total_transfers) = self.storage.get_transfer_count() { @@ -420,16 +642,28 @@ impl Sink for Erc1155Sink { } fn topics(&self) -> Vec { - vec![TopicInfo::new( - "erc1155.transfer", - vec![ - "token".to_string(), - "from".to_string(), - "to".to_string(), - "wallet".to_string(), - ], - "ERC1155 token transfers. Use 'wallet' filter for from OR to matching.", - )] + vec![ + TopicInfo::new( + "erc1155.transfer", + vec![ + "token".to_string(), + "from".to_string(), + "to".to_string(), + "wallet".to_string(), + ], + "ERC1155 token transfers. Use 'wallet' filter for from OR to matching.", + ), + TopicInfo::new( + "erc1155.metadata", + vec!["token".to_string()], + "ERC1155 token metadata updates (registered/updated token attributes).", + ), + TopicInfo::new( + "erc1155.uri", + vec!["token".to_string(), "token_id".to_string()], + "ERC1155 token URI updates (registered/updated token attributes).", + ), + ] } fn build_routes(&self) -> Router { diff --git a/crates/torii-erc1155/src/storage.rs b/crates/torii-erc1155/src/storage.rs index 43f8e65..cd1d56a 100644 --- a/crates/torii-erc1155/src/storage.rs +++ b/crates/torii-erc1155/src/storage.rs @@ -11,7 +11,9 @@ use rusqlite::{params, Connection}; use starknet::core::types::{Felt, U256}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use torii_common::{blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob}; +use torii_common::{ + blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob, TokenUriResult, TokenUriStore, +}; use crate::balance_fetcher::Erc1155BalanceFetchRequest; @@ -48,6 +50,16 @@ pub struct OperatorApprovalData { pub timestamp: Option, } +/// Token URI data +pub struct TokenUriData { + pub token: Felt, + pub token_id: U256, + pub uri: String, + pub block_number: u64, + pub tx_hash: Felt, + pub timestamp: Option, +} + /// Cursor for paginated transfer queries #[derive(Debug, Clone, Copy)] pub struct TransferCursor { @@ -189,18 +201,30 @@ impl Erc1155Storage { // URI metadata conn.execute( "CREATE TABLE IF NOT EXISTS token_uris ( - id INTEGER PRIMARY KEY AUTOINCREMENT, token BLOB NOT NULL, token_id BLOB NOT NULL, - uri TEXT NOT NULL, - block_number INTEGER NOT NULL, - tx_hash BLOB NOT NULL, - timestamp INTEGER, - UNIQUE(token, token_id) + uri TEXT, + metadata_json TEXT, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (token, token_id) )", [], )?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS token_attributes ( + token BLOB NOT NULL, + token_id BLOB NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (token, token_id, key), + FOREIGN KEY (token, token_id) REFERENCES token_uris(token, token_id) + ); + CREATE INDEX IF NOT EXISTS idx_token_attributes_token ON token_attributes(token); + CREATE INDEX IF NOT EXISTS idx_token_attributes_key ON token_attributes(key); + CREATE INDEX IF NOT EXISTS idx_token_attributes_key_value ON token_attributes(key, value);", + )?; + // Balance tracking tables // Tracks current balance per (contract, wallet, token_id) tuple conn.execute( @@ -255,6 +279,17 @@ impl Erc1155Storage { [], )?; + // Token metadata table + conn.execute( + "CREATE TABLE IF NOT EXISTS token_metadata ( + token BLOB PRIMARY KEY, + name TEXT, + symbol TEXT, + total_supply BLOB + )", + [], + )?; + tracing::info!(target: "torii_erc1155::storage", db_path = %db_path, "ERC1155 database initialized"); Ok(Self { @@ -384,6 +419,39 @@ impl Erc1155Storage { Ok(inserted) } + /// Insert or update token URIs in a single transaction + pub fn upsert_token_uris_batch(&self, uris: &[TokenUriData]) -> Result { + if uris.is_empty() { + return Ok(0); + } + + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let mut updated = 0; + + let mut stmt = tx.prepare_cached( + "INSERT INTO token_uris (token, token_id, uri, updated_at) + VALUES (?1, ?2, ?3, strftime('%s', 'now')) + ON CONFLICT(token, token_id) DO UPDATE SET + uri = excluded.uri, + updated_at = excluded.updated_at", + )?; + + for entry in uris { + let token_blob = felt_to_blob(entry.token); + let token_id_blob = u256_to_blob(entry.token_id); + let rows = stmt.execute(params![&token_blob, &token_id_blob, &entry.uri,])?; + + if rows > 0 { + updated += 1; + } + } + drop(stmt); + + tx.commit()?; + Ok(updated) + } + /// Get filtered transfers with cursor-based pagination pub fn get_transfers_filtered( &self, @@ -962,4 +1030,233 @@ impl Erc1155Storage { )?; Ok(count as u64) } + + // ===== Token Metadata Methods ===== + + /// Check if metadata exists for a token + pub fn has_token_metadata(&self, token: Felt) -> Result { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM token_metadata WHERE token = ?", + params![&token_blob], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Insert or update token metadata + pub fn upsert_token_metadata( + &self, + token: Felt, + name: Option<&str>, + symbol: Option<&str>, + total_supply: Option, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let supply_blob = total_supply.map(u256_to_blob); + conn.execute( + "INSERT INTO token_metadata (token, name, symbol, total_supply) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(token) DO UPDATE SET + name = COALESCE(excluded.name, token_metadata.name), + symbol = COALESCE(excluded.symbol, token_metadata.symbol), + total_supply = COALESCE(excluded.total_supply, token_metadata.total_supply)", + params![&token_blob, name, symbol, supply_blob], + )?; + Ok(()) + } + + /// Get token metadata + pub fn get_token_metadata( + &self, + token: Felt, + ) -> Result, Option, Option)>> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let result = conn + .query_row( + "SELECT name, symbol, total_supply FROM token_metadata WHERE token = ?", + params![&token_blob], + |row| { + let name: Option = row.get(0)?; + let symbol: Option = row.get(1)?; + let supply_bytes: Option> = row.get(2)?; + Ok((name, symbol, supply_bytes.map(|b| blob_to_u256(&b)))) + }, + ) + .ok(); + Ok(result) + } + + /// Get all token metadata + pub fn get_all_token_metadata( + &self, + ) -> Result, Option, Option)>> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT token, name, symbol, total_supply FROM token_metadata")?; + let rows = stmt.query_map([], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let supply_bytes: Option> = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + supply_bytes.map(|b| blob_to_u256(&b)), + )) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Get token metadata with cursor-based pagination. + /// + /// Returns at most `limit` rows and an optional next cursor token. + pub fn get_token_metadata_paginated( + &self, + cursor: Option, + limit: u32, + ) -> Result<( + Vec<(Felt, Option, Option, Option)>, + Option, + )> { + let conn = self.conn.lock().unwrap(); + let fetch_limit = limit.clamp(1, 1000) as usize + 1; + + let mut out = if let Some(cursor_token) = cursor { + let cursor_blob = felt_to_blob(cursor_token); + let mut stmt = conn.prepare( + "SELECT token, name, symbol, total_supply + FROM token_metadata + WHERE token > ?1 + ORDER BY token ASC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![&cursor_blob, fetch_limit as i64], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let supply_bytes: Option> = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + supply_bytes.map(|b| blob_to_u256(&b)), + )) + })?; + rows.collect::, _>>()? + } else { + let mut stmt = conn.prepare( + "SELECT token, name, symbol, total_supply + FROM token_metadata + ORDER BY token ASC + LIMIT ?1", + )?; + let rows = stmt.query_map(params![fetch_limit as i64], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let supply_bytes: Option> = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + supply_bytes.map(|b| blob_to_u256(&b)), + )) + })?; + rows.collect::, _>>()? + }; + + let capped = limit.clamp(1, 1000) as usize; + let next_cursor = if out.len() > capped { + let next = out[capped].0; + out.truncate(capped); + Some(next) + } else { + None + }; + + Ok((out, next_cursor)) + } + + /// Returns true if a token URI row exists for `(token, token_id)`. + pub fn has_token_uri(&self, token: Felt, token_id: U256) -> Result { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let token_id_blob = u256_to_blob(token_id); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM token_uris WHERE token = ?1 AND token_id = ?2", + params![&token_blob, &token_id_blob], + |row| row.get(0), + )?; + Ok(count > 0) + } +} + +#[async_trait::async_trait] +impl TokenUriStore for Erc1155Storage { + async fn store_token_uri(&self, result: &TokenUriResult) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + let token_blob = felt_to_blob(result.contract); + let token_id_blob = u256_to_blob(result.token_id); + + tx.execute( + "INSERT INTO token_uris (token, token_id, uri, metadata_json, updated_at) + VALUES (?1, ?2, ?3, ?4, strftime('%s', 'now')) + ON CONFLICT(token, token_id) DO UPDATE SET + uri = excluded.uri, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at", + params![ + &token_blob, + &token_id_blob, + result.uri.as_deref(), + result.metadata_json.as_deref() + ], + )?; + + tx.execute( + "DELETE FROM token_attributes WHERE token = ?1 AND token_id = ?2", + params![&token_blob, &token_id_blob], + )?; + + if let Some(metadata_json) = &result.metadata_json { + if let Ok(value) = serde_json::from_str::(metadata_json) { + if let Some(attrs) = value.get("attributes").and_then(|a| a.as_array()) { + let mut attr_stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO token_attributes (token, token_id, key, value) + VALUES (?1, ?2, ?3, ?4)", + )?; + + for attr in attrs { + let key = attr + .get("trait_type") + .or_else(|| attr.get("key")) + .and_then(|v| v.as_str()); + let value = attr.get("value").and_then(|v| { + v.as_str().map(ToOwned::to_owned).or_else(|| { + if v.is_null() { + None + } else { + Some(v.to_string()) + } + }) + }); + + if let (Some(key), Some(value)) = (key, value) { + attr_stmt.execute(params![&token_blob, &token_id_blob, key, value])?; + } + } + } + } + } + + tx.commit()?; + Ok(()) + } } diff --git a/crates/torii-erc20/proto/erc20.proto b/crates/torii-erc20/proto/erc20.proto index e3ec781..877cf94 100644 --- a/crates/torii-erc20/proto/erc20.proto +++ b/crates/torii-erc20/proto/erc20.proto @@ -186,6 +186,70 @@ message GetBalanceResponse { uint64 last_block = 2; } +// Balance row for batch balance queries +message BalanceEntry { + // Token contract address (32 bytes) + bytes token = 1; + // Wallet address (32 bytes) + bytes wallet = 2; + // Balance as U256 (variable length, up to 32 bytes) + bytes balance = 3; + // Last block number where balance was updated + uint64 last_block = 4; +} + +// Request for GetBalances RPC (batch balance query) +message GetBalancesRequest { + // Optional token filter. If set, only balances for this token. + optional bytes token = 1; + // Optional wallet filter. If set, only balances for this wallet. + optional bytes wallet = 2; + // Cursor from previous response (row id). Omit for first page. + optional int64 cursor = 3; + // Maximum number of rows to return (default: 1000, max: 10000) + uint32 limit = 4; +} + +// Response for GetBalances RPC +message GetBalancesResponse { + // List of balances matching filters + repeated BalanceEntry balances = 1; + // Cursor for next page (absent if no more results) + optional int64 next_cursor = 2; +} + +// ===== Token Metadata ===== + +// Request for GetTokenMetadata RPC +message GetTokenMetadataRequest { + // Token contract address (32 bytes). If empty, returns all tokens. + optional bytes token = 1; + // Cursor token (exclusive). Only used when token is not set. + optional bytes cursor = 2; + // Maximum number of entries to return (default: 100, max: 1000). + uint32 limit = 3; +} + +// Token metadata entry +message TokenMetadataEntry { + // Token contract address (32 bytes) + bytes token = 1; + // Token name (e.g. "Ether") + optional string name = 2; + // Token symbol (e.g. "ETH") + optional string symbol = 3; + // Token decimals (e.g. 18) + optional uint32 decimals = 4; +} + +// Response for GetTokenMetadata RPC +message GetTokenMetadataResponse { + // Token metadata entries + repeated TokenMetadataEntry tokens = 1; + // Cursor for next page (absent if no more results). + optional bytes next_cursor = 2; +} + // ===== Stats ===== // Request for GetStats RPC @@ -216,6 +280,12 @@ service Erc20 { // Get balance for a specific token and wallet rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse); + // Query balances in batch with optional token/wallet filters + rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse); + + // Get token metadata (name, symbol, decimals) + rpc GetTokenMetadata(GetTokenMetadataRequest) returns (GetTokenMetadataResponse); + // Subscribe to real-time transfer events with filtering rpc SubscribeTransfers(SubscribeTransfersRequest) returns (stream TransferUpdate); diff --git a/crates/torii-erc20/src/generated/erc20_descriptor.bin b/crates/torii-erc20/src/generated/erc20_descriptor.bin index c7676f5..6c0fb0b 100644 Binary files a/crates/torii-erc20/src/generated/erc20_descriptor.bin and b/crates/torii-erc20/src/generated/erc20_descriptor.bin differ diff --git a/crates/torii-erc20/src/generated/torii.sinks.erc20.rs b/crates/torii-erc20/src/generated/torii.sinks.erc20.rs index c9e4156..53f9970 100644 --- a/crates/torii-erc20/src/generated/torii.sinks.erc20.rs +++ b/crates/torii-erc20/src/generated/torii.sinks.erc20.rs @@ -212,6 +212,87 @@ pub struct GetBalanceResponse { #[prost(uint64, tag = "2")] pub last_block: u64, } +/// Balance row for batch balance queries +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct BalanceEntry { + /// Token contract address (32 bytes) + #[prost(bytes = "vec", tag = "1")] + pub token: ::prost::alloc::vec::Vec, + /// Wallet address (32 bytes) + #[prost(bytes = "vec", tag = "2")] + pub wallet: ::prost::alloc::vec::Vec, + /// Balance as U256 (variable length, up to 32 bytes) + #[prost(bytes = "vec", tag = "3")] + pub balance: ::prost::alloc::vec::Vec, + /// Last block number where balance was updated + #[prost(uint64, tag = "4")] + pub last_block: u64, +} +/// Request for GetBalances RPC (batch balance query) +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetBalancesRequest { + /// Optional token filter. If set, only balances for this token. + #[prost(bytes = "vec", optional, tag = "1")] + pub token: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Optional wallet filter. If set, only balances for this wallet. + #[prost(bytes = "vec", optional, tag = "2")] + pub wallet: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Cursor from previous response (row id). Omit for first page. + #[prost(int64, optional, tag = "3")] + pub cursor: ::core::option::Option, + /// Maximum number of rows to return (default: 1000, max: 10000) + #[prost(uint32, tag = "4")] + pub limit: u32, +} +/// Response for GetBalances RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetBalancesResponse { + /// List of balances matching filters + #[prost(message, repeated, tag = "1")] + pub balances: ::prost::alloc::vec::Vec, + /// Cursor for next page (absent if no more results) + #[prost(int64, optional, tag = "2")] + pub next_cursor: ::core::option::Option, +} +/// Request for GetTokenMetadata RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTokenMetadataRequest { + /// Token contract address (32 bytes). If empty, returns all tokens. + #[prost(bytes = "vec", optional, tag = "1")] + pub token: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Cursor token (exclusive). Only used when token is not set. + #[prost(bytes = "vec", optional, tag = "2")] + pub cursor: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Maximum number of entries to return (default: 100, max: 1000). + #[prost(uint32, tag = "3")] + pub limit: u32, +} +/// Token metadata entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TokenMetadataEntry { + /// Token contract address (32 bytes) + #[prost(bytes = "vec", tag = "1")] + pub token: ::prost::alloc::vec::Vec, + /// Token name (e.g. "Ether") + #[prost(string, optional, tag = "2")] + pub name: ::core::option::Option<::prost::alloc::string::String>, + /// Token symbol (e.g. "ETH") + #[prost(string, optional, tag = "3")] + pub symbol: ::core::option::Option<::prost::alloc::string::String>, + /// Token decimals (e.g. 18) + #[prost(uint32, optional, tag = "4")] + pub decimals: ::core::option::Option, +} +/// Response for GetTokenMetadata RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTokenMetadataResponse { + /// Token metadata entries + #[prost(message, repeated, tag = "1")] + pub tokens: ::prost::alloc::vec::Vec, + /// Cursor for next page (absent if no more results). + #[prost(bytes = "vec", optional, tag = "2")] + pub next_cursor: ::core::option::Option<::prost::alloc::vec::Vec>, +} /// Request for GetStats RPC #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct GetStatsRequest {} @@ -301,6 +382,22 @@ pub mod erc20_server { tonic::Response, tonic::Status, >; + /// Query balances in batch with optional token/wallet filters + async fn get_balances( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// Get token metadata (name, symbol, decimals) + async fn get_token_metadata( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Server streaming response type for the SubscribeTransfers method. type SubscribeTransfersStream: tonic::codegen::tokio_stream::Stream< Item = std::result::Result, @@ -548,6 +645,94 @@ pub mod erc20_server { }; Box::pin(fut) } + "/torii.sinks.erc20.Erc20/GetBalances" => { + #[allow(non_camel_case_types)] + struct GetBalancesSvc(pub Arc); + impl tonic::server::UnaryService + for GetBalancesSvc { + type Response = super::GetBalancesResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_balances(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetBalancesSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/torii.sinks.erc20.Erc20/GetTokenMetadata" => { + #[allow(non_camel_case_types)] + struct GetTokenMetadataSvc(pub Arc); + impl< + T: Erc20, + > tonic::server::UnaryService + for GetTokenMetadataSvc { + type Response = super::GetTokenMetadataResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_token_metadata(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetTokenMetadataSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/torii.sinks.erc20.Erc20/SubscribeTransfers" => { #[allow(non_camel_case_types)] struct SubscribeTransfersSvc(pub Arc); diff --git a/crates/torii-erc20/src/grpc_service.rs b/crates/torii-erc20/src/grpc_service.rs index a3504b9..25cbc69 100644 --- a/crates/torii-erc20/src/grpc_service.rs +++ b/crates/torii-erc20/src/grpc_service.rs @@ -6,10 +6,12 @@ //! - Indexer statistics (GetStats) use crate::proto::{ - erc20_server::Erc20 as Erc20Trait, Approval, ApprovalFilter, ApprovalUpdate, Cursor, - GetApprovalsRequest, GetApprovalsResponse, GetBalanceRequest, GetBalanceResponse, - GetStatsRequest, GetStatsResponse, GetTransfersRequest, GetTransfersResponse, - SubscribeApprovalsRequest, SubscribeTransfersRequest, Transfer, TransferFilter, TransferUpdate, + erc20_server::Erc20 as Erc20Trait, Approval, ApprovalFilter, ApprovalUpdate, BalanceEntry, + Cursor, GetApprovalsRequest, GetApprovalsResponse, GetBalanceRequest, GetBalanceResponse, + GetBalancesRequest, GetBalancesResponse, GetStatsRequest, GetStatsResponse, + GetTokenMetadataRequest, GetTokenMetadataResponse, GetTransfersRequest, GetTransfersResponse, + SubscribeApprovalsRequest, SubscribeTransfersRequest, TokenMetadataEntry, Transfer, + TransferFilter, TransferUpdate, }; use crate::storage::{ ApprovalCursor, ApprovalData, Erc20Storage, TransferCursor, TransferData, TransferDirection, @@ -394,6 +396,108 @@ impl Erc20Trait for Erc20Service { })) } + /// Query balances in batch with optional token/wallet filters + async fn get_balances( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let token = req.token.as_ref().and_then(|b| bytes_to_felt(b)); + let wallet = req.wallet.as_ref().and_then(|b| bytes_to_felt(b)); + let cursor = req.cursor; + let limit = if req.limit == 0 { + 1000 + } else { + req.limit.min(10_000) + }; + + tracing::debug!( + target: "torii_erc20::grpc", + "GetBalances: token={:?}, wallet={:?}, cursor={:?}, limit={}", + token.map(|t| format!("{t:#x}")), + wallet.map(|w| format!("{w:#x}")), + cursor, + limit + ); + + let (balances, next_cursor) = self + .storage + .get_balances_filtered(token, wallet, cursor, limit) + .map_err(|e| Status::internal(format!("Query failed: {e}")))?; + + let rows = balances + .into_iter() + .map(|b| BalanceEntry { + token: b.token.to_bytes_be().to_vec(), + wallet: b.wallet.to_bytes_be().to_vec(), + balance: u256_to_bytes(b.balance), + last_block: b.last_block, + }) + .collect(); + + Ok(Response::new(GetBalancesResponse { + balances: rows, + next_cursor, + })) + } + + /// Get token metadata (name, symbol, decimals) + async fn get_token_metadata( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + if let Some(token_bytes) = req.token { + let token = bytes_to_felt(&token_bytes) + .ok_or_else(|| Status::invalid_argument("Invalid token address"))?; + + let entries = match self.storage.get_token_metadata(token) { + Ok(Some((name, symbol, decimals))) => vec![TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name, + symbol, + decimals: decimals.map(|d| d as u32), + }], + Ok(None) => vec![], + Err(e) => return Err(Status::internal(format!("Query failed: {e}"))), + }; + + return Ok(Response::new(GetTokenMetadataResponse { + tokens: entries, + next_cursor: None, + })); + } + + let cursor = req.cursor.as_ref().and_then(|b| bytes_to_felt(b)); + let limit = if req.limit == 0 { + 100 + } else { + req.limit.min(1000) + }; + + let (all, next_cursor) = self + .storage + .get_token_metadata_paginated(cursor, limit) + .map_err(|e| Status::internal(format!("Query failed: {e}")))?; + + let entries = all + .into_iter() + .map(|(token, name, symbol, decimals)| TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name, + symbol, + decimals: decimals.map(|d| d as u32), + }) + .collect(); + + Ok(Response::new(GetTokenMetadataResponse { + tokens: entries, + next_cursor: next_cursor.map(|c| c.to_bytes_be().to_vec()), + })) + } + /// Subscribe to real-time transfer events with filtering type SubscribeTransfersStream = Pin> + Send>>; diff --git a/crates/torii-erc20/src/sink.rs b/crates/torii-erc20/src/sink.rs index 3feff69..d698b44 100644 --- a/crates/torii-erc20/src/sink.rs +++ b/crates/torii-erc20/src/sink.rs @@ -23,12 +23,12 @@ use prost::Message; use prost_types::Any; use starknet::core::types::{Felt, U256}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use torii::etl::sink::{EventBus, TopicInfo}; use torii::etl::{Envelope, ExtractionBatch, Sink, TypeId}; use torii::grpc::UpdateType; -use torii_common::u256_to_bytes; +use torii_common::{u256_to_bytes, MetadataFetcher}; /// Default threshold for "live" detection: 100 blocks from chain head. /// Events from blocks older than this won't be broadcast to real-time subscribers. @@ -50,6 +50,8 @@ pub struct Erc20Sink { grpc_service: Option, /// Balance fetcher for RPC calls (None = balance tracking disabled) balance_fetcher: Option>, + /// Metadata fetcher for token name/symbol/decimals + metadata_fetcher: Option>, } impl Erc20Sink { @@ -59,6 +61,7 @@ impl Erc20Sink { event_bus: None, grpc_service: None, balance_fetcher: None, + metadata_fetcher: None, } } @@ -76,7 +79,8 @@ impl Erc20Sink { /// - Fetch actual balance from the chain and adjust /// - Record adjustments in an audit table pub fn with_balance_tracking(mut self, provider: Arc>) -> Self { - self.balance_fetcher = Some(Arc::new(BalanceFetcher::new(provider))); + self.balance_fetcher = Some(Arc::new(BalanceFetcher::new(provider.clone()))); + self.metadata_fetcher = Some(Arc::new(MetadataFetcher::new(provider))); self } @@ -190,6 +194,28 @@ impl Erc20Sink { true } + + /// Filter function for ERC20 token metadata updates. + /// + /// Supports filters: + /// - "token": Filter by token contract address (hex string) + fn matches_metadata_filters( + metadata: &proto::TokenMetadataEntry, + filters: &HashMap, + ) -> bool { + if filters.is_empty() { + return true; + } + + if let Some(token_filter) = filters.get("token") { + let token_hex = format!("0x{}", hex::encode(&metadata.token)); + if !token_hex.eq_ignore_ascii_case(token_filter) { + return false; + } + } + + true + } } #[async_trait] @@ -259,6 +285,82 @@ impl Sink for Erc20Sink { } } + // Fetch metadata for any new token contracts we haven't seen before + if let Some(ref fetcher) = self.metadata_fetcher { + let mut new_tokens: HashSet = HashSet::new(); + for transfer in &transfers { + new_tokens.insert(transfer.token); + } + for approval in &approvals { + new_tokens.insert(approval.token); + } + + for token in new_tokens { + match self.storage.has_token_metadata(token) { + Ok(exists) => { + if exists { + continue; + } + + let meta = fetcher.fetch_erc20_metadata(token).await; + tracing::info!( + target: "torii_erc20::sink", + token = %format!("{:#x}", token), + name = ?meta.name, + symbol = ?meta.symbol, + decimals = ?meta.decimals, + "Fetched token metadata" + ); + if let Err(e) = self.storage.upsert_token_metadata( + token, + meta.name.as_deref(), + meta.symbol.as_deref(), + meta.decimals, + ) { + tracing::warn!( + target: "torii_erc20::sink", + token = %format!("{:#x}", token), + error = %e, + "Failed to store token metadata" + ); + } else if let Some(event_bus) = &self.event_bus { + let meta_entry = proto::TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name: meta.name, + symbol: meta.symbol, + decimals: meta.decimals.map(|d| d as u32), + }; + + let mut buf = Vec::new(); + meta_entry.encode(&mut buf)?; + let any = Any { + type_url: + "type.googleapis.com/torii.sinks.erc20.TokenMetadataEntry" + .to_string(), + value: buf, + }; + + event_bus.publish_protobuf( + "erc20.metadata", + "erc20.metadata", + &any, + &meta_entry, + UpdateType::Created, + Self::matches_metadata_filters, + ); + } + } + Err(e) => { + tracing::warn!( + target: "torii_erc20::sink", + error = %e, + "Failed to check token metadata" + ); + } + } + } + } + // Batch insert transfers if !transfers.is_empty() { let transfer_count = match self.storage.insert_transfers_batch(&transfers) { @@ -492,6 +594,11 @@ impl Sink for Erc20Sink { ], "ERC20 token approvals. Use 'account' filter for owner OR spender matching.", ), + TopicInfo::new( + "erc20.metadata", + vec!["token".to_string()], + "ERC20 token metadata updates (registered/updated token attributes).", + ), ] } diff --git a/crates/torii-erc20/src/storage.rs b/crates/torii-erc20/src/storage.rs index 0279ccc..f6d3722 100644 --- a/crates/torii-erc20/src/storage.rs +++ b/crates/torii-erc20/src/storage.rs @@ -337,6 +337,17 @@ impl Erc20Storage { [], )?; + // Token metadata table + conn.execute( + "CREATE TABLE IF NOT EXISTS token_metadata ( + token BLOB PRIMARY KEY, + name TEXT, + symbol TEXT, + decimals INTEGER + )", + [], + )?; + tracing::info!(target: "torii_erc20::storage", db_path = %db_path, "Database initialized"); Ok(Self { @@ -888,6 +899,86 @@ impl Erc20Storage { Ok(result.map(|(bytes, block)| (blob_to_u256(&bytes), block as u64))) } + /// Get balances with optional token/wallet filters and cursor pagination. + /// + /// Pagination is cursor-based on the `balances.id` primary key in ascending order. + /// Returns `(rows, next_cursor)`. + pub fn get_balances_filtered( + &self, + token: Option, + wallet: Option, + cursor: Option, + limit: u32, + ) -> Result<(Vec, Option)> { + let conn = self.conn.lock().unwrap(); + + let mut query = String::from( + "SELECT id, token, wallet, balance, last_block, last_tx_hash + FROM balances + WHERE 1=1", + ); + let mut params_vec: Vec> = Vec::new(); + + if let Some(token_addr) = token { + query.push_str(" AND token = ?"); + params_vec.push(Box::new(felt_to_blob(token_addr))); + } + + if let Some(wallet_addr) = wallet { + query.push_str(" AND wallet = ?"); + params_vec.push(Box::new(felt_to_blob(wallet_addr))); + } + + if let Some(c) = cursor { + query.push_str(" AND id > ?"); + params_vec.push(Box::new(c)); + } + + query.push_str(" ORDER BY id ASC LIMIT ?"); + params_vec.push(Box::new(limit as i64)); + + let mut stmt = conn.prepare(&query)?; + let params_refs: Vec<&dyn rusqlite::ToSql> = + params_vec.iter().map(std::convert::AsRef::as_ref).collect(); + + let rows = stmt.query_map(params_refs.as_slice(), |row| { + let id: i64 = row.get(0)?; + let token_bytes: Vec = row.get(1)?; + let wallet_bytes: Vec = row.get(2)?; + let balance_bytes: Vec = row.get(3)?; + let last_block: i64 = row.get(4)?; + let last_tx_hash_bytes: Vec = row.get(5)?; + + Ok(( + id, + BalanceData { + token: blob_to_felt(&token_bytes), + wallet: blob_to_felt(&wallet_bytes), + balance: blob_to_u256(&balance_bytes), + last_block: last_block as u64, + last_tx_hash: blob_to_felt(&last_tx_hash_bytes), + }, + )) + })?; + + let mut out: Vec = Vec::new(); + let mut last_id: Option = None; + + for row in rows { + let (id, data) = row?; + last_id = Some(id); + out.push(data); + } + + let next_cursor = if out.len() == limit as usize { + last_id + } else { + None + }; + + Ok((out, next_cursor)) + } + /// Get balances for multiple wallet/token pairs in a single query pub fn get_balances_batch( &self, @@ -1226,4 +1317,153 @@ impl Erc20Storage { })?; Ok(count as u64) } + + // ===== Token Metadata Methods ===== + + /// Check if metadata exists for a token + pub fn has_token_metadata(&self, token: Felt) -> Result { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM token_metadata WHERE token = ?", + params![&token_blob], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Insert or update token metadata + pub fn upsert_token_metadata( + &self, + token: Felt, + name: Option<&str>, + symbol: Option<&str>, + decimals: Option, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + conn.execute( + "INSERT INTO token_metadata (token, name, symbol, decimals) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(token) DO UPDATE SET + name = COALESCE(excluded.name, token_metadata.name), + symbol = COALESCE(excluded.symbol, token_metadata.symbol), + decimals = COALESCE(excluded.decimals, token_metadata.decimals)", + params![&token_blob, name, symbol, decimals.map(|d| d as i64)], + )?; + Ok(()) + } + + /// Get token metadata + pub fn get_token_metadata( + &self, + token: Felt, + ) -> Result, Option, Option)>> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let result = conn + .query_row( + "SELECT name, symbol, decimals FROM token_metadata WHERE token = ?", + params![&token_blob], + |row| { + let name: Option = row.get(0)?; + let symbol: Option = row.get(1)?; + let decimals: Option = row.get(2)?; + Ok((name, symbol, decimals.map(|d| d as u8))) + }, + ) + .ok(); + Ok(result) + } + + /// Get all token metadata + pub fn get_all_token_metadata( + &self, + ) -> Result, Option, Option)>> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT token, name, symbol, decimals FROM token_metadata")?; + let rows = stmt.query_map([], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let decimals: Option = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + decimals.map(|d| d as u8), + )) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Get token metadata with cursor-based pagination. + /// + /// Returns at most `limit` rows and an optional next cursor token. + pub fn get_token_metadata_paginated( + &self, + cursor: Option, + limit: u32, + ) -> Result<( + Vec<(Felt, Option, Option, Option)>, + Option, + )> { + let conn = self.conn.lock().unwrap(); + let fetch_limit = limit.clamp(1, 1000) as usize + 1; + + let mut out = if let Some(cursor_token) = cursor { + let cursor_blob = felt_to_blob(cursor_token); + let mut stmt = conn.prepare( + "SELECT token, name, symbol, decimals + FROM token_metadata + WHERE token > ?1 + ORDER BY token ASC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![&cursor_blob, fetch_limit as i64], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let decimals: Option = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + decimals.map(|d| d as u8), + )) + })?; + rows.collect::, _>>()? + } else { + let mut stmt = conn.prepare( + "SELECT token, name, symbol, decimals + FROM token_metadata + ORDER BY token ASC + LIMIT ?1", + )?; + let rows = stmt.query_map(params![fetch_limit as i64], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let decimals: Option = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + decimals.map(|d| d as u8), + )) + })?; + rows.collect::, _>>()? + }; + + let capped = limit.clamp(1, 1000) as usize; + let next_cursor = if out.len() > capped { + let next = out[capped].0; + out.truncate(capped); + Some(next) + } else { + None + }; + + Ok((out, next_cursor)) + } } diff --git a/crates/torii-erc721/proto/erc721.proto b/crates/torii-erc721/proto/erc721.proto index 6e45fb8..cb8bd26 100644 --- a/crates/torii-erc721/proto/erc721.proto +++ b/crates/torii-erc721/proto/erc721.proto @@ -180,6 +180,38 @@ message TransferUpdate { int64 timestamp = 2; } +// ===== Token Metadata ===== + +// Request for GetTokenMetadata RPC +message GetTokenMetadataRequest { + // Token contract address (32 bytes). If empty, returns all tokens. + optional bytes token = 1; + // Cursor token (exclusive). Only used when token is not set. + optional bytes cursor = 2; + // Maximum number of entries to return (default: 100, max: 1000). + uint32 limit = 3; +} + +// Token metadata entry +message TokenMetadataEntry { + // Token contract address (32 bytes) + bytes token = 1; + // Token name (e.g. "Loot Survivor") + optional string name = 2; + // Token symbol (e.g. "LS") + optional string symbol = 3; + // Total supply as U256 (variable length, up to 32 bytes) + optional bytes total_supply = 4; +} + +// Response for GetTokenMetadata RPC +message GetTokenMetadataResponse { + // Token metadata entries + repeated TokenMetadataEntry tokens = 1; + // Cursor for next page (absent if no more results). + optional bytes next_cursor = 2; +} + // ===== Stats ===== // Request for GetStats RPC @@ -210,6 +242,9 @@ service Erc721 { // Get the current owner of a specific NFT rpc GetOwner(GetOwnerRequest) returns (GetOwnerResponse); + // Get token metadata (name, symbol) + rpc GetTokenMetadata(GetTokenMetadataRequest) returns (GetTokenMetadataResponse); + // Subscribe to real-time transfer events with filtering rpc SubscribeTransfers(SubscribeTransfersRequest) returns (stream TransferUpdate); diff --git a/crates/torii-erc721/src/decoder.rs b/crates/torii-erc721/src/decoder.rs index ef494e5..15ed89a 100644 --- a/crates/torii-erc721/src/decoder.rs +++ b/crates/torii-erc721/src/decoder.rs @@ -85,12 +85,61 @@ impl TypedBody for OperatorApproval { } } +/// MetadataUpdate event (EIP-4906) — single token +#[derive(Debug, Clone)] +pub struct MetadataUpdate { + pub token: Felt, + pub token_id: U256, + pub block_number: u64, + pub transaction_hash: Felt, +} + +impl TypedBody for MetadataUpdate { + fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { + torii::etl::envelope::TypeId::new("erc721.metadata_update") + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +/// BatchMetadataUpdate event (EIP-4906) — range of tokens +#[derive(Debug, Clone)] +pub struct BatchMetadataUpdate { + pub token: Felt, + pub from_token_id: U256, + pub to_token_id: U256, + pub block_number: u64, + pub transaction_hash: Felt, +} + +impl TypedBody for BatchMetadataUpdate { + fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { + torii::etl::envelope::TypeId::new("erc721.batch_metadata_update") + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + /// ERC721 event decoder /// /// Decodes multiple ERC721 events: /// - Transfer(from, to, token_id) /// - Approval(owner, approved, token_id) /// - ApprovalForAll(owner, operator, approved) +/// - MetadataUpdate(token_id) — EIP-4906 +/// - BatchMetadataUpdate(from_token_id, to_token_id) — EIP-4906 /// /// Supports both modern (keys) and legacy (data-only) formats from OpenZeppelin. pub struct Erc721Decoder; @@ -115,6 +164,16 @@ impl Erc721Decoder { selector!("ApprovalForAll") } + /// MetadataUpdate event selector (EIP-4906): sn_keccak("MetadataUpdate") + fn metadata_update_selector() -> Felt { + selector!("MetadataUpdate") + } + + /// BatchMetadataUpdate event selector (EIP-4906): sn_keccak("BatchMetadataUpdate") + fn batch_metadata_update_selector() -> Felt { + selector!("BatchMetadataUpdate") + } + /// Decode Transfer event into envelope /// /// Transfer event signatures (supports both modern and legacy): @@ -378,6 +437,113 @@ impl Erc721Decoder { metadata, ))) } + + /// Decode MetadataUpdate event (EIP-4906) + /// + /// MetadataUpdate(uint256 tokenId): + /// - keys[0]: selector + /// - data[0]: token_id_low + /// - data[1]: token_id_high + /// OR: + /// - keys[0]: selector + /// - keys[1]: token_id_low + /// - keys[2]: token_id_high + async fn decode_metadata_update(&self, event: &EmittedEvent) -> Result> { + let token_id: U256; + + if event.data.len() >= 2 { + let low: u128 = event.data[0].try_into().unwrap_or(0); + let high: u128 = event.data[1].try_into().unwrap_or(0); + token_id = U256::from_words(low, high); + } else if event.keys.len() >= 3 { + let low: u128 = event.keys[1].try_into().unwrap_or(0); + let high: u128 = event.keys[2].try_into().unwrap_or(0); + token_id = U256::from_words(low, high); + } else if event.data.len() == 1 { + let id: u128 = event.data[0].try_into().unwrap_or(0); + token_id = U256::from(id); + } else if event.keys.len() == 2 { + let id: u128 = event.keys[1].try_into().unwrap_or(0); + token_id = U256::from(id); + } else { + tracing::warn!( + target: "torii_erc721::decoder", + token = %format!("{:#x}", event.from_address), + "Malformed MetadataUpdate event" + ); + return Ok(None); + } + + let update = MetadataUpdate { + token: event.from_address, + token_id, + block_number: event.block_number.unwrap_or(0), + transaction_hash: event.transaction_hash, + }; + + let envelope_id = format!( + "erc721_metadata_update_{}_{:#x}", + event.block_number.unwrap_or(0), + event.transaction_hash + ); + + Ok(Some(Envelope::new( + envelope_id, + Box::new(update), + HashMap::new(), + ))) + } + + /// Decode BatchMetadataUpdate event (EIP-4906) + /// + /// BatchMetadataUpdate(uint256 fromTokenId, uint256 toTokenId) + async fn decode_batch_metadata_update(&self, event: &EmittedEvent) -> Result> { + let from_token_id: U256; + let to_token_id: U256; + + if event.data.len() >= 4 { + let low: u128 = event.data[0].try_into().unwrap_or(0); + let high: u128 = event.data[1].try_into().unwrap_or(0); + from_token_id = U256::from_words(low, high); + let low: u128 = event.data[2].try_into().unwrap_or(0); + let high: u128 = event.data[3].try_into().unwrap_or(0); + to_token_id = U256::from_words(low, high); + } else if event.keys.len() >= 5 { + let low: u128 = event.keys[1].try_into().unwrap_or(0); + let high: u128 = event.keys[2].try_into().unwrap_or(0); + from_token_id = U256::from_words(low, high); + let low: u128 = event.keys[3].try_into().unwrap_or(0); + let high: u128 = event.keys[4].try_into().unwrap_or(0); + to_token_id = U256::from_words(low, high); + } else { + tracing::warn!( + target: "torii_erc721::decoder", + token = %format!("{:#x}", event.from_address), + "Malformed BatchMetadataUpdate event" + ); + return Ok(None); + } + + let update = BatchMetadataUpdate { + token: event.from_address, + from_token_id, + to_token_id, + block_number: event.block_number.unwrap_or(0), + transaction_hash: event.transaction_hash, + }; + + let envelope_id = format!( + "erc721_batch_metadata_update_{}_{:#x}", + event.block_number.unwrap_or(0), + event.transaction_hash + ); + + Ok(Some(Envelope::new( + envelope_id, + Box::new(update), + HashMap::new(), + ))) + } } impl Default for Erc721Decoder { @@ -411,6 +577,14 @@ impl Decoder for Erc721Decoder { if let Some(envelope) = self.decode_approval_for_all(event).await? { return Ok(vec![envelope]); } + } else if selector == Self::metadata_update_selector() { + if let Some(envelope) = self.decode_metadata_update(event).await? { + return Ok(vec![envelope]); + } + } else if selector == Self::batch_metadata_update_selector() { + if let Some(envelope) = self.decode_batch_metadata_update(event).await? { + return Ok(vec![envelope]); + } } else { tracing::trace!( target: "torii_erc721::decoder", diff --git a/crates/torii-erc721/src/generated/erc721_descriptor.bin b/crates/torii-erc721/src/generated/erc721_descriptor.bin index 14239dc..5430539 100644 Binary files a/crates/torii-erc721/src/generated/erc721_descriptor.bin and b/crates/torii-erc721/src/generated/erc721_descriptor.bin differ diff --git a/crates/torii-erc721/src/generated/torii.sinks.erc721.rs b/crates/torii-erc721/src/generated/torii.sinks.erc721.rs index 47cb152..c852005 100644 --- a/crates/torii-erc721/src/generated/torii.sinks.erc721.rs +++ b/crates/torii-erc721/src/generated/torii.sinks.erc721.rs @@ -221,6 +221,45 @@ pub struct TransferUpdate { #[prost(int64, tag = "2")] pub timestamp: i64, } +/// Request for GetTokenMetadata RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTokenMetadataRequest { + /// Token contract address (32 bytes). If empty, returns all tokens. + #[prost(bytes = "vec", optional, tag = "1")] + pub token: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Cursor token (exclusive). Only used when token is not set. + #[prost(bytes = "vec", optional, tag = "2")] + pub cursor: ::core::option::Option<::prost::alloc::vec::Vec>, + /// Maximum number of entries to return (default: 100, max: 1000). + #[prost(uint32, tag = "3")] + pub limit: u32, +} +/// Token metadata entry +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TokenMetadataEntry { + /// Token contract address (32 bytes) + #[prost(bytes = "vec", tag = "1")] + pub token: ::prost::alloc::vec::Vec, + /// Token name (e.g. "Loot Survivor") + #[prost(string, optional, tag = "2")] + pub name: ::core::option::Option<::prost::alloc::string::String>, + /// Token symbol (e.g. "LS") + #[prost(string, optional, tag = "3")] + pub symbol: ::core::option::Option<::prost::alloc::string::String>, + /// Total supply as U256 (variable length, up to 32 bytes) + #[prost(bytes = "vec", optional, tag = "4")] + pub total_supply: ::core::option::Option<::prost::alloc::vec::Vec>, +} +/// Response for GetTokenMetadata RPC +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTokenMetadataResponse { + /// Token metadata entries + #[prost(message, repeated, tag = "1")] + pub tokens: ::prost::alloc::vec::Vec, + /// Cursor for next page (absent if no more results). + #[prost(bytes = "vec", optional, tag = "2")] + pub next_cursor: ::core::option::Option<::prost::alloc::vec::Vec>, +} /// Request for GetStats RPC #[derive(Clone, Copy, PartialEq, ::prost::Message)] pub struct GetStatsRequest {} @@ -277,6 +316,14 @@ pub mod erc721_server { tonic::Response, tonic::Status, >; + /// Get token metadata (name, symbol) + async fn get_token_metadata( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Server streaming response type for the SubscribeTransfers method. type SubscribeTransfersStream: tonic::codegen::tokio_stream::Stream< Item = std::result::Result, @@ -510,6 +557,51 @@ pub mod erc721_server { }; Box::pin(fut) } + "/torii.sinks.erc721.Erc721/GetTokenMetadata" => { + #[allow(non_camel_case_types)] + struct GetTokenMetadataSvc(pub Arc); + impl< + T: Erc721, + > tonic::server::UnaryService + for GetTokenMetadataSvc { + type Response = super::GetTokenMetadataResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_token_metadata(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetTokenMetadataSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/torii.sinks.erc721.Erc721/SubscribeTransfers" => { #[allow(non_camel_case_types)] struct SubscribeTransfersSvc(pub Arc); diff --git a/crates/torii-erc721/src/grpc_service.rs b/crates/torii-erc721/src/grpc_service.rs index 847e0fc..1ffc343 100644 --- a/crates/torii-erc721/src/grpc_service.rs +++ b/crates/torii-erc721/src/grpc_service.rs @@ -3,8 +3,9 @@ use crate::proto::{ erc721_server::Erc721 as Erc721Trait, Cursor, GetOwnerRequest, GetOwnerResponse, GetOwnershipRequest, GetOwnershipResponse, GetStatsRequest, GetStatsResponse, - GetTransfersRequest, GetTransfersResponse, NftTransfer, Ownership, SubscribeTransfersRequest, - TransferFilter, TransferUpdate, + GetTokenMetadataRequest, GetTokenMetadataResponse, GetTransfersRequest, GetTransfersResponse, + NftTransfer, Ownership, SubscribeTransfersRequest, TokenMetadataEntry, TransferFilter, + TransferUpdate, }; use crate::storage::{Erc721Storage, NftTransferData, TransferCursor}; use async_trait::async_trait; @@ -245,6 +246,62 @@ impl Erc721Trait for Erc721Service { })) } + /// Get token metadata (name, symbol) + async fn get_token_metadata( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + if let Some(token_bytes) = req.token { + let token = bytes_to_felt(&token_bytes) + .ok_or_else(|| Status::invalid_argument("Invalid token address"))?; + + let entries = match self.storage.get_token_metadata(token) { + Ok(Some((name, symbol, total_supply))) => vec![TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name, + symbol, + total_supply: total_supply.map(u256_to_bytes), + }], + Ok(None) => vec![], + Err(e) => return Err(Status::internal(format!("Query failed: {e}"))), + }; + + return Ok(Response::new(GetTokenMetadataResponse { + tokens: entries, + next_cursor: None, + })); + } + + let cursor = req.cursor.as_ref().and_then(|b| bytes_to_felt(b)); + let limit = if req.limit == 0 { + 100 + } else { + req.limit.min(1000) + }; + + let (all, next_cursor) = self + .storage + .get_token_metadata_paginated(cursor, limit) + .map_err(|e| Status::internal(format!("Query failed: {e}")))?; + + let entries = all + .into_iter() + .map(|(token, name, symbol, total_supply)| TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name, + symbol, + total_supply: total_supply.map(u256_to_bytes), + }) + .collect(); + + Ok(Response::new(GetTokenMetadataResponse { + tokens: entries, + next_cursor: next_cursor.map(|c| c.to_bytes_be().to_vec()), + })) + } + /// Subscribe to real-time transfer events type SubscribeTransfersStream = Pin> + Send>>; diff --git a/crates/torii-erc721/src/lib.rs b/crates/torii-erc721/src/lib.rs index 2245ff3..1a9c211 100644 --- a/crates/torii-erc721/src/lib.rs +++ b/crates/torii-erc721/src/lib.rs @@ -50,7 +50,9 @@ pub mod proto { pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("generated/erc721_descriptor.bin"); // Re-export main types for convenience -pub use decoder::{Erc721Decoder, NftApproval, NftTransfer, OperatorApproval}; +pub use decoder::{ + BatchMetadataUpdate, Erc721Decoder, MetadataUpdate, NftApproval, NftTransfer, OperatorApproval, +}; pub use grpc_service::Erc721Service; pub use identification::Erc721Rule; pub use sink::Erc721Sink; diff --git a/crates/torii-erc721/src/sink.rs b/crates/torii-erc721/src/sink.rs index a8497fc..f9c6ee3 100644 --- a/crates/torii-erc721/src/sink.rs +++ b/crates/torii-erc721/src/sink.rs @@ -1,6 +1,7 @@ //! ERC721 sink for processing NFT transfers, approvals, and ownership use crate::decoder::{ + BatchMetadataUpdate as DecodedBatchMetadataUpdate, MetadataUpdate as DecodedMetadataUpdate, NftTransfer as DecodedNftTransfer, OperatorApproval as DecodedOperatorApproval, }; use crate::grpc_service::Erc721Service; @@ -11,12 +12,16 @@ use async_trait::async_trait; use axum::Router; use prost::Message; use prost_types::Any; -use std::collections::HashMap; +use starknet::core::types::Felt; +use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use torii::etl::sink::{EventBus, TopicInfo}; use torii::etl::{Envelope, ExtractionBatch, Sink, TypeId}; use torii::grpc::UpdateType; -use torii_common::u256_to_bytes; +use torii_common::{ + u256_to_bytes, MetadataFetcher, TokenStandard, TokenUriRequest, TokenUriSender, +}; /// Default threshold for "live" detection: 100 blocks from chain head. /// Events from blocks older than this won't be broadcast to real-time subscribers. @@ -35,6 +40,10 @@ pub struct Erc721Sink { storage: Arc, event_bus: Option>, grpc_service: Option, + /// Metadata fetcher for token name/symbol + metadata_fetcher: Option>, + /// Token URI service sender for async URI fetching + token_uri_sender: Option, } impl Erc721Sink { @@ -43,9 +52,23 @@ impl Erc721Sink { storage, event_bus: None, grpc_service: None, + metadata_fetcher: None, + token_uri_sender: None, } } + /// Enable metadata fetching with a provider + pub fn with_metadata_fetching(mut self, provider: Arc>) -> Self { + self.metadata_fetcher = Some(Arc::new(MetadataFetcher::new(provider))); + self + } + + /// Enable async token URI fetching + pub fn with_token_uri_sender(mut self, sender: TokenUriSender) -> Self { + self.token_uri_sender = Some(sender); + self + } + /// Set the gRPC service for dual publishing pub fn with_grpc_service(mut self, service: Erc721Service) -> Self { self.grpc_service = Some(service); @@ -103,6 +126,28 @@ impl Erc721Sink { true } + + /// Filter function for ERC721 token metadata updates. + /// + /// Supports filters: + /// - "token": Filter by token contract address (hex string) + fn matches_metadata_filters( + metadata: &proto::TokenMetadataEntry, + filters: &HashMap, + ) -> bool { + if filters.is_empty() { + return true; + } + + if let Some(token_filter) = filters.get("token") { + let token_hex = format!("0x{}", hex::encode(&metadata.token)); + if !token_hex.eq_ignore_ascii_case(token_filter) { + return false; + } + } + + true + } } #[async_trait] @@ -116,6 +161,8 @@ impl Sink for Erc721Sink { TypeId::new("erc721.transfer"), TypeId::new("erc721.approval"), TypeId::new("erc721.approval_for_all"), + TypeId::new("erc721.metadata_update"), + TypeId::new("erc721.batch_metadata_update"), ] } @@ -178,10 +225,147 @@ impl Sink for Erc721Sink { }); } } + // Handle MetadataUpdate (EIP-4906) — single token + else if envelope.type_id == TypeId::new("erc721.metadata_update") { + if let Some(update) = envelope + .body + .as_any() + .downcast_ref::() + { + if let Some(ref sender) = self.token_uri_sender { + sender + .request_update(TokenUriRequest { + contract: update.token, + token_id: update.token_id, + standard: TokenStandard::Erc721, + }) + .await; + } + } + } + // Handle BatchMetadataUpdate (EIP-4906) — range of tokens + else if envelope.type_id == TypeId::new("erc721.batch_metadata_update") { + if let Some(update) = envelope + .body + .as_any() + .downcast_ref::() + { + if let Some(ref sender) = self.token_uri_sender { + // For batch updates, we need to know which token IDs exist in the range. + // Fetch them from storage and request URI updates for each. + if let Ok(uris) = self.storage.get_token_uris_by_contract(update.token) { + for (token_id, _, _) in &uris { + if *token_id >= update.from_token_id + && *token_id <= update.to_token_id + { + sender + .request_update(TokenUriRequest { + contract: update.token, + token_id: *token_id, + standard: TokenStandard::Erc721, + }) + .await; + } + } + } + } + } + } // Note: erc721.approval (single token approval) could be handled similarly // but is less commonly needed for indexing purposes } + // Fetch metadata for any new token contracts + if let Some(ref fetcher) = self.metadata_fetcher { + let new_tokens: HashSet = transfers.iter().map(|t| t.token).collect(); + for token in new_tokens { + match self.storage.has_token_metadata(token) { + Ok(exists) => { + if exists { + continue; + } + + let meta = fetcher.fetch_erc721_metadata(token).await; + tracing::info!( + target: "torii_erc721::sink", + token = %format!("{:#x}", token), + name = ?meta.name, + symbol = ?meta.symbol, + "Fetched token metadata" + ); + if let Err(e) = self.storage.upsert_token_metadata( + token, + meta.name.as_deref(), + meta.symbol.as_deref(), + meta.total_supply, + ) { + tracing::warn!( + target: "torii_erc721::sink", + error = %e, + "Failed to store token metadata" + ); + } else if let Some(event_bus) = &self.event_bus { + let meta_entry = proto::TokenMetadataEntry { + token: token.to_bytes_be().to_vec(), + name: meta.name, + symbol: meta.symbol, + total_supply: meta.total_supply.map(u256_to_bytes), + }; + + let mut buf = Vec::new(); + meta_entry.encode(&mut buf)?; + let any = Any { + type_url: + "type.googleapis.com/torii.sinks.erc721.TokenMetadataEntry" + .to_string(), + value: buf, + }; + + event_bus.publish_protobuf( + "erc721.metadata", + "erc721.metadata", + &any, + &meta_entry, + UpdateType::Created, + Self::matches_metadata_filters, + ); + } + } + Err(e) => { + tracing::warn!(target: "torii_erc721::sink", error = %e, "Failed to check token metadata"); + } + } + } + } + + // Request token URI fetches for new token IDs + if let Some(ref sender) = self.token_uri_sender { + for transfer in &transfers { + match self + .storage + .has_token_uri(transfer.token, transfer.token_id) + { + Ok(false) => { + sender + .request_update(TokenUriRequest { + contract: transfer.token, + token_id: transfer.token_id, + standard: TokenStandard::Erc721, + }) + .await; + } + Ok(true) => {} + Err(e) => { + tracing::warn!( + target: "torii_erc721::sink", + error = %e, + "Failed to check token URI existence" + ); + } + } + } + } + // Batch insert transfers if !transfers.is_empty() { let transfer_count = match self.storage.insert_transfers_batch(&transfers) { @@ -295,16 +479,23 @@ impl Sink for Erc721Sink { } fn topics(&self) -> Vec { - vec![TopicInfo::new( - "erc721.transfer", - vec![ - "token".to_string(), - "from".to_string(), - "to".to_string(), - "wallet".to_string(), - ], - "ERC721 NFT transfers. Use 'wallet' filter for from OR to matching.", - )] + vec![ + TopicInfo::new( + "erc721.transfer", + vec![ + "token".to_string(), + "from".to_string(), + "to".to_string(), + "wallet".to_string(), + ], + "ERC721 NFT transfers. Use 'wallet' filter for from OR to matching.", + ), + TopicInfo::new( + "erc721.metadata", + vec!["token".to_string()], + "ERC721 token metadata updates (registered/updated token attributes).", + ), + ] } fn build_routes(&self) -> Router { diff --git a/crates/torii-erc721/src/storage.rs b/crates/torii-erc721/src/storage.rs index 9669572..e5f3bdf 100644 --- a/crates/torii-erc721/src/storage.rs +++ b/crates/torii-erc721/src/storage.rs @@ -7,7 +7,9 @@ use anyhow::Result; use rusqlite::{params, Connection}; use starknet::core::types::{Felt, U256}; use std::sync::{Arc, Mutex}; -use torii_common::{blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob}; +use torii_common::{ + blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob, TokenUriResult, TokenUriStore, +}; /// Storage for ERC721 NFT data pub struct Erc721Storage { @@ -215,6 +217,41 @@ impl Erc721Storage { [], )?; + // Token metadata table + conn.execute( + "CREATE TABLE IF NOT EXISTS token_metadata ( + token BLOB PRIMARY KEY, + name TEXT, + symbol TEXT, + total_supply BLOB + )", + [], + )?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS token_uris ( + token BLOB NOT NULL, + token_id BLOB NOT NULL, + uri TEXT, + metadata_json TEXT, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (token, token_id) + ); + CREATE INDEX IF NOT EXISTS idx_token_uris_token ON token_uris(token); + + CREATE TABLE IF NOT EXISTS token_attributes ( + token BLOB NOT NULL, + token_id BLOB NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (token, token_id, key), + FOREIGN KEY (token, token_id) REFERENCES token_uris(token, token_id) + ); + CREATE INDEX IF NOT EXISTS idx_token_attributes_token ON token_attributes(token); + CREATE INDEX IF NOT EXISTS idx_token_attributes_key ON token_attributes(key); + CREATE INDEX IF NOT EXISTS idx_token_attributes_key_value ON token_attributes(key, value);", + )?; + tracing::info!(target: "torii_erc721::storage", db_path = %db_path, "ERC721 database initialized"); Ok(Self { @@ -605,4 +642,255 @@ impl Erc721Storage { .ok(); Ok(block.map(|b| b as u64)) } + + // ===== Token Metadata Methods ===== + + /// Check if metadata exists for a token + pub fn has_token_metadata(&self, token: Felt) -> Result { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM token_metadata WHERE token = ?", + params![&token_blob], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Insert or update token metadata + pub fn upsert_token_metadata( + &self, + token: Felt, + name: Option<&str>, + symbol: Option<&str>, + total_supply: Option, + ) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let supply_blob = total_supply.map(u256_to_blob); + conn.execute( + "INSERT INTO token_metadata (token, name, symbol, total_supply) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(token) DO UPDATE SET + name = COALESCE(excluded.name, token_metadata.name), + symbol = COALESCE(excluded.symbol, token_metadata.symbol), + total_supply = COALESCE(excluded.total_supply, token_metadata.total_supply)", + params![&token_blob, name, symbol, supply_blob], + )?; + Ok(()) + } + + /// Get token metadata + pub fn get_token_metadata( + &self, + token: Felt, + ) -> Result, Option, Option)>> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let result = conn + .query_row( + "SELECT name, symbol, total_supply FROM token_metadata WHERE token = ?", + params![&token_blob], + |row| { + let name: Option = row.get(0)?; + let symbol: Option = row.get(1)?; + let supply_bytes: Option> = row.get(2)?; + Ok((name, symbol, supply_bytes.map(|b| blob_to_u256(&b)))) + }, + ) + .ok(); + Ok(result) + } + + /// Get all token metadata + pub fn get_all_token_metadata( + &self, + ) -> Result, Option, Option)>> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT token, name, symbol, total_supply FROM token_metadata")?; + let rows = stmt.query_map([], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let supply_bytes: Option> = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + supply_bytes.map(|b| blob_to_u256(&b)), + )) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Get token metadata with cursor-based pagination. + /// + /// Returns at most `limit` rows and an optional next cursor token. + pub fn get_token_metadata_paginated( + &self, + cursor: Option, + limit: u32, + ) -> Result<( + Vec<(Felt, Option, Option, Option)>, + Option, + )> { + let conn = self.conn.lock().unwrap(); + let fetch_limit = limit.clamp(1, 1000) as usize + 1; + + let mut out = if let Some(cursor_token) = cursor { + let cursor_blob = felt_to_blob(cursor_token); + let mut stmt = conn.prepare( + "SELECT token, name, symbol, total_supply + FROM token_metadata + WHERE token > ?1 + ORDER BY token ASC + LIMIT ?2", + )?; + let rows = stmt.query_map(params![&cursor_blob, fetch_limit as i64], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let supply_bytes: Option> = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + supply_bytes.map(|b| blob_to_u256(&b)), + )) + })?; + rows.collect::, _>>()? + } else { + let mut stmt = conn.prepare( + "SELECT token, name, symbol, total_supply + FROM token_metadata + ORDER BY token ASC + LIMIT ?1", + )?; + let rows = stmt.query_map(params![fetch_limit as i64], |row| { + let token_bytes: Vec = row.get(0)?; + let name: Option = row.get(1)?; + let symbol: Option = row.get(2)?; + let supply_bytes: Option> = row.get(3)?; + Ok(( + blob_to_felt(&token_bytes), + name, + symbol, + supply_bytes.map(|b| blob_to_u256(&b)), + )) + })?; + rows.collect::, _>>()? + }; + + let capped = limit.clamp(1, 1000) as usize; + let next_cursor = if out.len() > capped { + let next = out[capped].0; + out.truncate(capped); + Some(next) + } else { + None + }; + + Ok((out, next_cursor)) + } + + /// Returns true if a token URI row exists for `(token, token_id)`. + pub fn has_token_uri(&self, token: Felt, token_id: U256) -> Result { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let token_id_blob = u256_to_blob(token_id); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM token_uris WHERE token = ?1 AND token_id = ?2", + params![&token_blob, &token_id_blob], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Returns all token URI rows for a given contract. + pub fn get_token_uris_by_contract( + &self, + token: Felt, + ) -> Result, Option)>> { + let conn = self.conn.lock().unwrap(); + let token_blob = felt_to_blob(token); + let mut stmt = conn.prepare( + "SELECT token_id, uri, metadata_json + FROM token_uris + WHERE token = ?1 + ORDER BY token_id ASC", + )?; + let rows = stmt.query_map(params![&token_blob], |row| { + let token_id_bytes: Vec = row.get(0)?; + let uri: Option = row.get(1)?; + let metadata_json: Option = row.get(2)?; + Ok((blob_to_u256(&token_id_bytes), uri, metadata_json)) + })?; + rows.collect::, _>>().map_err(Into::into) + } +} + +#[async_trait::async_trait] +impl TokenUriStore for Erc721Storage { + async fn store_token_uri(&self, result: &TokenUriResult) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + let token_blob = felt_to_blob(result.contract); + let token_id_blob = u256_to_blob(result.token_id); + + tx.execute( + "INSERT INTO token_uris (token, token_id, uri, metadata_json, updated_at) + VALUES (?1, ?2, ?3, ?4, strftime('%s', 'now')) + ON CONFLICT(token, token_id) DO UPDATE SET + uri = excluded.uri, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at", + params![ + &token_blob, + &token_id_blob, + result.uri.as_deref(), + result.metadata_json.as_deref() + ], + )?; + + tx.execute( + "DELETE FROM token_attributes WHERE token = ?1 AND token_id = ?2", + params![&token_blob, &token_id_blob], + )?; + + if let Some(metadata_json) = &result.metadata_json { + if let Ok(value) = serde_json::from_str::(metadata_json) { + if let Some(attrs) = value.get("attributes").and_then(|a| a.as_array()) { + let mut attr_stmt = tx.prepare_cached( + "INSERT OR REPLACE INTO token_attributes (token, token_id, key, value) + VALUES (?1, ?2, ?3, ?4)", + )?; + + for attr in attrs { + let key = attr + .get("trait_type") + .or_else(|| attr.get("key")) + .and_then(|v| v.as_str()); + let value = attr.get("value").and_then(|v| { + v.as_str().map(ToOwned::to_owned).or_else(|| { + if v.is_null() { + None + } else { + Some(v.to_string()) + } + }) + }); + + if let (Some(key), Some(value)) = (key, value) { + attr_stmt.execute(params![&token_blob, &token_id_blob, key, value])?; + } + } + } + } + } + + tx.commit()?; + Ok(()) + } } diff --git a/src/etl/extractor/starknet_helpers.rs b/src/etl/extractor/starknet_helpers.rs index 45b03f3..99c0593 100644 --- a/src/etl/extractor/starknet_helpers.rs +++ b/src/etl/extractor/starknet_helpers.rs @@ -317,11 +317,15 @@ impl ContractAbi { /// Check if ABI contains a function with the given name, /// recursively checking interfaces. + /// + /// This matches: + /// - Exact function names (e.g., "transfer") + /// - Fully qualified names (e.g., "openzeppelin::token::erc20::ERC20::transfer") pub fn has_function(&self, name: &str) -> bool { if let Some(abi) = &self.abi { for entry in abi { if let AbiEntry::Function(func) = entry { - if func.name == name { + if func.name == name || func.name.ends_with(&format!("::{name}")) { return true; } } @@ -329,7 +333,7 @@ impl ContractAbi { if let AbiEntry::Interface(interface) = entry { for function in &interface.items { if let AbiEntry::Function(func) = function { - if func.name == name { + if func.name == name || func.name.ends_with(&format!("::{name}")) { return true; } } @@ -341,7 +345,7 @@ impl ContractAbi { if let Some(legacy_abi) = &self.legacy_abi { for entry in legacy_abi { if let LegacyContractAbiEntry::Function(func) = entry { - if func.name == name { + if func.name == name || func.name.ends_with(&format!("::{name}")) { return true; } } @@ -352,6 +356,11 @@ impl ContractAbi { } /// Check if ABI contains an event with the given name. + /// + /// This matches: + /// - Exact event names (e.g., "Transfer") + /// - Fully qualified names (e.g., "openzeppelin::token::erc20::ERC20::Transfer") + /// - Enum variant names (e.g., "Transfer" inside an Event enum) pub fn has_event(&self, name: &str) -> bool { if let Some(abi) = &self.abi { for entry in abi { @@ -359,17 +368,25 @@ impl ContractAbi { use starknet::core::types::contract::AbiEvent; match event { AbiEvent::Typed(TypedAbiEvent::Struct(s)) => { - if s.name == name { + // Check exact name or if fully qualified name ends with target name + if s.name == name || s.name.ends_with(&format!("::{name}")) { return true; } } AbiEvent::Typed(TypedAbiEvent::Enum(e)) => { - if e.name == name { + // Check enum name + if e.name == name || e.name.ends_with(&format!("::{name}")) { return true; } + // Check enum variants (for nested events like OpenZeppelin v0.7.0) + for variant in &e.variants { + if variant.name == name { + return true; + } + } } AbiEvent::Untyped(u) => { - if u.name == name { + if u.name == name || u.name.ends_with(&format!("::{name}")) { return true; } } @@ -381,7 +398,7 @@ impl ContractAbi { if let Some(legacy_abi) = &self.legacy_abi { for entry in legacy_abi { if let LegacyContractAbiEntry::Event(event) = entry { - if event.name == name { + if event.name == name || event.name.ends_with(&format!("::{name}")) { return true; } } diff --git a/src/grpc.rs b/src/grpc.rs index 23a3edb..6e4b062 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -228,7 +228,7 @@ impl Torii for ToriiService { let client_id = sub_req.client_id.clone(); // Register client and set up subscriptions - subscription_manager.register_client(client_id.clone(), tx); + subscription_manager.register_client(client_id.clone(), tx.clone()); subscription_manager.update_subscriptions( &client_id, sub_req.topics, @@ -241,12 +241,12 @@ impl Torii for ToriiService { client_id ); - // Spawn task to clean up on disconnect + // Clean up as soon as the stream receiver is dropped (client disconnects). + // This avoids stale client entries accumulating in SubscriptionManager. let cleanup_manager = subscription_manager; let cleanup_id = client_id; tokio::spawn(async move { - // Wait for receiver to be dropped (client disconnects) - tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; + tx.closed().await; cleanup_manager.unregister_client(&cleanup_id); }); diff --git a/torii.js/src/client/ToriiClient.ts b/torii.js/src/client/ToriiClient.ts index 7226250..deaa07f 100644 --- a/torii.js/src/client/ToriiClient.ts +++ b/torii.js/src/client/ToriiClient.ts @@ -18,7 +18,7 @@ import { BaseSinkClient } from './BaseSinkClient'; import { GrpcTransport } from './GrpcTransport'; -import { decodeProtobufObject } from './protobuf'; +import { decodeProtobufObject, decodeWithSchema, getSchemaRegistry } from './protobuf'; // Type for a client class constructor type ClientClass = new (baseUrl: string) => T; @@ -134,6 +134,8 @@ class ToriiClientImpl { onConnected?: () => void ): Promise<() => void> { this._abortController = new AbortController(); + const registry = getSchemaRegistry(); + const topicUpdateSchema = registry['torii.TopicUpdate'] || registry['TopicUpdate']; const topicsEncoded = topics.map((t) => { const sub: Record = { f1: t.topic }; @@ -153,21 +155,72 @@ class ToriiClientImpl { for await (const response of this._transport.streamCall>( '/torii.Torii/SubscribeToTopicsStream', request, - { abort: this._abortController?.signal, onConnected } + { + abort: this._abortController?.signal, + onConnected, + responseSchema: topicUpdateSchema, + } )) { - let data = response.f5; - if (data instanceof Uint8Array) { + const normalized = topicUpdateSchema + ? response + : { + topic: String(response.f1 ?? ''), + updateType: Number(response.f2 ?? 0), + timestamp: Number(response.f3 ?? 0), + typeId: String(response.f4 ?? ''), + data: response.f5, + }; + + let data = (normalized as Record).data; + + if (data && typeof data === 'object') { + const anyData = data as Record; + const typeUrl = + typeof anyData.typeUrl === 'string' ? (anyData.typeUrl as string) : ''; + const anyValue = anyData.value; + + if (typeUrl && anyValue instanceof Uint8Array) { + const fullTypeName = typeUrl.split('/').pop() ?? ''; + const shortTypeName = fullTypeName.split('.').pop() ?? ''; + const payloadSchema = registry[fullTypeName] || registry[shortTypeName]; + + if (payloadSchema) { + try { + data = { + typeUrl, + value: decodeWithSchema(anyValue, payloadSchema), + }; + } catch { + data = { + typeUrl, + value: decodeProtobufObject(anyValue), + }; + } + } else { + try { + data = { + typeUrl, + value: decodeProtobufObject(anyValue), + }; + } catch { + // Keep raw Any when payload cannot be decoded. + } + } + } + } else if (data instanceof Uint8Array) { + // Backward compatibility if no schema is registered. try { data = decodeProtobufObject(data); } catch { // Leave as raw bytes } } + onUpdate({ - topic: String(response.f1 ?? ''), - updateType: Number(response.f2 ?? 0), - timestamp: Number(response.f3 ?? 0), - typeId: String(response.f4 ?? ''), + topic: String((normalized as Record).topic ?? ''), + updateType: Number((normalized as Record).updateType ?? 0), + timestamp: Number((normalized as Record).timestamp ?? 0), + typeId: String((normalized as Record).typeId ?? ''), data, }); } diff --git a/torii.js/src/client/protobuf.ts b/torii.js/src/client/protobuf.ts index 3c80d29..a7f1ebb 100644 --- a/torii.js/src/client/protobuf.ts +++ b/torii.js/src/client/protobuf.ts @@ -384,8 +384,23 @@ function mapFieldNumbersToNames( } // Handle google.protobuf.Any specially - if (fieldSchema.messageType === 'Any' && typeof value === 'object' && value !== null) { - const anyValue = value as Record; + if (fieldSchema.messageType === 'Any') { + let anyRaw: unknown = value; + if (anyRaw instanceof Uint8Array) { + try { + anyRaw = decodeProtobufObject(anyRaw); + } catch { + result[fieldName] = { typeUrl: '', value: anyRaw }; + continue; + } + } + + if (typeof anyRaw !== 'object' || anyRaw === null) { + result[fieldName] = { typeUrl: '', value: anyRaw }; + continue; + } + + const anyValue = anyRaw as Record; const typeUrl = String(anyValue.f1 ?? ''); const innerValue = anyValue.f2; @@ -396,6 +411,18 @@ function mapFieldNumbersToNames( // Try to find schema by short name or full name const innerSchema = schemaRegistry[typeName] || schemaRegistry[fullTypeName]; + if (innerSchema && innerValue instanceof Uint8Array) { + try { + result[fieldName] = { + typeUrl, + value: decodeWithSchema(innerValue, innerSchema), + }; + continue; + } catch { + // Fall through and keep raw value below. + } + } + if (innerSchema && typeof innerValue === 'object' && innerValue !== null) { result[fieldName] = { typeUrl,