Skip to content

Commit

Permalink
fix: semaphore balance calls (#3879)
Browse files Browse the repository at this point in the history
  • Loading branch information
ganchoradkov authored Feb 19, 2025
1 parent f9e66b9 commit d7cf2f5
Show file tree
Hide file tree
Showing 10 changed files with 352 additions and 75 deletions.
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: '' })
}

// 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

0 comments on commit d7cf2f5

Please sign in to comment.