diff --git a/.env.example b/.env.example index f851d1d..6214cc1 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,8 @@ CONFIG_INDEXER_URL=https://dev.ponder.testnet.juicedollar.com/ CONFIG_INDEXER_FALLBACK_URL=https://dev.ponder.testnet.juicedollar.com/ CONFIG_CHAIN=testnet -RPC_URL_MAINNET=https://rpc.testnet.juiceswap.com/ +RPC_URL_MAINNET=https://rpc.citrea.xyz +RPC_URL_TESTNET=https://rpc.testnet.citrea.xyz COINGECKO_API_KEY=[API-KEY] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5fa91f4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Code Owners for JuiceDollar/api +# These users must approve all pull requests to protected branches + +# Default owners for everything in the repo +* @Danswar @TaprootFreak diff --git a/.github/workflows/api-dev.yaml b/.github/workflows/api-dev.yaml index ab54b87..83d2736 100644 --- a/.github/workflows/api-dev.yaml +++ b/.github/workflows/api-dev.yaml @@ -8,7 +8,8 @@ on: env: DOCKER_TAGS: dfxswiss/juicedollar-api:beta AZURE_RESOURCE_GROUP: rg-dfx-api-dev - AZURE_CONTAINER_APP: ca-dfx-jdta-dev + AZURE_CONTAINER_APP_1: ca-dfx-jdta-dev + AZURE_CONTAINER_APP_2: ca-dfx-jdma-dev DEPLOY_INFO: ${{ github.ref_name }}-${{ github.sha }} jobs: @@ -43,11 +44,17 @@ jobs: with: creds: ${{ secrets.DFX_DEV_CREDENTIALS }} - - name: Update Azure Container App + - name: Update Azure Container App 1 uses: azure/CLI@v2 with: inlineScript: | - az containerapp update --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.AZURE_CONTAINER_APP }} --image ${{ env.DOCKER_TAGS }} --set-env-vars DEPLOY_INFO=${{ env.DEPLOY_INFO }} + az containerapp update --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.AZURE_CONTAINER_APP_1 }} --image ${{ env.DOCKER_TAGS }} --set-env-vars DEPLOY_INFO=${{ env.DEPLOY_INFO }} + + - name: Update Azure Container App 2 + uses: azure/CLI@v2 + with: + inlineScript: | + az containerapp update --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.AZURE_CONTAINER_APP_2 }} --image ${{ env.DOCKER_TAGS }} --set-env-vars DEPLOY_INFO=${{ env.DEPLOY_INFO }} - name: Logout from Azure run: | diff --git a/.github/workflows/api-prd.yaml b/.github/workflows/api-prd.yaml index 92df1d3..a07684e 100644 --- a/.github/workflows/api-prd.yaml +++ b/.github/workflows/api-prd.yaml @@ -8,7 +8,8 @@ on: env: DOCKER_TAGS: dfxswiss/juicedollar-api:latest AZURE_RESOURCE_GROUP: rg-dfx-api-prd - AZURE_CONTAINER_APP: ca-dfx-jdta-prd + AZURE_CONTAINER_APP_1: ca-dfx-jdta-prd + AZURE_CONTAINER_APP_2: ca-dfx-jdma-prd DEPLOY_INFO: ${{ github.ref_name }}-${{ github.sha }} jobs: @@ -43,11 +44,17 @@ jobs: with: creds: ${{ secrets.DFX_PRD_CREDENTIALS }} - - name: Update Azure Container App + - name: Update Azure Container App 1 uses: azure/CLI@v2 with: inlineScript: | - az containerapp update --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.AZURE_CONTAINER_APP }} --image ${{ env.DOCKER_TAGS }} --set-env-vars DEPLOY_INFO=${{ env.DEPLOY_INFO }} + az containerapp update --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.AZURE_CONTAINER_APP_1 }} --image ${{ env.DOCKER_TAGS }} --set-env-vars DEPLOY_INFO=${{ env.DEPLOY_INFO }} + + - name: Update Azure Container App 2 + uses: azure/CLI@v2 + with: + inlineScript: | + az containerapp update --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.AZURE_CONTAINER_APP_2 }} --image ${{ env.DOCKER_TAGS }} --set-env-vars DEPLOY_INFO=${{ env.DEPLOY_INFO }} - name: Logout from Azure run: | diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..b733717 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] + +jobs: + build: + name: Build & Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Lint + run: yarn lint + continue-on-error: true diff --git a/LICENSE b/LICENSE index 2b368ce..a147b3b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,8 @@ MIT License Copyright (c) 2024 frankencoin -Copyright (c) 2024 samclassix Copyright (c) 2024 deuro -Copyright (c) 2025 juicedollar +Copyright (c) 2026 juicedollar Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/api.config.ts b/api.config.ts index 379793b..1eb8538 100644 --- a/api.config.ts +++ b/api.config.ts @@ -1,12 +1,14 @@ import { Chain, createPublicClient, http } from 'viem'; import { Logger } from '@nestjs/common'; -import { testnet } from 'chains'; +import { mainnet, testnet } from 'chains'; import * as dotenv from 'dotenv'; dotenv.config(); // Verify environment -if (process.env.RPC_URL_MAINNET === undefined) throw new Error('RPC_URL_MAINNET not available'); +const isMainnet = process.env.CONFIG_CHAIN === 'mainnet'; +if (isMainnet && process.env.RPC_URL_MAINNET === undefined) throw new Error('RPC_URL_MAINNET not available'); +if (!isMainnet && process.env.RPC_URL_TESTNET === undefined) throw new Error('RPC_URL_TESTNET not available'); if (process.env.COINGECKO_API_KEY === undefined) throw new Error('COINGECKO_API_KEY not available'); // Config type @@ -40,10 +42,10 @@ export const CONFIG: ConfigType = { indexer: process.env.CONFIG_INDEXER_URL, indexerFallback: process.env.CONFIG_INDEXER_FALLBACK_URL, coingeckoApiKey: process.env.COINGECKO_API_KEY, - chain: testnet, + chain: isMainnet ? mainnet : testnet, network: { mainnet: process.env.RPC_URL_MAINNET, - testnet: process.env.RPC_URL_MAINNET, + testnet: process.env.RPC_URL_TESTNET, }, telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN, @@ -72,7 +74,7 @@ process.env.NTBA_FIX_350 = 'true'; export const VIEM_CHAIN = CONFIG.chain; export const VIEM_CONFIG = createPublicClient({ chain: VIEM_CHAIN, - transport: http(CONFIG.network.mainnet), + transport: http(isMainnet ? CONFIG.network.mainnet : CONFIG.network.testnet), batch: { multicall: { wait: 200, diff --git a/chains.ts b/chains.ts index 7b80f2c..6274eec 100644 --- a/chains.ts +++ b/chains.ts @@ -12,15 +12,14 @@ export const testnet = defineChain({ }, }); -// Juice Mainnet - To define later, same as testnet for now export const mainnet = defineChain({ - id: 62831, + id: 4114, name: 'Mainnet', nativeCurrency: { name: 'cBTC', symbol: 'cBTC', decimals: 18 }, rpcUrls: { - default: { http: ['https://rpc.testnet.citrea.xyz'] }, + default: { http: ['https://rpc.citrea.xyz'] }, }, blockExplorers: { - default: { name: 'Juice Explorer', url: 'https://explorer.testnet.citrea.xyz' }, + default: { name: 'Citrea Explorer', url: 'https://explorer.citrea.xyz' }, }, }); diff --git a/ecosystem/ecosystem.collateral.service.ts b/ecosystem/ecosystem.collateral.service.ts index fd49aac..28fee13 100644 --- a/ecosystem/ecosystem.collateral.service.ts +++ b/ecosystem/ecosystem.collateral.service.ts @@ -85,8 +85,7 @@ export class EcosystemCollateralService { const protocolStablecoinAddress = this.pricesService.getMint()?.address; if (!protocolStablecoinAddress) return null; - const protocolStablecoinPrice = prices[protocolStablecoinAddress.toLowerCase()]?.price?.usd as number; - if (!protocolStablecoinPrice) return null; + if (!prices[protocolStablecoinAddress.toLowerCase()]?.price?.usd) return null; const ecosystemTotalValueLocked: PriceQueryCurrencies = {}; const map: { [key: Address]: ApiEcosystemCollateralStatsItem } = {}; @@ -106,7 +105,6 @@ export class EcosystemCollateralService { const totalBalanceNumUsd = parseInt(formatUnits(totalBalance, c.decimals)) * price; const totalValueLocked: PriceQueryCurrencies = { usd: totalBalanceNumUsd, - eur: totalBalanceNumUsd / protocolStablecoinPrice, }; // upsert ecosystemTotalValueLocked usd @@ -116,13 +114,6 @@ export class EcosystemCollateralService { ecosystemTotalValueLocked.usd += totalValueLocked.usd; } - // upsert ecosystemTotalValueLocked eur - if (!ecosystemTotalValueLocked.eur) { - ecosystemTotalValueLocked.eur = totalValueLocked.eur; - } else { - ecosystemTotalValueLocked.eur += totalValueLocked.eur; - } - // upsert map map[c.address.toLowerCase() as Address] = { address: c.address, @@ -142,7 +133,6 @@ export class EcosystemCollateralService { totalValueLocked, price: { usd: price, - eur: Math.round((price / protocolStablecoinPrice) * 100) / 100, }, }; } diff --git a/package.json b/package.json index daa6567..1372aa4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@juicedollar/api", - "version": "0.0.1", + "version": "1.1.0", "private": false, "license": "MIT", "homepage": "https://api.juicedollar.com", @@ -32,7 +32,7 @@ }, "dependencies": { "@apollo/client": "^3.10.5", - "@juicedollar/jusd": "1.0.6", + "@juicedollar/jusd": "^1.1.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", diff --git a/positions/positions.service.ts b/positions/positions.service.ts index 7ef7093..f07d230 100644 --- a/positions/positions.service.ts +++ b/positions/positions.service.ts @@ -45,6 +45,7 @@ export class PositionsService { expiration: cached.expiration, reserveContribution: cached.reserveContribution, annualInterestPPM: cached.annualInterestPPM, + principal: cached.principal, }; } diff --git a/positions/positions.types.ts b/positions/positions.types.ts index f9e680a..42cb7a1 100644 --- a/positions/positions.types.ts +++ b/positions/positions.types.ts @@ -129,4 +129,5 @@ export type ApiPositionDefault = { expiration: number; reserveContribution: number; annualInterestPPM: number; + principal: string; }; diff --git a/prices/prices.controller.ts b/prices/prices.controller.ts index 4e79c7d..294d0d2 100644 --- a/prices/prices.controller.ts +++ b/prices/prices.controller.ts @@ -49,14 +49,6 @@ export class PricesController { return this.pricesService.getCollateral(); } - @Get('eur') - @ApiResponse({ - description: 'Returns the price of EUR in USD', - }) - getEuroPrice(): Promise { - return this.pricesService.getEuroPrice(); - } - @Get('poolshares') @ApiResponse({ description: `Returns the current price of the ${POOL_SHARES_SYMBOL} token`, diff --git a/prices/prices.service.ts b/prices/prices.service.ts index c8eab20..2aed006 100644 --- a/prices/prices.service.ts +++ b/prices/prices.service.ts @@ -15,13 +15,20 @@ import { PriceQueryObjectArray, } from './prices.types'; -const randRef: number = Math.random() * 0.4 + 0.8; +// Mapping of testnet token symbols to Coingecko IDs for real price fetching +const TESTNET_COINGECKO_MAPPING: Record = { + WCBTC: 'bitcoin', + WBTC: 'bitcoin', + WETH: 'ethereum', + ETH: 'ethereum', + BTC: 'bitcoin', + JUSD: null, // Stablecoin, use hardcoded $1 +}; @Injectable() export class PricesService { private readonly logger = new Logger(this.constructor.name); private fetchedPrices: PriceQueryObjectArray = {}; - private euroPrice: PriceQueryCurrencies = {}; private poolSharesPrice: PriceQueryCurrencies = {}; constructor( @@ -58,13 +65,11 @@ export class PricesService { } async getPoolSharesPrice(): Promise { - if (!this.poolSharesPrice) this.poolSharesPrice = await this.fetchFromEcosystemSharePools(this.getPoolShares()); - if (!this.euroPrice) this.euroPrice = await this.fetchEuroPrice(); - + if (!this.poolSharesPrice?.usd) { + this.poolSharesPrice = await this.fetchFromEcosystemSharePools(this.getPoolShares()); + } return { - usd: Number(this.poolSharesPrice.usd.toFixed(4)), - eur: Number(this.poolSharesPrice.eur.toFixed(4)), - btc: Number((this.poolSharesPrice.eur * this.euroPrice.btc).toFixed(9)), + usd: Number(this.poolSharesPrice?.usd?.toFixed(4) || 0), }; } @@ -84,63 +89,51 @@ export class PricesService { return c; } - async getEuroPrice(): Promise { - if (!this.euroPrice) this.euroPrice = await this.fetchEuroPrice(); - - return { - usd: Number(this.euroPrice.usd.toFixed(4)), - eur: Number(this.euroPrice.eur.toFixed(4)), - btc: Number(this.euroPrice.btc.toFixed(9)), - }; - } - async fetchFromEcosystemSharePools(erc: ERC20Info): Promise { const price = this.poolShares.getEcosystemPoolSharesInfo()?.values?.price; if (!price) return null; - const protocolStablecoinAddress = ADDRESS[VIEM_CHAIN.id].juiceDollar.toLowerCase(); - const quote = this.euroPrice?.usd || this.fetchedPrices[protocolStablecoinAddress]?.price?.usd; - const usdPrice = quote ? price * quote : price; - - this.poolSharesPrice = { usd: usdPrice, eur: price }; + // Price from ecosystem is already in JUSD, which equals USD (1 JUSD = 1 USD) + this.poolSharesPrice = { usd: price }; return this.poolSharesPrice; } async fetchSourcesCoingecko(erc: ERC20Info): Promise { // all mainnet addresses if ((VIEM_CHAIN.id as number) === 1) { - const url = `/api/v3/simple/token_price/ethereum?contract_addresses=${erc.address}&vs_currencies=usd%2Ceur`; + const url = `/api/v3/simple/token_price/ethereum?contract_addresses=${erc.address}&vs_currencies=usd`; const data = await (await COINGECKO_CLIENT(url)).json(); if (data.status) { this.logger.debug(data.status?.error_message || 'Error fetching price from coingecko'); return null; } - return Object.values(data)[0] as { usd: number; eur: number }; + const result = Object.values(data)[0] as { usd: number } | undefined; + if (!result?.usd) { + this.logger.warn(`No price data from Coingecko for ${erc.symbol} (${erc.address})`); + return null; + } + return { usd: result.usd }; } else { - // all other chain addresses (test deployments) - const calc = (value: number) => { - const ref: number = 1718033809979; - return value * randRef * (1 + ((Date.now() - ref) / (3600 * 24 * 365)) * 0.001 + Math.random() * 0.01); - }; - // @dev: this is just for testnet soft price mapping - let price = { usd: calc(1) }; - return price; - } - } + // Testnet: Map token symbols to real Coingecko prices + const symbol = erc.symbol?.toUpperCase(); + const coingeckoId = symbol ? TESTNET_COINGECKO_MAPPING[symbol] : null; + + if (coingeckoId) { + try { + const url = `/api/v3/simple/price?ids=${coingeckoId}&vs_currencies=usd`; + const data = await (await COINGECKO_CLIENT(url)).json(); + if (data[coingeckoId]?.usd) { + this.logger.debug(`Fetched real price for ${erc.symbol} via ${coingeckoId}: $${data[coingeckoId].usd}`); + return { usd: data[coingeckoId].usd }; + } + } catch (error) { + this.logger.warn(`Failed to fetch price for ${erc.symbol}: ${error.message || error}`); + } + } - async fetchEuroPrice(): Promise { - const url = `/api/v3/simple/price?ids=usd&vs_currencies=eur%2Cbtc`; - const data = await (await COINGECKO_CLIENT(url)).json(); - if (data.status) { - this.logger.debug(data.status?.error_message || 'Error fetching price from coingecko'); - return null; + // Fallback for stablecoins and unknown tokens + return { usd: 1 }; } - - return { - eur: 1, - usd: 1 / Number(data.usd.eur), - btc: 1 / Number(data.usd.eur / data.usd.btc), - }; } async fetchPrice(erc: ERC20Info): Promise { @@ -154,15 +147,24 @@ export class PricesService { async updatePrices() { this.logger.debug('Updating Prices'); - const euroPrice = await this.fetchEuroPrice(); - if (euroPrice) this.euroPrice = euroPrice; - const poolShares = this.getPoolShares(); - const m = this.getMint(); + const mint = this.getMint(); const c = this.getCollateral(); - if (!m || Object.values(c).length == 0) return; - const a = [poolShares, m, ...Object.values(c)]; + if (!mint || Object.values(c).length == 0) return; + + // JUSD is always $1 (stablecoin) - no need to fetch from Coingecko + const mintAddr = mint.address.toLowerCase() as Address; + if (!this.fetchedPrices[mintAddr]) { + this.fetchedPrices[mintAddr] = { + ...mint, + timestamp: Date.now(), + price: { usd: 1 }, + }; + } + + // Only fetch prices for poolShares and collateral tokens + const a = [poolShares, ...Object.values(c)]; const pricesQuery: PriceQueryObjectArray = {}; let pricesQueryNewCount: number = 0; @@ -178,12 +180,12 @@ export class PricesService { pricesQueryNewCount += 1; this.logger.debug(`Price for ${erc.name} not available, trying to fetch...`); const price = await this.fetchPrice(erc); - if (!price) pricesQueryNewCountFailed += 1; + if (!price?.usd) pricesQueryNewCountFailed += 1; pricesQuery[addr] = { ...erc, - timestamp: price === null ? 0 : Date.now(), - price: price === null ? { usd: 1 } : price, + timestamp: price?.usd ? Date.now() : 0, + price: price?.usd ? price : { usd: 1 }, }; } else if (oldEntry.timestamp + 300_000 < Date.now()) { // needs to update => try to fetch @@ -191,7 +193,7 @@ export class PricesService { this.logger.debug(`Price for ${erc.name} out of date, trying to fetch...`); const price = await this.fetchPrice(erc); - if (!price) { + if (!price?.usd) { pricesQueryUpdateCountFailed += 1; } else { pricesQuery[addr] = { @@ -201,17 +203,6 @@ export class PricesService { }; } } - - const protocolStablecoinPrice: number = - this.euroPrice?.usd || this.fetchedPrices[ADDRESS[VIEM_CHAIN.id].juiceDollar.toLowerCase()]?.price?.usd; - - if (protocolStablecoinPrice) { - const priceUsd = pricesQuery[addr]?.price?.usd; - const priceEur = pricesQuery[addr]?.price?.eur; - if (priceUsd && !priceEur) { - pricesQuery[addr].price.eur = Math.round((priceUsd / protocolStablecoinPrice) * 100) / 100; - } - } } const updatesCnt = pricesQueryNewCount + pricesQueryUpdateCount; diff --git a/prices/prices.types.ts b/prices/prices.types.ts index 90e2a36..94feaec 100644 --- a/prices/prices.types.ts +++ b/prices/prices.types.ts @@ -13,10 +13,8 @@ export type ERC20Info = { decimals: number; }; -// TODO: Implement other currencies export type PriceQueryCurrencies = { usd?: number; - eur?: number; btc?: number; }; diff --git a/socialmedia/telegram/messages/Help.message.ts b/socialmedia/telegram/messages/Help.message.ts index 7b85f64..7745691 100644 --- a/socialmedia/telegram/messages/Help.message.ts +++ b/socialmedia/telegram/messages/Help.message.ts @@ -5,9 +5,9 @@ export function HelpMessage(groups: string[], group: string, handles: string[]): const isSubscribed = groups.includes(group); return ` -*Welcome to the d-EURO API Bot* +*Welcome to the JuiceDollar API Bot* -I am listening to changes within the d-EURO ecosystem. +I am listening to changes within the JuiceDollar ecosystem. *Available commands:* ${handles.join('\n')} @@ -21,6 +21,6 @@ Chain/Network: ${CONFIG.chain.name} (${CONFIG.chain.id}) Time: ${new Date().toString().split(' ').slice(0, 5).join(' ')} [Goto App](${AppUrl('')}) -[Github Api](https://github.com/d-EURO/api) +[Github Api](https://github.com/JuiceDollar/api) `; } diff --git a/socialmedia/telegram/messages/StartUp.message.ts b/socialmedia/telegram/messages/StartUp.message.ts index 21b56ed..ef295b4 100644 --- a/socialmedia/telegram/messages/StartUp.message.ts +++ b/socialmedia/telegram/messages/StartUp.message.ts @@ -3,9 +3,9 @@ import { AppUrl } from 'utils/func-helper'; export function StartUpMessage(handles: string[]): string { return ` -*Hello again, from the d-EURO API Bot!* +*Hello again, from the JuiceDollar API Bot!* -I have updated and restarted and am back online, listening to changes within the d-EURO ecosystem. +I have updated and restarted and am back online, listening to changes within the JuiceDollar ecosystem. *Available subscription handles:* ${handles.join('\n')} @@ -16,6 +16,6 @@ Chain/Network: ${CONFIG.chain.name} (${CONFIG.chain.id}) Time: ${new Date().toString().split(' ').slice(0, 5).join(' ')} [Goto App](${AppUrl('')}) -[Github Api](https://github.com/d-EURO/api) +[Github Api](https://github.com/JuiceDollar/api) `; } diff --git a/yarn.lock b/yarn.lock index bbd417a..3b3cddb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -902,10 +902,10 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@juicedollar/jusd@1.0.6": - version "1.0.6" - resolved "https://registry.yarnpkg.com/@juicedollar/jusd/-/jusd-1.0.6.tgz#5e28a415a71b1abd77d1c278ae2816b95fc72bb3" - integrity sha512-5fp4n5rAyq/pw6rEbCWEdJ0U5v50NHh/cR63gG+Km8aNj8GO1wxG53/cNuXku/45TWGGFsboDci18C4X8tdEOg== +"@juicedollar/jusd@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@juicedollar/jusd/-/jusd-1.1.0.tgz#0f85fd081873ebb90854bd76bb29aeca1272239e" + integrity sha512-+rMzg1bFgtUJRloEUFJDWBbb+0AUNOFwKS2Qc7JMP14Ngu1VhvAs00pBqC1O+OjQu7cc1twU7VS7W1XhVnYdMw== dependencies: "@openzeppelin/contracts" "^5.1.0" hardhat-abi-exporter "^2.10.0"