Skip to content

Commit a1ce372

Browse files
authored
Sequential rank-ordered balance fetcher (#4063)
- **feat(app2): rank in token schema** - **feat(app2): sequential token fetcher** - **refactor(app2): more effective balance fetcher** - **feat(app2): query tokens by rank** - **feat(app2): general token blacklist mechanism** - **fix(app2): cleanup**
2 parents 9cc7c12 + 71b702a commit a1ce372

File tree

9 files changed

+693
-43193
lines changed

9 files changed

+693
-43193
lines changed

app2/src/generated/graphql-env.d.ts

Lines changed: 530 additions & 43097 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app2/src/lib/components/model/TokenComponent.svelte

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ interface Props {
1111
chain: Chain
1212
denom: TokenRawDenom
1313
amount?: TokenRawAmount
14+
showRank?: boolean
1415
}
1516
16-
const { chain, denom, amount = undefined }: Props = $props()
17+
const { chain, denom, amount = undefined, showRank = true }: Props = $props()
1718
1819
// Start the query when the component mounts
1920
$effect(() => {
@@ -89,6 +90,13 @@ const displayDenom = $derived(
8990
<section class="flex justify-between items-center">
9091
{#if token.value.representations.length > 0}
9192
<h2 class="text-white font-bold text-lg">{token.value.representations[0].symbol}</h2>
93+
<span class="text-neutral-500">
94+
{#if Option.isSome(token.value.rank)}
95+
Rank: #{token.value.rank.value}
96+
{:else}
97+
Unranked
98+
{/if}
99+
</span>
92100
{/if}
93101
</section>
94102
<section>

app2/src/lib/constants/tokens.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TokenRawDenom } from "$lib/schema/token"
2+
3+
/**
4+
* List of token denoms that should be excluded from display and processing
5+
*/
6+
export const TOKEN_BLACKLIST: Array<TokenRawDenom> = [
7+
TokenRawDenom.make("0x0000000000000000000000000000000000000000"),
8+
TokenRawDenom.make("0xb7fb16053a3e3d4306791045769ec686f6ec4432")
9+
]
10+
11+
/**
12+
* Checks if a token denom is blacklisted
13+
*/
14+
export const isTokenBlacklisted = (denom: TokenRawDenom): boolean => {
15+
return TOKEN_BLACKLIST.includes(denom)
16+
}

app2/src/lib/queries/tokens.svelte.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Effect, Option, Schema } from "effect"
22
import type { UniversalChainId } from "$lib/schema/chain"
3-
import { Token, TokenRawDenom } from "$lib/schema/token"
3+
import { Tokens } from "$lib/schema/token"
4+
import { isTokenBlacklisted } from "$lib/constants/tokens"
45
import { createQueryGraphql } from "$lib/utils/queries"
56
import { tokensStore } from "$lib/stores/tokens.svelte"
67
import { graphql } from "gql.tada"
@@ -9,10 +10,10 @@ export const tokensQuery = (universalChainId: UniversalChainId) =>
910
Effect.gen(function* () {
1011
yield* Effect.log(`zkgm starting token fetcher for ${universalChainId}`)
1112
const response = yield* createQueryGraphql({
12-
schema: Schema.Struct({ v2_tokens: Schema.Array(Token) }),
13+
schema: Schema.Struct({ v2_tokens: Tokens }),
1314
document: graphql(`
1415
query TokensForChain($universal_chain_id: String!) @cached(ttl: 60) {
15-
v2_tokens(args: { p_universal_chain_id: $universal_chain_id }) {
16+
v2_tokens(args: { p_universal_chain_id: $universal_chain_id }, order_by: {rank: asc_nulls_last}) {
1617
rank
1718
denom
1819
representations {
@@ -47,15 +48,8 @@ export const tokensQuery = (universalChainId: UniversalChainId) =>
4748
Effect.runSync(Effect.log(`storing new tokens for ${universalChainId}`))
4849
tokensStore.setData(
4950
universalChainId,
50-
// Can be removed when this invalid denom is removed from hubble
51-
data.pipe(
52-
Option.map(d =>
53-
d.v2_tokens.filter(
54-
token =>
55-
token.denom !== TokenRawDenom.make("0x0000000000000000000000000000000000000000")
56-
)
57-
)
58-
)
51+
// Filter out blacklisted tokens
52+
data.pipe(Option.map(d => d.v2_tokens.filter(token => !isTokenBlacklisted(token.denom))))
5953
)
6054
},
6155
writeError: error => {

app2/src/lib/schema/token.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class TokenWrapping extends Schema.Class<TokenWrapping>("TokenWrapping")(
5151
}) {}
5252

5353
export class Token extends Schema.Class<Token>("Token")({
54+
rank: Schema.OptionFromNullOr(Schema.Int),
5455
denom: TokenRawDenom,
5556
representations: Schema.Array(TokenRepresentation),
5657
wrapping: Schema.Array(TokenWrapping)

app2/src/lib/services/cosmos/balances.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { Data, Effect, Option, Schema, Schedule } from "effect"
1+
import { Data, Effect, Option, Schema } from "effect"
22
import { fetchDecode } from "$lib/utils/queries"
3-
import type { DurationInput } from "effect/Duration"
43
import { RawTokenBalance, TokenRawAmount, type TokenRawDenom } from "$lib/schema/token"
54
import type { Chain, NoRpcError } from "$lib/schema/chain"
65
import { type AddressCosmosCanonical, AddressCosmosDisplay } from "$lib/schema/address"
@@ -74,28 +73,23 @@ const fetchCosmosBankBalance = ({
7473
`${rpcUrl}/cosmos/bank/v1beta1/balances/${walletAddress}/by_denom?denom=${denom}`
7574
).pipe(Effect.map(response => response.balance.amount))
7675

77-
export const createCosmosBalanceQuery = ({
76+
// Core function to fetch a single Cosmos balance
77+
export const fetchCosmosBalance = ({
7878
chain,
7979
tokenAddress,
80-
walletAddress,
81-
refetchInterval,
82-
writeData,
83-
writeError
80+
walletAddress
8481
}: {
8582
chain: Chain
8683
tokenAddress: TokenRawDenom
8784
walletAddress: AddressCosmosCanonical
88-
refetchInterval: DurationInput
89-
writeData: (data: RawTokenBalance) => void
90-
writeError: (error: Option.Option<FetchCosmosBalanceError>) => void
9185
}) => {
92-
const fetcherPipeline = Effect.gen(function* () {
86+
return Effect.gen(function* () {
9387
const rpcUrl = yield* chain.requireRpcUrl("rest")
9488
const displayAddress = yield* chain.toCosmosDisplay(walletAddress)
9589
const decodedDenom = yield* fromHexString(tokenAddress)
9690

9791
yield* Effect.log(
98-
`starting balance fetcher for ${chain.universal_chain_id}:${displayAddress}:${decodedDenom}`
92+
`fetching balance for ${chain.universal_chain_id}:${displayAddress}:${decodedDenom}`
9993
)
10094

10195
const fetchBalance = decodedDenom.startsWith(`${chain.addr_prefix}1`)
@@ -112,17 +106,8 @@ export const createCosmosBalanceQuery = ({
112106

113107
let balance = yield* Effect.retry(fetchBalance, cosmosBalanceRetrySchedule)
114108

115-
writeData(RawTokenBalance.make(Option.some(TokenRawAmount.make(balance))))
116-
writeError(Option.none())
109+
return RawTokenBalance.make(Option.some(TokenRawAmount.make(balance)))
117110
}).pipe(
118-
Effect.tapError(error => Effect.sync(() => writeError(Option.some(error)))),
119-
Effect.catchAll(_ => Effect.succeed(null))
120-
)
121-
122-
return Effect.repeat(
123-
fetcherPipeline,
124-
Schedule.addDelay(Schedule.repeatForever, () => refetchInterval)
125-
).pipe(
126111
Effect.scoped,
127112
Effect.provide(FetchHttpClient.layer),
128113
withTracerDisabledWhen(() => true) // important! this prevents CORS issues: https://github.com/Effect-TS/effect/issues/4568

app2/src/lib/services/evm/balances.ts

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { Schedule, Data, Effect, Option, Schema } from "effect"
1+
import { Data, Effect, Option, Schema } from "effect"
22
import { erc20Abi, type PublicClient } from "viem"
33
import type { TimeoutException } from "effect/Cause"
4-
import type { DurationInput } from "effect/Duration"
54
import type { GetBalanceErrorType, ReadContractErrorType } from "viem"
65
import { getPublicClient } from "$lib/services/evm/clients"
76
import { RawTokenBalance, TokenRawAmount, type TokenRawDenom } from "$lib/schema/token"
@@ -66,27 +65,22 @@ const fetchEvmErc20Balance = ({
6665
catch: err => new ReadContractError({ cause: err as ReadContractErrorType })
6766
})
6867

69-
export const createEvmBalanceQuery = ({
68+
// Core function to fetch a single EVM balance
69+
export const fetchEvmBalance = ({
7070
chain,
7171
tokenAddress,
72-
walletAddress,
73-
refetchInterval,
74-
writeData,
75-
writeError
72+
walletAddress
7673
}: {
7774
chain: Chain
7875
tokenAddress: TokenRawDenom
7976
walletAddress: AddressEvmCanonical
80-
refetchInterval: DurationInput
81-
writeData: (data: RawTokenBalance) => void
82-
writeError: (error: Option.Option<FetchEvmBalanceError>) => void
8377
}) => {
84-
const fetcherPipeline = Effect.gen(function* (_) {
78+
return Effect.gen(function* (_) {
8579
const client = yield* getPublicClient(chain)
8680
const decodedDenom = yield* fromHexString(tokenAddress)
8781

8882
yield* Effect.log(
89-
`starting balances fetcher for ${chain.universal_chain_id}:${walletAddress}:${tokenAddress}`
83+
`fetching balance for ${chain.universal_chain_id}:${walletAddress}:${tokenAddress}`
9084
)
9185

9286
const fetchBalance =
@@ -96,15 +90,6 @@ export const createEvmBalanceQuery = ({
9690

9791
const balance = yield* Effect.retry(fetchBalance, evmBalanceRetrySchedule)
9892

99-
writeData(RawTokenBalance.make(Option.some(TokenRawAmount.make(balance))))
100-
writeError(Option.none())
101-
}).pipe(
102-
Effect.tapError(error => Effect.sync(() => writeError(Option.some(error)))),
103-
Effect.catchAll(_ => Effect.succeed(null))
104-
)
105-
106-
return Effect.repeat(
107-
fetcherPipeline,
108-
Schedule.addDelay(Schedule.repeatForever, () => refetchInterval)
109-
)
93+
return RawTokenBalance.make(Option.some(TokenRawAmount.make(balance)))
94+
})
11095
}

app2/src/lib/stores/balances.svelte.ts

Lines changed: 113 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import { Effect, type Fiber, Option } from "effect"
1+
import { Effect, type Fiber, Option, Queue } from "effect"
22
import type { TokenRawDenom } from "$lib/schema/token"
33
import type { Chain, UniversalChainId } from "$lib/schema/chain"
44
import { RawTokenBalance } from "$lib/schema/token"
5-
import { createEvmBalanceQuery, type FetchEvmBalanceError } from "$lib/services/evm/balances"
6-
import {
7-
createCosmosBalanceQuery,
8-
type FetchCosmosBalanceError
9-
} from "$lib/services/cosmos/balances"
5+
import { fetchEvmBalance, type FetchEvmBalanceError } from "$lib/services/evm/balances"
6+
import { fetchCosmosBalance, type FetchCosmosBalanceError } from "$lib/services/cosmos/balances"
107
import { SvelteMap } from "svelte/reactivity"
118
import {
129
AddressEvmCanonical,
@@ -24,12 +21,29 @@ const createKey = (
2421
denom: TokenRawDenom
2522
): BalanceKey => `${universalChainId}:${address}:${denom}`
2623

24+
// Type for a balance fetch request
25+
type BalanceFetchRequest = {
26+
chain: Chain
27+
address: AddressCanonicalBytes
28+
denom: TokenRawDenom
29+
}
30+
31+
// Type for chain key
32+
type ChainKey = `${UniversalChainId}:${AddressCanonicalBytes}`
33+
34+
// Helper to create the chain key
35+
const createChainKey = (
36+
universalChainId: UniversalChainId,
37+
address: AddressCanonicalBytes
38+
): ChainKey => `${universalChainId}:${address}`
39+
2740
export class BalancesStore {
2841
data = $state(new SvelteMap<BalanceKey, RawTokenBalance>())
2942
errors = $state(
3043
new SvelteMap<BalanceKey, Option.Option<FetchEvmBalanceError | FetchCosmosBalanceError>>()
3144
)
32-
fibers = $state(new SvelteMap<BalanceKey, Fiber.RuntimeFiber<number, never>>())
45+
chainFibers = $state(new SvelteMap<ChainKey, Fiber.RuntimeFiber<void, never>>())
46+
pendingRequests = $state(new SvelteMap<ChainKey, Array<BalanceFetchRequest>>())
3347

3448
setBalance(
3549
universalChainId: UniversalChainId,
@@ -65,37 +79,102 @@ export class BalancesStore {
6579
return this.errors.get(createKey(universalChainId, address, denom)) ?? Option.none()
6680
}
6781

68-
fetchBalance(chain: Chain, address: AddressCanonicalBytes, denom: TokenRawDenom) {
69-
const key = createKey(chain.universal_chain_id, address, denom)
82+
// Process balance requests for a specific chain one at a time
83+
private processBatchedBalances(
84+
chain: Chain,
85+
address: AddressCanonicalBytes,
86+
denoms: Array<TokenRawDenom>
87+
) {
88+
const chainKey = createChainKey(chain.universal_chain_id, address)
89+
const self = this
7090

71-
// If there's already a query running for this combination, don't start another one
72-
if (this.fibers.has(key)) {
91+
// If there's already a query running for this chain, don't start another one
92+
if (this.chainFibers.has(chainKey)) {
93+
// Add these requests to pending
94+
const existing = this.pendingRequests.get(chainKey) || []
95+
const newRequests = denoms.map(denom => ({ chain, address, denom }))
96+
this.pendingRequests.set(chainKey, [...existing, ...newRequests])
7397
return
7498
}
7599

76-
let query =
77-
chain.rpc_type === "evm"
78-
? createEvmBalanceQuery({
79-
chain,
80-
tokenAddress: denom,
81-
walletAddress: AddressEvmCanonical.make(address),
82-
refetchInterval: "15 minutes",
83-
writeData: balance =>
84-
this.setBalance(chain.universal_chain_id, address, denom, balance),
85-
writeError: error => this.setError(chain.universal_chain_id, address, denom, error)
86-
})
87-
: createCosmosBalanceQuery({
88-
chain,
89-
tokenAddress: denom,
90-
walletAddress: AddressCosmosCanonical.make(address),
91-
refetchInterval: "15 minutes",
92-
writeData: balance =>
93-
this.setBalance(chain.universal_chain_id, address, denom, balance),
94-
writeError: error => this.setError(chain.universal_chain_id, address, denom, error)
95-
})
96-
97-
const fiber = Effect.runFork(query)
98-
this.fibers.set(key, fiber)
100+
// Create a queue for processing balance requests
101+
const batchProcessor = Effect.gen(function* (_) {
102+
// Create a queue for balance requests
103+
const queue = yield* Queue.unbounded<BalanceFetchRequest>()
104+
105+
// Add all denoms to the queue
106+
for (const denom of denoms) {
107+
yield* Queue.offer(queue, { chain, address, denom })
108+
}
109+
110+
yield* Effect.forever(
111+
Effect.gen(function* (_) {
112+
// Take the next request from the queue
113+
const request = yield* Queue.take(queue)
114+
const { chain, address, denom } = request
115+
116+
// Process the balance request
117+
yield* Effect.gen(function* (_) {
118+
let balance: RawTokenBalance
119+
if (chain.rpc_type === "evm") {
120+
balance = yield* fetchEvmBalance({
121+
chain,
122+
tokenAddress: denom,
123+
walletAddress: AddressEvmCanonical.make(address)
124+
})
125+
} else {
126+
balance = yield* fetchCosmosBalance({
127+
chain,
128+
tokenAddress: denom,
129+
walletAddress: AddressCosmosCanonical.make(address)
130+
})
131+
}
132+
133+
// Update the balance
134+
self.setBalance(chain.universal_chain_id, address, denom, balance)
135+
self.setError(chain.universal_chain_id, address, denom, Option.none())
136+
}).pipe(
137+
Effect.catchAll(error => {
138+
// Update the error
139+
self.setError(chain.universal_chain_id, address, denom, Option.some(error))
140+
return Effect.succeed(undefined)
141+
})
142+
)
143+
})
144+
)
145+
}).pipe(
146+
Effect.catchAll(error => {
147+
Effect.logError("error processing balance batch:", error)
148+
return Effect.succeed(undefined)
149+
}),
150+
Effect.ensuring(
151+
Effect.sync(() => {
152+
// Check if there are pending requests for this chain
153+
const pending = self.pendingRequests.get(chainKey) || []
154+
self.pendingRequests.delete(chainKey)
155+
self.chainFibers.delete(chainKey)
156+
157+
// If there are pending requests, process them
158+
if (pending.length > 0) {
159+
const pendingDenoms = pending.map(req => req.denom)
160+
self.processBatchedBalances(chain, address, pendingDenoms)
161+
}
162+
})
163+
)
164+
)
165+
166+
// Run the batch processor
167+
const fiber = Effect.runFork(batchProcessor)
168+
this.chainFibers.set(chainKey, fiber)
169+
}
170+
171+
fetchBalance(chain: Chain, address: AddressCanonicalBytes, denom: TokenRawDenom) {
172+
this.processBatchedBalances(chain, address, [denom])
173+
}
174+
175+
fetchBalances(chain: Chain, address: AddressCanonicalBytes, denoms: Array<TokenRawDenom>) {
176+
if (denoms.length === 0) return
177+
this.processBatchedBalances(chain, address, denoms)
99178
}
100179
}
101180

0 commit comments

Comments
 (0)