Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: semaphore balance calls #3879

Merged
merged 8 commits into from
Feb 19, 2025
51 changes: 33 additions & 18 deletions packages/adapters/bitcoin/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { UnitsUtil } from './utils/UnitsUtil.js'
export class BitcoinAdapter extends AdapterBlueprint<BitcoinConnector> {
private eventsToUnbind: (() => void)[] = []
private api: BitcoinApi.Interface
private balancePromises: Record<string, Promise<AdapterBlueprint.GetBalanceResult>> = {}

constructor({ api = {}, ...params }: BitcoinAdapter.ConstructorParams = {}) {
super({
Expand Down Expand Up @@ -172,31 +173,45 @@ export class BitcoinAdapter extends AdapterBlueprint<BitcoinConnector> {
const network = params.caipNetwork

if (network?.chainNamespace === 'bip122') {
const utxos = await this.api.getUTXOs({
network,
address: params.address
})

const caipAddress = `${params?.caipNetwork?.caipNetworkId}:${params.address}`

const cachedPromise = this.balancePromises[caipAddress]
if (cachedPromise) {
return cachedPromise
}

const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress)
if (cachedBalance) {
return { balance: cachedBalance.balance, symbol: cachedBalance.symbol }
}

const balance = utxos.reduce((acc, utxo) => acc + utxo.value, 0)
const formattedBalance = UnitsUtil.parseSatoshis(balance.toString(), network)

StorageUtil.updateNativeBalanceCache({
caipAddress,
balance: formattedBalance,
symbol: network.nativeCurrency.symbol,
timestamp: Date.now()
this.balancePromises[caipAddress] = new Promise<AdapterBlueprint.GetBalanceResult>(
async resolve => {
const utxos = await this.api.getUTXOs({
network,
address: params.address
})

const balance = utxos.reduce((acc, utxo) => acc + utxo.value, 0)
const formattedBalance = UnitsUtil.parseSatoshis(balance.toString(), network)

StorageUtil.updateNativeBalanceCache({
caipAddress,
balance: formattedBalance,
symbol: network.nativeCurrency.symbol,
timestamp: Date.now()
})

resolve({
balance: formattedBalance,
symbol: network.nativeCurrency.symbol
})
}
).finally(() => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.balancePromises[caipAddress]
})

return {
balance: formattedBalance,
symbol: network.nativeCurrency.symbol
}
return this.balancePromises[caipAddress] || Promise.resolve({ balance: '0', symbol: '' })
Copy link
Member Author

@ganchoradkov ganchoradkov Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

|| Promise.resolve({ balance: '0', symbol: '' })
is not needed because the fx will either resolve or throw but typescript refuses to accept that this.balancePromises[caipAddress] is assigned 10 lines above and is not undefined

}

// Get balance
Expand Down
44 changes: 44 additions & 0 deletions packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,50 @@ describe('BitcoinAdapter', () => {
StorageUtil.clearAddressCache()
})

it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => {
// delay the response to simulate http request latency
const latency = 1000
const numSimultaneousRequests = 10
const expectedSentRequests = 1

api.getUTXOs.mockResolvedValue(
new Promise(resolve => {
setTimeout(() => {
resolve([
mockUTXO({ value: 10000 }),
mockUTXO({ value: 20000 }),
mockUTXO({ value: 30000 }),
mockUTXO({ value: 10000000000 })
])
}, latency)
}) as any
)

const result = await Promise.all([
...Array.from({ length: numSimultaneousRequests }).map(() =>
adapter.getBalance({
address: 'mock_address',
chainId: bitcoin.id,
caipNetwork: bitcoin
})
)
])

expect(api.getUTXOs).toHaveBeenCalledTimes(expectedSentRequests)
expect(result.length).toBe(numSimultaneousRequests)
expect(expectedSentRequests).to.be.lt(numSimultaneousRequests)

// verify all calls got the same balance
for (const balance of result) {
expect(balance).toEqual({
balance: '100.0006',
symbol: 'BTC'
})
}

StorageUtil.clearAddressCache()
})

it('should return empty balance if no UTXOs', async () => {
api.getUTXOs.mockResolvedValueOnce([])

Expand Down
44 changes: 30 additions & 14 deletions packages/adapters/ethers/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface EIP6963ProviderDetail {
export class EthersAdapter extends AdapterBlueprint {
private ethersConfig?: ProviderType
public adapterType = 'ethers'
private balancePromises: Record<string, Promise<AdapterBlueprint.GetBalanceResult>> = {}

constructor() {
super({})
Expand Down Expand Up @@ -463,30 +464,45 @@ export class EthersAdapter extends AdapterBlueprint {
const caipNetwork = this.caipNetworks?.find((c: CaipNetwork) => c.id === params.chainId)

if (caipNetwork && caipNetwork.chainNamespace === 'eip155') {
const jsonRpcProvider = new JsonRpcProvider(caipNetwork.rpcUrls.default.http[0], {
chainId: caipNetwork.id as number,
name: caipNetwork.name
})

const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}`

const cachedPromise = this.balancePromises[caipAddress]
if (cachedPromise) {
return cachedPromise
}
const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress)
if (cachedBalance) {
return { balance: cachedBalance.balance, symbol: cachedBalance.symbol }
}

const jsonRpcProvider = new JsonRpcProvider(caipNetwork.rpcUrls.default.http[0], {
chainId: caipNetwork.id as number,
name: caipNetwork.name
})

if (jsonRpcProvider) {
try {
const balance = await jsonRpcProvider.getBalance(params.address)
const formattedBalance = formatEther(balance)

StorageUtil.updateNativeBalanceCache({
caipAddress,
balance: formattedBalance,
symbol: caipNetwork.nativeCurrency.symbol,
timestamp: Date.now()
this.balancePromises[caipAddress] = new Promise<AdapterBlueprint.GetBalanceResult>(
async resolve => {
const balance = await jsonRpcProvider.getBalance(params.address)

const formattedBalance = formatEther(balance)

StorageUtil.updateNativeBalanceCache({
caipAddress,
balance: formattedBalance,
symbol: caipNetwork.nativeCurrency.symbol,
timestamp: Date.now()
})

resolve({ balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol })
}
).finally(() => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.balancePromises[caipAddress]
})

return { balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol }
return this.balancePromises[caipAddress] || { balance: '', symbol: '' }
} catch (error) {
return { balance: '', symbol: '' }
}
Expand Down
42 changes: 42 additions & 0 deletions packages/adapters/ethers/src/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,48 @@ describe('EthersAdapter', () => {
symbol: 'ETH'
})
})

it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => {
adapter.caipNetworks = mockCaipNetworks
const mockBalance = BigInt(1500000000000000000)
// delay the response to simulate http request latency
const latency = 1000
const numSimultaneousRequests = 10
const expectedSentRequests = 1
let mockedImplementationCalls = 0
vi.mocked(JsonRpcProvider).mockImplementation(
() =>
({
getBalance: vi.fn().mockResolvedValue(
new Promise(resolve => {
mockedImplementationCalls++
setTimeout(() => resolve(mockBalance), latency)
})
)
}) as any
)

const result = await Promise.all([
...Array.from({ length: numSimultaneousRequests }).map(() =>
adapter.getBalance({
address: '0x123',
chainId: 1
})
)
])

expect(mockedImplementationCalls).to.eql(expectedSentRequests)
expect(result.length).toBe(numSimultaneousRequests)
expect(expectedSentRequests).to.be.lt(numSimultaneousRequests)

// verify all calls got the same balance
for (const balance of result) {
expect(balance).toEqual({
balance: '1.5',
symbol: 'ETH'
})
}
})
})

describe('EthersAdapter -getProfile', () => {
Expand Down
46 changes: 31 additions & 15 deletions packages/adapters/ethers5/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface EIP6963ProviderDetail {
export class Ethers5Adapter extends AdapterBlueprint {
private ethersConfig?: ProviderType
public adapterType = 'ethers'
private balancePromises: Record<string, Promise<AdapterBlueprint.GetBalanceResult>> = {}

constructor() {
super({})
Expand Down Expand Up @@ -462,6 +463,18 @@ export class Ethers5Adapter extends AdapterBlueprint {
const caipNetwork = this.caipNetworks?.find((c: CaipNetwork) => c.id === params.chainId)

if (caipNetwork) {
const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}`

const cachedPromise = this.balancePromises[caipAddress]
if (cachedPromise) {
return cachedPromise
}

const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress)
if (cachedBalance) {
return { balance: cachedBalance.balance, symbol: cachedBalance.symbol }
}

const jsonRpcProvider = new ethers.providers.JsonRpcProvider(
caipNetwork.rpcUrls.default.http[0],
{
Expand All @@ -470,25 +483,28 @@ export class Ethers5Adapter extends AdapterBlueprint {
}
)

const caipAddress = `${caipNetwork.caipNetworkId}:${params.address}`
const cachedBalance = StorageUtil.getNativeBalanceCacheForCaipAddress(caipAddress)
if (cachedBalance) {
return { balance: cachedBalance.balance, symbol: cachedBalance.symbol }
}

if (jsonRpcProvider) {
try {
const balance = await jsonRpcProvider.getBalance(params.address)
const formattedBalance = formatEther(balance)

StorageUtil.updateNativeBalanceCache({
caipAddress,
balance: formattedBalance,
symbol: caipNetwork.nativeCurrency.symbol,
timestamp: Date.now()
this.balancePromises[caipAddress] = new Promise<AdapterBlueprint.GetBalanceResult>(
async resolve => {
const balance = await jsonRpcProvider.getBalance(params.address)
const formattedBalance = formatEther(balance)

StorageUtil.updateNativeBalanceCache({
caipAddress,
balance: formattedBalance,
symbol: caipNetwork.nativeCurrency.symbol,
timestamp: Date.now()
})

resolve({ balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol })
}
).finally(() => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.balancePromises[caipAddress]
})

return { balance: formattedBalance, symbol: caipNetwork.nativeCurrency.symbol }
return this.balancePromises[caipAddress] || { balance: '', symbol: '' }
} catch (error) {
return { balance: '', symbol: '' }
}
Expand Down
42 changes: 42 additions & 0 deletions packages/adapters/ethers5/src/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,48 @@ describe('Ethers5Adapter', () => {
})
})

it('should call getBalance once even when multiple adapter requests are sent at the same time', async () => {
adapter.caipNetworks = mockCaipNetworks
const mockBalance = BigInt(1500000000000000000)
// delay the response to simulate http request latency
const latency = 1000
const numSimultaneousRequests = 10
const expectedSentRequests = 1
let mockedImplementationCalls = 0
vi.mocked(providers.JsonRpcProvider).mockImplementation(
() =>
({
getBalance: vi.fn().mockResolvedValue(
new Promise(resolve => {
mockedImplementationCalls++
setTimeout(() => resolve(mockBalance), latency)
})
)
}) as any
)

const result = await Promise.all([
...Array.from({ length: numSimultaneousRequests }).map(() =>
adapter.getBalance({
address: '0x123',
chainId: 1
})
)
])

expect(mockedImplementationCalls).to.eql(expectedSentRequests)
expect(result.length).toBe(numSimultaneousRequests)
expect(expectedSentRequests).to.be.lt(numSimultaneousRequests)

// verify all calls got the same balance
for (const balance of result) {
expect(balance).toEqual({
balance: '1.5',
symbol: 'ETH'
})
}
})

describe('Ethers5Adapter -getProfile', () => {
it('should get profile successfully', async () => {
const mockEnsName = 'test.eth'
Expand Down
Loading
Loading