From ae6a91a8c36592c9c45e625cb1961bed253b2d60 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Fri, 3 Jan 2025 10:06:26 +0000 Subject: [PATCH] Additionally fetch exchange rates from ICPSwap (#7139) --- frontend/openchat-agent/codegen.sh | 3 ++ .../src/services/icpSwap/candid/can.did | 10 ++++ .../src/services/icpSwap/candid/idl.d.ts | 7 +++ .../src/services/icpSwap/candid/idl.js | 12 +++++ .../src/services/icpSwap/candid/types.d.ts | 15 ++++++ .../src/services/icpSwap/icpSwapClient.ts | 27 ++++++++++ .../src/services/icpSwap/mappers.ts | 20 ++++++++ .../src/services/icpcoins/icpCoinsClient.ts | 14 +++-- .../src/services/icpcoins/mappers.ts | 20 +++----- .../src/services/openchatAgent.ts | 51 ++++++++++++++++--- frontend/openchat-agent/src/utils/maths.ts | 13 +++++ frontend/openchat-client/src/stores/crypto.ts | 8 ++- frontend/openchat-shared/src/domain/crypto.ts | 1 - 13 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 frontend/openchat-agent/src/services/icpSwap/candid/can.did create mode 100644 frontend/openchat-agent/src/services/icpSwap/candid/idl.d.ts create mode 100644 frontend/openchat-agent/src/services/icpSwap/candid/idl.js create mode 100644 frontend/openchat-agent/src/services/icpSwap/candid/types.d.ts create mode 100644 frontend/openchat-agent/src/services/icpSwap/icpSwapClient.ts create mode 100644 frontend/openchat-agent/src/services/icpSwap/mappers.ts create mode 100644 frontend/openchat-agent/src/utils/maths.ts diff --git a/frontend/openchat-agent/codegen.sh b/frontend/openchat-agent/codegen.sh index e758ee891c..b9db6a3bd7 100755 --- a/frontend/openchat-agent/codegen.sh +++ b/frontend/openchat-agent/codegen.sh @@ -35,6 +35,9 @@ didc bind ./src/services/dexes/sonic/swaps/candid/can.did -t js > ./src/services didc bind ./src/services/icpcoins/candid/can.did -t ts > ./src/services/icpcoins/candid/types.d.ts didc bind ./src/services/icpcoins/candid/can.did -t js > ./src/services/icpcoins/candid/idl.js +didc bind ./src/services/icpSwap/candid/can.did -t ts > ./src/services/icpSwap/candid/types.d.ts +didc bind ./src/services/icpSwap/candid/can.did -t js > ./src/services/icpSwap/candid/idl.js + didc bind ./src/services/icpLedgerIndex/candid/can.did -t ts > ./src/services/icpLedgerIndex/candid/types.d.ts didc bind ./src/services/icpLedgerIndex/candid/can.did -t js > ./src/services/icpLedgerIndex/candid/idl.js diff --git a/frontend/openchat-agent/src/services/icpSwap/candid/can.did b/frontend/openchat-agent/src/services/icpSwap/candid/can.did new file mode 100644 index 0000000000..b80270d513 --- /dev/null +++ b/frontend/openchat-agent/src/services/icpSwap/candid/can.did @@ -0,0 +1,10 @@ +type PublicTokenOverview = + record { + address: text; + priceUSD: float64; + symbol: text; + volumeUSD7d: float64; + }; +service : { + getAllTokens: () -> (vec PublicTokenOverview) query; +} \ No newline at end of file diff --git a/frontend/openchat-agent/src/services/icpSwap/candid/idl.d.ts b/frontend/openchat-agent/src/services/icpSwap/candid/idl.d.ts new file mode 100644 index 0000000000..bfa9202fd9 --- /dev/null +++ b/frontend/openchat-agent/src/services/icpSwap/candid/idl.d.ts @@ -0,0 +1,7 @@ +import type { IDL } from "@dfinity/candid"; +import { _SERVICE } from "./types"; +export { + _SERVICE as ICPSwapService, +}; + +export const idlFactory: IDL.InterfaceFactory; diff --git a/frontend/openchat-agent/src/services/icpSwap/candid/idl.js b/frontend/openchat-agent/src/services/icpSwap/candid/idl.js new file mode 100644 index 0000000000..ec1a0f1f18 --- /dev/null +++ b/frontend/openchat-agent/src/services/icpSwap/candid/idl.js @@ -0,0 +1,12 @@ +export const idlFactory = ({ IDL }) => { + const PublicTokenOverview = IDL.Record({ + 'volumeUSD7d' : IDL.Float64, + 'address' : IDL.Text, + 'priceUSD' : IDL.Float64, + 'symbol' : IDL.Text, + }); + return IDL.Service({ + 'getAllTokens' : IDL.Func([], [IDL.Vec(PublicTokenOverview)], ['query']), + }); +}; +export const init = ({ IDL }) => { return []; }; diff --git a/frontend/openchat-agent/src/services/icpSwap/candid/types.d.ts b/frontend/openchat-agent/src/services/icpSwap/candid/types.d.ts new file mode 100644 index 0000000000..1d0299fd79 --- /dev/null +++ b/frontend/openchat-agent/src/services/icpSwap/candid/types.d.ts @@ -0,0 +1,15 @@ +import type { Principal } from '@dfinity/principal'; +import type { ActorMethod } from '@dfinity/agent'; +import type { IDL } from '@dfinity/candid'; + +export interface PublicTokenOverview { + 'volumeUSD7d' : number, + 'address' : string, + 'priceUSD' : number, + 'symbol' : string, +} +export interface _SERVICE { + 'getAllTokens' : ActorMethod<[], Array>, +} +export declare const idlFactory: IDL.InterfaceFactory; +export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[]; diff --git a/frontend/openchat-agent/src/services/icpSwap/icpSwapClient.ts b/frontend/openchat-agent/src/services/icpSwap/icpSwapClient.ts new file mode 100644 index 0000000000..3320658a83 --- /dev/null +++ b/frontend/openchat-agent/src/services/icpSwap/icpSwapClient.ts @@ -0,0 +1,27 @@ +import type { HttpAgent, Identity } from "@dfinity/agent"; +import type { CryptocurrencyDetails, TokenExchangeRates } from "openchat-shared"; +import { idlFactory, type ICPSwapService } from "./candid/idl"; +import { CandidService } from "../candidService"; +import { getAllTokensResponse } from "./mappers"; +import type { ExchangeRateClient } from "../openchatAgent"; + +const ICPSWAP_CANISTER_ID = "ggzvv-5qaaa-aaaag-qck7a-cai"; + +export class IcpSwapClient extends CandidService implements ExchangeRateClient { + private service: ICPSwapService; + + constructor(identity: Identity, agent: HttpAgent) { + super(identity, agent, ICPSWAP_CANISTER_ID); + + this.service = this.createServiceClient(idlFactory); + } + + exchangeRates( + supportedTokens: CryptocurrencyDetails[], + ): Promise> { + return this.handleQueryResponse( + () => this.service.getAllTokens(), + (resp) => getAllTokensResponse(resp, supportedTokens), + ); + } +} diff --git a/frontend/openchat-agent/src/services/icpSwap/mappers.ts b/frontend/openchat-agent/src/services/icpSwap/mappers.ts new file mode 100644 index 0000000000..f68c5a4f32 --- /dev/null +++ b/frontend/openchat-agent/src/services/icpSwap/mappers.ts @@ -0,0 +1,20 @@ +import { type CryptocurrencyDetails, type TokenExchangeRates } from "openchat-shared"; +import type { PublicTokenOverview } from "./candid/types"; + +export function getAllTokensResponse( + candid: Array, + supportedTokens: CryptocurrencyDetails[], +): Record { + const exchangeRates: Record = {}; + const supportedLedgers = new Set(supportedTokens.map((t) => t.ledger)); + + for (const token of candid) { + if (supportedLedgers.has(token.address)) { + exchangeRates[token.symbol.trim().toLowerCase()] = { + toUSD: token.priceUSD, + }; + } + } + + return exchangeRates; +} diff --git a/frontend/openchat-agent/src/services/icpcoins/icpCoinsClient.ts b/frontend/openchat-agent/src/services/icpcoins/icpCoinsClient.ts index 7327a835c6..64c57be235 100644 --- a/frontend/openchat-agent/src/services/icpcoins/icpCoinsClient.ts +++ b/frontend/openchat-agent/src/services/icpcoins/icpCoinsClient.ts @@ -1,12 +1,13 @@ import type { HttpAgent, Identity } from "@dfinity/agent"; -import type { TokenExchangeRates } from "openchat-shared"; +import type { CryptocurrencyDetails, TokenExchangeRates } from "openchat-shared"; import { idlFactory, type ICPCoinsService } from "./candid/idl"; import { CandidService } from "../candidService"; import { getLatestResponse } from "./mappers"; +import type { ExchangeRateClient } from "../openchatAgent"; const ICPCOINS_CANISTER_ID = "u45jl-liaaa-aaaam-abppa-cai"; -export class IcpCoinsClient extends CandidService { +export class IcpCoinsClient extends CandidService implements ExchangeRateClient { private service: ICPCoinsService; constructor(identity: Identity, agent: HttpAgent) { @@ -15,7 +16,12 @@ export class IcpCoinsClient extends CandidService { this.service = this.createServiceClient(idlFactory); } - exchangeRates(): Promise> { - return this.handleQueryResponse(() => this.service.get_latest(), getLatestResponse); + exchangeRates( + supportedTokens: CryptocurrencyDetails[], + ): Promise> { + return this.handleQueryResponse( + () => this.service.get_latest(), + (resp) => getLatestResponse(resp, supportedTokens), + ); } } diff --git a/frontend/openchat-agent/src/services/icpcoins/mappers.ts b/frontend/openchat-agent/src/services/icpcoins/mappers.ts index 28e5919890..d5267b8e0b 100644 --- a/frontend/openchat-agent/src/services/icpcoins/mappers.ts +++ b/frontend/openchat-agent/src/services/icpcoins/mappers.ts @@ -1,9 +1,14 @@ -import type { TokenExchangeRates } from "openchat-shared"; +import type { CryptocurrencyDetails, TokenExchangeRates } from "openchat-shared"; import type { LatestTokenRow } from "./candid/types"; export function getLatestResponse( candid: Array, + supportedTokens: CryptocurrencyDetails[], ): Record { + const supportedSymbols = new Set(supportedTokens.map((t) => t.symbol.toLowerCase())); + supportedSymbols.add("btc"); + supportedSymbols.add("eth"); + const exchangeRates: Record = {}; for (const row of candid) { @@ -11,20 +16,11 @@ export function getLatestResponse( const [_pair, pairText, rate] = row; const [from, to] = parseSymbolPair(pairText); - if (to === "usd") { - exchangeRates[from] = { ...exchangeRates[from], toUSD: rate }; - } else if (to === "icp") { - exchangeRates[from] = { ...exchangeRates[from], toICP: rate }; + if (to === "usd" && supportedSymbols.has(from)) { + exchangeRates[from] = { toUSD: rate }; } } - exchangeRates["icp"] = { ...exchangeRates["icp"], toICP: 1 }; - - const icpToUsd = exchangeRates["icp"]["toUSD"]; - if (icpToUsd !== undefined) { - exchangeRates["ckusdc"] = { toICP: 1 / icpToUsd, toUSD: 1 }; - } - return exchangeRates; } diff --git a/frontend/openchat-agent/src/services/openchatAgent.ts b/frontend/openchat-agent/src/services/openchatAgent.ts index 283db1822f..6eebe91a56 100644 --- a/frontend/openchat-agent/src/services/openchatAgent.ts +++ b/frontend/openchat-agent/src/services/openchatAgent.ts @@ -37,7 +37,7 @@ import { GroupIndexClient } from "./groupIndex/groupIndex.client"; import { MarketMakerClient } from "./marketMaker/marketMaker.client"; import { RegistryClient } from "./registry/registry.client"; import { DexesAgent } from "./dexes"; -import { chunk, distinctBy, toRecord } from "../utils/list"; +import { chunk, distinctBy, toRecord, toRecord2 } from "../utils/list"; import { measure } from "./common/profiling"; import { buildBlobUrl, @@ -259,6 +259,7 @@ import { import { AnonUserClient } from "./user/anonUser.client"; import { excludeLatestKnownUpdateIfBeforeFix } from "./common/replicaUpToDateChecker"; import { IcpCoinsClient } from "./icpcoins/icpCoinsClient"; +import { IcpSwapClient } from "./icpSwap/icpSwapClient"; import { IcpLedgerIndexClient } from "./icpLedgerIndex/icpLedgerIndex.client"; import { TranslationsClient } from "./translations/translations.client"; import { CkbtcMinterClient } from "./ckbtcMinter/ckbtcMinter"; @@ -274,6 +275,7 @@ import { clearCache as clearReferralCache, getCommunityReferral, } from "../utils/referralCache"; +import { mean } from "../utils/maths"; export class OpenChatAgent extends EventTarget { private _agent: HttpAgent; @@ -291,7 +293,7 @@ export class OpenChatAgent extends EventTarget { private _ledgerIndexClients: Record; private _groupClients: Record; private _communityClients: Record; - private _icpcoinsClient: IcpCoinsClient; + private _exchangeRateClients: ExchangeRateClient[]; private _signInWithEmailClient: SignInWithEmailClient; private _signInWithEthereumClient: SignInWithEthereumClient; private _signInWithSolanaClient: SignInWithSolanaClient; @@ -345,7 +347,10 @@ export class OpenChatAgent extends EventTarget { config.blobUrlPattern, ); this._dataClient = new DataClient(identity, this._agent, config); - this._icpcoinsClient = new IcpCoinsClient(identity, this._agent); + this._exchangeRateClients = [ + new IcpCoinsClient(identity, this._agent), + new IcpSwapClient(identity, this._agent), + ]; this.translationsClient = new TranslationsClient( identity, this._agent, @@ -3531,10 +3536,36 @@ export class OpenChatAgent extends EventTarget { return this._registryClient.setTokenEnabled(ledger, enabled); } - exchangeRates(): Promise> { - return isMainnet(this.config.icUrl) - ? this._icpcoinsClient.exchangeRates() - : Promise.resolve({}); + async exchangeRates(): Promise> { + const supportedTokens = this._registryValue?.tokenDetails; + + if (supportedTokens === undefined || !isMainnet(this.config.icUrl)) { + return Promise.resolve({}); + } + + const exchangeRatesFromAllProviders = await Promise.allSettled( + this._exchangeRateClients.map((c) => c.exchangeRates(supportedTokens)), + ); + + const grouped: Record = {}; + for (const response of exchangeRatesFromAllProviders) { + if (response.status === "fulfilled") { + for (const [token, exchangeRates] of Object.entries(response.value)) { + if (grouped[token] === undefined) { + grouped[token] = []; + } + grouped[token].push(exchangeRates); + } + } + } + + return toRecord2( + Object.entries(grouped), + ([token, _]) => token, + ([_, group]) => ({ + toUSD: mean(group.map((e) => e.toUSD)), + }), + ); } reportedMessages(userId: string | undefined): Promise { @@ -4111,3 +4142,9 @@ export class OpenChatAgent extends EventTarget { }); } } + +export interface ExchangeRateClient { + exchangeRates( + supportedTokens: CryptocurrencyDetails[], + ): Promise>; +} diff --git a/frontend/openchat-agent/src/utils/maths.ts b/frontend/openchat-agent/src/utils/maths.ts new file mode 100644 index 0000000000..9628e7633c --- /dev/null +++ b/frontend/openchat-agent/src/utils/maths.ts @@ -0,0 +1,13 @@ +export function mean(arr: (number | undefined)[]): number { + let total = 0; + let count = 0; + + for (const value of arr) { + if (value !== undefined) { + total += value; + count++; + } + } + + return count > 0 ? total / count : 0; +} diff --git a/frontend/openchat-client/src/stores/crypto.ts b/frontend/openchat-client/src/stores/crypto.ts index e4964f4c6c..5201146edd 100644 --- a/frontend/openchat-client/src/stores/crypto.ts +++ b/frontend/openchat-client/src/stores/crypto.ts @@ -47,9 +47,11 @@ export const lastCryptoSent = { export const enhancedCryptoLookup = derived( [cryptoLookup, cryptoBalance, exchangeRatesLookupStore], ([$lookup, $balance, $exchangeRatesLookup]) => { + const xrICPtoDollar = $exchangeRatesLookup["icp"]?.toUSD; const xrBTCtoDollar = $exchangeRatesLookup["btc"]?.toUSD; const xrETHtoDollar = $exchangeRatesLookup["eth"]?.toUSD; + const xrDollarToICP = xrICPtoDollar === undefined ? 0 : 1 / xrICPtoDollar; const xrDollarToBTC = xrBTCtoDollar === undefined ? 0 : 1 / xrBTCtoDollar; const xrDollarToETH = xrETHtoDollar === undefined ? 0 : 1 / xrETHtoDollar; @@ -60,8 +62,10 @@ export const enhancedCryptoLookup = derived( const rates = $exchangeRatesLookup[symbolLower]; const xrUSD = rates?.toUSD; const dollarBalance = xrUSD !== undefined ? xrUSD * balanceWholeUnits : undefined; - const xrICP = rates?.toICP; - const icpBalance = xrICP !== undefined ? xrICP * balanceWholeUnits : undefined; + const icpBalance = + dollarBalance !== undefined && xrDollarToICP !== undefined + ? dollarBalance * xrDollarToICP + : undefined; const btcBalance = dollarBalance !== undefined && xrDollarToBTC !== undefined ? dollarBalance * xrDollarToBTC diff --git a/frontend/openchat-shared/src/domain/crypto.ts b/frontend/openchat-shared/src/domain/crypto.ts index 224f7e0c74..31682f88f6 100644 --- a/frontend/openchat-shared/src/domain/crypto.ts +++ b/frontend/openchat-shared/src/domain/crypto.ts @@ -109,7 +109,6 @@ export type AccountTransactions = { export type AccountTransactionResult = Failure | (Success & AccountTransactions); export type TokenExchangeRates = { - toICP: number | undefined; toUSD: number | undefined; };